mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.30
This commit is contained in:
+2
-2
@@ -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) */}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 }> {
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user