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 [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
useEffect(() => {
const handler = (e: MouseEvent) => {
@@ -341,11 +364,17 @@ function Dashboard({ token }: { token: string }) {
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) {
if (n.type === "service") {
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;
});
}
}, [filteredServices, filteredConnections, canInteract]);
}, [filteredServices, filteredConnections, canInteract, containerSettings, globalThresholds, discordEnabled]);
// Recompute edges + handles on drag end (not every pixel)
const recomputeEdges = useCallback((currentNodes: Node[]) => {
@@ -786,6 +815,7 @@ function Dashboard({ token }: { token: string }) {
envFiles={envFiles}
onEnvFileChange={handleEnvFileChange}
events={events}
onContainerSettingsChange={(uid, settings) => setContainerSettings((prev) => ({ ...prev, [uid]: settings }))}
/>
)}
</div>
+2 -2
View File
@@ -140,7 +140,7 @@ const en = {
"detail.memoryUsage": "Memory Usage",
"detail.memory": "Memory",
"detail.noStats": "No stats available",
"detail.cpuHistory": "CPU History",
"detail.cpuHistory": "Usage History",
"detail.memoryHistory": "Memory History",
"detail.noHistory": "No historical data available",
"detail.loadingHistory": "Loading history...",
@@ -398,7 +398,7 @@ const es: Record<TranslationKey, string> = {
"detail.memoryUsage": "Uso de Memoria",
"detail.memory": "Memoria",
"detail.noStats": "No hay estad\u00edsticas disponibles",
"detail.cpuHistory": "Historial de CPU",
"detail.cpuHistory": "Historial de Consumo",
"detail.memoryHistory": "Historial de Memoria",
"detail.noHistory": "No hay datos hist\u00f3ricos disponibles",
"detail.loadingHistory": "Cargando historial...",
+12 -2
View File
@@ -38,6 +38,8 @@ interface ServiceNodeData {
activeHandles?: string[];
highlighted?: boolean;
locked?: boolean;
cpuThreshold?: number;
memThreshold?: number;
[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-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<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)}%` }}
/>
</div>
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<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)}%` }}
/>
</div>
+7 -2
View File
@@ -62,9 +62,12 @@ interface DetailPanelProps {
envFiles: Record<string, string>;
onEnvFileChange: (composeFile: string, envFile: string | null) => void;
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 [initialLogs, setInitialLogs] = useState<LogLine[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
@@ -145,11 +148,13 @@ export function DetailPanel({ service, stats, logLines, token, closing, locked,
});
setCsSaved(true);
setTimeout(() => setCsSaved(false), 1500);
// Notify parent so dashboard ServiceNode thresholds update live
onContainerSettingsChange?.(service.uid, containerSettings);
} catch {}
setCsSaving(false);
}, 500);
return () => clearTimeout(timer);
}, [containerSettings, csLoaded, service.uid, token]);
}, [containerSettings, csLoaded, service.uid, token, onContainerSettingsChange]);
// Scroll modal to bottom when opened or when logs arrive
useEffect(() => {
+10 -9
View File
@@ -55,9 +55,11 @@ export function computeMemoryBreakdown(memoryStats: any): {
export async function pollStats(services: Service[]): Promise<Stats[]> {
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 {
const container = docker.getContainer(svc.id);
const raw = await Promise.race([
@@ -76,8 +78,6 @@ export async function pollStats(services: Service[]): Promise<Stats[]> {
? (cpuDelta / sysDelta) * onlineCpus * 100
: 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
? (cpuHost * 100000 / svc.cpu_quota)
: cpuHost;
@@ -86,7 +86,7 @@ export async function pollStats(services: Service[]): Promise<Stats[]> {
const memLimit = mb.limit || 1;
const TO_MB = 1024 * 1024;
results.push({
return {
service: svc.uid,
cpu: parseFloat(cpu.toFixed(2)),
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)),
limit_mb: parseFloat((mb.limit / TO_MB).toFixed(1)),
},
});
};
} 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) {