This commit is contained in:
RGJorge
2026-05-10 23:39:12 +00:00
parent 713504dc65
commit 3962e6c319
19 changed files with 1382 additions and 275 deletions
+2 -2
View File
@@ -84,7 +84,7 @@ function Dashboard({ token }: { token: string }) {
const onPositions = useCallback((pos: Record<string, { x: number; y: number }>) => {
savedPositions.current = pos;
}, []);
const { services, connections, stats, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince, actionErrors, dismissActionError, clearActionErrors, pushActionError } = useDocker(token, statsStore, onPositions);
const { services, connections, stats, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince, actionErrors, dismissActionError, clearActionErrors, pushActionError, eventLogStream, notificationStream } = useDocker(token, statsStore, onPositions);
const { config: serverConfig, canInteract } = useServerConfig(token);
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
@@ -522,7 +522,7 @@ function Dashboard({ token }: { token: string }) {
<ActionErrorToast errors={actionErrors} onDismiss={dismissActionError} onClearAll={clearActionErrors} />
{activePage === "monitoring" && <MonitoringPage events={events} token={token} services={services} />}
{activePage === "monitoring" && <MonitoringPage events={events} token={token} services={services} eventLogStream={eventLogStream} notificationStream={notificationStream} />}
{activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} token={token} />}
{/* Canvas — inset (only visible on dashboard) */}
+1 -1
View File
@@ -4,7 +4,7 @@ import {
LayoutDashboard, Activity, Settings, Bell,
Play, Square, RotateCcw,
} from "lucide-react";
import type { Service, DockerEvent } from "../../shared/types";
import type { Service, DockerEvent, NotificationLogEntry } from "../../shared/types";
import { useT } from "../i18n";
export type Page = "dashboard" | "monitoring" | "settings";
+33 -2
View File
@@ -68,6 +68,28 @@ export function Sparkline({
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
// Clip canvas drawing to a rounded rectangle matching the `rounded-lg`
// (8px radius) of the parent wrapper. This is more robust than relying
// on CSS overflow:hidden alone — guarantees no fill/stroke leaks past
// the rounded shape due to subpixel/anti-aliasing artifacts.
const RADIUS = 8;
ctx.beginPath();
if (typeof (ctx as any).roundRect === "function") {
(ctx as any).roundRect(0, 0, w, h, RADIUS);
} else {
// Fallback for older browsers
ctx.moveTo(RADIUS, 0);
ctx.lineTo(w - RADIUS, 0);
ctx.quadraticCurveTo(w, 0, w, RADIUS);
ctx.lineTo(w, h - RADIUS);
ctx.quadraticCurveTo(w, h, w - RADIUS, h);
ctx.lineTo(RADIUS, h);
ctx.quadraticCurveTo(0, h, 0, h - RADIUS);
ctx.lineTo(0, RADIUS);
ctx.quadraticCurveTo(0, 0, RADIUS, 0);
}
ctx.clip();
if (data.length === 0) {
ctx.fillStyle = "#64748b";
ctx.font = "11px sans-serif";
@@ -79,7 +101,16 @@ export function Sparkline({
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);
// Auto-scale Y to data range with 30% headroom for visual breathing room.
// Floor at 0.1 (not 1) so values like 0.1% don't get pancaked against the bottom.
// Threshold is included in the scale ONLY when data is reasonably close to it
// (≥30% of threshold); otherwise low values would get pancaked at the bottom.
const dataMax = Math.max(...data);
let max = Math.max(dataMax * 1.3, 0.1);
if (threshold !== undefined && threshold > 0 && dataMax >= threshold * 0.3) {
max = Math.max(max, threshold * 1.1);
}
const range = max || 1;
const xStep = data.length > 1 ? plotW / (data.length - 1) : plotW;
@@ -302,7 +333,7 @@ export function Sparkline({
ref={canvasRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className="cursor-crosshair"
className="cursor-crosshair block"
/>
</div>
{/* Tooltip — below the chart */}
+2 -2
View File
@@ -42,7 +42,7 @@ export function StatsCard({
<div className="flex items-center gap-3 bg-slate-800/60 border border-slate-700/40 rounded-lg px-3 py-2.5 overflow-visible">
{/* Left: label + value + limit */}
<div className="shrink-0 min-w-[52px]">
<span className="text-[10px] uppercase tracking-wider text-slate-500 block leading-tight">{label}</span>
<span className="text-[10px] uppercase tracking-wider text-slate-500 block leading-tight whitespace-pre-line">{label}</span>
<span className="text-sm font-mono font-semibold block leading-tight mt-0.5" style={{ color }}>
{value}
</span>
@@ -53,7 +53,7 @@ export function StatsCard({
)}
</div>
{/* Center: sparkline */}
<div className="flex-1 min-w-0 bg-slate-900/60 rounded-lg pt-1 overflow-visible">
<div className="flex-1 min-w-0 bg-slate-900/60 rounded-lg overflow-visible">
<Sparkline
data={data}
timestamps={timestamps}
+9 -5
View File
@@ -65,11 +65,15 @@ export function ThresholdBar({ label, value, threshold, isCustom, showThreshold,
className={`relative ${showThreshold ? "h-3" : "h-2"} bg-slate-800 rounded-full group ${showThreshold ? "cursor-pointer" : ""}`}
onClick={(e) => { if (showThreshold && !dragging) onThresholdChange(calcPercent(e.clientX)); }}
>
{/* Usage fill */}
<div
className={`absolute inset-y-0 left-0 rounded-full transition-all duration-500 ${barColor}`}
style={{ width: `${Math.min(value, 100)}%` }}
/>
{/* Clip wrapper ensures the fill always respects the track's rounded shape,
even at very low values (otherwise rounded-full on the inner fill creates
a tiny pill that looks detached from the track edge). */}
<div className="absolute inset-0 rounded-full overflow-hidden">
<div
className={`absolute inset-y-0 left-0 transition-all duration-500 ${barColor}`}
style={{ width: `${Math.min(value, 100)}%` }}
/>
</div>
{/* Threshold handle — only when notifications enabled */}
{showThreshold && (
<div
+122 -18
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useState, useRef, useLayoutEffect } from "react";
import { createPortal } from "react-dom";
import { HelpCircle } from "lucide-react";
interface TooltipProps {
@@ -8,36 +9,139 @@ interface TooltipProps {
/** Icon size. Default: 13 */
size?: number;
/** Where the popover opens relative to the icon. Default: "top" */
placement?: "top" | "bottom";
placement?: "top" | "bottom" | "right";
/** Keep text on a single line (no wrapping). Width grows to fit content. */
nowrap?: boolean;
}
export function Tooltip({ text, width = "w-56", size = 13, placement = "top" }: TooltipProps) {
const GAP = 8; // px between icon and popover
export function Tooltip({ text, width = "w-56", size = 13, placement = "top", nowrap = false }: TooltipProps) {
const [show, setShow] = useState(false);
const popoverPos =
placement === "top"
? "bottom-full mb-2"
: "top-full mt-2";
const arrowPos =
placement === "top"
? "top-full -mt-px border-t-slate-700"
: "bottom-full -mb-px border-b-slate-700";
const [pos, setPos] = useState<{ left: number; top: number; arrowLeft: number; arrowTop: number; flippedTo: "top" | "bottom" | "right" } | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!show || !btnRef.current) return;
const compute = () => {
const btn = btnRef.current;
const pop = popRef.current;
if (!btn || !pop) return;
const btnRect = btn.getBoundingClientRect();
const popRect = pop.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const btnCenterX = btnRect.left + btnRect.width / 2;
const btnCenterY = btnRect.top + btnRect.height / 2;
let left: number;
let top: number;
let arrowLeft = 0;
let arrowTop = 0;
let actual: "top" | "bottom" | "right" = placement;
if (placement === "right") {
// Popover to the right of icon, vertically centered
left = btnRect.right + GAP;
top = btnCenterY - popRect.height / 2;
// Flip to left/bottom if no room to the right
if (left + popRect.width + 8 > vw) {
// fallback to bottom
actual = "bottom";
left = Math.max(8, Math.min(btnCenterX - popRect.width / 2, vw - popRect.width - 8));
top = btnRect.bottom + GAP;
arrowLeft = btnCenterX - left;
} else {
top = Math.max(8, Math.min(top, vh - popRect.height - 8));
arrowTop = btnCenterY - top;
}
} else {
// Center horizontally on icon, clamp to viewport
left = btnCenterX - popRect.width / 2;
left = Math.max(8, Math.min(left, vw - popRect.width - 8));
arrowLeft = btnCenterX - left;
if (placement === "top") {
const candidateTop = btnRect.top - popRect.height - GAP;
if (candidateTop < 8 && btnRect.bottom + popRect.height + GAP < vh) {
actual = "bottom";
top = btnRect.bottom + GAP;
} else {
top = candidateTop;
}
} else {
// bottom
const candidateTop = btnRect.bottom + GAP;
if (candidateTop + popRect.height + 8 > vh && btnRect.top - popRect.height - GAP > 0) {
actual = "top";
top = btnRect.top - popRect.height - GAP;
} else {
top = candidateTop;
}
}
}
setPos({ left, top, arrowLeft, arrowTop, flippedTo: actual });
};
compute();
window.addEventListener("scroll", compute, true);
window.addEventListener("resize", compute);
return () => {
window.removeEventListener("scroll", compute, true);
window.removeEventListener("resize", compute);
};
}, [show, placement]);
return (
<span className="relative inline-flex">
<>
<button
ref={btnRef}
type="button"
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
onClick={(e) => { e.stopPropagation(); setShow((v) => !v); }}
className="text-slate-500 hover:text-slate-300 transition-colors"
className="text-slate-500 hover:text-slate-300 transition-colors inline-flex items-center"
>
<HelpCircle size={size} />
</button>
{show && (
<div className={`absolute ${popoverPos} left-1/2 -translate-x-1/2 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-xs text-slate-200 ${width} text-left shadow-xl z-50 leading-relaxed whitespace-normal`}>
{show && createPortal(
<div
ref={popRef}
style={{
position: "fixed",
left: pos?.left ?? -9999,
top: pos?.top ?? -9999,
visibility: pos ? "visible" : "hidden",
zIndex: 99999,
}}
className={`px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-xs text-slate-200 ${nowrap ? "whitespace-nowrap" : `${width} whitespace-pre-line`} text-left shadow-xl leading-relaxed`}
>
{text}
<div className={`absolute ${arrowPos} left-1/2 -translate-x-1/2 border-4 border-transparent`} />
</div>
{pos && pos.flippedTo === "right" && (
<div
className="absolute right-full border-4 border-transparent border-r-slate-700"
style={{ top: pos.arrowTop - 4 }}
/>
)}
{pos && pos.flippedTo === "top" && (
<div
className="absolute top-full border-4 border-transparent border-t-slate-700"
style={{ left: pos.arrowLeft - 4 }}
/>
)}
{pos && pos.flippedTo === "bottom" && (
<div
className="absolute bottom-full border-4 border-transparent border-b-slate-700"
style={{ left: pos.arrowLeft - 4 }}
/>
)}
</div>,
document.body
)}
</span>
</>
);
}
+15 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage, ActionError } from "../../shared/types";
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage, ActionError, EventLogEntry, NotificationLogEntry } from "../../shared/types";
import type { StatsStore } from "./useStatsStore";
import { arraysEqual, applyProcessing as applyProcessingPure } from "./processing";
@@ -10,6 +10,8 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
const [events, setEvents] = useState<DockerEvent[]>([]);
const [logLines, setLogLines] = useState<LogLine[]>([]);
const [actionErrors, setActionErrors] = useState<ActionError[]>([]);
const [eventLogStream, setEventLogStream] = useState<EventLogEntry[]>([]);
const [notificationStream, setNotificationStream] = useState<NotificationLogEntry[]>([]);
// Processing state: uid → { expected state, start time, min duration before clearing }
const processingRef = useRef<Map<string, { expected: Service["state"]; startedAt: number; minDuration: number }>>(new Map());
const processingIntervalsRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
@@ -35,6 +37,11 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
setServices((prev) => prev.length === 0 ? data.services : prev);
setConnections((prev) => prev.length === 0 ? data.connections : prev);
if (onPositions) onPositions(data.positions || {});
// Hydrate stats immediately so charts/cards don't wait for next WS poll
if (Array.isArray(data.stats) && data.stats.length > 0) {
for (const s of data.stats as Stats[]) statsRef.current.set(s.service, s);
if (statsStore) statsStore.update(statsRef.current);
}
})
.catch(() => {});
}, [token]);
@@ -131,6 +138,12 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
]);
break;
}
case "event_log":
setEventLogStream((prev) => [msg.data, ...prev].slice(0, 50));
break;
case "notification_log":
setNotificationStream((prev) => [msg.data, ...prev].slice(0, 50));
break;
}
} catch (err) {
console.error("Failed to parse WS message:", err);
@@ -241,5 +254,5 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
]);
}, []);
return { services, connections, stats: statsRef.current, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince, actionErrors, dismissActionError, clearActionErrors, pushActionError };
return { services, connections, stats: statsRef.current, events, connected, logLines, sendMessage, clearLogLines, setProcessing, clearProcessing, getLogsSince, actionErrors, dismissActionError, clearActionErrors, pushActionError, eventLogStream, notificationStream };
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useState, useCallback, useRef } from "react";
import type { EventLogEntry, NotificationLogEntry, WSMessage } from "../../shared/types";
export function useEventsLog(token: string, limit = 200) {
const [events, setEvents] = useState<EventLogEntry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`/api/events?limit=${limit}`, { headers })
.then((r) => (r.ok ? r.json() : []))
.then((d: EventLogEntry[]) => { setEvents(d); setLoading(false); })
.catch(() => { setEvents([]); setLoading(false); });
}, [token, limit]);
const prepend = useCallback((entry: EventLogEntry) => {
setEvents((prev) => [entry, ...prev].slice(0, 1000));
}, []);
return { events, loading, prepend };
}
export function useNotificationsLog(token: string, limit = 100) {
const [notifications, setNotifications] = useState<NotificationLogEntry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`/api/notifications?limit=${limit}`, { headers })
.then((r) => (r.ok ? r.json() : []))
.then((d: NotificationLogEntry[]) => { setNotifications(d); setLoading(false); })
.catch(() => { setNotifications([]); setLoading(false); });
}, [token, limit]);
const prepend = useCallback((entry: NotificationLogEntry) => {
setNotifications((prev) => [entry, ...prev].slice(0, 500));
}, []);
return { notifications, loading, prepend };
}
/** Hook into the existing WebSocket to receive event_log / notification_log push messages.
* Pass the ws ref from useDocker (or use a separate WS listener).
* Simplest: a tiny dedicated WebSocket that listens for these two message types. */
export function useEventsNotificationsLive(token: string, onEvent: (e: EventLogEntry) => void, onNotification: (n: NotificationLogEntry) => void) {
const wsRef = useRef<WebSocket | null>(null);
const eventCb = useRef(onEvent);
const notifCb = useRef(onNotification);
eventCb.current = onEvent;
notifCb.current = onNotification;
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
wsRef.current = ws;
ws.onopen = () => {
if (token) ws.send(JSON.stringify({ type: "auth", token }));
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data) as WSMessage;
if (msg.type === "event_log") eventCb.current(msg.data);
else if (msg.type === "notification_log") notifCb.current(msg.data);
} catch {}
};
return () => {
try { ws.close(); } catch {}
};
}, [token]);
}
+7 -2
View File
@@ -1,11 +1,16 @@
import { useState, useEffect } from "react";
import type { StatsHistoryPoint, StatsRange } from "../../shared/types";
export function useStatsHistory(uid: string, range: StatsRange, token: string) {
export function useStatsHistory(uid: string, range: StatsRange, token: string, enabled = true) {
const [data, setData] = useState<StatsHistoryPoint[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!enabled) {
setData([]);
setLoading(false);
return;
}
setLoading(true);
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -20,7 +25,7 @@ export function useStatsHistory(uid: string, range: StatsRange, token: string) {
setData([]);
setLoading(false);
});
}, [uid, range, token]);
}, [uid, range, token, enabled]);
return { data, loading };
}
+32 -2
View File
@@ -112,6 +112,12 @@ const en = {
"detail.unlimited": "Unlimited",
"detail.threshold": "Threshold",
"detail.thresholdTooltip": "Alert threshold — sends a Discord notification when exceeded",
"detail.memBreakdown": "Memory breakdown",
"detail.memAnon": "Anon (process memory)",
"detail.memCache": "Page cache (reclaimable)",
"detail.memTotal": "Total reserved (incl. cache)",
"detail.memLimit": "Limit",
"detail.memTooltipHint": "ContainerFlow shows real usage (anon). Cache is reclaimable by the kernel under pressure — same logic as `docker stats` CLI.",
"detail.limit": "Limit",
"detail.limitTooltip": "Maximum resource allocated to this container in Docker",
"detail.avg": "Avg",
@@ -183,12 +189,21 @@ const en = {
"logPanel.noLogs": "No logs available",
// Monitoring page
"monitoring.title": "Event History",
"monitoring.title": "Monitoring",
"monitoring.titleTooltip": "CPU/RAM history, events and notifications of your containers.",
"monitoring.subtitle": "Docker container events in real-time",
"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.totals": "Totals (all filtered containers)",
"monitoring.totalCpu": "CPU (T)",
"monitoring.totalMem": "MEM (T)",
"monitoring.clearFilters": "Clear filters",
"monitoring.tabHistory": "History",
"monitoring.tabEvents": "Events",
"monitoring.tabNotifications": "Notifications",
"monitoring.noNotifications": "No notifications yet. They will appear here when containers change state, hit resource thresholds, or you run UI actions (mirrors Discord webhooks).",
"monitoring.loadingHistory": "Loading historical data...",
"monitoring.noHistoryData": "No historical data available yet",
"monitoring.selectFilter": "Select a service or load all to view history",
@@ -353,6 +368,12 @@ const es: Record<TranslationKey, string> = {
"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.memBreakdown": "Desglose de memoria",
"detail.memAnon": "Anon (memoria de procesos)",
"detail.memCache": "Page cache (liberable)",
"detail.memTotal": "Total reservado (incl. cache)",
"detail.memLimit": "L\u00edmite",
"detail.memTooltipHint": "ContainerFlow muestra uso real (anon). El cache es liberable por el kernel bajo presi\u00f3n \u2014 misma l\u00f3gica que `docker stats` CLI.",
"detail.limit": "L\u00edmite",
"detail.limitTooltip": "Recurso m\u00e1ximo asignado a este contenedor en Docker",
"detail.avg": "Prom",
@@ -424,12 +445,21 @@ const es: Record<TranslationKey, string> = {
"logPanel.noLogs": "No hay logs disponibles",
// Monitoring page
"monitoring.title": "Historial de Eventos",
"monitoring.title": "Monitoreo",
"monitoring.titleTooltip": "Historial de consumo (CPU/RAM), eventos y notificaciones de tus containers.",
"monitoring.subtitle": "Eventos de contenedores Docker en tiempo real",
"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.totals": "Totales (todos los containers filtrados)",
"monitoring.totalCpu": "CPU (T)",
"monitoring.totalMem": "MEM (T)",
"monitoring.clearFilters": "Limpiar filtros",
"monitoring.tabHistory": "Historial",
"monitoring.tabEvents": "Eventos",
"monitoring.tabNotifications": "Notificaciones",
"monitoring.noNotifications": "Sin notificaciones todavía. Aparecerán aquí cuando los containers cambien de estado, superen umbrales de recursos o ejecutes acciones (espejo de Discord).",
"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",
+625 -202
View File
@@ -1,10 +1,11 @@
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 type { DockerEvent, StatsRange, Service, ContainerSettings, DiscordConfig, StatsHistoryPoint, EventLogEntry, NotificationLogEntry } from "../../shared/types";
import { useT } from "../i18n";
import { useAllStatsHistory } from "../hooks/useStatsHistory";
import { useAllStatsHistory, useStatsHistory } from "../hooks/useStatsHistory";
import { StatsCard } from "../components/StatsCard";
import { ThresholdBar } from "../components/ThresholdBar";
import { Tooltip } from "../components/Tooltip";
import { guessIcon } from "../nodes/ServiceNode";
function timeAgo(ts: number): string {
@@ -81,11 +82,14 @@ interface MonitoringPageProps {
events: DockerEvent[];
token: string;
services: Service[];
eventLogStream: EventLogEntry[];
notificationStream: NotificationLogEntry[];
}
export function MonitoringPage({ events, token, services }: MonitoringPageProps) {
export function MonitoringPage({ events, token, services, eventLogStream, notificationStream }: MonitoringPageProps) {
const { t } = useT();
const [statsRange, setStatsRange] = useState<StatsRange>("1h");
const [activeTab, setActiveTab] = useState<"history" | "events" | "notifications">("history");
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(new Set());
const [selectedServices, setSelectedServices] = useState<Set<string>>(new Set());
const [expandedService, setExpandedService] = useState<string | null>(null);
@@ -247,21 +251,34 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps)
: `${selectedServices.size} ${t("footer.containers")}`;
return (
<div className="flex-1 min-h-0 overflow-auto p-6">
<div className="flex-1 min-h-0 mx-2 mt-1 mb-1 rounded-xl overflow-auto ring-1 ring-slate-700/60 p-6">
<div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Activity size={24} className="text-cyan-400" />
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-white">{t("monitoring.title")}</h1>
<p className="text-sm text-slate-500">{t("monitoring.subtitle")}</p>
<Tooltip text={t("monitoring.titleTooltip")} size={14} placement="right" nowrap />
</div>
</div>
{/* Filters */}
{allServiceNames.length > 0 && (
<div className="flex items-center gap-2">
{/* Clear filters — only when something is active */}
{(selectedProjects.size > 0 || selectedServices.size > 0) && (
<button
onClick={() => {
setSelectedProjects(new Set());
setSelectedServices(new Set());
}}
className="text-slate-500 hover:text-slate-300 p-1.5 rounded-md hover:bg-slate-800/60 transition-colors"
title={t("monitoring.clearFilters")}
>
<RotateCcw size={14} />
</button>
)}
{/* Project filter */}
{allProjects.length > 1 && (
<FilterDropdown
@@ -271,7 +288,13 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps)
dropdownRef={projectRef}
>
<button
onClick={() => { setSelectedProjects(new Set(allProjects)); }}
onClick={() => {
if (selectedProjects.size === allProjects.length) {
setSelectedProjects(new Set());
} else {
setSelectedProjects(new Set(allProjects));
}
}}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
@@ -310,7 +333,13 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps)
dropdownRef={serviceRef}
>
<button
onClick={() => { setSelectedServices(new Set(projectFilteredServices)); }}
onClick={() => {
if (selectedServices.size === projectFilteredServices.length && projectFilteredServices.length > 0) {
setSelectedServices(new Set());
} else {
setSelectedServices(new Set(projectFilteredServices));
}
}}
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
@@ -345,7 +374,31 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps)
)}
</div>
{/* Tabs */}
<div className="flex items-center border-b border-slate-700/40 mb-4">
{([
{ key: "history" as const, label: t("monitoring.tabHistory") },
{ key: "events" as const, label: t("monitoring.tabEvents") },
{ key: "notifications" as const, label: t("monitoring.tabNotifications") },
]).map((tab) => {
const isActive = activeTab === tab.key;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 py-2.5 text-sm font-medium relative transition-colors ${
isActive ? "text-cyan-400" : "text-slate-500 hover:text-slate-300"
}`}
>
{tab.label}
<span className={`absolute bottom-0 left-0 right-0 h-px bg-cyan-400 transition-transform duration-300 ease-out origin-center ${isActive ? "scale-x-100" : "scale-x-0"}`} />
</button>
);
})}
</div>
{/* Resource Usage History */}
{activeTab === "history" && (
<div className="bg-slate-800/50 border border-slate-700/60 rounded-xl overflow-hidden mb-6">
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700/40">
<div className="flex items-center gap-2">
@@ -388,205 +441,575 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps)
{t("monitoring.noHistoryData")}
</div>
) : (
<div className="divide-y divide-slate-700/40">
{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 (
<div key={svc} className="px-5 py-3">
<div className="flex items-center gap-2 mb-2">
<ServiceIcon uid={svc} services={services} />
<div className="min-w-0">
<span className="text-xs text-slate-300 font-medium truncate block">{shortName}</span>
{svc.includes("/") && (
<span className="text-[10px] text-slate-500 truncate block leading-tight">{svc.split("/")[0]}</span>
)}
</div>
<div className="flex-1" />
{discordEnabled && (
<button
onClick={() => {
const opening = configService !== svc;
setConfigService(opening ? svc : null);
if (opening) setExpandedService(svc);
else setExpandedService(null);
}}
className={`p-1 rounded hover:bg-slate-700/60 transition-colors ${configService === svc ? "text-cyan-400" : "text-slate-500 hover:text-slate-300"}`}
title={t("detail.config")}
>
<Settings size={14} />
</button>
)}
<button
onClick={() => setExpandedService(isExpanded ? null : svc)}
className="p-1 rounded hover:bg-slate-700/60 text-slate-500 hover:text-slate-300 transition-colors"
title={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
</div>
{/* 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 (
<div className="mb-2 bg-slate-900/90 border border-slate-700/40 rounded-lg px-4 py-3 space-y-3">
{/* Notifications toggle */}
<div className="flex items-center justify-between">
<span className="text-xs text-slate-300">{t("detail.notifications")}</span>
<button
type="button"
onClick={() => saveContainerSetting(svc, { ...settings, notificationsEnabled: !settings.notificationsEnabled })}
className={`relative w-9 h-5 rounded-full transition-colors ${settings.notificationsEnabled ? "bg-cyan-600" : "bg-slate-600"}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${settings.notificationsEnabled ? "translate-x-4" : "translate-x-0"}`} />
</button>
</div>
{settings.notificationsEnabled && (
<>
<ThresholdBar
label={t("detail.cpuUsage")}
value={cpuVal}
threshold={cpuTh}
isCustom={settings.cpuThreshold !== null}
showThreshold={true}
thresholdLabel={t("detail.cpuThreshold")}
tagLabel={settings.cpuThreshold !== null ? t("detail.custom") : t("detail.global")}
hintLabel={t("detail.thresholdHint")}
onThresholdChange={(v) => {
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"
/>
<ThresholdBar
label={t("detail.memoryUsage")}
value={memVal}
threshold={memTh}
isCustom={settings.memThreshold !== null}
showThreshold={true}
thresholdLabel={t("detail.memThreshold")}
tagLabel={settings.memThreshold !== null ? t("detail.custom") : t("detail.global")}
hintLabel={t("detail.thresholdHint")}
onThresholdChange={(v) => {
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"
/>
</>
)}
</div>
);
})()}
<div className={isExpanded ? "space-y-2" : "grid grid-cols-2 gap-2"}>
<StatsCard
label="CPU"
value={latest ? `${latest.cpu.toFixed(1)}%` : "—"}
limit={cpuLimit}
data={points.map((p) => 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")}
/>
<StatsCard
label="MEM"
value={latest ? `${latest.mem_mb.toFixed(0)} MB` : "—"}
limit={memLimit}
data={points.map((p) => 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")}
/>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Events list */}
<div className="bg-slate-800/50 border border-slate-700/60 rounded-xl overflow-hidden">
{filteredEvents.length === 0 ? (
<div className="px-6 py-12 text-center text-slate-500">
<Activity size={32} className="mx-auto mb-3 opacity-40" />
<p>{t("monitoring.noEvents")}</p>
</div>
) : (
<div className="divide-y divide-slate-700/40">
{filteredEvents.map((ev, i) => (
<div key={`${ev.service}-${ev.time}-${i}`} className="flex items-center gap-4 px-5 py-3 hover:bg-slate-700/30 transition-colors">
<div className="w-8 h-8 rounded-lg bg-slate-700/60 flex items-center justify-center flex-shrink-0">
{eventIcon(ev.action)}
</div>
<ServiceIcon uid={ev.service} services={services} />
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-1.5">
<span className="text-sm text-slate-200 font-medium truncate">
{ev.service.split("/").pop() || ev.service}
</span>
{ev.service.includes("/") && (
<span className="text-[10px] text-slate-500 truncate">
{ev.service.split("/")[0]}
</span>
)}
</div>
<span className={`text-xs font-mono ${actionColor(ev.action)}`}>{ev.action}</span>
</div>
<span className="text-xs text-slate-500 font-mono flex-shrink-0">{timeAgo(ev.time)}</span>
</div>
<>
<MonitoringTotalsCard
historyByService={filteredHistory}
services={services}
filteredUids={finalFilteredServices}
title={
selectedServices.size > 0
? (selectedServices.size === 1
? ([...selectedServices][0].split("/").pop() || [...selectedServices][0])
: `${selectedServices.size} ${t("footer.containers")}`)
: selectedProjects.size === 1
? [...selectedProjects][0]
: selectedProjects.size === allProjects.length
? t("monitoring.allProjects")
: `${selectedProjects.size} ${t("filter.projects").toLowerCase()}`
}
/>
<div className="divide-y divide-slate-700/40">
{historyServiceNames.map((svc) => (
<MonitoringServiceCard
key={svc}
svc={svc}
services={services}
containerSettings={containerSettings}
setContainerSettings={setContainerSettings}
globalThresholds={globalThresholds}
discordEnabled={discordEnabled}
configService={configService}
setConfigService={setConfigService}
expandedService={expandedService}
setExpandedService={setExpandedService}
saveContainerSetting={saveContainerSetting}
debouncedSave={debouncedSave}
globalRange={statsRange}
fallbackData={filteredHistory[svc] || []}
token={token}
/>
))}
</div>
</div>
</>
)}
</div>
)}
{/* Alert Rules placeholder */}
<div className="mt-8 bg-slate-800/30 border border-dashed border-slate-700/60 rounded-xl p-6 text-center">
<AlertTriangle size={24} className="mx-auto mb-2 text-slate-600" />
<p className="text-sm text-slate-500 font-medium">{t("monitoring.alertRules")}</p>
<p className="text-xs text-slate-600 mt-1">{t("monitoring.alertRulesDesc")}</p>
</div>
{/* Events log (persistent, from SQLite) */}
{activeTab === "events" && (
<EventsLogTab
token={token}
services={services}
liveStream={eventLogStream}
filteredUids={finalFilteredServices}
hasActiveFilter={hasActiveFilter}
/>
)}
{/* Notifications log (persistent, mirrors Discord) */}
{activeTab === "notifications" && (
<NotificationsLogTab
token={token}
services={services}
liveStream={notificationStream}
filteredUids={finalFilteredServices}
hasActiveFilter={hasActiveFilter}
/>
)}
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────────────────────
// Aggregated totals card — sum of CPU% and memory across all filtered services
// ──────────────────────────────────────────────────────────────────────────────
function MonitoringTotalsCard({
historyByService,
services,
filteredUids,
title,
}: {
historyByService: Record<string, StatsHistoryPoint[]>;
services: Service[];
filteredUids: Set<string>;
title: string;
}) {
const { t } = useT();
// Aggregate per-timestamp totals across all filtered services.
// CPU: sum of per-container CPU% (can exceed 100% on multi-core hosts — informative).
// MEM: sum of per-container mem_mb (absolute memory usage).
const totals = useMemo(() => {
const buckets = new Map<number, { cpu: number; mem_mb: number }>();
for (const [svc, points] of Object.entries(historyByService)) {
if (!filteredUids.has(svc)) continue;
for (const p of points) {
const existing = buckets.get(p.timestamp);
if (existing) {
existing.cpu += p.cpu;
existing.mem_mb += p.mem_mb;
} else {
buckets.set(p.timestamp, { cpu: p.cpu, mem_mb: p.mem_mb });
}
}
}
return [...buckets.entries()]
.sort(([a], [b]) => a - b)
.map(([ts, v]) => ({ timestamp: ts, cpu: v.cpu, mem_mb: v.mem_mb }));
}, [historyByService, filteredUids]);
// Sum container memory limits for the "X / Y" display
const totalMemLimitMb = useMemo(() => {
let sum = 0;
for (const s of services) {
if (!filteredUids.has(s.uid)) continue;
if (s.memory_limit > 0) sum += s.memory_limit / 1024 / 1024;
}
return sum;
}, [services, filteredUids]);
// Hide totals card if it would be redundant with the single service card below
if (totals.length === 0) return null;
if (filteredUids.size <= 1) return null;
const latest = totals[totals.length - 1];
const cpuValue = `${latest.cpu.toFixed(1)}%`;
const memValue = latest.mem_mb >= 1024
? `${(latest.mem_mb / 1024).toFixed(2)} GB`
: `${latest.mem_mb.toFixed(0)} MB`;
const memLimit = totalMemLimitMb > 0
? (totalMemLimitMb >= 1024 ? `${(totalMemLimitMb / 1024).toFixed(1)} GB` : `${totalMemLimitMb.toFixed(0)} MB`)
: undefined;
const formatMem = (v: number) => v >= 1024 ? `${(v / 1024).toFixed(2)} GB` : `${v.toFixed(0)} MB`;
const containerCount = filteredUids.size;
return (
<div className="px-5 py-3 border-b border-slate-700/40 bg-slate-900/40">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-slate-300 font-medium truncate">{title}</span>
<span className="text-[10px] text-slate-500">· {containerCount} {t("footer.containers")}</span>
</div>
<div className="grid grid-cols-2 gap-2">
<StatsCard
label={t("monitoring.totalCpu")}
value={cpuValue}
data={totals.map((p) => p.cpu)}
timestamps={totals.map((p) => p.timestamp)}
hoverValues={totals.map((p) => p.cpu)}
color="#10b981"
sparklineHeight={56}
formatHoverValue={(v) => `${v.toFixed(1)}%`}
showAverage
formatAverage={(v) => `${v.toFixed(1)}%`}
avgLabel={t("detail.avg")}
/>
<StatsCard
label={t("monitoring.totalMem")}
value={memValue}
limit={memLimit}
data={totals.map((p) => p.mem_mb)}
timestamps={totals.map((p) => p.timestamp)}
hoverValues={totals.map((p) => p.mem_mb)}
color="#10b981"
sparklineHeight={56}
formatHoverValue={formatMem}
showAverage
formatAverage={formatMem}
avgLabel={t("detail.avg")}
/>
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────────────────────
// Per-service card with its own range override
// ──────────────────────────────────────────────────────────────────────────────
interface MonitoringServiceCardProps {
svc: string;
services: Service[];
containerSettings: Record<string, ContainerSettings>;
setContainerSettings: React.Dispatch<React.SetStateAction<Record<string, ContainerSettings>>>;
globalThresholds: { cpu: number; mem: number };
discordEnabled: boolean;
configService: string | null;
setConfigService: React.Dispatch<React.SetStateAction<string | null>>;
expandedService: string | null;
setExpandedService: React.Dispatch<React.SetStateAction<string | null>>;
saveContainerSetting: (uid: string, settings: ContainerSettings) => Promise<void>;
debouncedSave: (uid: string, settings: ContainerSettings) => void;
globalRange: StatsRange;
fallbackData: StatsHistoryPoint[];
token: string;
}
function MonitoringServiceCard({
svc,
services,
containerSettings,
setContainerSettings,
globalThresholds,
discordEnabled,
configService,
setConfigService,
expandedService,
setExpandedService,
saveContainerSetting,
debouncedSave,
globalRange,
fallbackData,
token,
}: MonitoringServiceCardProps) {
const { t } = useT();
const [localRange, setLocalRange] = useState<StatsRange | null>(null);
// When the global range changes, reset this card's local override so it
// follows the new global. User clicking the global filter expresses intent
// "show all at this range".
useEffect(() => {
setLocalRange(null);
}, [globalRange]);
const hasOverride = localRange !== null;
const effectiveRange = localRange ?? globalRange;
// Only fetch when overridden — otherwise the page's useAllStatsHistory covers it.
const { data: ownData, loading: ownLoading } = useStatsHistory(svc, effectiveRange, token, hasOverride);
const points = hasOverride ? ownData : fallbackData;
const loading = hasOverride ? ownLoading : false;
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 (
<div className="px-5 py-3">
<div className="flex items-center gap-2 mb-2">
<ServiceIcon uid={svc} services={services} />
<div className="min-w-0">
<span className="text-xs text-slate-300 font-medium truncate block">{shortName}</span>
{svc.includes("/") && (
<span className="text-[10px] text-slate-500 truncate block leading-tight">{svc.split("/")[0]}</span>
)}
</div>
<div className="flex-1" />
{/* Per-card range buttons */}
<div className="flex gap-0.5 mr-1">
{(["1h", "6h", "24h", "7d"] as StatsRange[]).map((r) => {
const active = effectiveRange === r;
const isOverrideHighlight = active && hasOverride;
return (
<button
key={r}
onClick={() => setLocalRange(r === globalRange ? null : r)}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium transition-colors ${
active
? isOverrideHighlight
? "bg-purple-500/20 text-purple-300"
: "bg-cyan-500/20 text-cyan-300"
: "text-slate-500 hover:text-slate-300 hover:bg-slate-700"
}`}
title={isOverrideHighlight ? "Override (click on the matching global range to reset)" : undefined}
>
{r}
</button>
);
})}
</div>
{discordEnabled && (
<button
onClick={() => {
const opening = configService !== svc;
setConfigService(opening ? svc : null);
if (opening) setExpandedService(svc);
else setExpandedService(null);
}}
className={`p-1 rounded hover:bg-slate-700/60 transition-colors ${configService === svc ? "text-cyan-400" : "text-slate-500 hover:text-slate-300"}`}
title={t("detail.config")}
>
<Settings size={14} />
</button>
)}
<button
onClick={() => setExpandedService(isExpanded ? null : svc)}
className="p-1 rounded hover:bg-slate-700/60 text-slate-500 hover:text-slate-300 transition-colors"
title={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
</div>
{/* 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 (
<div className="mb-2 bg-slate-900/90 border border-slate-700/40 rounded-lg px-4 py-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-300">{t("detail.notifications")}</span>
<button
type="button"
onClick={() => saveContainerSetting(svc, { ...settings, notificationsEnabled: !settings.notificationsEnabled })}
className={`relative w-9 h-5 rounded-full transition-colors ${settings.notificationsEnabled ? "bg-cyan-600" : "bg-slate-600"}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${settings.notificationsEnabled ? "translate-x-4" : "translate-x-0"}`} />
</button>
</div>
{settings.notificationsEnabled && (
<>
<ThresholdBar
label={t("detail.cpuUsage")}
value={cpuVal}
threshold={cpuTh}
isCustom={settings.cpuThreshold !== null}
showThreshold={true}
thresholdLabel={t("detail.cpuThreshold")}
tagLabel={settings.cpuThreshold !== null ? t("detail.custom") : t("detail.global")}
hintLabel={t("detail.thresholdHint")}
onThresholdChange={(v) => {
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"
/>
<ThresholdBar
label={t("detail.memoryUsage")}
value={memVal}
threshold={memTh}
isCustom={settings.memThreshold !== null}
showThreshold={true}
thresholdLabel={t("detail.memThreshold")}
tagLabel={settings.memThreshold !== null ? t("detail.custom") : t("detail.global")}
hintLabel={t("detail.thresholdHint")}
onThresholdChange={(v) => {
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"
/>
</>
)}
</div>
);
})()}
{loading ? (
<div className="text-slate-500 text-[11px] text-center py-4">{t("monitoring.loadingHistory")}</div>
) : (
<div className={isExpanded ? "space-y-2" : "grid grid-cols-2 gap-2"}>
<StatsCard
label="CPU"
value={latest ? `${latest.cpu.toFixed(1)}%` : "—"}
limit={cpuLimit}
data={points.map((p) => 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")}
/>
<StatsCard
label="MEM"
value={latest ? `${latest.mem_mb.toFixed(0)} MB` : "—"}
limit={memLimit}
data={points.map((p) => 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")}
/>
</div>
)}
</div>
);
}
// ──────────────────────────────────────────────────────────────────────────────
// Events log tab — Docker events + UI actions, persistent in SQLite
// ──────────────────────────────────────────────────────────────────────────────
function EventsLogTab({ token, services, liveStream, filteredUids, hasActiveFilter }: { token: string; services: Service[]; liveStream: EventLogEntry[]; filteredUids: Set<string>; hasActiveFilter: boolean }) {
const { t } = useT();
const [events, setEvents] = useState<EventLogEntry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch("/api/events?limit=200", { headers })
.then((r) => r.ok ? r.json() : [])
.then((data: EventLogEntry[]) => { setEvents(data); setLoading(false); })
.catch(() => { setEvents([]); setLoading(false); });
}, [token]);
// Merge live stream into events, dedupe by id, then apply monitoring filter
const allEvents = useMemo(() => {
const seen = new Set<number>();
const merged: EventLogEntry[] = [];
for (const e of [...liveStream, ...events]) {
if (seen.has(e.id)) continue;
seen.add(e.id);
if (hasActiveFilter && !filteredUids.has(e.service)) continue;
merged.push(e);
}
return merged;
}, [events, liveStream, filteredUids, hasActiveFilter]);
if (loading) {
return <div className="bg-slate-800/50 border border-slate-700/60 rounded-xl px-6 py-12 text-center text-slate-500">{t("monitoring.loadingHistory")}</div>;
}
if (allEvents.length === 0) {
return (
<div className="bg-slate-800/50 border border-slate-700/60 rounded-xl px-6 py-12 text-center text-slate-500">
<Activity size={32} className="mx-auto mb-3 opacity-40" />
<p>{t("monitoring.noEvents")}</p>
</div>
);
}
return (
<div className="bg-slate-800/50 border border-slate-700/60 rounded-xl overflow-hidden">
<div className="divide-y divide-slate-700/40">
{allEvents.map((ev) => (
<div key={ev.id} className="flex items-center gap-4 px-5 py-3 hover:bg-slate-700/30 transition-colors">
<div className="w-8 h-8 rounded-lg bg-slate-700/60 flex items-center justify-center flex-shrink-0">
{eventIcon(ev.action)}
</div>
<ServiceIcon uid={ev.service} services={services} />
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-1.5">
<span className="text-sm text-slate-200 font-medium truncate">
{ev.service.split("/").pop() || ev.service}
</span>
{ev.service.includes("/") && (
<span className="text-[10px] text-slate-500 truncate">
{ev.service.split("/")[0]}
</span>
)}
<span className={`text-[9px] uppercase font-medium px-1.5 py-0.5 rounded ${ev.source === "ui" ? "bg-cyan-500/20 text-cyan-300" : "bg-slate-700/60 text-slate-400"}`}>
{ev.source}
</span>
</div>
<span className={`text-xs font-mono ${actionColor(ev.action)}`}>{ev.action}</span>
{ev.error_msg && (
<pre className="mt-1 text-[10px] text-red-300 font-mono whitespace-pre-wrap break-words max-h-16 overflow-auto">{ev.error_msg}</pre>
)}
</div>
<span className="text-xs text-slate-500 font-mono flex-shrink-0">{timeAgo(ev.timestamp)}</span>
</div>
))}
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────────────────────
// Notifications log tab — mirrors Discord webhooks, persistent in SQLite
// ──────────────────────────────────────────────────────────────────────────────
function levelStyles(level: NotificationLogEntry["level"]) {
switch (level) {
case "error": return { ring: "border-red-500/40", iconBg: "bg-red-500/15", iconColor: "text-red-400", titleColor: "text-red-300" };
case "warning": return { ring: "border-amber-500/40", iconBg: "bg-amber-500/15", iconColor: "text-amber-400", titleColor: "text-amber-300" };
case "info": return { ring: "border-slate-700/40", iconBg: "bg-slate-700/60", iconColor: "text-slate-400", titleColor: "text-slate-200" };
}
}
function NotificationsLogTab({ token, services, liveStream, filteredUids, hasActiveFilter }: { token: string; services: Service[]; liveStream: NotificationLogEntry[]; filteredUids: Set<string>; hasActiveFilter: boolean }) {
const { t } = useT();
const [notifications, setNotifications] = useState<NotificationLogEntry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch("/api/notifications?limit=100", { headers })
.then((r) => r.ok ? r.json() : [])
.then((data: NotificationLogEntry[]) => { setNotifications(data); setLoading(false); })
.catch(() => { setNotifications([]); setLoading(false); });
}, [token]);
const allNotifs = useMemo(() => {
const seen = new Set<number>();
const merged: NotificationLogEntry[] = [];
for (const n of [...liveStream, ...notifications]) {
if (seen.has(n.id)) continue;
seen.add(n.id);
if (hasActiveFilter && !filteredUids.has(n.service)) continue;
merged.push(n);
}
return merged;
}, [notifications, liveStream, filteredUids, hasActiveFilter]);
if (loading) {
return <div className="bg-slate-800/50 border border-slate-700/60 rounded-xl px-6 py-12 text-center text-slate-500">{t("monitoring.loadingHistory")}</div>;
}
if (allNotifs.length === 0) {
return (
<div className="bg-slate-800/50 border border-slate-700/60 rounded-xl px-6 py-12 text-center text-slate-500">
<AlertTriangle size={32} className="mx-auto mb-3 opacity-40" />
<p className="text-sm">{t("monitoring.noNotifications")}</p>
</div>
);
}
return (
<div className="space-y-2">
{allNotifs.map((n) => {
const s = levelStyles(n.level);
return (
<div key={n.id} className={`bg-slate-800/50 border ${s.ring} rounded-lg px-4 py-3`}>
<div className="flex items-start gap-3">
<div className={`w-7 h-7 rounded-lg ${s.iconBg} flex items-center justify-center shrink-0`}>
<AlertTriangle size={14} className={s.iconColor} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className={`text-sm font-semibold ${s.titleColor}`}>{n.title}</span>
<span className="text-[10px] text-slate-500 font-mono">{n.type}</span>
<span className="flex-1" />
<span className="text-[10px] text-slate-500 font-mono">{timeAgo(n.timestamp)}</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<ServiceIcon uid={n.service} services={services} />
<span className="text-xs text-slate-400 truncate">{n.service.split("/").pop() || n.service}</span>
{n.service.includes("/") && <span className="text-[10px] text-slate-500">· {n.service.split("/")[0]}</span>}
</div>
<pre className="mt-1.5 text-xs text-slate-300 font-mono whitespace-pre-wrap break-words leading-relaxed">{n.message}</pre>
</div>
</div>
</div>
);
})}
</div>
);
}
+1 -1
View File
@@ -102,7 +102,7 @@ export function SettingsPage({ projects, servicesCount, token }: SettingsPagePro
};
return (
<div className="flex-1 min-h-0 overflow-auto p-6">
<div className="flex-1 min-h-0 mx-2 mt-1 mb-1 rounded-xl overflow-auto ring-1 ring-slate-700/60 p-6">
<div>
{/* Header */}
<div className="flex items-center gap-3 mb-6">
+22 -4
View File
@@ -965,6 +965,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, locked,
limitLabel={t("detail.limit")}
thresholdTooltip={t("detail.thresholdTooltip")}
limitTooltip={t("detail.limitTooltip")}
valueTooltip={stats.mem_breakdown ? formatMemTooltip(stats.mem_breakdown, t) : undefined}
/>
</div>
@@ -1308,14 +1309,17 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
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 }) {
function StatCard({ label, value, extra, color, limit, threshold, thresholdLabel, limitLabel, thresholdTooltip, limitTooltip, valueTooltip }: { label: string; value: string; extra?: string; color: string; limit?: string; threshold?: string; thresholdLabel?: string; limitLabel?: string; thresholdTooltip?: string; limitTooltip?: string; valueTooltip?: string }) {
return (
<div className="bg-slate-800/80 rounded-lg px-4 py-3">
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">{label}</span>
<div className="flex items-center gap-1.5 mb-1">
<span className="text-[11px] uppercase tracking-wider text-slate-500">{label}</span>
{valueTooltip && <Tooltip text={valueTooltip} size={11} width="w-72" placement="bottom" />}
</div>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-1.5">
<span className={`text-xl font-mono font-semibold ${color}`}>{value}</span>
{extra && <span className="text-xs text-slate-500 ml-2">{extra}</span>}
{extra && <span className="text-xs text-slate-500">{extra}</span>}
</div>
{(threshold || limit) && (
<div className="flex flex-col items-end gap-0.5">
@@ -1340,6 +1344,20 @@ function StatCard({ label, value, extra, color, limit, threshold, thresholdLabel
);
}
function formatMemTooltip(b: NonNullable<Stats["mem_breakdown"]>, t: (k: any) => string): string {
const fmt = (mb: number) => mb >= 1024 ? `${(mb / 1024).toFixed(2)} GB` : `${mb.toFixed(0)} MB`;
return [
`${t("detail.memBreakdown")}:`,
"",
` ${t("detail.memAnon")}: ${fmt(b.anon_mb)}`,
` ${t("detail.memCache")}: ${fmt(b.cache_mb)}`,
` ${t("detail.memTotal")}: ${fmt(b.total_mb)}`,
` ${t("detail.memLimit")}: ${fmt(b.limit_mb)}`,
"",
t("detail.memTooltipHint"),
].join("\n");
}
function formatTimestamp(ts: string): string {
try {
const d = new Date(ts);
+53 -23
View File
@@ -1,6 +1,13 @@
import fs from "fs";
import path from "path";
import type { DiscordConfig } from "../shared/types";
import { insertNotification, type NotificationLogEntry } from "./events-db";
// External listener (set by index.ts) so we can broadcast new notifications via WS
let onNotificationLogged: ((entry: NotificationLogEntry) => void) | null = null;
export function setNotificationListener(fn: typeof onNotificationLogged) {
onNotificationLogged = fn;
}
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
const CONFIG_FILE = path.join(DATA_DIR, ".dockerflow-discord.json");
@@ -70,7 +77,7 @@ const downServices = new Map<string, number>(); // service → timestamp when it
* for services that are still down after the cooldown period.
*/
export function checkDownServices(config: DiscordConfig): void {
if (!config.enabled || !config.events.containerStateChanges) return;
if (!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)
@@ -79,19 +86,12 @@ export function checkDownServices(config: DiscordConfig): void {
// Don't send "Still Down" for less than 1 minute
if (downMinutes < 1) continue;
const cooldownKey = `down:${service}`;
if (!isOnCooldown(cooldownKey, config.downReminderMinutes)) {
setCooldown(cooldownKey);
queueWebhook(config.webhookUrl, {
username: "ContainerFlow",
embeds: [{
title: "Container Still Down",
color: 0xef4444,
description: `**${service}**\n\nDown for: \`${downMinutes} min\`\nStatus: \`offline\``,
footer: { text: "ContainerFlow" },
timestamp: new Date().toISOString(),
}],
});
}
sendEmbed(config, {
title: "Container Still Down",
color: 0xef4444,
description: `**${service}**\n\nDown for: \`${downMinutes} min\`\nStatus: \`offline\``,
footer: { text: "ContainerFlow" },
}, cooldownKey, { type: "state_change", service });
}
}
@@ -138,10 +138,40 @@ function queueWebhook(url: string, body: any): Promise<void> {
});
}
function sendEmbed(config: DiscordConfig, embed: any, cooldownKey?: string): void {
if (!config.enabled || !config.webhookUrl) return;
/** Map embed color to notification level for in-app log */
function colorToLevel(color: number): NotificationLogEntry["level"] {
if (color === 0xef4444) return "error"; // red
if (color === 0xf59e0b) return "warning"; // amber
if (color === 0x22c55e) return "info"; // green
if (color === 0x3b82f6) return "info"; // blue
return "info";
}
function sendEmbed(
config: DiscordConfig,
embed: any,
cooldownKey?: string,
meta?: { type: NotificationLogEntry["type"]; service: string },
): void {
if (cooldownKey && isOnCooldown(cooldownKey, config.cooldownMinutes)) return;
if (cooldownKey) setCooldown(cooldownKey);
// Always log to in-app notifications (regardless of Discord webhook config)
if (meta) {
try {
const entry = insertNotification(
meta.type,
meta.service,
colorToLevel(embed.color),
embed.title || "Notification",
embed.description || "",
);
if (entry && onNotificationLogged) onNotificationLogged(entry);
} catch {}
}
// Send to Discord only if enabled + configured
if (!config.enabled || !config.webhookUrl) return;
queueWebhook(config.webhookUrl, {
username: "ContainerFlow",
embeds: [{ ...embed, timestamp: new Date().toISOString() }],
@@ -175,7 +205,7 @@ function flushPendingDown(service: string): void {
color: 0xef4444,
description: `**${service}**\n\nAction: \`${action}\``,
footer: { text: "ContainerFlow" },
}, cooldownKey);
}, cooldownKey, { type: "state_change", service });
}
function cancelPendingDown(service: string): void {
@@ -218,7 +248,7 @@ export function notifyStateChange(service: string, action: string, config: Disco
color: 0xf59e0b,
description: `**${service}**\n\nAction: \`redeployed\``,
footer: { text: "ContainerFlow" },
}, cooldownKey);
}, cooldownKey, { type: "state_change", service });
} else {
// Fresh start (no preceding stop/die)
const cooldownKey = `state:start:${service}`;
@@ -227,7 +257,7 @@ export function notifyStateChange(service: string, action: string, config: Disco
color: 0x22c55e,
description: `**${service}**\n\nAction: \`start\``,
footer: { text: "ContainerFlow" },
}, cooldownKey);
}, cooldownKey, { type: "state_change", service });
}
return;
}
@@ -247,7 +277,7 @@ export function notifyStateChange(service: string, action: string, config: Disco
color: colors[action] || 0x94a3b8,
description: `**${service}**\n\nAction: \`${action}\``,
footer: { text: "ContainerFlow" },
}, cooldownKey);
}, cooldownKey, { type: "state_change", service });
}
export function notifyResourceAlert(service: string, resource: "cpu" | "memory", value: number, threshold: number, config: DiscordConfig): void {
@@ -259,7 +289,7 @@ export function notifyResourceAlert(service: string, resource: "cpu" | "memory",
color,
description: `**${service}**\n\nCurrent: \`${value.toFixed(1)}%\`\nThreshold: \`${threshold}%\``,
footer: { text: "ContainerFlow" },
}, cooldownKey);
}, cooldownKey, { type: "resource_alert", service });
}
export function notifyUIAction(service: string, action: string, config: DiscordConfig): void {
@@ -270,7 +300,7 @@ export function notifyUIAction(service: string, action: string, config: DiscordC
color: 0x3b82f6,
description: `**${service}**\n\nAction: \`${action}\``,
footer: { text: "ContainerFlow" },
}, cooldownKey);
}, cooldownKey, { type: "ui_action", service });
}
export function notifyActionError(service: string, action: string, error: string, config: DiscordConfig): void {
@@ -282,7 +312,7 @@ export function notifyActionError(service: string, action: string, error: string
color: 0xef4444,
description: `**${service}**\n\n\`\`\`\n${truncated}\n\`\`\``,
footer: { text: "ContainerFlow" },
}, cooldownKey);
}, cooldownKey, { type: "action_error", service });
}
export async function testWebhook(webhookUrl: string): Promise<{ ok: boolean; error?: string }> {
+149
View File
@@ -0,0 +1,149 @@
import { Database } from "bun:sqlite";
import path from "path";
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
const DB_PATH = path.join(DATA_DIR, ".dockerflow-events.db");
const MAX_EVENTS = 1000;
const MAX_NOTIFICATIONS = 500;
let db: Database;
export interface EventLogEntry {
id: number;
timestamp: number;
service: string;
action: string;
source: "docker" | "ui";
error_msg: string | null;
}
export interface NotificationLogEntry {
id: number;
timestamp: number;
type: "state_change" | "resource_alert" | "ui_action" | "action_error";
service: string;
level: "info" | "warning" | "error";
title: string;
message: string;
}
export function initEventsDB() {
db = new Database(DB_PATH);
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA synchronous = NORMAL");
db.exec(`
CREATE TABLE IF NOT EXISTS events_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
service TEXT NOT NULL,
action TEXT NOT NULL,
source TEXT NOT NULL,
error_msg TEXT
)
`);
db.exec("CREATE INDEX IF NOT EXISTS idx_events_time ON events_log (timestamp DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_events_service ON events_log (service, timestamp DESC)");
db.exec(`
CREATE TABLE IF NOT EXISTS notifications_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
type TEXT NOT NULL,
service TEXT NOT NULL,
level TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL
)
`);
db.exec("CREATE INDEX IF NOT EXISTS idx_notif_time ON notifications_log (timestamp DESC)");
// Initial prune + schedule periodic
pruneOld();
setInterval(pruneOld, 60 * 60_000); // hourly
}
export function insertEvent(service: string, action: string, source: "docker" | "ui", errorMsg?: string): EventLogEntry | null {
if (!db) return null;
const ts = Math.floor(Date.now() / 1000);
const result = db.prepare(
"INSERT INTO events_log (timestamp, service, action, source, error_msg) VALUES (?, ?, ?, ?, ?)"
).run(ts, service, action, source, errorMsg || null);
return {
id: Number(result.lastInsertRowid),
timestamp: ts,
service,
action,
source,
error_msg: errorMsg || null,
};
}
export function insertNotification(
type: NotificationLogEntry["type"],
service: string,
level: NotificationLogEntry["level"],
title: string,
message: string,
): NotificationLogEntry | null {
if (!db) return null;
const ts = Math.floor(Date.now() / 1000);
const result = db.prepare(
"INSERT INTO notifications_log (timestamp, type, service, level, title, message) VALUES (?, ?, ?, ?, ?, ?)"
).run(ts, type, service, level, title, message);
return {
id: Number(result.lastInsertRowid),
timestamp: ts,
type,
service,
level,
title,
message,
};
}
export function getEvents(opts: { limit?: number; since?: number; service?: string; action?: string } = {}): EventLogEntry[] {
if (!db) return [];
const limit = Math.min(opts.limit ?? 200, 1000);
const where: string[] = [];
const args: any[] = [];
if (opts.since !== undefined) { where.push("timestamp >= ?"); args.push(opts.since); }
if (opts.service) { where.push("service = ?"); args.push(opts.service); }
if (opts.action) { where.push("action = ?"); args.push(opts.action); }
const whereSQL = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
const rows = db.prepare(
`SELECT * FROM events_log ${whereSQL} ORDER BY timestamp DESC, id DESC LIMIT ?`
).all(...args, limit) as EventLogEntry[];
return rows;
}
export function getNotifications(opts: { limit?: number; since?: number; type?: string; level?: string } = {}): NotificationLogEntry[] {
if (!db) return [];
const limit = Math.min(opts.limit ?? 100, 500);
const where: string[] = [];
const args: any[] = [];
if (opts.since !== undefined) { where.push("timestamp >= ?"); args.push(opts.since); }
if (opts.type) { where.push("type = ?"); args.push(opts.type); }
if (opts.level) { where.push("level = ?"); args.push(opts.level); }
const whereSQL = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
const rows = db.prepare(
`SELECT * FROM notifications_log ${whereSQL} ORDER BY timestamp DESC, id DESC LIMIT ?`
).all(...args, limit) as NotificationLogEntry[];
return rows;
}
/** Trim oldest entries beyond the retention cap. Called hourly + on startup. */
export function pruneOld() {
if (!db) return;
try {
db.prepare(
`DELETE FROM events_log WHERE id IN (
SELECT id FROM events_log ORDER BY timestamp DESC, id DESC LIMIT -1 OFFSET ?
)`
).run(MAX_EVENTS);
db.prepare(
`DELETE FROM notifications_log WHERE id IN (
SELECT id FROM notifications_log ORDER BY timestamp DESC, id DESC LIMIT -1 OFFSET ?
)`
).run(MAX_NOTIFICATIONS);
} catch {}
}
+49 -4
View File
@@ -6,10 +6,11 @@ import path from "path";
import fs from "fs";
import { docker, discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
import { pollStats, watchDockerEvents } from "./watcher";
import { loadDiscordConfig, saveDiscordConfig, notifyStateChange, notifyResourceAlert, notifyUIAction, notifyActionError, testWebhook, checkDownServices } from "./discord";
import { loadDiscordConfig, saveDiscordConfig, notifyStateChange, notifyResourceAlert, notifyUIAction, notifyActionError, testWebhook, checkDownServices, setNotificationListener } from "./discord";
import { loadContainerSettings, saveContainerSettings } from "./container-settings";
import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db";
import type { Service, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types";
import { initEventsDB, insertEvent, insertNotification, getEvents, getNotifications, type EventLogEntry, type NotificationLogEntry } from "./events-db";
import type { Service, Stats, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types";
/** Directory for persistent data files (SQLite, JSON configs, positions).
* Default: ./data subdirectory of cwd. Override via DATA_DIR env var. */
@@ -197,7 +198,11 @@ app.get("/api/init", async (c) => {
positions = JSON.parse(fs.readFileSync(POSITIONS_FILE, "utf-8"));
}
} catch {}
return c.json({ services, connections, positions });
// Return cached stats (may be empty briefly during cold start).
// We deliberately do NOT trigger a fresh pollStats here — on cold start
// with many containers it can exceed Bun's 10s request timeout and hang
// the dashboard. The first regular poll (within ~3s) populates via WS.
return c.json({ services, connections, positions, stats: lastStats });
});
// ── Server config (read by frontend to disable buttons for non-allowed paths) ──
@@ -626,6 +631,23 @@ app.get("/api/stats/history", (c) => {
return c.json(getAllServicesStatsHistory(range));
});
// ── Events + Notifications log ──
app.get("/api/events", (c) => {
const limit = parseInt(c.req.query("limit") || "200");
const service = c.req.query("service") || undefined;
const action = c.req.query("action") || undefined;
const since = c.req.query("since") ? parseInt(c.req.query("since")!) : undefined;
return c.json(getEvents({ limit, service, action, since }));
});
app.get("/api/notifications", (c) => {
const limit = parseInt(c.req.query("limit") || "100");
const type = c.req.query("type") || undefined;
const level = c.req.query("level") || undefined;
const since = c.req.query("since") ? parseInt(c.req.query("since")!) : undefined;
return c.json(getNotifications({ limit, type, level, since }));
});
// ── Cache headers for static assets ──
app.use("/*", async (c, next) => {
await next();
@@ -714,6 +736,7 @@ async function refreshStats(services: Service[]) {
statsLockTimer = setTimeout(() => { statsLock = false; }, 30000);
try {
const stats = await pollStats(services);
lastStats = stats; // cache for /api/init (avoid 3s wait on page load)
broadcast({ type: "stats", data: stats });
try { insertStats(stats); } catch {}
// Check resource thresholds and down services for Discord alerts
@@ -768,9 +791,22 @@ function immediateRefresh() {
refreshServices();
}
// Actions worth persisting in the events log. `stop` is intentionally excluded
// — Docker emits BOTH `stop` (command issued) and `die` (process terminated)
// when stopping a container, which results in duplicate entries. `die` always
// fires when a container exits (intentional stop or crash), so it covers both.
const PERSISTED_ACTIONS = new Set(["start", "die", "restart", "health_status"]);
watchDockerEvents((event) => {
broadcast({ type: "docker_event", data: event });
scheduleRefresh();
// Persist to events log only if action is meaningful + non-duplicate
if (PERSISTED_ACTIONS.has(event.action)) {
try {
const entry = insertEvent(event.service, event.action, "docker");
if (entry) broadcast({ type: "event_log", data: entry } as any);
} catch {}
}
try {
const config = loadDiscordConfig();
notifyStateChange(event.service, event.action, config);
@@ -780,11 +816,20 @@ watchDockerEvents((event) => {
// ── Stats polling ──
let lastServicesHash = "";
let lastConnectionsHash = "";
/** Last stats snapshot sent in /api/init so frontend has data immediately
* instead of waiting for the next polling cycle (~3s wait). */
let lastStats: Stats[] = [];
setInterval(refreshServices, POLL_INTERVAL_MS);
// ── Init stats DB ──
// ── Init persistent DBs ──
initStatsDB();
initEventsDB();
// Wire up notification listener so new notifications stream to UI via WebSocket
setNotificationListener((entry) => {
try { broadcast({ type: "notification_log", data: entry } as any); } catch {}
});
// ── Start ──
const server = Bun.serve({
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { computeMemoryBreakdown } from "./watcher";
describe("computeMemoryBreakdown", () => {
it("subtracts inactive_file from usage (cgroup v2)", () => {
// Real example from a busy DB container on cgroup v2
const memStats = {
usage: 2108977152,
limit: 2147483648,
stats: {
anon: 77737984,
file: 1996709888,
inactive_file: 1811337216,
active_file: 38223872,
},
};
const r = computeMemoryBreakdown(memStats);
expect(r.total).toBe(2108977152);
expect(r.cache).toBe(1811337216);
expect(r.anon).toBe(77737984);
expect(r.limit).toBe(2147483648);
// real = 2108977152 - 1811337216 = 297639936 (~283 MB, real usage)
expect(r.real).toBe(297639936);
// NOT the inflated value of 2108977152 (~2.01 GB)
expect(r.real).toBeLessThan(memStats.usage);
});
it("uses total_inactive_file for cgroup v1", () => {
const memStats = {
usage: 1000000000,
limit: 2000000000,
stats: {
total_rss: 200000000,
total_inactive_file: 700000000,
cache: 800000000,
},
};
const r = computeMemoryBreakdown(memStats);
// Prefers total_inactive_file over generic cache
expect(r.cache).toBe(700000000);
expect(r.anon).toBe(200000000); // total_rss
expect(r.real).toBe(300000000);
});
it("falls back to cache field for legacy cgroup v1", () => {
const memStats = {
usage: 500000000,
limit: 1000000000,
stats: {
rss: 100000000,
cache: 350000000,
},
};
const r = computeMemoryBreakdown(memStats);
expect(r.cache).toBe(350000000);
expect(r.anon).toBe(100000000); // rss
expect(r.real).toBe(150000000);
});
it("clamps negative real usage to 0", () => {
// Edge case: stats reports cache > usage (race condition)
const memStats = {
usage: 100000000,
limit: 1000000000,
stats: {
inactive_file: 150000000,
},
};
const r = computeMemoryBreakdown(memStats);
expect(r.real).toBe(0);
});
it("handles missing stats gracefully", () => {
const memStats = { usage: 100000000, limit: 1000000000 };
const r = computeMemoryBreakdown(memStats);
expect(r.cache).toBe(0);
expect(r.anon).toBe(0);
expect(r.real).toBe(100000000); // no cache to subtract, real = total
expect(r.total).toBe(100000000);
});
it("handles completely empty input", () => {
const r = computeMemoryBreakdown(undefined);
expect(r.real).toBe(0);
expect(r.cache).toBe(0);
expect(r.anon).toBe(0);
expect(r.total).toBe(0);
expect(r.limit).toBe(0);
});
it("matches docker stats CLI for the user's reported case (~12% real vs 98% inflated)", () => {
// From the bug report: ninjasagacw-db-1 cgroup v2
const memStats = {
usage: 2108977152, // 2.01 GB raw
limit: 2147483648, // 2.0 GB limit
stats: {
anon: 77737984,
inactive_file: 1811337216,
active_file: 38223872,
},
};
const r = computeMemoryBreakdown(memStats);
const realPercent = (r.real / r.limit) * 100;
const rawPercent = (r.total / r.limit) * 100;
// Before fix: would show ~98%
expect(rawPercent).toBeGreaterThan(95);
// After fix: shows ~14% (close to docker stats CLI's 12%)
expect(realPercent).toBeLessThan(20);
expect(realPercent).toBeGreaterThan(10);
});
});
+39 -4
View File
@@ -1,6 +1,34 @@
import { docker } from "./docker";
import type { Service, Stats, DockerEvent } from "../shared/types";
/** Compute real memory usage by subtracting reclaimable page cache.
* Mirrors `docker stats` CLI logic. Works for cgroup v1 and v2.
*
* Why: memory_stats.usage includes the kernel page cache (file-backed pages
* the kernel keeps in RAM "just in case"). That cache is INSTANTLY reclaimable
* under memory pressure and is NOT real usage. Containers with heavy I/O
* (DBs, collectors) appear at 90-100% when actually using 10-15%.
*
* Returns: { real, cache, anon, total, limit } all in bytes. */
export function computeMemoryBreakdown(memoryStats: any): {
real: number;
cache: number;
anon: number;
total: number;
limit: number;
} {
const total = memoryStats?.usage ?? 0;
const limit = memoryStats?.limit ?? 0;
const s = memoryStats?.stats ?? {};
// cgroup v2: 'inactive_file'
// cgroup v1: 'total_inactive_file' (recursive) or 'cache' (legacy)
const cache = s.inactive_file ?? s.total_inactive_file ?? s.cache ?? 0;
// anon = process memory (heap, stack). cgroup v2: 'anon'. cgroup v1: 'rss' or 'total_rss'.
const anon = s.anon ?? s.total_rss ?? s.rss ?? 0;
const real = Math.max(0, total - cache);
return { real, cache, anon, total, limit };
}
export async function pollStats(services: Service[]): Promise<Stats[]> {
const running = services.filter((s) => s.state === "running");
const results: Stats[] = [];
@@ -30,14 +58,21 @@ export async function pollStats(services: Service[]): Promise<Stats[]> {
? (cpuHost * 100000 / svc.cpu_quota)
: cpuHost;
const memUsage = raw.memory_stats.usage || 0;
const memLimit = raw.memory_stats.limit || 1;
const mb = computeMemoryBreakdown(raw.memory_stats);
const memLimit = mb.limit || 1;
const TO_MB = 1024 * 1024;
results.push({
service: svc.uid,
cpu: parseFloat(cpu.toFixed(2)),
mem_mb: parseFloat((memUsage / 1024 / 1024).toFixed(1)),
mem_percent: parseFloat(((memUsage / memLimit) * 100).toFixed(1)),
mem_mb: parseFloat((mb.real / TO_MB).toFixed(1)),
mem_percent: parseFloat(((mb.real / memLimit) * 100).toFixed(1)),
mem_breakdown: {
anon_mb: parseFloat((mb.anon / TO_MB).toFixed(1)),
cache_mb: parseFloat((mb.cache / TO_MB).toFixed(1)),
total_mb: parseFloat((mb.total / TO_MB).toFixed(1)),
limit_mb: parseFloat((mb.limit / TO_MB).toFixed(1)),
},
});
} catch {
// Container may have stopped between discovery and stats
+36 -1
View File
@@ -32,8 +32,22 @@ export interface Connection {
export interface Stats {
service: string;
cpu: number;
/** Real memory usage in MB (usage minus reclaimable page cache).
* Matches what `docker stats` CLI shows. */
mem_mb: number;
/** Real memory usage as percentage of limit. */
mem_percent: number;
/** Optional breakdown for tooltips (only present in live stats, not persisted). */
mem_breakdown?: {
/** Anonymous memory (process heap, stack) in MB */
anon_mb: number;
/** Reclaimable page cache in MB (inactive_file) */
cache_mb: number;
/** Total reserved including cache (raw memory_stats.usage) in MB */
total_mb: number;
/** Container memory limit in MB */
limit_mb: number;
};
}
export interface DockerEvent {
@@ -99,6 +113,25 @@ export interface ServerConfig {
restrictedMode: boolean;
}
export interface EventLogEntry {
id: number;
timestamp: number;
service: string;
action: string;
source: "docker" | "ui";
error_msg: string | null;
}
export interface NotificationLogEntry {
id: number;
timestamp: number;
type: "state_change" | "resource_alert" | "ui_action" | "action_error";
service: string;
level: "info" | "warning" | "error";
title: string;
message: string;
}
export type WSMessage =
| { type: "services"; data: Service[] }
| { type: "connections"; data: Connection[] }
@@ -107,4 +140,6 @@ export type WSMessage =
| { type: "subscribe_logs"; container: string }
| { type: "unsubscribe_logs" }
| { type: "log_line"; data: LogLine }
| { type: "action_error"; data: { uid: string; action: string; error: string } };
| { type: "action_error"; data: { uid: string; action: string; error: string } }
| { type: "event_log"; data: EventLogEntry }
| { type: "notification_log"; data: NotificationLogEntry };