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 }>) => {
|
const onPositions = useCallback((pos: Record<string, { x: number; y: number }>) => {
|
||||||
savedPositions.current = pos;
|
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 { config: serverConfig, canInteract } = useServerConfig(token);
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
@@ -522,7 +522,7 @@ function Dashboard({ token }: { token: string }) {
|
|||||||
|
|
||||||
<ActionErrorToast errors={actionErrors} onDismiss={dismissActionError} onClearAll={clearActionErrors} />
|
<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} />}
|
{activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} token={token} />}
|
||||||
|
|
||||||
{/* Canvas — inset (only visible on dashboard) */}
|
{/* Canvas — inset (only visible on dashboard) */}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
LayoutDashboard, Activity, Settings, Bell,
|
LayoutDashboard, Activity, Settings, Bell,
|
||||||
Play, Square, RotateCcw,
|
Play, Square, RotateCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Service, DockerEvent } from "../../shared/types";
|
import type { Service, DockerEvent, NotificationLogEntry } from "../../shared/types";
|
||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
|
|
||||||
export type Page = "dashboard" | "monitoring" | "settings";
|
export type Page = "dashboard" | "monitoring" | "settings";
|
||||||
|
|||||||
@@ -68,6 +68,28 @@ export function Sparkline({
|
|||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
ctx.clearRect(0, 0, w, h);
|
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) {
|
if (data.length === 0) {
|
||||||
ctx.fillStyle = "#64748b";
|
ctx.fillStyle = "#64748b";
|
||||||
ctx.font = "11px sans-serif";
|
ctx.font = "11px sans-serif";
|
||||||
@@ -79,7 +101,16 @@ export function Sparkline({
|
|||||||
const plotW = w - PAD.left - PAD.right;
|
const plotW = w - PAD.left - PAD.right;
|
||||||
const plotH = h - PAD.top - PAD.bottom;
|
const plotH = h - PAD.top - PAD.bottom;
|
||||||
const avg = data.reduce((a, b) => a + b, 0) / data.length;
|
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 range = max || 1;
|
||||||
const xStep = data.length > 1 ? plotW / (data.length - 1) : plotW;
|
const xStep = data.length > 1 ? plotW / (data.length - 1) : plotW;
|
||||||
|
|
||||||
@@ -302,7 +333,7 @@ export function Sparkline({
|
|||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
className="cursor-crosshair"
|
className="cursor-crosshair block"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Tooltip — below the chart */}
|
{/* 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">
|
<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 */}
|
{/* Left: label + value + limit */}
|
||||||
<div className="shrink-0 min-w-[52px]">
|
<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 }}>
|
<span className="text-sm font-mono font-semibold block leading-tight mt-0.5" style={{ color }}>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
@@ -53,7 +53,7 @@ export function StatsCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Center: sparkline */}
|
{/* 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
|
<Sparkline
|
||||||
data={data}
|
data={data}
|
||||||
timestamps={timestamps}
|
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" : ""}`}
|
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)); }}
|
onClick={(e) => { if (showThreshold && !dragging) onThresholdChange(calcPercent(e.clientX)); }}
|
||||||
>
|
>
|
||||||
{/* Usage fill */}
|
{/* 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
|
<div
|
||||||
className={`absolute inset-y-0 left-0 rounded-full transition-all duration-500 ${barColor}`}
|
className={`absolute inset-y-0 left-0 transition-all duration-500 ${barColor}`}
|
||||||
style={{ width: `${Math.min(value, 100)}%` }}
|
style={{ width: `${Math.min(value, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{/* Threshold handle — only when notifications enabled */}
|
{/* Threshold handle — only when notifications enabled */}
|
||||||
{showThreshold && (
|
{showThreshold && (
|
||||||
<div
|
<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";
|
import { HelpCircle } from "lucide-react";
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
@@ -8,36 +9,139 @@ interface TooltipProps {
|
|||||||
/** Icon size. Default: 13 */
|
/** Icon size. Default: 13 */
|
||||||
size?: number;
|
size?: number;
|
||||||
/** Where the popover opens relative to the icon. Default: "top" */
|
/** 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 [show, setShow] = useState(false);
|
||||||
const popoverPos =
|
const [pos, setPos] = useState<{ left: number; top: number; arrowLeft: number; arrowTop: number; flippedTo: "top" | "bottom" | "right" } | null>(null);
|
||||||
placement === "top"
|
const btnRef = useRef<HTMLButtonElement>(null);
|
||||||
? "bottom-full mb-2"
|
const popRef = useRef<HTMLDivElement>(null);
|
||||||
: "top-full mt-2";
|
|
||||||
const arrowPos =
|
useLayoutEffect(() => {
|
||||||
placement === "top"
|
if (!show || !btnRef.current) return;
|
||||||
? "top-full -mt-px border-t-slate-700"
|
|
||||||
: "bottom-full -mb-px border-b-slate-700";
|
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 (
|
return (
|
||||||
<span className="relative inline-flex">
|
<>
|
||||||
<button
|
<button
|
||||||
|
ref={btnRef}
|
||||||
type="button"
|
type="button"
|
||||||
onMouseEnter={() => setShow(true)}
|
onMouseEnter={() => setShow(true)}
|
||||||
onMouseLeave={() => setShow(false)}
|
onMouseLeave={() => setShow(false)}
|
||||||
onClick={(e) => { e.stopPropagation(); setShow((v) => !v); }}
|
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} />
|
<HelpCircle size={size} />
|
||||||
</button>
|
</button>
|
||||||
{show && (
|
{show && createPortal(
|
||||||
<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`}>
|
<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}
|
{text}
|
||||||
<div className={`absolute ${arrowPos} left-1/2 -translate-x-1/2 border-4 border-transparent`} />
|
{pos && pos.flippedTo === "right" && (
|
||||||
</div>
|
<div
|
||||||
|
className="absolute right-full border-4 border-transparent border-r-slate-700"
|
||||||
|
style={{ top: pos.arrowTop - 4 }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</span>
|
{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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
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 type { StatsStore } from "./useStatsStore";
|
||||||
import { arraysEqual, applyProcessing as applyProcessingPure } from "./processing";
|
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 [events, setEvents] = useState<DockerEvent[]>([]);
|
||||||
const [logLines, setLogLines] = useState<LogLine[]>([]);
|
const [logLines, setLogLines] = useState<LogLine[]>([]);
|
||||||
const [actionErrors, setActionErrors] = useState<ActionError[]>([]);
|
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 }
|
// 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 processingRef = useRef<Map<string, { expected: Service["state"]; startedAt: number; minDuration: number }>>(new Map());
|
||||||
const processingIntervalsRef = useRef<Map<string, ReturnType<typeof setInterval>>>(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);
|
setServices((prev) => prev.length === 0 ? data.services : prev);
|
||||||
setConnections((prev) => prev.length === 0 ? data.connections : prev);
|
setConnections((prev) => prev.length === 0 ? data.connections : prev);
|
||||||
if (onPositions) onPositions(data.positions || {});
|
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(() => {});
|
.catch(() => {});
|
||||||
}, [token]);
|
}, [token]);
|
||||||
@@ -131,6 +138,12 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
|
|||||||
]);
|
]);
|
||||||
break;
|
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) {
|
} catch (err) {
|
||||||
console.error("Failed to parse WS message:", 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 { useState, useEffect } from "react";
|
||||||
import type { StatsHistoryPoint, StatsRange } from "../../shared/types";
|
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 [data, setData] = useState<StatsHistoryPoint[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setData([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||||
@@ -20,7 +25,7 @@ export function useStatsHistory(uid: string, range: StatsRange, token: string) {
|
|||||||
setData([]);
|
setData([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, [uid, range, token]);
|
}, [uid, range, token, enabled]);
|
||||||
|
|
||||||
return { data, loading };
|
return { data, loading };
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-2
@@ -112,6 +112,12 @@ const en = {
|
|||||||
"detail.unlimited": "Unlimited",
|
"detail.unlimited": "Unlimited",
|
||||||
"detail.threshold": "Threshold",
|
"detail.threshold": "Threshold",
|
||||||
"detail.thresholdTooltip": "Alert threshold — sends a Discord notification when exceeded",
|
"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.limit": "Limit",
|
||||||
"detail.limitTooltip": "Maximum resource allocated to this container in Docker",
|
"detail.limitTooltip": "Maximum resource allocated to this container in Docker",
|
||||||
"detail.avg": "Avg",
|
"detail.avg": "Avg",
|
||||||
@@ -183,12 +189,21 @@ const en = {
|
|||||||
"logPanel.noLogs": "No logs available",
|
"logPanel.noLogs": "No logs available",
|
||||||
|
|
||||||
// Monitoring page
|
// 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.subtitle": "Docker container events in real-time",
|
||||||
"monitoring.noEvents": "No events yet. Events will appear here as containers start, stop, or restart.",
|
"monitoring.noEvents": "No events yet. Events will appear here as containers start, stop, or restart.",
|
||||||
"monitoring.alertRules": "Alert Rules",
|
"monitoring.alertRules": "Alert Rules",
|
||||||
"monitoring.alertRulesDesc": "Configure alerting rules for container events \u2014 coming soon",
|
"monitoring.alertRulesDesc": "Configure alerting rules for container events \u2014 coming soon",
|
||||||
"monitoring.statsHistory": "Resource Usage History",
|
"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.loadingHistory": "Loading historical data...",
|
||||||
"monitoring.noHistoryData": "No historical data available yet",
|
"monitoring.noHistoryData": "No historical data available yet",
|
||||||
"monitoring.selectFilter": "Select a service or load all to view history",
|
"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.unlimited": "Sin l\u00edmite",
|
||||||
"detail.threshold": "Umbral",
|
"detail.threshold": "Umbral",
|
||||||
"detail.thresholdTooltip": "Umbral de alerta \u2014 env\u00eda una notificaci\u00f3n a Discord cuando se supera",
|
"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.limit": "L\u00edmite",
|
||||||
"detail.limitTooltip": "Recurso m\u00e1ximo asignado a este contenedor en Docker",
|
"detail.limitTooltip": "Recurso m\u00e1ximo asignado a este contenedor en Docker",
|
||||||
"detail.avg": "Prom",
|
"detail.avg": "Prom",
|
||||||
@@ -424,12 +445,21 @@ const es: Record<TranslationKey, string> = {
|
|||||||
"logPanel.noLogs": "No hay logs disponibles",
|
"logPanel.noLogs": "No hay logs disponibles",
|
||||||
|
|
||||||
// Monitoring page
|
// 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.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.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.alertRules": "Reglas de Alerta",
|
||||||
"monitoring.alertRulesDesc": "Configurar reglas de alerta para eventos de contenedores \u2014 pr\u00f3ximamente",
|
"monitoring.alertRulesDesc": "Configurar reglas de alerta para eventos de contenedores \u2014 pr\u00f3ximamente",
|
||||||
"monitoring.statsHistory": "Historial de Uso de Recursos",
|
"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.loadingHistory": "Cargando datos hist\u00f3ricos...",
|
||||||
"monitoring.noHistoryData": "No hay datos hist\u00f3ricos disponibles a\u00fan",
|
"monitoring.noHistoryData": "No hay datos hist\u00f3ricos disponibles a\u00fan",
|
||||||
"monitoring.selectFilter": "Selecciona un servicio o carga todos para ver el historial",
|
"monitoring.selectFilter": "Selecciona un servicio o carga todos para ver el historial",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
||||||
import { Activity, Play, Square, RotateCcw, AlertTriangle, BarChart3, ChevronDown, Check, Maximize2, Minimize2, Settings } from "lucide-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 { useT } from "../i18n";
|
||||||
import { useAllStatsHistory } from "../hooks/useStatsHistory";
|
import { useAllStatsHistory, useStatsHistory } from "../hooks/useStatsHistory";
|
||||||
import { StatsCard } from "../components/StatsCard";
|
import { StatsCard } from "../components/StatsCard";
|
||||||
import { ThresholdBar } from "../components/ThresholdBar";
|
import { ThresholdBar } from "../components/ThresholdBar";
|
||||||
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { guessIcon } from "../nodes/ServiceNode";
|
import { guessIcon } from "../nodes/ServiceNode";
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
function timeAgo(ts: number): string {
|
||||||
@@ -81,11 +82,14 @@ interface MonitoringPageProps {
|
|||||||
events: DockerEvent[];
|
events: DockerEvent[];
|
||||||
token: string;
|
token: string;
|
||||||
services: Service[];
|
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 { t } = useT();
|
||||||
const [statsRange, setStatsRange] = useState<StatsRange>("1h");
|
const [statsRange, setStatsRange] = useState<StatsRange>("1h");
|
||||||
|
const [activeTab, setActiveTab] = useState<"history" | "events" | "notifications">("history");
|
||||||
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(new Set());
|
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(new Set());
|
||||||
const [selectedServices, setSelectedServices] = useState<Set<string>>(new Set());
|
const [selectedServices, setSelectedServices] = useState<Set<string>>(new Set());
|
||||||
const [expandedService, setExpandedService] = useState<string | null>(null);
|
const [expandedService, setExpandedService] = useState<string | null>(null);
|
||||||
@@ -247,21 +251,34 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps)
|
|||||||
: `${selectedServices.size} ${t("footer.containers")}`;
|
: `${selectedServices.size} ${t("footer.containers")}`;
|
||||||
|
|
||||||
return (
|
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>
|
<div>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Activity size={24} className="text-cyan-400" />
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
{allServiceNames.length > 0 && (
|
{allServiceNames.length > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<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 */}
|
{/* Project filter */}
|
||||||
{allProjects.length > 1 && (
|
{allProjects.length > 1 && (
|
||||||
<FilterDropdown
|
<FilterDropdown
|
||||||
@@ -271,7 +288,13 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps)
|
|||||||
dropdownRef={projectRef}
|
dropdownRef={projectRef}
|
||||||
>
|
>
|
||||||
<button
|
<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"
|
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 ${
|
<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}
|
dropdownRef={serviceRef}
|
||||||
>
|
>
|
||||||
<button
|
<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"
|
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 ${
|
<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>
|
</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 */}
|
{/* Resource Usage History */}
|
||||||
|
{activeTab === "history" && (
|
||||||
<div className="bg-slate-800/50 border border-slate-700/60 rounded-xl overflow-hidden mb-6">
|
<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 justify-between px-5 py-3 border-b border-slate-700/40">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -388,9 +441,236 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps)
|
|||||||
{t("monitoring.noHistoryData")}
|
{t("monitoring.noHistoryData")}
|
||||||
</div>
|
</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">
|
<div className="divide-y divide-slate-700/40">
|
||||||
{historyServiceNames.map((svc) => {
|
{historyServiceNames.map((svc) => (
|
||||||
const points = filteredHistory[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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 shortName = svc.split("/").pop() || svc;
|
||||||
const cs = containerSettings[svc];
|
const cs = containerSettings[svc];
|
||||||
const svcNotifs = discordEnabled && (cs?.notificationsEnabled !== false);
|
const svcNotifs = discordEnabled && (cs?.notificationsEnabled !== false);
|
||||||
@@ -402,8 +682,9 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps)
|
|||||||
const svcData = services.find((s) => s.uid === svc);
|
const svcData = services.find((s) => s.uid === svc);
|
||||||
const cpuLimit = svcData && svcData.cpu_quota > 0 ? `${(svcData.cpu_quota / 1000).toFixed(0)}%` : undefined;
|
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;
|
const memLimit = svcData && svcData.memory_limit > 0 ? `${(svcData.memory_limit / 1024 / 1024).toFixed(0)} MB` : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={svc} className="px-5 py-3">
|
<div className="px-5 py-3">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<ServiceIcon uid={svc} services={services} />
|
<ServiceIcon uid={svc} services={services} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -413,6 +694,29 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<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 && (
|
{discordEnabled && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -445,7 +749,6 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps)
|
|||||||
const memMb = latest?.mem_mb ?? 0;
|
const memMb = latest?.mem_mb ?? 0;
|
||||||
return (
|
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="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">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-slate-300">{t("detail.notifications")}</span>
|
<span className="text-xs text-slate-300">{t("detail.notifications")}</span>
|
||||||
<button
|
<button
|
||||||
@@ -506,6 +809,9 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps)
|
|||||||
</div>
|
</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"}>
|
<div className={isExpanded ? "space-y-2" : "grid grid-cols-2 gap-2"}>
|
||||||
<StatsCard
|
<StatsCard
|
||||||
label="CPU"
|
label="CPU"
|
||||||
@@ -538,24 +844,61 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps)
|
|||||||
avgLabel={t("detail.avg")}
|
avgLabel={t("detail.avg")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Events list */}
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
<div className="bg-slate-800/50 border border-slate-700/60 rounded-xl overflow-hidden">
|
// Events log tab — Docker events + UI actions, persistent in SQLite
|
||||||
{filteredEvents.length === 0 ? (
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
<div className="px-6 py-12 text-center text-slate-500">
|
|
||||||
|
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" />
|
<Activity size={32} className="mx-auto mb-3 opacity-40" />
|
||||||
<p>{t("monitoring.noEvents")}</p>
|
<p>{t("monitoring.noEvents")}</p>
|
||||||
</div>
|
</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">
|
<div className="divide-y divide-slate-700/40">
|
||||||
{filteredEvents.map((ev, i) => (
|
{allEvents.map((ev) => (
|
||||||
<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 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">
|
<div className="w-8 h-8 rounded-lg bg-slate-700/60 flex items-center justify-center flex-shrink-0">
|
||||||
{eventIcon(ev.action)}
|
{eventIcon(ev.action)}
|
||||||
</div>
|
</div>
|
||||||
@@ -570,23 +913,103 @@ export function MonitoringPage({ events, token, services }: MonitoringPageProps)
|
|||||||
{ev.service.split("/")[0]}
|
{ev.service.split("/")[0]}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<span className={`text-xs font-mono ${actionColor(ev.action)}`}>{ev.action}</span>
|
<span className={`text-xs font-mono ${actionColor(ev.action)}`}>{ev.action}</span>
|
||||||
</div>
|
{ev.error_msg && (
|
||||||
<span className="text-xs text-slate-500 font-mono flex-shrink-0">{timeAgo(ev.time)}</span>
|
<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>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-xs text-slate-500 font-mono flex-shrink-0">{timeAgo(ev.timestamp)}</span>
|
||||||
{/* 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>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</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 (
|
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>
|
<div>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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")}
|
limitLabel={t("detail.limit")}
|
||||||
thresholdTooltip={t("detail.thresholdTooltip")}
|
thresholdTooltip={t("detail.thresholdTooltip")}
|
||||||
limitTooltip={t("detail.limitTooltip")}
|
limitTooltip={t("detail.limitTooltip")}
|
||||||
|
valueTooltip={stats.mem_breakdown ? formatMemTooltip(stats.mem_breakdown, t) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="bg-slate-800/80 rounded-lg px-4 py-3">
|
<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 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>
|
<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>
|
</div>
|
||||||
{(threshold || limit) && (
|
{(threshold || limit) && (
|
||||||
<div className="flex flex-col items-end gap-0.5">
|
<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 {
|
function formatTimestamp(ts: string): string {
|
||||||
try {
|
try {
|
||||||
const d = new Date(ts);
|
const d = new Date(ts);
|
||||||
|
|||||||
+49
-19
@@ -1,6 +1,13 @@
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import type { DiscordConfig } from "../shared/types";
|
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 DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "data");
|
||||||
const CONFIG_FILE = path.join(DATA_DIR, ".dockerflow-discord.json");
|
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.
|
* for services that are still down after the cooldown period.
|
||||||
*/
|
*/
|
||||||
export function checkDownServices(config: DiscordConfig): void {
|
export function checkDownServices(config: DiscordConfig): void {
|
||||||
if (!config.enabled || !config.events.containerStateChanges) return;
|
if (!config.events.containerStateChanges) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [service, downSince] of downServices) {
|
for (const [service, downSince] of downServices) {
|
||||||
// Skip services that are still in debounce window (might be restarting)
|
// 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
|
// Don't send "Still Down" for less than 1 minute
|
||||||
if (downMinutes < 1) continue;
|
if (downMinutes < 1) continue;
|
||||||
const cooldownKey = `down:${service}`;
|
const cooldownKey = `down:${service}`;
|
||||||
if (!isOnCooldown(cooldownKey, config.downReminderMinutes)) {
|
sendEmbed(config, {
|
||||||
setCooldown(cooldownKey);
|
|
||||||
queueWebhook(config.webhookUrl, {
|
|
||||||
username: "ContainerFlow",
|
|
||||||
embeds: [{
|
|
||||||
title: "Container Still Down",
|
title: "Container Still Down",
|
||||||
color: 0xef4444,
|
color: 0xef4444,
|
||||||
description: `**${service}**\n\nDown for: \`${downMinutes} min\`\nStatus: \`offline\``,
|
description: `**${service}**\n\nDown for: \`${downMinutes} min\`\nStatus: \`offline\``,
|
||||||
footer: { text: "ContainerFlow" },
|
footer: { text: "ContainerFlow" },
|
||||||
timestamp: new Date().toISOString(),
|
}, 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 {
|
/** Map embed color to notification level for in-app log */
|
||||||
if (!config.enabled || !config.webhookUrl) return;
|
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 && isOnCooldown(cooldownKey, config.cooldownMinutes)) return;
|
||||||
if (cooldownKey) setCooldown(cooldownKey);
|
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, {
|
queueWebhook(config.webhookUrl, {
|
||||||
username: "ContainerFlow",
|
username: "ContainerFlow",
|
||||||
embeds: [{ ...embed, timestamp: new Date().toISOString() }],
|
embeds: [{ ...embed, timestamp: new Date().toISOString() }],
|
||||||
@@ -175,7 +205,7 @@ function flushPendingDown(service: string): void {
|
|||||||
color: 0xef4444,
|
color: 0xef4444,
|
||||||
description: `**${service}**\n\nAction: \`${action}\``,
|
description: `**${service}**\n\nAction: \`${action}\``,
|
||||||
footer: { text: "ContainerFlow" },
|
footer: { text: "ContainerFlow" },
|
||||||
}, cooldownKey);
|
}, cooldownKey, { type: "state_change", service });
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelPendingDown(service: string): void {
|
function cancelPendingDown(service: string): void {
|
||||||
@@ -218,7 +248,7 @@ export function notifyStateChange(service: string, action: string, config: Disco
|
|||||||
color: 0xf59e0b,
|
color: 0xf59e0b,
|
||||||
description: `**${service}**\n\nAction: \`redeployed\``,
|
description: `**${service}**\n\nAction: \`redeployed\``,
|
||||||
footer: { text: "ContainerFlow" },
|
footer: { text: "ContainerFlow" },
|
||||||
}, cooldownKey);
|
}, cooldownKey, { type: "state_change", service });
|
||||||
} else {
|
} else {
|
||||||
// Fresh start (no preceding stop/die)
|
// Fresh start (no preceding stop/die)
|
||||||
const cooldownKey = `state:start:${service}`;
|
const cooldownKey = `state:start:${service}`;
|
||||||
@@ -227,7 +257,7 @@ export function notifyStateChange(service: string, action: string, config: Disco
|
|||||||
color: 0x22c55e,
|
color: 0x22c55e,
|
||||||
description: `**${service}**\n\nAction: \`start\``,
|
description: `**${service}**\n\nAction: \`start\``,
|
||||||
footer: { text: "ContainerFlow" },
|
footer: { text: "ContainerFlow" },
|
||||||
}, cooldownKey);
|
}, cooldownKey, { type: "state_change", service });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -247,7 +277,7 @@ export function notifyStateChange(service: string, action: string, config: Disco
|
|||||||
color: colors[action] || 0x94a3b8,
|
color: colors[action] || 0x94a3b8,
|
||||||
description: `**${service}**\n\nAction: \`${action}\``,
|
description: `**${service}**\n\nAction: \`${action}\``,
|
||||||
footer: { text: "ContainerFlow" },
|
footer: { text: "ContainerFlow" },
|
||||||
}, cooldownKey);
|
}, cooldownKey, { type: "state_change", service });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyResourceAlert(service: string, resource: "cpu" | "memory", value: number, threshold: number, config: DiscordConfig): void {
|
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,
|
color,
|
||||||
description: `**${service}**\n\nCurrent: \`${value.toFixed(1)}%\`\nThreshold: \`${threshold}%\``,
|
description: `**${service}**\n\nCurrent: \`${value.toFixed(1)}%\`\nThreshold: \`${threshold}%\``,
|
||||||
footer: { text: "ContainerFlow" },
|
footer: { text: "ContainerFlow" },
|
||||||
}, cooldownKey);
|
}, cooldownKey, { type: "resource_alert", service });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyUIAction(service: string, action: string, config: DiscordConfig): void {
|
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,
|
color: 0x3b82f6,
|
||||||
description: `**${service}**\n\nAction: \`${action}\``,
|
description: `**${service}**\n\nAction: \`${action}\``,
|
||||||
footer: { text: "ContainerFlow" },
|
footer: { text: "ContainerFlow" },
|
||||||
}, cooldownKey);
|
}, cooldownKey, { type: "ui_action", service });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyActionError(service: string, action: string, error: string, config: DiscordConfig): void {
|
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,
|
color: 0xef4444,
|
||||||
description: `**${service}**\n\n\`\`\`\n${truncated}\n\`\`\``,
|
description: `**${service}**\n\n\`\`\`\n${truncated}\n\`\`\``,
|
||||||
footer: { text: "ContainerFlow" },
|
footer: { text: "ContainerFlow" },
|
||||||
}, cooldownKey);
|
}, cooldownKey, { type: "action_error", service });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testWebhook(webhookUrl: string): Promise<{ ok: boolean; error?: string }> {
|
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 fs from "fs";
|
||||||
import { docker, discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
|
import { docker, discoverServices, discoverConnections, getContainerLogs, streamContainerLogs } from "./docker";
|
||||||
import { pollStats, watchDockerEvents } from "./watcher";
|
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 { loadContainerSettings, saveContainerSettings } from "./container-settings";
|
||||||
import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db";
|
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).
|
/** Directory for persistent data files (SQLite, JSON configs, positions).
|
||||||
* Default: ./data subdirectory of cwd. Override via DATA_DIR env var. */
|
* 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"));
|
positions = JSON.parse(fs.readFileSync(POSITIONS_FILE, "utf-8"));
|
||||||
}
|
}
|
||||||
} catch {}
|
} 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) ──
|
// ── 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));
|
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 ──
|
// ── Cache headers for static assets ──
|
||||||
app.use("/*", async (c, next) => {
|
app.use("/*", async (c, next) => {
|
||||||
await next();
|
await next();
|
||||||
@@ -714,6 +736,7 @@ async function refreshStats(services: Service[]) {
|
|||||||
statsLockTimer = setTimeout(() => { statsLock = false; }, 30000);
|
statsLockTimer = setTimeout(() => { statsLock = false; }, 30000);
|
||||||
try {
|
try {
|
||||||
const stats = await pollStats(services);
|
const stats = await pollStats(services);
|
||||||
|
lastStats = stats; // cache for /api/init (avoid 3s wait on page load)
|
||||||
broadcast({ type: "stats", data: stats });
|
broadcast({ type: "stats", data: stats });
|
||||||
try { insertStats(stats); } catch {}
|
try { insertStats(stats); } catch {}
|
||||||
// Check resource thresholds and down services for Discord alerts
|
// Check resource thresholds and down services for Discord alerts
|
||||||
@@ -768,9 +791,22 @@ function immediateRefresh() {
|
|||||||
refreshServices();
|
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) => {
|
watchDockerEvents((event) => {
|
||||||
broadcast({ type: "docker_event", data: event });
|
broadcast({ type: "docker_event", data: event });
|
||||||
scheduleRefresh();
|
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 {
|
try {
|
||||||
const config = loadDiscordConfig();
|
const config = loadDiscordConfig();
|
||||||
notifyStateChange(event.service, event.action, config);
|
notifyStateChange(event.service, event.action, config);
|
||||||
@@ -780,11 +816,20 @@ watchDockerEvents((event) => {
|
|||||||
// ── Stats polling ──
|
// ── Stats polling ──
|
||||||
let lastServicesHash = "";
|
let lastServicesHash = "";
|
||||||
let lastConnectionsHash = "";
|
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);
|
setInterval(refreshServices, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
// ── Init stats DB ──
|
// ── Init persistent DBs ──
|
||||||
initStatsDB();
|
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 ──
|
// ── Start ──
|
||||||
const server = Bun.serve({
|
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 { docker } from "./docker";
|
||||||
import type { Service, Stats, DockerEvent } from "../shared/types";
|
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[]> {
|
export async function pollStats(services: Service[]): Promise<Stats[]> {
|
||||||
const running = services.filter((s) => s.state === "running");
|
const running = services.filter((s) => s.state === "running");
|
||||||
const results: Stats[] = [];
|
const results: Stats[] = [];
|
||||||
@@ -30,14 +58,21 @@ export async function pollStats(services: Service[]): Promise<Stats[]> {
|
|||||||
? (cpuHost * 100000 / svc.cpu_quota)
|
? (cpuHost * 100000 / svc.cpu_quota)
|
||||||
: cpuHost;
|
: cpuHost;
|
||||||
|
|
||||||
const memUsage = raw.memory_stats.usage || 0;
|
const mb = computeMemoryBreakdown(raw.memory_stats);
|
||||||
const memLimit = raw.memory_stats.limit || 1;
|
const memLimit = mb.limit || 1;
|
||||||
|
const TO_MB = 1024 * 1024;
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
service: svc.uid,
|
service: svc.uid,
|
||||||
cpu: parseFloat(cpu.toFixed(2)),
|
cpu: parseFloat(cpu.toFixed(2)),
|
||||||
mem_mb: parseFloat((memUsage / 1024 / 1024).toFixed(1)),
|
mem_mb: parseFloat((mb.real / TO_MB).toFixed(1)),
|
||||||
mem_percent: parseFloat(((memUsage / memLimit) * 100).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 {
|
} catch {
|
||||||
// Container may have stopped between discovery and stats
|
// Container may have stopped between discovery and stats
|
||||||
|
|||||||
+36
-1
@@ -32,8 +32,22 @@ export interface Connection {
|
|||||||
export interface Stats {
|
export interface Stats {
|
||||||
service: string;
|
service: string;
|
||||||
cpu: number;
|
cpu: number;
|
||||||
|
/** Real memory usage in MB (usage minus reclaimable page cache).
|
||||||
|
* Matches what `docker stats` CLI shows. */
|
||||||
mem_mb: number;
|
mem_mb: number;
|
||||||
|
/** Real memory usage as percentage of limit. */
|
||||||
mem_percent: number;
|
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 {
|
export interface DockerEvent {
|
||||||
@@ -99,6 +113,25 @@ export interface ServerConfig {
|
|||||||
restrictedMode: boolean;
|
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 =
|
export type WSMessage =
|
||||||
| { type: "services"; data: Service[] }
|
| { type: "services"; data: Service[] }
|
||||||
| { type: "connections"; data: Connection[] }
|
| { type: "connections"; data: Connection[] }
|
||||||
@@ -107,4 +140,6 @@ export type WSMessage =
|
|||||||
| { type: "subscribe_logs"; container: string }
|
| { type: "subscribe_logs"; container: string }
|
||||||
| { type: "unsubscribe_logs" }
|
| { type: "unsubscribe_logs" }
|
||||||
| { type: "log_line"; data: LogLine }
|
| { 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