This commit is contained in:
RGJorge
2026-05-11 00:30:55 +00:00
parent 6cb5bc8e34
commit 17e805d0fc
5 changed files with 64 additions and 18 deletions
+33 -3
View File
@@ -138,6 +138,29 @@ function Dashboard({ token }: { token: string }) {
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; service: Service } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; service: Service } | null>(null);
const [envFiles, setEnvFiles] = useState<Record<string, string>>({}); const [envFiles, setEnvFiles] = useState<Record<string, string>>({});
// Thresholds (per-container settings + global Discord config). Used in
// dashboard ServiceNode to color progress bars amber when exceeded.
const [containerSettings, setContainerSettings] = useState<Record<string, { notificationsEnabled?: boolean; cpuThreshold?: number | null; memThreshold?: number | null }>>({});
const [globalThresholds, setGlobalThresholds] = useState<{ cpu: number; mem: number }>({ cpu: 50, mem: 60 });
const [discordEnabled, setDiscordEnabled] = useState(false);
useEffect(() => {
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch("/api/container-settings", { headers })
.then((r) => r.ok ? r.json() : {})
.then(setContainerSettings)
.catch(() => {});
fetch("/api/discord-config", { headers })
.then((r) => r.ok ? r.json() : null)
.then((c: any) => {
if (c) {
setGlobalThresholds({ cpu: c.thresholds?.cpuPercent ?? 50, mem: c.thresholds?.memPercent ?? 60 });
setDiscordEnabled(!!(c.enabled && c.webhookUrl));
}
})
.catch(() => {});
}, [token]);
// Close filter dropdown on outside click // Close filter dropdown on outside click
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
@@ -341,11 +364,17 @@ function Dashboard({ token }: { token: string }) {
const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections); const { nodes: newNodes } = buildLayout(filteredServices, filteredConnections);
// Mark service nodes as locked when restricted mode is active // Mark service nodes as locked + inject effective thresholds for progress bar coloring
for (const n of newNodes) { for (const n of newNodes) {
if (n.type === "service") { if (n.type === "service") {
const svc = filteredServices.find((s) => s.uid === n.id); const svc = filteredServices.find((s) => s.uid === n.id);
if (svc) (n.data as any).locked = !canInteract(svc); if (svc) {
(n.data as any).locked = !canInteract(svc);
const cs = containerSettings[svc.uid];
const notifsOn = discordEnabled && (cs?.notificationsEnabled !== false);
(n.data as any).cpuThreshold = notifsOn ? (cs?.cpuThreshold ?? globalThresholds.cpu) : undefined;
(n.data as any).memThreshold = notifsOn ? (cs?.memThreshold ?? globalThresholds.mem) : undefined;
}
} }
} }
@@ -429,7 +458,7 @@ function Dashboard({ token }: { token: string }) {
return result; return result;
}); });
} }
}, [filteredServices, filteredConnections, canInteract]); }, [filteredServices, filteredConnections, canInteract, containerSettings, globalThresholds, discordEnabled]);
// Recompute edges + handles on drag end (not every pixel) // Recompute edges + handles on drag end (not every pixel)
const recomputeEdges = useCallback((currentNodes: Node[]) => { const recomputeEdges = useCallback((currentNodes: Node[]) => {
@@ -786,6 +815,7 @@ function Dashboard({ token }: { token: string }) {
envFiles={envFiles} envFiles={envFiles}
onEnvFileChange={handleEnvFileChange} onEnvFileChange={handleEnvFileChange}
events={events} events={events}
onContainerSettingsChange={(uid, settings) => setContainerSettings((prev) => ({ ...prev, [uid]: settings }))}
/> />
)} )}
</div> </div>
+2 -2
View File
@@ -140,7 +140,7 @@ const en = {
"detail.memoryUsage": "Memory Usage", "detail.memoryUsage": "Memory Usage",
"detail.memory": "Memory", "detail.memory": "Memory",
"detail.noStats": "No stats available", "detail.noStats": "No stats available",
"detail.cpuHistory": "CPU History", "detail.cpuHistory": "Usage History",
"detail.memoryHistory": "Memory History", "detail.memoryHistory": "Memory History",
"detail.noHistory": "No historical data available", "detail.noHistory": "No historical data available",
"detail.loadingHistory": "Loading history...", "detail.loadingHistory": "Loading history...",
@@ -398,7 +398,7 @@ const es: Record<TranslationKey, string> = {
"detail.memoryUsage": "Uso de Memoria", "detail.memoryUsage": "Uso de Memoria",
"detail.memory": "Memoria", "detail.memory": "Memoria",
"detail.noStats": "No hay estad\u00edsticas disponibles", "detail.noStats": "No hay estad\u00edsticas disponibles",
"detail.cpuHistory": "Historial de CPU", "detail.cpuHistory": "Historial de Consumo",
"detail.memoryHistory": "Historial de Memoria", "detail.memoryHistory": "Historial de Memoria",
"detail.noHistory": "No hay datos hist\u00f3ricos disponibles", "detail.noHistory": "No hay datos hist\u00f3ricos disponibles",
"detail.loadingHistory": "Cargando historial...", "detail.loadingHistory": "Cargando historial...",
+12 -2
View File
@@ -38,6 +38,8 @@ interface ServiceNodeData {
activeHandles?: string[]; activeHandles?: string[];
highlighted?: boolean; highlighted?: boolean;
locked?: boolean; locked?: boolean;
cpuThreshold?: number;
memThreshold?: number;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -221,13 +223,21 @@ export const ServiceNode = memo(function ServiceNode({ data, id }: NodeProps) {
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden"> <div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div <div
className="h-full bg-cyan-500/60 rounded-full transition-all duration-700" className={`h-full rounded-full transition-all duration-700 ${
d.cpuThreshold !== undefined && nodeStats.cpu > d.cpuThreshold
? "bg-amber-500/80"
: "bg-cyan-500/60"
}`}
style={{ width: `${Math.min(nodeStats.cpu, 100)}%` }} style={{ width: `${Math.min(nodeStats.cpu, 100)}%` }}
/> />
</div> </div>
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden"> <div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div <div
className="h-full bg-violet-500/60 rounded-full transition-all duration-700" className={`h-full rounded-full transition-all duration-700 ${
d.memThreshold !== undefined && nodeStats.mem_percent > d.memThreshold
? "bg-amber-500/80"
: "bg-violet-500/60"
}`}
style={{ width: `${Math.min(nodeStats.mem_percent, 100)}%` }} style={{ width: `${Math.min(nodeStats.mem_percent, 100)}%` }}
/> />
</div> </div>
+7 -2
View File
@@ -62,9 +62,12 @@ interface DetailPanelProps {
envFiles: Record<string, string>; envFiles: Record<string, string>;
onEnvFileChange: (composeFile: string, envFile: string | null) => void; onEnvFileChange: (composeFile: string, envFile: string | null) => void;
events: DockerEvent[]; events: DockerEvent[];
/** Called when container settings change (thresholds, notifications toggle).
* Lets the parent (App.tsx) update dashboard ServiceNode threshold coloring live. */
onContainerSettingsChange?: (uid: string, settings: ContainerSettings) => void;
} }
export function DetailPanel({ service, stats, logLines, token, closing, locked, onClose, onAction, clearProcessing, pushActionError, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, initialTab, envFiles, onEnvFileChange, events }: DetailPanelProps) { export function DetailPanel({ service, stats, logLines, token, closing, locked, onClose, onAction, clearProcessing, pushActionError, sendMessage, clearLogLines, connections, services, getLogsSince, initialLogsFullscreen, initialTab, envFiles, onEnvFileChange, events, onContainerSettingsChange }: DetailPanelProps) {
const { t } = useT(); const { t } = useT();
const [initialLogs, setInitialLogs] = useState<LogLine[]>([]); const [initialLogs, setInitialLogs] = useState<LogLine[]>([]);
const [autoScroll, setAutoScroll] = useState(true); const [autoScroll, setAutoScroll] = useState(true);
@@ -145,11 +148,13 @@ export function DetailPanel({ service, stats, logLines, token, closing, locked,
}); });
setCsSaved(true); setCsSaved(true);
setTimeout(() => setCsSaved(false), 1500); setTimeout(() => setCsSaved(false), 1500);
// Notify parent so dashboard ServiceNode thresholds update live
onContainerSettingsChange?.(service.uid, containerSettings);
} catch {} } catch {}
setCsSaving(false); setCsSaving(false);
}, 500); }, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [containerSettings, csLoaded, service.uid, token]); }, [containerSettings, csLoaded, service.uid, token, onContainerSettingsChange]);
// Scroll modal to bottom when opened or when logs arrive // Scroll modal to bottom when opened or when logs arrive
useEffect(() => { useEffect(() => {
+10 -9
View File
@@ -55,9 +55,11 @@ export function computeMemoryBreakdown(memoryStats: any): {
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[] = [];
for (const svc of running) { // Poll all containers in parallel — sequential polling makes the first cycle
// take ~3s × N containers, blocking the dashboard on page load. The Docker
// daemon handles concurrent stats requests fine.
const results = await Promise.all(running.map(async (svc): Promise<Stats | null> => {
try { try {
const container = docker.getContainer(svc.id); const container = docker.getContainer(svc.id);
const raw = await Promise.race([ const raw = await Promise.race([
@@ -76,8 +78,6 @@ export async function pollStats(services: Service[]): Promise<Stats[]> {
? (cpuDelta / sysDelta) * onlineCpus * 100 ? (cpuDelta / sysDelta) * onlineCpus * 100
: 0; : 0;
// If container has a CPU limit, show % relative to its allocation
// cpu_quota: 100000 = 1 core; cpuHost: % of one host core
const cpu = svc.cpu_quota > 0 const cpu = svc.cpu_quota > 0
? (cpuHost * 100000 / svc.cpu_quota) ? (cpuHost * 100000 / svc.cpu_quota)
: cpuHost; : cpuHost;
@@ -86,7 +86,7 @@ export async function pollStats(services: Service[]): Promise<Stats[]> {
const memLimit = mb.limit || 1; const memLimit = mb.limit || 1;
const TO_MB = 1024 * 1024; const TO_MB = 1024 * 1024;
results.push({ return {
service: svc.uid, service: svc.uid,
cpu: parseFloat(cpu.toFixed(2)), cpu: parseFloat(cpu.toFixed(2)),
mem_mb: parseFloat((mb.real / TO_MB).toFixed(1)), mem_mb: parseFloat((mb.real / TO_MB).toFixed(1)),
@@ -97,13 +97,14 @@ export async function pollStats(services: Service[]): Promise<Stats[]> {
total_mb: parseFloat((mb.total / TO_MB).toFixed(1)), total_mb: parseFloat((mb.total / TO_MB).toFixed(1)),
limit_mb: parseFloat((mb.limit / 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, or stats timed out
return null;
} }
} }));
return results; return results.filter((r): r is Stats => r !== null);
} }
export function watchDockerEvents(onEvent: (event: DockerEvent) => void) { export function watchDockerEvents(onEvent: (event: DockerEvent) => void) {