diff --git a/.gitignore b/.gitignore index 7787458..88fc811 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ dist/ *.log .env .dockerflow-*.json +.dockerflow-*.db +.dockerflow-*.db-wal +.dockerflow-*.db-shm diff --git a/src/client/App.tsx b/src/client/App.tsx index e130578..e37f9e7 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -86,7 +86,30 @@ function Dashboard({ token }: { token: string }) { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const initialLayoutDone = useRef(false); - const [activePage, setActivePage] = useState("dashboard"); + const PAGE_PATHS: Record = { + "monitoreo": "monitoring", "monitoring": "monitoring", + "configuracion": "settings", "settings": "settings", + }; + const PAGE_SLUGS: Record = { dashboard: "", monitoring: "monitoreo", settings: "configuracion" }; + + const getPageFromPath = (): Page => { + const path = window.location.pathname.replace(/^\//, ""); + return PAGE_PATHS[path] || "dashboard"; + }; + const [activePage, setActivePage] = useState(getPageFromPath); + + // Sync URL with active page (browser back/forward) + useEffect(() => { + const handler = () => setActivePage(getPageFromPath()); + window.addEventListener("popstate", handler); + return () => window.removeEventListener("popstate", handler); + }, []); + + const navigateTo = useCallback((page: Page) => { + const slug = PAGE_SLUGS[page]; + window.history.pushState(null, "", slug ? `/${slug}` : "/"); + setActivePage(page); + }, []); const [hiddenProjects, setHiddenProjects] = useState>(loadFilter); const [filterOpen, setFilterOpen] = useState(false); const filterRef = useRef(null); @@ -482,11 +505,11 @@ function Dashboard({ token }: { token: string }) { token={token} totalStats={totalStats} activePage={activePage} - onPageChange={(page) => { setContextMenu(null); setActivePage(page); }} + onPageChange={(page) => { setContextMenu(null); navigateTo(page); }} events={events} /> - {activePage === "monitoring" && } + {activePage === "monitoring" && } {activePage === "settings" && } {/* Canvas — inset (only visible on dashboard) */} diff --git a/src/client/components/Sparkline.tsx b/src/client/components/Sparkline.tsx new file mode 100644 index 0000000..262811a --- /dev/null +++ b/src/client/components/Sparkline.tsx @@ -0,0 +1,328 @@ +import { useRef, useEffect, useState, useCallback } from "react"; +import { useT } from "../i18n"; + +interface SparklineProps { + data: number[]; + timestamps?: number[]; + hoverValues?: number[]; + width?: number; + height?: number; + color?: string; + threshold?: number; + showArea?: boolean; + showAverage?: boolean; + formatAverage?: (v: number) => string; + className?: string; + formatValue?: (v: number) => string; + formatHoverValue?: (v: number) => string; +} + +const PAD = { top: 4, bottom: 0, left: 0, right: 0 }; + +function formatDateTime(ts: number): string { + const d = new Date(ts * 1000); + const dd = String(d.getDate()).padStart(2, "0"); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const time = d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); + return `${dd}/${mm} ${time}`; +} + +export function Sparkline({ + data, + timestamps, + hoverValues, + width: propWidth, + height: propHeight = 60, + color = "#06b6d4", + threshold, + showArea = true, + showAverage = false, + formatAverage, + className, + formatValue, + formatHoverValue, +}: SparklineProps) { + const { t } = useT(); + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [hoverIndex, setHoverIndex] = useState(null); + const [dims, setDims] = useState<{ w: number; h: number }>({ w: 0, h: propHeight }); + + // Draw the sparkline + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const w = propWidth || dims.w; + const h = propHeight; + if (w === 0) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + + if (data.length === 0) { + ctx.fillStyle = "#64748b"; + ctx.font = "11px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText(t("detail.noHistory"), w / 2, h / 2 + 4); + return; + } + + const plotW = w - PAD.left - PAD.right; + const plotH = h - PAD.top - PAD.bottom; + const avg = data.reduce((a, b) => a + b, 0) / data.length; + const max = Math.max(...data, threshold ?? 0, avg, 1); + const range = max || 1; + const xStep = data.length > 1 ? plotW / (data.length - 1) : plotW; + + const toX = (i: number) => PAD.left + i * xStep; + const toY = (v: number) => PAD.top + plotH - (v / range) * plotH; + + const AMBER = "#f59e0b"; + const hasThreshold = threshold !== undefined && threshold > 0; + + // Helper: pick color based on whether value exceeds threshold + const segColor = (v: number) => hasThreshold && v >= threshold ? AMBER : color; + + // Helper: interpolate X where data crosses threshold between two points + const crossX = (i0: number, i1: number) => { + const v0 = data[i0], v1 = data[i1]; + const t = (threshold! - v0) / (v1 - v0); + return toX(i0) + t * (toX(i1) - toX(i0)); + }; + + // Build segments: groups of consecutive points with the same over/under state + // Each segment includes the crossing point so lines connect smoothly + type Seg = { points: { x: number; y: number }[]; over: boolean }; + const segments: Seg[] = []; + if (data.length > 1 && hasThreshold) { + let cur: Seg = { points: [{ x: toX(0), y: toY(data[0]) }], over: data[0] >= threshold }; + for (let i = 1; i < data.length; i++) { + const over = data[i] >= threshold; + if (over !== cur.over) { + // Crossing point + const cx = crossX(i - 1, i); + const cy = toY(threshold); + cur.points.push({ x: cx, y: cy }); + segments.push(cur); + cur = { points: [{ x: cx, y: cy }], over }; + } + cur.points.push({ x: toX(i), y: toY(data[i]) }); + } + segments.push(cur); + } + + // Area fill + if (showArea && data.length > 1) { + if (hasThreshold && segments.length > 0) { + for (const seg of segments) { + if (seg.points.length < 2) continue; + ctx.beginPath(); + ctx.moveTo(seg.points[0].x, seg.points[0].y); + for (let j = 1; j < seg.points.length; j++) ctx.lineTo(seg.points[j].x, seg.points[j].y); + ctx.lineTo(seg.points[seg.points.length - 1].x, PAD.top + plotH); + ctx.lineTo(seg.points[0].x, PAD.top + plotH); + ctx.closePath(); + const c = seg.over ? AMBER : color; + const cr = parseInt(c.slice(1, 3), 16); + const cg = parseInt(c.slice(3, 5), 16); + const cb = parseInt(c.slice(5, 7), 16); + const gradient = ctx.createLinearGradient(0, PAD.top, 0, PAD.top + plotH); + gradient.addColorStop(0, `rgba(${cr},${cg},${cb},0.35)`); + gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0.08)`); + ctx.fillStyle = gradient; + ctx.fill(); + } + } else { + ctx.beginPath(); + ctx.moveTo(toX(0), toY(data[0])); + for (let i = 1; i < data.length; i++) ctx.lineTo(toX(i), toY(data[i])); + ctx.lineTo(toX(data.length - 1), PAD.top + plotH); + ctx.lineTo(toX(0), PAD.top + plotH); + ctx.closePath(); + const cr = parseInt(color.slice(1, 3), 16); + const cg = parseInt(color.slice(3, 5), 16); + const cb = parseInt(color.slice(5, 7), 16); + const gradient = ctx.createLinearGradient(0, PAD.top, 0, PAD.top + plotH); + gradient.addColorStop(0, `rgba(${cr},${cg},${cb},0.25)`); + gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0.02)`); + ctx.fillStyle = gradient; + ctx.fill(); + } + } + + // Line stroke + if (data.length > 1) { + if (hasThreshold && segments.length > 0) { + for (const seg of segments) { + if (seg.points.length < 2) continue; + ctx.beginPath(); + ctx.moveTo(seg.points[0].x, seg.points[0].y); + for (let j = 1; j < seg.points.length; j++) ctx.lineTo(seg.points[j].x, seg.points[j].y); + ctx.strokeStyle = seg.over ? AMBER : color; + ctx.lineWidth = 1.5; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.stroke(); + } + } else { + ctx.beginPath(); + ctx.moveTo(toX(0), toY(data[0])); + for (let i = 1; i < data.length; i++) ctx.lineTo(toX(i), toY(data[i])); + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.stroke(); + } + } else { + ctx.beginPath(); + ctx.arc(toX(0), toY(data[0]), 2, 0, Math.PI * 2); + ctx.fillStyle = segColor(data[0]); + ctx.fill(); + } + + // Threshold dashed line + if (hasThreshold) { + const y = toY(threshold); + if (y >= PAD.top && y <= PAD.top + plotH) { + ctx.beginPath(); + ctx.setLineDash([4, 4]); + ctx.moveTo(PAD.left, y); + ctx.lineTo(w - PAD.right, y); + ctx.strokeStyle = AMBER; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.setLineDash([]); + } + } + + // Average dashed line + if (showAverage && data.length > 1) { + const avgY = toY(avg); + if (avgY >= PAD.top && avgY <= PAD.top + plotH) { + ctx.beginPath(); + ctx.setLineDash([3, 3]); + ctx.moveTo(PAD.left, avgY); + ctx.lineTo(w - PAD.right, avgY); + ctx.strokeStyle = "rgba(148, 163, 184, 0.5)"; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.setLineDash([]); + } + } + + // Hover crosshair + dot + if (hoverIndex !== null && hoverIndex >= 0 && hoverIndex < data.length) { + const hx = toX(hoverIndex); + const hy = toY(data[hoverIndex]); + + // Vertical line + ctx.beginPath(); + ctx.moveTo(hx, PAD.top); + ctx.lineTo(hx, PAD.top + plotH); + ctx.strokeStyle = "rgba(148, 163, 184, 0.4)"; + ctx.lineWidth = 1; + ctx.stroke(); + + // Dot + const dotColor = hasThreshold && data[hoverIndex] >= threshold ? AMBER : color; + ctx.beginPath(); + ctx.arc(hx, hy, 3.5, 0, Math.PI * 2); + ctx.fillStyle = dotColor; + ctx.fill(); + ctx.beginPath(); + ctx.arc(hx, hy, 2, 0, Math.PI * 2); + ctx.fillStyle = "#0f172a"; + ctx.fill(); + } + }, [data, propWidth, propHeight, color, threshold, showArea, showAverage, formatAverage, hoverIndex, dims.w]); + + // Mouse tracking + const handleMouseMove = useCallback((e: React.MouseEvent) => { + if (data.length === 0) return; + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const plotW = dims.w - PAD.left - PAD.right; + const xStep = data.length > 1 ? plotW / (data.length - 1) : plotW; + const idx = Math.round((mouseX - PAD.left) / xStep); + const clamped = Math.max(0, Math.min(data.length - 1, idx)); + setHoverIndex(clamped); + }, [data.length, dims.w]); + + const handleMouseLeave = useCallback(() => { + setHoverIndex(null); + }, []); + + // ResizeObserver for responsive width — triggers re-draw when container resizes + useEffect(() => { + if (propWidth) return; + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + const w = Math.floor(entry.contentRect.width); + if (w > 0) setDims((prev) => prev.w !== w ? { ...prev, w } : prev); + } + }); + observer.observe(container); + return () => observer.disconnect(); + }, [propWidth]); + + // Tooltip content + const tooltip = hoverIndex !== null && hoverIndex >= 0 && hoverIndex < data.length + ? { + value: (() => { + if (hoverValues && hoverValues[hoverIndex] !== undefined) { + return formatHoverValue ? formatHoverValue(hoverValues[hoverIndex]) : `${hoverValues[hoverIndex].toFixed(1)}`; + } + return formatValue ? formatValue(data[hoverIndex]) : `${data[hoverIndex].toFixed(1)}%`; + })(), + time: timestamps && timestamps[hoverIndex] ? formatDateTime(timestamps[hoverIndex]) : null, + x: PAD.left + (data.length > 1 ? (dims.w - PAD.left - PAD.right) / (data.length - 1) : 0) * hoverIndex, + } + : null; + + return ( +
+
+ +
+ {/* Tooltip — below the chart */} + {tooltip && ( +
+
+ {tooltip.value} + {tooltip.time && ( + {tooltip.time} + )} +
+
+ )} +
+ ); +} diff --git a/src/client/components/StatsCard.tsx b/src/client/components/StatsCard.tsx new file mode 100644 index 0000000..4a84617 --- /dev/null +++ b/src/client/components/StatsCard.tsx @@ -0,0 +1,81 @@ +import { Sparkline } from "./Sparkline"; + +interface StatsCardProps { + label: string; + value: string; + limit?: string; + data: number[]; + timestamps?: number[]; + hoverValues?: number[]; + color: string; + threshold?: number; + sparklineHeight?: number; + formatValue?: (v: number) => string; + formatHoverValue?: (v: number) => string; + showAverage?: boolean; + formatAverage?: (v: number) => string; + avgLabel?: string; +} + +export function StatsCard({ + label, + value, + limit, + data, + timestamps, + hoverValues, + color, + threshold, + sparklineHeight = 52, + formatValue, + formatHoverValue, + showAverage, + formatAverage, + avgLabel, +}: StatsCardProps) { + const avgSource = hoverValues && hoverValues.length > 0 ? hoverValues : data; + const avg = showAverage && avgSource.length > 0 + ? avgSource.reduce((a, b) => a + b, 0) / avgSource.length + : null; + + return ( +
+ {/* Left: label + value + limit */} +
+ {label} + + {value} + + {limit && ( + + / {limit} + + )} +
+ {/* Center: sparkline */} +
+ +
+ {/* Right: average label outside sparkline */} + {avg !== null && ( +
+ {avgLabel || "Avg"} + + {formatAverage ? formatAverage(avg) : `${avg.toFixed(1)}`} + +
+ )} +
+ ); +} diff --git a/src/client/components/ThresholdBar.tsx b/src/client/components/ThresholdBar.tsx new file mode 100644 index 0000000..c180778 --- /dev/null +++ b/src/client/components/ThresholdBar.tsx @@ -0,0 +1,122 @@ +import { useRef, useState, useCallback, useEffect } from "react"; +import { RotateCw } from "lucide-react"; + +interface ThresholdBarProps { + label: string; + value: number; + threshold: number; + isCustom: boolean; + showThreshold: boolean; + thresholdLabel: string; + tagLabel: string; + hintLabel: string; + onThresholdChange: (v: number) => void; + onReset: () => void; + formatValue: (v: number) => string; + formatThreshold?: (threshold: number) => string; + baseColor?: "emerald" | "cyan" | "purple"; +} + +export function ThresholdBar({ label, value, threshold, isCustom, showThreshold, thresholdLabel, tagLabel, hintLabel, onThresholdChange, onReset, formatValue, formatThreshold, baseColor = "emerald" }: ThresholdBarProps) { + const barRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [hovering, setHovering] = useState(false); + + const calcPercent = useCallback((clientX: number) => { + if (!barRef.current) return threshold; + const rect = barRef.current.getBoundingClientRect(); + const pct = Math.round(((clientX - rect.left) / rect.width) * 100); + return Math.max(5, Math.min(100, pct)); + }, [threshold]); + + useEffect(() => { + if (!dragging) return; + const onMove = (e: MouseEvent) => { onThresholdChange(calcPercent(e.clientX)); }; + const onUp = () => { setDragging(false); }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + return () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; + }, [dragging, calcPercent, onThresholdChange]); + + // Touch support + useEffect(() => { + if (!dragging) return; + const onMove = (e: TouchEvent) => { if (e.touches[0]) onThresholdChange(calcPercent(e.touches[0].clientX)); }; + const onEnd = () => { setDragging(false); }; + window.addEventListener("touchmove", onMove); + window.addEventListener("touchend", onEnd); + return () => { window.removeEventListener("touchmove", onMove); window.removeEventListener("touchend", onEnd); }; + }, [dragging, calcPercent, onThresholdChange]); + + const baseColorClass = baseColor === "purple" ? "bg-purple-500" : baseColor === "cyan" ? "bg-cyan-500" : "bg-emerald-500"; + const barColor = showThreshold + ? (value > threshold ? "bg-amber-500" : baseColorClass) + : (value > 80 ? "bg-amber-500" : baseColorClass); + const showTooltip = dragging || hovering; + + return ( +
+
+ {label} + {formatValue(value)} +
+
{ if (showThreshold && !dragging) onThresholdChange(calcPercent(e.clientX)); }} + > + {/* Usage fill */} +
+ {/* Threshold handle — only when notifications enabled */} + {showThreshold && ( +
{ e.preventDefault(); setDragging(true); }} + onTouchStart={(e) => { e.preventDefault(); setDragging(true); }} + onMouseEnter={() => setHovering(true)} + onMouseLeave={() => setHovering(false)} + > + {/* Invisible wider hit area */} +
+ {/* Vertical line */} +
+ {/* Drag handle diamond */} +
+ {/* Tooltip */} + {showTooltip && ( +
+ {threshold}% + {formatThreshold && {formatThreshold(threshold)}} +
+ )} +
+ )} +
+ {/* Label row — only when notifications enabled */} + {showThreshold && ( +
+ {hintLabel} +
+ {tagLabel} + {isCustom && ( + + )} + {threshold}% +
+
+ )} +
+ ); +} diff --git a/src/client/hooks/useStatsHistory.ts b/src/client/hooks/useStatsHistory.ts new file mode 100644 index 0000000..227986c --- /dev/null +++ b/src/client/hooks/useStatsHistory.ts @@ -0,0 +1,50 @@ +import { useState, useEffect } from "react"; +import type { StatsHistoryPoint, StatsRange } from "../../shared/types"; + +export function useStatsHistory(uid: string, range: StatsRange, token: string) { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + + fetch(`/api/stats/history/${uid}?range=${range}`, { headers }) + .then((r) => r.ok ? r.json() : []) + .then((d: StatsHistoryPoint[]) => { + setData(d); + setLoading(false); + }) + .catch(() => { + setData([]); + setLoading(false); + }); + }, [uid, range, token]); + + return { data, loading }; +} + +export function useAllStatsHistory(range: StatsRange, token: string) { + const [data, setData] = useState>({}); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + + fetch(`/api/stats/history?range=${range}`, { headers }) + .then((r) => r.ok ? r.json() : {}) + .then((d: Record) => { + setData(d); + setLoading(false); + }) + .catch(() => { + setData({}); + setLoading(false); + }); + }, [range, token]); + + return { data, loading }; +} diff --git a/src/client/i18n.tsx b/src/client/i18n.tsx index 3336978..c6181dd 100644 --- a/src/client/i18n.tsx +++ b/src/client/i18n.tsx @@ -87,6 +87,11 @@ const en = { "detail.memoryLimit": "Memory Limit", "detail.cpuQuota": "CPU Quota", "detail.unlimited": "Unlimited", + "detail.threshold": "Threshold", + "detail.thresholdTooltip": "Alert threshold — sends a Discord notification when exceeded", + "detail.limit": "Limit", + "detail.limitTooltip": "Maximum resource allocated to this container in Docker", + "detail.avg": "Avg", "detail.healthCheck": "Health Check", "detail.healthNotConfigured": "Not configured", "detail.recentChecks": "Recent checks", @@ -104,6 +109,10 @@ const en = { "detail.memoryUsage": "Memory Usage", "detail.memory": "Memory", "detail.noStats": "No stats available", + "detail.cpuHistory": "CPU History", + "detail.memoryHistory": "Memory History", + "detail.noHistory": "No historical data available", + "detail.loadingHistory": "Loading history...", // Detail panel - Warning banners "detail.noMemoryLimit": "No memory limit configured in Docker", @@ -155,6 +164,15 @@ const en = { "monitoring.noEvents": "No events yet. Events will appear here as containers start, stop, or restart.", "monitoring.alertRules": "Alert Rules", "monitoring.alertRulesDesc": "Configure alerting rules for container events \u2014 coming soon", + "monitoring.statsHistory": "Resource Usage History", + "monitoring.loadingHistory": "Loading historical data...", + "monitoring.noHistoryData": "No historical data available yet", + "monitoring.selectFilter": "Select a service or load all to view history", + "monitoring.loadAll": "Load all", + "monitoring.allServices": "All services", + "monitoring.allProjects": "All projects", + "monitoring.filterService": "Filter by service", + "monitoring.filterProject": "Filter by project", // Settings page "settings.title": "Settings", @@ -286,6 +304,11 @@ const es: Record = { "detail.memoryLimit": "L\u00edmite de Memoria", "detail.cpuQuota": "Cuota de CPU", "detail.unlimited": "Sin l\u00edmite", + "detail.threshold": "Umbral", + "detail.thresholdTooltip": "Umbral de alerta \u2014 env\u00eda una notificaci\u00f3n a Discord cuando se supera", + "detail.limit": "L\u00edmite", + "detail.limitTooltip": "Recurso m\u00e1ximo asignado a este contenedor en Docker", + "detail.avg": "Prom", "detail.healthCheck": "Health Check", "detail.healthNotConfigured": "No configurado", "detail.recentChecks": "Chequeos recientes", @@ -303,6 +326,10 @@ const es: Record = { "detail.memoryUsage": "Uso de Memoria", "detail.memory": "Memoria", "detail.noStats": "No hay estad\u00edsticas disponibles", + "detail.cpuHistory": "Historial de CPU", + "detail.memoryHistory": "Historial de Memoria", + "detail.noHistory": "No hay datos hist\u00f3ricos disponibles", + "detail.loadingHistory": "Cargando historial...", // Detail panel - Warning banners "detail.noMemoryLimit": "Sin l\u00edmite de memoria configurado en Docker", @@ -354,6 +381,15 @@ const es: Record = { "monitoring.noEvents": "Sin eventos a\u00fan. Los eventos aparecer\u00e1n aqu\u00ed cuando los contenedores inicien, se detengan o reinicien.", "monitoring.alertRules": "Reglas de Alerta", "monitoring.alertRulesDesc": "Configurar reglas de alerta para eventos de contenedores \u2014 pr\u00f3ximamente", + "monitoring.statsHistory": "Historial de Uso de Recursos", + "monitoring.loadingHistory": "Cargando datos hist\u00f3ricos...", + "monitoring.noHistoryData": "No hay datos hist\u00f3ricos disponibles a\u00fan", + "monitoring.selectFilter": "Selecciona un servicio o carga todos para ver el historial", + "monitoring.loadAll": "Cargar todos", + "monitoring.allServices": "Todos los servicios", + "monitoring.allProjects": "Todos los proyectos", + "monitoring.filterService": "Filtrar por servicio", + "monitoring.filterProject": "Filtrar por proyecto", // Settings page "settings.title": "Configuraci\u00f3n", diff --git a/src/client/nodes/ServiceNode.tsx b/src/client/nodes/ServiceNode.tsx index 7a9b0f9..690f6f7 100644 --- a/src/client/nodes/ServiceNode.tsx +++ b/src/client/nodes/ServiceNode.tsx @@ -85,7 +85,7 @@ const nameIconMap: { pattern: string; icon: LucideIcon; color: string }[] = [ { pattern: "api", icon: Server, color: "#3b82f6" }, ]; -function guessIcon(image: string, name: string): { Icon: LucideIcon; color: string } { +export function guessIcon(image: string, name: string): { Icon: LucideIcon; color: string } { const lowerImage = image.toLowerCase(); const lowerName = name.toLowerCase(); diff --git a/src/client/pages/MonitoringPage.tsx b/src/client/pages/MonitoringPage.tsx index 6428119..34fba74 100644 --- a/src/client/pages/MonitoringPage.tsx +++ b/src/client/pages/MonitoringPage.tsx @@ -1,6 +1,11 @@ -import { Activity, Play, Square, RotateCcw, AlertTriangle } from "lucide-react"; -import type { DockerEvent } from "../../shared/types"; +import { useState, useMemo, useRef, useEffect, useCallback } from "react"; +import { Activity, Play, Square, RotateCcw, AlertTriangle, BarChart3, ChevronDown, Check, Maximize2, Minimize2, Settings } from "lucide-react"; +import type { DockerEvent, StatsRange, Service, ContainerSettings, DiscordConfig } from "../../shared/types"; import { useT } from "../i18n"; +import { useAllStatsHistory } from "../hooks/useStatsHistory"; +import { StatsCard } from "../components/StatsCard"; +import { ThresholdBar } from "../components/ThresholdBar"; +import { guessIcon } from "../nodes/ServiceNode"; function timeAgo(ts: number): string { const diff = Math.floor((Date.now() / 1000) - ts); @@ -33,42 +38,539 @@ function actionColor(action: string): string { } } -interface MonitoringPageProps { - events: DockerEvent[]; +function ServiceIcon({ uid, services }: { uid: string; services: Service[] }) { + const svc = services.find((s) => s.uid === uid); + if (!svc) return null; + const { Icon, color } = guessIcon(svc.image, svc.name); + return ( +
+ +
+ ); } -export function MonitoringPage({ events }: MonitoringPageProps) { +function FilterDropdown({ label, open, onToggle, children, dropdownRef }: { + label: string; + open: boolean; + onToggle: () => void; + children: React.ReactNode; + dropdownRef: React.RefObject; +}) { + return ( +
+ + {open && ( +
+ {children} +
+ )} +
+ ); +} + +interface MonitoringPageProps { + events: DockerEvent[]; + token: string; + services: Service[]; +} + +export function MonitoringPage({ events, token, services }: MonitoringPageProps) { const { t } = useT(); - const sorted = [...events].reverse(); + const [statsRange, setStatsRange] = useState("1h"); + const [selectedProjects, setSelectedProjects] = useState>(new Set()); + const [selectedServices, setSelectedServices] = useState>(new Set()); + const [expandedService, setExpandedService] = useState(null); + const [configService, setConfigService] = useState(null); + const [projectFilterOpen, setProjectFilterOpen] = useState(false); + const [serviceFilterOpen, setServiceFilterOpen] = useState(false); + const projectRef = useRef(null); + const serviceRef = useRef(null); + const { data: allHistory, loading: historyLoading } = useAllStatsHistory(statsRange, token); + const [containerSettings, setContainerSettings] = useState>({}); + const [globalThresholds, setGlobalThresholds] = useState<{ cpu: number; mem: number }>({ cpu: 80, mem: 90 }); + const [discordEnabled, setDiscordEnabled] = useState(false); + + // Load thresholds + useEffect(() => { + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; + fetch("/api/container-settings", { headers }) + .then((r) => r.ok ? r.json() : {}) + .then((data: Record) => setContainerSettings(data)) + .catch(() => {}); + fetch("/api/discord-config", { headers }) + .then((r) => r.ok ? r.json() : null) + .then((data: DiscordConfig | null) => { + if (data) { + setGlobalThresholds({ cpu: data.thresholds.cpuPercent, mem: data.thresholds.memPercent }); + setDiscordEnabled(data.enabled && !!data.webhookUrl); + } + }) + .catch(() => {}); + }, [token]); + + // Save a single container's settings + const saveContainerSetting = useCallback(async (uid: string, settings: ContainerSettings) => { + setContainerSettings((prev) => ({ ...prev, [uid]: settings })); + try { + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + await fetch("/api/container-settings", { + method: "PUT", + headers, + body: JSON.stringify({ uid, settings }), + }); + } catch {} + }, [token]); + + // Auto-save container settings on drag (debounced) + const csSaveTimer = useRef | null>(null); + const debouncedSave = useCallback((uid: string, settings: ContainerSettings) => { + if (csSaveTimer.current) clearTimeout(csSaveTimer.current); + csSaveTimer.current = setTimeout(() => { + saveContainerSetting(uid, settings); + }, 400); + }, [saveContainerSetting]); + + // Close dropdowns on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (projectRef.current && !projectRef.current.contains(e.target as HTMLElement)) { + setProjectFilterOpen(false); + } + if (serviceRef.current && !serviceRef.current.contains(e.target as HTMLElement)) { + setServiceFilterOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + // Build all known services from services prop + events + history + const allServiceNames = useMemo(() => { + const names = new Set(); + for (const s of services) names.add(s.uid); + for (const ev of events) names.add(ev.service); + for (const svc of Object.keys(allHistory)) names.add(svc); + return [...names].sort(); + }, [services, events, allHistory]); + + // Extract unique projects + const allProjects = useMemo(() => { + const projects = new Set(); + for (const svc of allServiceNames) { + const slash = svc.indexOf("/"); + projects.add(slash >= 0 ? svc.slice(0, slash) : "standalone"); + } + return [...projects].sort(); + }, [allServiceNames]); + + // Services filtered by selected projects + const projectFilteredServices = useMemo(() => { + if (selectedProjects.size === 0) return allServiceNames; + return allServiceNames.filter((svc) => { + const slash = svc.indexOf("/"); + const project = slash >= 0 ? svc.slice(0, slash) : "standalone"; + return selectedProjects.has(project); + }); + }, [allServiceNames, selectedProjects]); + + // Final filtered set (project filter + service filter) + // When no service is explicitly selected, show nothing (require selection) + const hasActiveFilter = selectedServices.size > 0 || selectedProjects.size > 0; + const finalFilteredServices = useMemo(() => { + if (selectedServices.size > 0) return new Set(projectFilteredServices.filter((svc) => selectedServices.has(svc))); + if (selectedProjects.size > 0) return new Set(projectFilteredServices); + return new Set(); + }, [projectFilteredServices, selectedServices, selectedProjects]); + + // Filtered data + const filteredHistory = useMemo(() => { + const result: Record = {}; + for (const [svc, points] of Object.entries(allHistory)) { + if (finalFilteredServices.has(svc)) result[svc] = points; + } + return result; + }, [allHistory, finalFilteredServices]); + + const filteredEvents = useMemo(() => { + const sorted = [...events].reverse(); + if (!hasActiveFilter) return sorted; + return sorted.filter((ev) => finalFilteredServices.has(ev.service)); + }, [events, finalFilteredServices, hasActiveFilter]); + + const historyServiceNames = Object.keys(filteredHistory).sort(); + + // Toggle helpers + const toggleProject = (project: string) => { + setSelectedProjects((prev) => { + const next = new Set(prev); + if (next.has(project)) next.delete(project); + else next.add(project); + return next; + }); + }; + + const toggleService = (svc: string) => { + setSelectedServices((prev) => { + const next = new Set(prev); + if (next.has(svc)) next.delete(svc); + else next.add(svc); + return next; + }); + }; + + // Labels + const projectLabel = selectedProjects.size === 0 + ? t("monitoring.filterProject") + : selectedProjects.size === allProjects.length + ? t("monitoring.allProjects") + : selectedProjects.size === 1 + ? [...selectedProjects][0] + : `${selectedProjects.size} ${t("filter.projects").toLowerCase()}`; + + const serviceLabel = selectedServices.size === 0 + ? t("monitoring.filterService") + : selectedServices.size === projectFilteredServices.length + ? t("monitoring.allServices") + : selectedServices.size === 1 + ? ([...selectedServices][0].split("/").pop() || [...selectedServices][0]) + : `${selectedServices.size} ${t("footer.containers")}`; return (
-
+
{/* Header */} -
- -
-

{t("monitoring.title")}

-

{t("monitoring.subtitle")}

+
+
+ +
+

{t("monitoring.title")}

+

{t("monitoring.subtitle")}

+
+ + {/* Filters */} + {allServiceNames.length > 0 && ( +
+ {/* Project filter */} + {allProjects.length > 1 && ( + { setProjectFilterOpen((v) => !v); setServiceFilterOpen(false); }} + dropdownRef={projectRef} + > + +
+ {allProjects.map((project) => { + const isSelected = selectedProjects.has(project); + return ( + + ); + })} + + )} + + {/* Service filter */} + { setServiceFilterOpen((v) => !v); setProjectFilterOpen(false); }} + dropdownRef={serviceRef} + > + +
+ {projectFilteredServices.map((svc) => { + const shortName = svc.split("/").pop() || svc; + const isSelected = selectedServices.has(svc); + return ( + + ); + })} + +
+ )} +
+ + {/* Resource Usage History */} +
+
+
+ + {t("monitoring.statsHistory")} +
+
+ {(["1h", "6h", "24h", "7d"] as StatsRange[]).map((r) => ( + + ))} +
+
+ {!hasActiveFilter ? ( +
+ +

{t("monitoring.selectFilter")}

+ +
+ ) : historyLoading ? ( +
+ {t("monitoring.loadingHistory")} +
+ ) : historyServiceNames.length === 0 ? ( +
+ {t("monitoring.noHistoryData")} +
+ ) : ( +
+ {historyServiceNames.map((svc) => { + const points = filteredHistory[svc] || []; + const shortName = svc.split("/").pop() || svc; + const cs = containerSettings[svc]; + const svcNotifs = discordEnabled && (cs?.notificationsEnabled !== false); + const cpuThreshold = svcNotifs ? (cs?.cpuThreshold ?? globalThresholds.cpu) : undefined; + const memThreshold = svcNotifs ? (cs?.memThreshold ?? globalThresholds.mem) : undefined; + const latest = points.length > 0 ? points[points.length - 1] : null; + const isExpanded = expandedService === svc; + const chartHeight = isExpanded ? 120 : 56; + const svcData = services.find((s) => s.uid === svc); + const cpuLimit = svcData && svcData.cpu_quota > 0 ? `${(svcData.cpu_quota / 1000).toFixed(0)}%` : undefined; + const memLimit = svcData && svcData.memory_limit > 0 ? `${(svcData.memory_limit / 1024 / 1024).toFixed(0)} MB` : undefined; + return ( +
+
+ +
+ {shortName} + {svc.includes("/") && ( + {svc.split("/")[0]} + )} +
+
+ {discordEnabled && ( + + )} + +
+ {/* Inline config panel */} + {configService === svc && (() => { + const settings = cs || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }; + const cpuTh = settings.cpuThreshold ?? globalThresholds.cpu; + const memTh = settings.memThreshold ?? globalThresholds.mem; + const cpuVal = latest?.cpu ?? 0; + const memVal = latest?.mem_percent ?? 0; + const memMb = latest?.mem_mb ?? 0; + return ( +
+ {/* Notifications toggle */} +
+ {t("detail.notifications")} + +
+ {settings.notificationsEnabled && ( + <> + { + setContainerSettings((prev) => { + const cur = prev[svc] || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }; + const updated = { ...cur, cpuThreshold: v }; + debouncedSave(svc, updated); + return { ...prev, [svc]: updated }; + }); + }} + onReset={() => saveContainerSetting(svc, { ...(cs || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }), cpuThreshold: null })} + formatValue={(v) => `${v.toFixed(1)}%`} + baseColor="cyan" + /> + { + setContainerSettings((prev) => { + const cur = prev[svc] || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }; + const updated = { ...cur, memThreshold: v }; + debouncedSave(svc, updated); + return { ...prev, [svc]: updated }; + }); + }} + onReset={() => saveContainerSetting(svc, { ...(cs || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }), memThreshold: null })} + formatValue={() => `${memMb.toFixed(0)} MB (${memVal.toFixed(1)}%)`} + formatThreshold={svcData && svcData.memory_limit > 0 ? (th) => `${((th / 100) * svcData.memory_limit / 1024 / 1024).toFixed(0)} MB` : undefined} + baseColor="purple" + /> + + )} +
+ ); + })()} +
+ p.cpu)} + timestamps={points.map((p) => p.timestamp)} + hoverValues={points.map((p) => p.cpu)} + color="#06b6d4" + threshold={cpuThreshold} + sparklineHeight={chartHeight} + formatHoverValue={(v) => `${v.toFixed(2)}%`} + showAverage + formatAverage={(v) => `${v.toFixed(2)}%`} + avgLabel={t("detail.avg")} + /> + p.mem_percent)} + timestamps={points.map((p) => p.timestamp)} + hoverValues={points.map((p) => p.mem_mb)} + color="#a78bfa" + threshold={memThreshold} + sparklineHeight={chartHeight} + formatHoverValue={(v) => `${v.toFixed(0)} MB`} + showAverage + formatAverage={(v) => `${v.toFixed(0)} MB`} + avgLabel={t("detail.avg")} + /> +
+
+ ); + })} +
+ )}
{/* Events list */}
- {sorted.length === 0 ? ( + {filteredEvents.length === 0 ? (

{t("monitoring.noEvents")}

) : (
- {sorted.map((ev, i) => ( + {filteredEvents.map((ev, i) => (
{eventIcon(ev.action)}
+
- {ev.service} +
+ + {ev.service.split("/").pop() || ev.service} + + {ev.service.includes("/") && ( + + {ev.service.split("/")[0]} + + )} +
{ev.action}
{timeAgo(ev.time)} diff --git a/src/client/pages/SettingsPage.tsx b/src/client/pages/SettingsPage.tsx index 9fbb451..4ad0a8b 100644 --- a/src/client/pages/SettingsPage.tsx +++ b/src/client/pages/SettingsPage.tsx @@ -125,7 +125,7 @@ export function SettingsPage({ projects, servicesCount, token }: SettingsPagePro return (
-
+
{/* Header */}
diff --git a/src/client/panels/DetailPanel.tsx b/src/client/panels/DetailPanel.tsx index 9b47c06..54f3669 100644 --- a/src/client/panels/DetailPanel.tsx +++ b/src/client/panels/DetailPanel.tsx @@ -1,7 +1,10 @@ import { useEffect, useRef, useState, useCallback, useMemo, startTransition } from "react"; -import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info as InfoIcon, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink, Pencil, HelpCircle } from "lucide-react"; -import type { Service, Stats, LogLine, WSMessage, Connection, DockerEvent, ContainerSettings } from "../../shared/types"; +import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info as InfoIcon, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink, Pencil, HelpCircle, Save } from "lucide-react"; +import type { Service, Stats, LogLine, WSMessage, Connection, DockerEvent, ContainerSettings, DiscordConfig, StatsRange } from "../../shared/types"; import { useT } from "../i18n"; +import { useStatsHistory } from "../hooks/useStatsHistory"; +import { StatsCard } from "../components/StatsCard"; +import { ThresholdBar } from "../components/ThresholdBar"; type Tab = "info" | "config" | "env" | "stats"; @@ -87,6 +90,10 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, const [csLoaded, setCsLoaded] = useState(false); const [csSaving, setCsSaving] = useState(false); const [csSaved, setCsSaved] = useState(false); + const [globalThresholds, setGlobalThresholds] = useState<{ cpu: number; mem: number }>({ cpu: 80, mem: 90 }); + const [discordEnabled, setDiscordEnabled] = useState(false); + const [statsRange, setStatsRange] = useState("1h"); + const { data: historyData, loading: historyLoading } = useStatsHistory(service.uid, statsRange, token); useEffect(() => { const headers: Record = {}; @@ -98,24 +105,40 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, setCsLoaded(true); }) .catch(() => setCsLoaded(true)); + fetch("/api/discord-config", { headers }) + .then((r) => r.ok ? r.json() : null) + .then((cfg: DiscordConfig | null) => { + if (cfg) { + setGlobalThresholds({ cpu: cfg.thresholds.cpuPercent, mem: cfg.thresholds.memPercent }); + setDiscordEnabled(cfg.enabled && !!cfg.webhookUrl); + } + }) + .catch(() => {}); }, [service.uid, token]); - const saveContainerSettings = useCallback(async () => { - setCsSaving(true); - setCsSaved(false); - try { - const headers: Record = { "Content-Type": "application/json" }; - if (token) headers["Authorization"] = `Bearer ${token}`; - await fetch("/api/container-settings", { - method: "PUT", - headers, - body: JSON.stringify({ uid: service.uid, settings: containerSettings }), - }); - setCsSaved(true); - setTimeout(() => setCsSaved(false), 2000); - } catch {} - setCsSaving(false); - }, [service.uid, token, containerSettings]); + // Auto-save container settings on change (debounced) + const csLoadedRef = useRef(false); + useEffect(() => { + if (!csLoaded) return; + // Skip the first render after loading + if (!csLoadedRef.current) { csLoadedRef.current = true; return; } + const timer = setTimeout(async () => { + setCsSaving(true); + try { + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + await fetch("/api/container-settings", { + method: "PUT", + headers, + body: JSON.stringify({ uid: service.uid, settings: containerSettings }), + }); + setCsSaved(true); + setTimeout(() => setCsSaved(false), 1500); + } catch {} + setCsSaving(false); + }, 500); + return () => clearTimeout(timer); + }, [containerSettings, csLoaded, service.uid, token]); // Scroll modal to bottom when opened or when logs arrive useEffect(() => { @@ -881,68 +904,142 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose, {/* Notifications toggle */} {csLoaded && (
- {t("detail.notifications")} + {t("detail.notifications")}
)} {stats ? ( <> -
- 80 ? "text-red-400" : stats.cpu > 50 ? "text-yellow-400" : "text-emerald-400"} /> - 80 ? "text-red-400" : stats.mem_percent > 50 ? "text-yellow-400" : "text-emerald-400"} /> +
+ (discordEnabled && containerSettings.notificationsEnabled ? (containerSettings.cpuThreshold ?? globalThresholds.cpu) : 80) ? "text-amber-400" : "text-cyan-400"} + limit={service.cpu_quota > 0 ? `${(service.cpu_quota / 1000).toFixed(0)}%` : undefined} + threshold={discordEnabled && containerSettings.notificationsEnabled ? `${containerSettings.cpuThreshold ?? globalThresholds.cpu}%` : undefined} + thresholdLabel={t("detail.threshold")} + limitLabel={t("detail.limit")} + thresholdTooltip={t("detail.thresholdTooltip")} + limitTooltip={t("detail.limitTooltip")} + /> + (discordEnabled && containerSettings.notificationsEnabled ? (containerSettings.memThreshold ?? globalThresholds.mem) : 80) ? "text-amber-400" : "text-purple-400"} + limit={service.memory_limit > 0 ? `${(service.memory_limit / 1024 / 1024).toFixed(0)} MB` : undefined} + threshold={discordEnabled && containerSettings.notificationsEnabled ? `${containerSettings.memThreshold ?? globalThresholds.mem}%` : undefined} + thresholdLabel={t("detail.threshold")} + limitLabel={t("detail.limit")} + thresholdTooltip={t("detail.thresholdTooltip")} + limitTooltip={t("detail.limitTooltip")} + />
{/* CPU bar with draggable threshold */} setContainerSettings((s) => ({ ...s, cpuThreshold: v }))} onReset={() => setContainerSettings((s) => ({ ...s, cpuThreshold: null }))} formatValue={(v) => `${v.toFixed(1)}%`} + baseColor="cyan" /> - {/* Memory bar with draggable threshold */} + {/* Memory bar with draggable threshold — extra top margin for drag handle clearance */} +
setContainerSettings((s) => ({ ...s, memThreshold: v }))} onReset={() => setContainerSettings((s) => ({ ...s, memThreshold: null }))} formatValue={() => `${stats.mem_mb.toFixed(0)} MB (${stats.mem_percent.toFixed(1)}%)`} + formatThreshold={service.memory_limit > 0 ? (th) => `${((th / 100) * service.memory_limit / 1024 / 1024).toFixed(0)} MB` : undefined} + baseColor="purple" /> ) : (
{t("detail.noStats")}
)} - {/* Save button */} - {csLoaded && ( - - )} + {/* History sparklines */} +
+
+ {t("detail.cpuHistory")} +
+ {(["1h", "6h", "24h", "7d"] as StatsRange[]).map((r) => ( + + ))} +
+
+ {historyLoading ? ( +
{t("detail.loadingHistory")}
+ ) : ( +
+ 0 ? `${(service.cpu_quota / 1000).toFixed(0)}%` : undefined} + data={historyData.map((p) => p.cpu)} + timestamps={historyData.map((p) => p.timestamp)} + hoverValues={historyData.map((p) => p.cpu)} + color="#06b6d4" + threshold={discordEnabled && containerSettings.notificationsEnabled ? (containerSettings.cpuThreshold ?? globalThresholds.cpu) : undefined} + sparklineHeight={60} + formatHoverValue={(v) => `${v.toFixed(2)}%`} + showAverage + formatAverage={(v) => `${v.toFixed(2)}%`} + avgLabel={t("detail.avg")} + /> + 0 ? `${(service.memory_limit / 1024 / 1024).toFixed(0)} MB` : undefined} + data={historyData.map((p) => p.mem_percent)} + timestamps={historyData.map((p) => p.timestamp)} + hoverValues={historyData.map((p) => p.mem_mb)} + color="#a78bfa" + threshold={discordEnabled && containerSettings.notificationsEnabled ? (containerSettings.memThreshold ?? globalThresholds.mem) : undefined} + sparklineHeight={60} + formatHoverValue={(v) => `${v.toFixed(0)} MB`} + showAverage + formatAverage={(v) => `${v.toFixed(0)} MB`} + avgLabel={t("detail.avg")} + /> +
+ )} +
+
)} @@ -1185,124 +1282,58 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono? ); } -function ThresholdBar({ label, value, threshold, isCustom, showThreshold, thresholdLabel, tagLabel, hintLabel, onThresholdChange, onReset, formatValue }: { - label: string; - value: number; - threshold: number; - isCustom: boolean; - showThreshold: boolean; - thresholdLabel: string; - tagLabel: string; - hintLabel: string; - onThresholdChange: (v: number) => void; - onReset: () => void; - formatValue: (v: number) => string; -}) { - const barRef = useRef(null); - const [dragging, setDragging] = useState(false); - const [hovering, setHovering] = useState(false); - - const calcPercent = useCallback((clientX: number) => { - if (!barRef.current) return threshold; - const rect = barRef.current.getBoundingClientRect(); - const pct = Math.round(((clientX - rect.left) / rect.width) * 100); - return Math.max(5, Math.min(100, pct)); - }, [threshold]); - - useEffect(() => { - if (!dragging) return; - const onMove = (e: MouseEvent) => { onThresholdChange(calcPercent(e.clientX)); }; - const onUp = () => { setDragging(false); }; - window.addEventListener("mousemove", onMove); - window.addEventListener("mouseup", onUp); - return () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); }; - }, [dragging, calcPercent, onThresholdChange]); - - // Touch support - useEffect(() => { - if (!dragging) return; - const onMove = (e: TouchEvent) => { if (e.touches[0]) onThresholdChange(calcPercent(e.touches[0].clientX)); }; - const onEnd = () => { setDragging(false); }; - window.addEventListener("touchmove", onMove); - window.addEventListener("touchend", onEnd); - return () => { window.removeEventListener("touchmove", onMove); window.removeEventListener("touchend", onEnd); }; - }, [dragging, calcPercent, onThresholdChange]); - - const barColor = showThreshold - ? (value > threshold ? "bg-red-500" : value > 50 ? "bg-yellow-500" : "bg-emerald-500") - : (value > 80 ? "bg-red-500" : value > 50 ? "bg-yellow-500" : "bg-emerald-500"); - const showTooltip = dragging || hovering; +function Tooltip({ text }: { text: string }) { + const [show, setShow] = useState(false); return ( -
-
- {label} - {formatValue(value)} -
-
{ if (showThreshold && !dragging) onThresholdChange(calcPercent(e.clientX)); }} + + + {show && ( +
+ {text} +
+
+ )} + + ); +} + +function StatCard({ label, value, extra, color, limit, threshold, thresholdLabel, limitLabel, thresholdTooltip, limitTooltip }: { label: string; value: string; extra?: string; color: string; limit?: string; threshold?: string; thresholdLabel?: string; limitLabel?: string; thresholdTooltip?: string; limitTooltip?: string }) { + return ( +
+ {label} +
+
+ {value} + {extra && {extra}} +
+ {(threshold || limit) && ( +
+ {threshold && ( +
+ {thresholdLabel}: + {threshold} + {thresholdTooltip && } +
+ )} + {limit && ( +
+ {limitLabel}: + {limit} + {limitTooltip && }
)}
)}
- {/* Label row — only when notifications enabled */} - {showThreshold && ( -
- {hintLabel} -
- {threshold}% - {isCustom && ( - - )} - {tagLabel} -
-
- )} -
- ); -} - -function StatCard({ label, value, extra, color }: { label: string; value: string; extra?: string; color: string }) { - return ( -
- {label} - {value} - {extra && {extra}}
); } diff --git a/src/server/discord.ts b/src/server/discord.ts index eb63a86..cb7652f 100644 --- a/src/server/discord.ts +++ b/src/server/discord.ts @@ -73,9 +73,13 @@ export function checkDownServices(config: DiscordConfig): void { if (!config.enabled || !config.events.containerStateChanges) return; const now = Date.now(); for (const [service, downSince] of downServices) { + // Skip services that are still in debounce window (might be restarting) + if (pendingDown.has(service)) continue; + const downMinutes = Math.floor((now - downSince) / 60_000); + // Don't send "Still Down" for less than 1 minute + if (downMinutes < 1) continue; const cooldownKey = `down:${service}`; if (!isOnCooldown(cooldownKey, config.downReminderMinutes)) { - const downMinutes = Math.floor((now - downSince) / 60_000); setCooldown(cooldownKey); queueWebhook(config.webhookUrl, { username: "ContainerFlow", @@ -146,45 +150,101 @@ function sendEmbed(config: DiscordConfig, embed: any, cooldownKey?: string): voi // ── Notification functions ── -const STATE_COLORS: Record = { - start: 0x22c55e, // green - stop: 0xef4444, // red - die: 0xef4444, - restart: 0xf59e0b, // orange - health_status: 0xf59e0b, - create: 0x3b82f6, // blue - destroy: 0xef4444, -}; +const STATE_DEBOUNCE_MS = 15_000; // Wait 15s before notifying stop/die to detect restarts -const STATE_TITLES: Record = { - start: "Container Started", - stop: "Container Stopped", - die: "Container Crashed", - restart: "Container Restarted", - health_status: "Health Status Changed", - create: "Container Created", - destroy: "Container Destroyed", -}; +// Pending stop/die events waiting to be flushed or cancelled +const pendingDown = new Map; config: DiscordConfig }>(); + +function flushPendingDown(service: string): void { + const pending = pendingDown.get(service); + if (!pending) return; + pendingDown.delete(service); + clearTimeout(pending.timer); + + const { action, config } = pending; + + // Now we know it's a real stop/crash (no start followed within the debounce window) + downServices.set(service, Date.now()); + // Set cooldown for down reminders so the first "Still Down" doesn't fire immediately + setCooldown(`down:${service}`); + + const cooldownKey = `state:${action}:${service}`; + const title = action === "die" ? "Container Crashed" : "Container Stopped"; + sendEmbed(config, { + title, + color: 0xef4444, + description: `**${service}**\n\nAction: \`${action}\``, + footer: { text: "ContainerFlow" }, + }, cooldownKey); +} + +function cancelPendingDown(service: string): void { + const pending = pendingDown.get(service); + if (pending) { + clearTimeout(pending.timer); + pendingDown.delete(service); + } +} export function notifyStateChange(service: string, action: string, config: DiscordConfig): void { if (!config.events.containerStateChanges) return; - // Track down services for re-alerting + // Ignore create/destroy — they're internal Docker lifecycle noise + if (action === "create" || action === "destroy") return; + if (action === "die" || action === "stop") { - downServices.set(service, Date.now()); - } else if (action === "start") { - // Service recovered — stop tracking and clear die/stop cooldowns + // Don't notify immediately — buffer to detect restart sequences + // If there's already a pending event for this service, keep the first one + if (pendingDown.has(service)) return; + const timer = setTimeout(() => flushPendingDown(service), STATE_DEBOUNCE_MS); + pendingDown.set(service, { action, timer, config }); + return; + } + + if (action === "start") { + const wasPending = pendingDown.has(service); + cancelPendingDown(service); + + // Service recovered — stop tracking and clear cooldowns downServices.delete(service); clearCooldown(`state:die:${service}`); clearCooldown(`state:stop:${service}`); + + if (wasPending) { + // stop/die → start within debounce window = restart/redeploy, send single message + const cooldownKey = `state:restart:${service}`; + sendEmbed(config, { + title: "Container Restarted", + color: 0xf59e0b, + description: `**${service}**\n\nAction: \`redeployed\``, + footer: { text: "ContainerFlow" }, + }, cooldownKey); + } else { + // Fresh start (no preceding stop/die) + const cooldownKey = `state:start:${service}`; + sendEmbed(config, { + title: "Container Started", + color: 0x22c55e, + description: `**${service}**\n\nAction: \`start\``, + footer: { text: "ContainerFlow" }, + }, cooldownKey); + } + return; } - // Cooldown per action per service + // Other events (restart, health_status) + const titles: Record = { + restart: "Container Restarted", + health_status: "Health Status Changed", + }; + const colors: Record = { + restart: 0xf59e0b, + health_status: 0xf59e0b, + }; const cooldownKey = `state:${action}:${service}`; - const title = STATE_TITLES[action] || `Container ${action}`; sendEmbed(config, { - title, - color: STATE_COLORS[action] || 0x94a3b8, + title: titles[action] || `Container ${action}`, + color: colors[action] || 0x94a3b8, description: `**${service}**\n\nAction: \`${action}\``, footer: { text: "ContainerFlow" }, }, cooldownKey); diff --git a/src/server/index.ts b/src/server/index.ts index ade5ac5..33a308a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -8,7 +8,8 @@ import { docker, discoverServices, discoverConnections, getContainerLogs, stream import { pollStats, watchDockerEvents } from "./watcher"; import { loadDiscordConfig, saveDiscordConfig, notifyStateChange, notifyResourceAlert, notifyUIAction, notifyActionError, testWebhook, checkDownServices } from "./discord"; import { loadContainerSettings, saveContainerSettings } from "./container-settings"; -import type { Service, WSMessage, DiscordConfig, ContainerSettings } from "../shared/types"; +import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db"; +import type { Service, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types"; /** Directory for persistent data files (positions, env overrides) */ const DATA_DIR = process.env.DATA_DIR || process.cwd(); @@ -471,6 +472,22 @@ app.put("/api/container-settings", async (c) => { } }); +// ── Stats history ── +const VALID_RANGES = new Set(["1h", "6h", "24h", "7d"]); + +app.get("/api/stats/history/:uid{.+}", (c) => { + const uid = c.req.param("uid"); + const range = (c.req.query("range") || "1h") as StatsRange; + if (!VALID_RANGES.has(range)) return c.json({ error: "Invalid range" }, 400); + return c.json(getStatsHistory(uid, range)); +}); + +app.get("/api/stats/history", (c) => { + const range = (c.req.query("range") || "1h") as StatsRange; + if (!VALID_RANGES.has(range)) return c.json({ error: "Invalid range" }, 400); + return c.json(getAllServicesStatsHistory(range)); +}); + // ── Cache headers for static assets ── app.use("/*", async (c, next) => { await next(); @@ -560,6 +577,7 @@ async function refreshStats(services: Service[]) { try { const stats = await pollStats(services); broadcast({ type: "stats", data: stats }); + try { insertStats(stats); } catch {} // Check resource thresholds and down services for Discord alerts try { const discordConfig = loadDiscordConfig(); @@ -627,6 +645,9 @@ let lastConnectionsHash = ""; setInterval(refreshServices, POLL_INTERVAL_MS); +// ── Init stats DB ── +initStatsDB(); + // ── Start ── const server = Bun.serve({ hostname: HOST, diff --git a/src/server/stats-db.ts b/src/server/stats-db.ts new file mode 100644 index 0000000..7bcb872 --- /dev/null +++ b/src/server/stats-db.ts @@ -0,0 +1,126 @@ +import { Database } from "bun:sqlite"; +import path from "path"; +import type { Stats, StatsHistoryPoint, StatsRange } from "../shared/types"; + +const DATA_DIR = process.env.DATA_DIR || process.cwd(); +const DB_PATH = path.join(DATA_DIR, ".dockerflow-stats.db"); + +let db: Database; + +const RANGE_BUCKET: Record = { + "1h": 30, + "6h": 60, + "24h": 300, + "7d": 1800, +}; + +const RANGE_SECONDS: Record = { + "1h": 3600, + "6h": 21600, + "24h": 86400, + "7d": 604800, +}; + +export function initStatsDB() { + db = new Database(DB_PATH); + db.exec("PRAGMA journal_mode = WAL"); + db.exec("PRAGMA synchronous = NORMAL"); + db.exec(` + CREATE TABLE IF NOT EXISTS stats_raw ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service TEXT NOT NULL, + timestamp INTEGER NOT NULL, + cpu REAL NOT NULL, + mem_mb REAL NOT NULL, + mem_percent REAL NOT NULL + ) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_service_time ON stats_raw (service, timestamp) + `); + + // Initial cleanup + cleanupOldStats(); + + // Schedule cleanup every hour + setInterval(cleanupOldStats, 3600_000); +} + +export function insertStats(stats: Stats[]) { + if (!db || stats.length === 0) return; + const now = Math.floor(Date.now() / 1000); + const stmt = db.prepare( + "INSERT INTO stats_raw (service, timestamp, cpu, mem_mb, mem_percent) VALUES (?, ?, ?, ?, ?)" + ); + const transaction = db.transaction(() => { + for (const s of stats) { + stmt.run(s.service, now, s.cpu, s.mem_mb, s.mem_percent); + } + }); + transaction(); +} + +export function getStatsHistory(service: string, range: StatsRange): StatsHistoryPoint[] { + if (!db) return []; + const bucket = RANGE_BUCKET[range]; + const since = Math.floor(Date.now() / 1000) - RANGE_SECONDS[range]; + + const rows = db.prepare(` + SELECT + (timestamp / ?) * ? AS ts, + AVG(cpu) AS cpu, + AVG(mem_mb) AS mem_mb, + AVG(mem_percent) AS mem_percent + FROM stats_raw + WHERE service = ? AND timestamp >= ? + GROUP BY ts + ORDER BY ts ASC + `).all(bucket, bucket, service, since) as { ts: number; cpu: number; mem_mb: number; mem_percent: number }[]; + + return rows.map((r) => ({ + timestamp: r.ts, + cpu: r.cpu, + mem_mb: r.mem_mb, + mem_percent: r.mem_percent, + })); +} + +export function getAllServicesStatsHistory(range: StatsRange): Record { + if (!db) return {}; + const bucket = RANGE_BUCKET[range]; + const since = Math.floor(Date.now() / 1000) - RANGE_SECONDS[range]; + + const rows = db.prepare(` + SELECT + service, + (timestamp / ?) * ? AS ts, + AVG(cpu) AS cpu, + AVG(mem_mb) AS mem_mb, + AVG(mem_percent) AS mem_percent + FROM stats_raw + WHERE timestamp >= ? + GROUP BY service, ts + ORDER BY service, ts ASC + `).all(bucket, bucket, since) as { service: string; ts: number; cpu: number; mem_mb: number; mem_percent: number }[]; + + const result: Record = {}; + for (const r of rows) { + if (!result[r.service]) result[r.service] = []; + result[r.service].push({ + timestamp: r.ts, + cpu: r.cpu, + mem_mb: r.mem_mb, + mem_percent: r.mem_percent, + }); + } + return result; +} + +export function cleanupOldStats() { + if (!db) return; + const cutoff = Math.floor(Date.now() / 1000) - RANGE_SECONDS["7d"]; + db.prepare("DELETE FROM stats_raw WHERE timestamp < ?").run(cutoff); + try { + db.exec("VACUUM"); + } catch {} +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 74a8f25..912ef7e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -76,6 +76,15 @@ export interface ContainerSettings { memThreshold: number | null; } +export interface StatsHistoryPoint { + timestamp: number; + cpu: number; + mem_mb: number; + mem_percent: number; +} + +export type StatsRange = "1h" | "6h" | "24h" | "7d"; + export type WSMessage = | { type: "services"; data: Service[] } | { type: "connections"; data: Connection[] }