mirror of
https://github.com/RGJorge/ContainerFlow.git
synced 2026-08-03 07:21:42 +02:00
v0.0.27
This commit is contained in:
@@ -3,3 +3,6 @@ dist/
|
||||
*.log
|
||||
.env
|
||||
.dockerflow-*.json
|
||||
.dockerflow-*.db
|
||||
.dockerflow-*.db-wal
|
||||
.dockerflow-*.db-shm
|
||||
|
||||
+26
-3
@@ -86,7 +86,30 @@ function Dashboard({ token }: { token: string }) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const initialLayoutDone = useRef(false);
|
||||
const [activePage, setActivePage] = useState<Page>("dashboard");
|
||||
const PAGE_PATHS: Record<string, Page> = {
|
||||
"monitoreo": "monitoring", "monitoring": "monitoring",
|
||||
"configuracion": "settings", "settings": "settings",
|
||||
};
|
||||
const PAGE_SLUGS: Record<Page, string> = { dashboard: "", monitoring: "monitoreo", settings: "configuracion" };
|
||||
|
||||
const getPageFromPath = (): Page => {
|
||||
const path = window.location.pathname.replace(/^\//, "");
|
||||
return PAGE_PATHS[path] || "dashboard";
|
||||
};
|
||||
const [activePage, setActivePage] = useState<Page>(getPageFromPath);
|
||||
|
||||
// Sync URL with active page (browser back/forward)
|
||||
useEffect(() => {
|
||||
const handler = () => setActivePage(getPageFromPath());
|
||||
window.addEventListener("popstate", handler);
|
||||
return () => window.removeEventListener("popstate", handler);
|
||||
}, []);
|
||||
|
||||
const navigateTo = useCallback((page: Page) => {
|
||||
const slug = PAGE_SLUGS[page];
|
||||
window.history.pushState(null, "", slug ? `/${slug}` : "/");
|
||||
setActivePage(page);
|
||||
}, []);
|
||||
const [hiddenProjects, setHiddenProjects] = useState<Set<string>>(loadFilter);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const filterRef = useRef<HTMLDivElement>(null);
|
||||
@@ -482,11 +505,11 @@ function Dashboard({ token }: { token: string }) {
|
||||
token={token}
|
||||
totalStats={totalStats}
|
||||
activePage={activePage}
|
||||
onPageChange={(page) => { setContextMenu(null); setActivePage(page); }}
|
||||
onPageChange={(page) => { setContextMenu(null); navigateTo(page); }}
|
||||
events={events}
|
||||
/>
|
||||
|
||||
{activePage === "monitoring" && <MonitoringPage events={events} />}
|
||||
{activePage === "monitoring" && <MonitoringPage events={events} token={token} services={services} />}
|
||||
{activePage === "settings" && <SettingsPage projects={projects} servicesCount={services.length} token={token} />}
|
||||
|
||||
{/* Canvas — inset (only visible on dashboard) */}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useRef, useEffect, useState, useCallback } from "react";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[];
|
||||
timestamps?: number[];
|
||||
hoverValues?: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
color?: string;
|
||||
threshold?: number;
|
||||
showArea?: boolean;
|
||||
showAverage?: boolean;
|
||||
formatAverage?: (v: number) => string;
|
||||
className?: string;
|
||||
formatValue?: (v: number) => string;
|
||||
formatHoverValue?: (v: number) => string;
|
||||
}
|
||||
|
||||
const PAD = { top: 4, bottom: 0, left: 0, right: 0 };
|
||||
|
||||
function formatDateTime(ts: number): string {
|
||||
const d = new Date(ts * 1000);
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const time = d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
return `${dd}/${mm} ${time}`;
|
||||
}
|
||||
|
||||
export function Sparkline({
|
||||
data,
|
||||
timestamps,
|
||||
hoverValues,
|
||||
width: propWidth,
|
||||
height: propHeight = 60,
|
||||
color = "#06b6d4",
|
||||
threshold,
|
||||
showArea = true,
|
||||
showAverage = false,
|
||||
formatAverage,
|
||||
className,
|
||||
formatValue,
|
||||
formatHoverValue,
|
||||
}: SparklineProps) {
|
||||
const { t } = useT();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
const [dims, setDims] = useState<{ w: number; h: number }>({ w: 0, h: propHeight });
|
||||
|
||||
// Draw the sparkline
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const w = propWidth || dims.w;
|
||||
const h = propHeight;
|
||||
if (w === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (data.length === 0) {
|
||||
ctx.fillStyle = "#64748b";
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(t("detail.noHistory"), w / 2, h / 2 + 4);
|
||||
return;
|
||||
}
|
||||
|
||||
const plotW = w - PAD.left - PAD.right;
|
||||
const plotH = h - PAD.top - PAD.bottom;
|
||||
const avg = data.reduce((a, b) => a + b, 0) / data.length;
|
||||
const max = Math.max(...data, threshold ?? 0, avg, 1);
|
||||
const range = max || 1;
|
||||
const xStep = data.length > 1 ? plotW / (data.length - 1) : plotW;
|
||||
|
||||
const toX = (i: number) => PAD.left + i * xStep;
|
||||
const toY = (v: number) => PAD.top + plotH - (v / range) * plotH;
|
||||
|
||||
const AMBER = "#f59e0b";
|
||||
const hasThreshold = threshold !== undefined && threshold > 0;
|
||||
|
||||
// Helper: pick color based on whether value exceeds threshold
|
||||
const segColor = (v: number) => hasThreshold && v >= threshold ? AMBER : color;
|
||||
|
||||
// Helper: interpolate X where data crosses threshold between two points
|
||||
const crossX = (i0: number, i1: number) => {
|
||||
const v0 = data[i0], v1 = data[i1];
|
||||
const t = (threshold! - v0) / (v1 - v0);
|
||||
return toX(i0) + t * (toX(i1) - toX(i0));
|
||||
};
|
||||
|
||||
// Build segments: groups of consecutive points with the same over/under state
|
||||
// Each segment includes the crossing point so lines connect smoothly
|
||||
type Seg = { points: { x: number; y: number }[]; over: boolean };
|
||||
const segments: Seg[] = [];
|
||||
if (data.length > 1 && hasThreshold) {
|
||||
let cur: Seg = { points: [{ x: toX(0), y: toY(data[0]) }], over: data[0] >= threshold };
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const over = data[i] >= threshold;
|
||||
if (over !== cur.over) {
|
||||
// Crossing point
|
||||
const cx = crossX(i - 1, i);
|
||||
const cy = toY(threshold);
|
||||
cur.points.push({ x: cx, y: cy });
|
||||
segments.push(cur);
|
||||
cur = { points: [{ x: cx, y: cy }], over };
|
||||
}
|
||||
cur.points.push({ x: toX(i), y: toY(data[i]) });
|
||||
}
|
||||
segments.push(cur);
|
||||
}
|
||||
|
||||
// Area fill
|
||||
if (showArea && data.length > 1) {
|
||||
if (hasThreshold && segments.length > 0) {
|
||||
for (const seg of segments) {
|
||||
if (seg.points.length < 2) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(seg.points[0].x, seg.points[0].y);
|
||||
for (let j = 1; j < seg.points.length; j++) ctx.lineTo(seg.points[j].x, seg.points[j].y);
|
||||
ctx.lineTo(seg.points[seg.points.length - 1].x, PAD.top + plotH);
|
||||
ctx.lineTo(seg.points[0].x, PAD.top + plotH);
|
||||
ctx.closePath();
|
||||
const c = seg.over ? AMBER : color;
|
||||
const cr = parseInt(c.slice(1, 3), 16);
|
||||
const cg = parseInt(c.slice(3, 5), 16);
|
||||
const cb = parseInt(c.slice(5, 7), 16);
|
||||
const gradient = ctx.createLinearGradient(0, PAD.top, 0, PAD.top + plotH);
|
||||
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},0.35)`);
|
||||
gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0.08)`);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
}
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toX(0), toY(data[0]));
|
||||
for (let i = 1; i < data.length; i++) ctx.lineTo(toX(i), toY(data[i]));
|
||||
ctx.lineTo(toX(data.length - 1), PAD.top + plotH);
|
||||
ctx.lineTo(toX(0), PAD.top + plotH);
|
||||
ctx.closePath();
|
||||
const cr = parseInt(color.slice(1, 3), 16);
|
||||
const cg = parseInt(color.slice(3, 5), 16);
|
||||
const cb = parseInt(color.slice(5, 7), 16);
|
||||
const gradient = ctx.createLinearGradient(0, PAD.top, 0, PAD.top + plotH);
|
||||
gradient.addColorStop(0, `rgba(${cr},${cg},${cb},0.25)`);
|
||||
gradient.addColorStop(1, `rgba(${cr},${cg},${cb},0.02)`);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// Line stroke
|
||||
if (data.length > 1) {
|
||||
if (hasThreshold && segments.length > 0) {
|
||||
for (const seg of segments) {
|
||||
if (seg.points.length < 2) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(seg.points[0].x, seg.points[0].y);
|
||||
for (let j = 1; j < seg.points.length; j++) ctx.lineTo(seg.points[j].x, seg.points[j].y);
|
||||
ctx.strokeStyle = seg.over ? AMBER : color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
}
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toX(0), toY(data[0]));
|
||||
for (let i = 1; i < data.length; i++) ctx.lineTo(toX(i), toY(data[i]));
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
ctx.stroke();
|
||||
}
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.arc(toX(0), toY(data[0]), 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = segColor(data[0]);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Threshold dashed line
|
||||
if (hasThreshold) {
|
||||
const y = toY(threshold);
|
||||
if (y >= PAD.top && y <= PAD.top + plotH) {
|
||||
ctx.beginPath();
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.moveTo(PAD.left, y);
|
||||
ctx.lineTo(w - PAD.right, y);
|
||||
ctx.strokeStyle = AMBER;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Average dashed line
|
||||
if (showAverage && data.length > 1) {
|
||||
const avgY = toY(avg);
|
||||
if (avgY >= PAD.top && avgY <= PAD.top + plotH) {
|
||||
ctx.beginPath();
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.moveTo(PAD.left, avgY);
|
||||
ctx.lineTo(w - PAD.right, avgY);
|
||||
ctx.strokeStyle = "rgba(148, 163, 184, 0.5)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Hover crosshair + dot
|
||||
if (hoverIndex !== null && hoverIndex >= 0 && hoverIndex < data.length) {
|
||||
const hx = toX(hoverIndex);
|
||||
const hy = toY(data[hoverIndex]);
|
||||
|
||||
// Vertical line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(hx, PAD.top);
|
||||
ctx.lineTo(hx, PAD.top + plotH);
|
||||
ctx.strokeStyle = "rgba(148, 163, 184, 0.4)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// Dot
|
||||
const dotColor = hasThreshold && data[hoverIndex] >= threshold ? AMBER : color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(hx, hy, 3.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = dotColor;
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(hx, hy, 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#0f172a";
|
||||
ctx.fill();
|
||||
}
|
||||
}, [data, propWidth, propHeight, color, threshold, showArea, showAverage, formatAverage, hoverIndex, dims.w]);
|
||||
|
||||
// Mouse tracking
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (data.length === 0) return;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const plotW = dims.w - PAD.left - PAD.right;
|
||||
const xStep = data.length > 1 ? plotW / (data.length - 1) : plotW;
|
||||
const idx = Math.round((mouseX - PAD.left) / xStep);
|
||||
const clamped = Math.max(0, Math.min(data.length - 1, idx));
|
||||
setHoverIndex(clamped);
|
||||
}, [data.length, dims.w]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoverIndex(null);
|
||||
}, []);
|
||||
|
||||
// ResizeObserver for responsive width — triggers re-draw when container resizes
|
||||
useEffect(() => {
|
||||
if (propWidth) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
const w = Math.floor(entry.contentRect.width);
|
||||
if (w > 0) setDims((prev) => prev.w !== w ? { ...prev, w } : prev);
|
||||
}
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [propWidth]);
|
||||
|
||||
// Tooltip content
|
||||
const tooltip = hoverIndex !== null && hoverIndex >= 0 && hoverIndex < data.length
|
||||
? {
|
||||
value: (() => {
|
||||
if (hoverValues && hoverValues[hoverIndex] !== undefined) {
|
||||
return formatHoverValue ? formatHoverValue(hoverValues[hoverIndex]) : `${hoverValues[hoverIndex].toFixed(1)}`;
|
||||
}
|
||||
return formatValue ? formatValue(data[hoverIndex]) : `${data[hoverIndex].toFixed(1)}%`;
|
||||
})(),
|
||||
time: timestamps && timestamps[hoverIndex] ? formatDateTime(timestamps[hoverIndex]) : null,
|
||||
x: PAD.left + (data.length > 1 ? (dims.w - PAD.left - PAD.right) / (data.length - 1) : 0) * hoverIndex,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative ${className || ""}`}>
|
||||
<div className="overflow-hidden rounded-lg">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="cursor-crosshair"
|
||||
/>
|
||||
</div>
|
||||
{/* Tooltip — below the chart */}
|
||||
{tooltip && (
|
||||
<div
|
||||
className="absolute pointer-events-none z-10"
|
||||
style={{
|
||||
left: `${Math.max(45, Math.min(tooltip.x, dims.w - 45))}px`,
|
||||
bottom: "-22px",
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
<div className="bg-slate-700 border border-slate-600 rounded px-1.5 py-0.5 shadow-lg whitespace-nowrap flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-mono font-semibold" style={{ color }}>{tooltip.value}</span>
|
||||
{tooltip.time && (
|
||||
<span className="text-[9px] text-slate-400 font-mono">{tooltip.time}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Sparkline } from "./Sparkline";
|
||||
|
||||
interface StatsCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
limit?: string;
|
||||
data: number[];
|
||||
timestamps?: number[];
|
||||
hoverValues?: number[];
|
||||
color: string;
|
||||
threshold?: number;
|
||||
sparklineHeight?: number;
|
||||
formatValue?: (v: number) => string;
|
||||
formatHoverValue?: (v: number) => string;
|
||||
showAverage?: boolean;
|
||||
formatAverage?: (v: number) => string;
|
||||
avgLabel?: string;
|
||||
}
|
||||
|
||||
export function StatsCard({
|
||||
label,
|
||||
value,
|
||||
limit,
|
||||
data,
|
||||
timestamps,
|
||||
hoverValues,
|
||||
color,
|
||||
threshold,
|
||||
sparklineHeight = 52,
|
||||
formatValue,
|
||||
formatHoverValue,
|
||||
showAverage,
|
||||
formatAverage,
|
||||
avgLabel,
|
||||
}: StatsCardProps) {
|
||||
const avgSource = hoverValues && hoverValues.length > 0 ? hoverValues : data;
|
||||
const avg = showAverage && avgSource.length > 0
|
||||
? avgSource.reduce((a, b) => a + b, 0) / avgSource.length
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 bg-slate-800/60 border border-slate-700/40 rounded-lg px-3 py-2.5 overflow-visible">
|
||||
{/* Left: label + value + limit */}
|
||||
<div className="shrink-0 min-w-[52px]">
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 block leading-tight">{label}</span>
|
||||
<span className="text-sm font-mono font-semibold block leading-tight mt-0.5" style={{ color }}>
|
||||
{value}
|
||||
</span>
|
||||
{limit && (
|
||||
<span className="text-[9px] text-slate-500 font-mono block leading-tight mt-0.5">
|
||||
/ {limit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Center: sparkline */}
|
||||
<div className="flex-1 min-w-0 bg-slate-900/60 rounded-lg pt-1 overflow-visible">
|
||||
<Sparkline
|
||||
data={data}
|
||||
timestamps={timestamps}
|
||||
hoverValues={hoverValues}
|
||||
color={color}
|
||||
height={sparklineHeight}
|
||||
threshold={threshold}
|
||||
className="w-full"
|
||||
formatValue={formatValue}
|
||||
formatHoverValue={formatHoverValue}
|
||||
showAverage={showAverage}
|
||||
/>
|
||||
</div>
|
||||
{/* Right: average label outside sparkline */}
|
||||
{avg !== null && (
|
||||
<div className="shrink-0 text-center min-w-[36px]">
|
||||
<span className="text-[9px] uppercase tracking-wider text-slate-500 block leading-tight">{avgLabel || "Avg"}</span>
|
||||
<span className="text-[11px] font-mono text-slate-400 block leading-tight mt-0.5">
|
||||
{formatAverage ? formatAverage(avg) : `${avg.toFixed(1)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useRef, useState, useCallback, useEffect } from "react";
|
||||
import { RotateCw } from "lucide-react";
|
||||
|
||||
interface ThresholdBarProps {
|
||||
label: string;
|
||||
value: number;
|
||||
threshold: number;
|
||||
isCustom: boolean;
|
||||
showThreshold: boolean;
|
||||
thresholdLabel: string;
|
||||
tagLabel: string;
|
||||
hintLabel: string;
|
||||
onThresholdChange: (v: number) => void;
|
||||
onReset: () => void;
|
||||
formatValue: (v: number) => string;
|
||||
formatThreshold?: (threshold: number) => string;
|
||||
baseColor?: "emerald" | "cyan" | "purple";
|
||||
}
|
||||
|
||||
export function ThresholdBar({ label, value, threshold, isCustom, showThreshold, thresholdLabel, tagLabel, hintLabel, onThresholdChange, onReset, formatValue, formatThreshold, baseColor = "emerald" }: ThresholdBarProps) {
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [hovering, setHovering] = useState(false);
|
||||
|
||||
const calcPercent = useCallback((clientX: number) => {
|
||||
if (!barRef.current) return threshold;
|
||||
const rect = barRef.current.getBoundingClientRect();
|
||||
const pct = Math.round(((clientX - rect.left) / rect.width) * 100);
|
||||
return Math.max(5, Math.min(100, pct));
|
||||
}, [threshold]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const onMove = (e: MouseEvent) => { onThresholdChange(calcPercent(e.clientX)); };
|
||||
const onUp = () => { setDragging(false); };
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
return () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); };
|
||||
}, [dragging, calcPercent, onThresholdChange]);
|
||||
|
||||
// Touch support
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const onMove = (e: TouchEvent) => { if (e.touches[0]) onThresholdChange(calcPercent(e.touches[0].clientX)); };
|
||||
const onEnd = () => { setDragging(false); };
|
||||
window.addEventListener("touchmove", onMove);
|
||||
window.addEventListener("touchend", onEnd);
|
||||
return () => { window.removeEventListener("touchmove", onMove); window.removeEventListener("touchend", onEnd); };
|
||||
}, [dragging, calcPercent, onThresholdChange]);
|
||||
|
||||
const baseColorClass = baseColor === "purple" ? "bg-purple-500" : baseColor === "cyan" ? "bg-cyan-500" : "bg-emerald-500";
|
||||
const barColor = showThreshold
|
||||
? (value > threshold ? "bg-amber-500" : baseColorClass)
|
||||
: (value > 80 ? "bg-amber-500" : baseColorClass);
|
||||
const showTooltip = dragging || hovering;
|
||||
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<div className="flex justify-between text-xs text-slate-500 mb-2.5">
|
||||
<span>{label}</span>
|
||||
<span>{formatValue(value)}</span>
|
||||
</div>
|
||||
<div
|
||||
ref={barRef}
|
||||
className={`relative ${showThreshold ? "h-3" : "h-2"} bg-slate-800 rounded-full group ${showThreshold ? "cursor-pointer" : ""}`}
|
||||
onClick={(e) => { if (showThreshold && !dragging) onThresholdChange(calcPercent(e.clientX)); }}
|
||||
>
|
||||
{/* Usage fill */}
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 rounded-full transition-all duration-500 ${barColor}`}
|
||||
style={{ width: `${Math.min(value, 100)}%` }}
|
||||
/>
|
||||
{/* Threshold handle — only when notifications enabled */}
|
||||
{showThreshold && (
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 z-10 select-none touch-none cursor-grab active:cursor-grabbing"
|
||||
style={{ left: `${threshold}%` }}
|
||||
onMouseDown={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onTouchStart={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
>
|
||||
{/* Invisible wider hit area */}
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 w-5 h-10" />
|
||||
{/* Vertical line */}
|
||||
<div className={`w-0.5 h-5 rounded-full transition-colors pointer-events-none ${dragging ? "bg-amber-300" : "bg-amber-400/80 group-hover:bg-amber-400"}`} />
|
||||
{/* Drag handle diamond */}
|
||||
<div className={`absolute -top-1 left-1/2 -translate-x-1/2 w-2.5 h-2.5 rotate-45 rounded-[1px] border transition-colors pointer-events-none ${
|
||||
dragging ? "bg-amber-300 border-amber-200" : "bg-amber-400/90 border-amber-500/50 group-hover:bg-amber-400"
|
||||
}`} />
|
||||
{/* Tooltip */}
|
||||
{showTooltip && (
|
||||
<div className={`absolute left-1/2 -translate-x-1/2 px-1.5 py-0.5 bg-slate-700 rounded text-[10px] font-mono whitespace-nowrap shadow-lg ${formatThreshold ? "-top-[38px]" : "-top-7"}`}>
|
||||
<span className="text-amber-300 block text-center">{threshold}%</span>
|
||||
{formatThreshold && <span className="text-slate-400 block text-center text-[9px]">{formatThreshold(threshold)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Label row — only when notifications enabled */}
|
||||
{showThreshold && (
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-[10px] text-slate-600">{hintLabel}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[9px] text-slate-600">{tagLabel}</span>
|
||||
{isCustom && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors"
|
||||
title="Reset to global"
|
||||
>
|
||||
<RotateCw size={10} />
|
||||
</button>
|
||||
)}
|
||||
<span className="text-[10px] text-amber-400/70 font-mono">{threshold}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { StatsHistoryPoint, StatsRange } from "../../shared/types";
|
||||
|
||||
export function useStatsHistory(uid: string, range: StatsRange, token: string) {
|
||||
const [data, setData] = useState<StatsHistoryPoint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
fetch(`/api/stats/history/${uid}?range=${range}`, { headers })
|
||||
.then((r) => r.ok ? r.json() : [])
|
||||
.then((d: StatsHistoryPoint[]) => {
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setData([]);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [uid, range, token]);
|
||||
|
||||
return { data, loading };
|
||||
}
|
||||
|
||||
export function useAllStatsHistory(range: StatsRange, token: string) {
|
||||
const [data, setData] = useState<Record<string, StatsHistoryPoint[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
fetch(`/api/stats/history?range=${range}`, { headers })
|
||||
.then((r) => r.ok ? r.json() : {})
|
||||
.then((d: Record<string, StatsHistoryPoint[]>) => {
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setData({});
|
||||
setLoading(false);
|
||||
});
|
||||
}, [range, token]);
|
||||
|
||||
return { data, loading };
|
||||
}
|
||||
@@ -87,6 +87,11 @@ const en = {
|
||||
"detail.memoryLimit": "Memory Limit",
|
||||
"detail.cpuQuota": "CPU Quota",
|
||||
"detail.unlimited": "Unlimited",
|
||||
"detail.threshold": "Threshold",
|
||||
"detail.thresholdTooltip": "Alert threshold — sends a Discord notification when exceeded",
|
||||
"detail.limit": "Limit",
|
||||
"detail.limitTooltip": "Maximum resource allocated to this container in Docker",
|
||||
"detail.avg": "Avg",
|
||||
"detail.healthCheck": "Health Check",
|
||||
"detail.healthNotConfigured": "Not configured",
|
||||
"detail.recentChecks": "Recent checks",
|
||||
@@ -104,6 +109,10 @@ const en = {
|
||||
"detail.memoryUsage": "Memory Usage",
|
||||
"detail.memory": "Memory",
|
||||
"detail.noStats": "No stats available",
|
||||
"detail.cpuHistory": "CPU History",
|
||||
"detail.memoryHistory": "Memory History",
|
||||
"detail.noHistory": "No historical data available",
|
||||
"detail.loadingHistory": "Loading history...",
|
||||
|
||||
// Detail panel - Warning banners
|
||||
"detail.noMemoryLimit": "No memory limit configured in Docker",
|
||||
@@ -155,6 +164,15 @@ const en = {
|
||||
"monitoring.noEvents": "No events yet. Events will appear here as containers start, stop, or restart.",
|
||||
"monitoring.alertRules": "Alert Rules",
|
||||
"monitoring.alertRulesDesc": "Configure alerting rules for container events \u2014 coming soon",
|
||||
"monitoring.statsHistory": "Resource Usage History",
|
||||
"monitoring.loadingHistory": "Loading historical data...",
|
||||
"monitoring.noHistoryData": "No historical data available yet",
|
||||
"monitoring.selectFilter": "Select a service or load all to view history",
|
||||
"monitoring.loadAll": "Load all",
|
||||
"monitoring.allServices": "All services",
|
||||
"monitoring.allProjects": "All projects",
|
||||
"monitoring.filterService": "Filter by service",
|
||||
"monitoring.filterProject": "Filter by project",
|
||||
|
||||
// Settings page
|
||||
"settings.title": "Settings",
|
||||
@@ -286,6 +304,11 @@ const es: Record<TranslationKey, string> = {
|
||||
"detail.memoryLimit": "L\u00edmite de Memoria",
|
||||
"detail.cpuQuota": "Cuota de CPU",
|
||||
"detail.unlimited": "Sin l\u00edmite",
|
||||
"detail.threshold": "Umbral",
|
||||
"detail.thresholdTooltip": "Umbral de alerta \u2014 env\u00eda una notificaci\u00f3n a Discord cuando se supera",
|
||||
"detail.limit": "L\u00edmite",
|
||||
"detail.limitTooltip": "Recurso m\u00e1ximo asignado a este contenedor en Docker",
|
||||
"detail.avg": "Prom",
|
||||
"detail.healthCheck": "Health Check",
|
||||
"detail.healthNotConfigured": "No configurado",
|
||||
"detail.recentChecks": "Chequeos recientes",
|
||||
@@ -303,6 +326,10 @@ 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.memoryHistory": "Historial de Memoria",
|
||||
"detail.noHistory": "No hay datos hist\u00f3ricos disponibles",
|
||||
"detail.loadingHistory": "Cargando historial...",
|
||||
|
||||
// Detail panel - Warning banners
|
||||
"detail.noMemoryLimit": "Sin l\u00edmite de memoria configurado en Docker",
|
||||
@@ -354,6 +381,15 @@ const es: Record<TranslationKey, string> = {
|
||||
"monitoring.noEvents": "Sin eventos a\u00fan. Los eventos aparecer\u00e1n aqu\u00ed cuando los contenedores inicien, se detengan o reinicien.",
|
||||
"monitoring.alertRules": "Reglas de Alerta",
|
||||
"monitoring.alertRulesDesc": "Configurar reglas de alerta para eventos de contenedores \u2014 pr\u00f3ximamente",
|
||||
"monitoring.statsHistory": "Historial de Uso de Recursos",
|
||||
"monitoring.loadingHistory": "Cargando datos hist\u00f3ricos...",
|
||||
"monitoring.noHistoryData": "No hay datos hist\u00f3ricos disponibles a\u00fan",
|
||||
"monitoring.selectFilter": "Selecciona un servicio o carga todos para ver el historial",
|
||||
"monitoring.loadAll": "Cargar todos",
|
||||
"monitoring.allServices": "Todos los servicios",
|
||||
"monitoring.allProjects": "Todos los proyectos",
|
||||
"monitoring.filterService": "Filtrar por servicio",
|
||||
"monitoring.filterProject": "Filtrar por proyecto",
|
||||
|
||||
// Settings page
|
||||
"settings.title": "Configuraci\u00f3n",
|
||||
|
||||
@@ -85,7 +85,7 @@ const nameIconMap: { pattern: string; icon: LucideIcon; color: string }[] = [
|
||||
{ pattern: "api", icon: Server, color: "#3b82f6" },
|
||||
];
|
||||
|
||||
function guessIcon(image: string, name: string): { Icon: LucideIcon; color: string } {
|
||||
export function guessIcon(image: string, name: string): { Icon: LucideIcon; color: string } {
|
||||
const lowerImage = image.toLowerCase();
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Activity, Play, Square, RotateCcw, AlertTriangle } from "lucide-react";
|
||||
import type { DockerEvent } from "../../shared/types";
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
||||
import { Activity, Play, Square, RotateCcw, AlertTriangle, BarChart3, ChevronDown, Check, Maximize2, Minimize2, Settings } from "lucide-react";
|
||||
import type { DockerEvent, StatsRange, Service, ContainerSettings, DiscordConfig } from "../../shared/types";
|
||||
import { useT } from "../i18n";
|
||||
import { useAllStatsHistory } from "../hooks/useStatsHistory";
|
||||
import { StatsCard } from "../components/StatsCard";
|
||||
import { ThresholdBar } from "../components/ThresholdBar";
|
||||
import { guessIcon } from "../nodes/ServiceNode";
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Math.floor((Date.now() / 1000) - ts);
|
||||
@@ -33,42 +38,539 @@ function actionColor(action: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
interface MonitoringPageProps {
|
||||
events: DockerEvent[];
|
||||
function ServiceIcon({ uid, services }: { uid: string; services: Service[] }) {
|
||||
const svc = services.find((s) => s.uid === uid);
|
||||
if (!svc) return null;
|
||||
const { Icon, color } = guessIcon(svc.image, svc.name);
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center w-6 h-6 rounded shrink-0"
|
||||
style={{ backgroundColor: `${color}22` }}
|
||||
>
|
||||
<Icon size={14} style={{ color }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitoringPage({ events }: MonitoringPageProps) {
|
||||
function FilterDropdown({ label, open, onToggle, children, dropdownRef }: {
|
||||
label: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
dropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 text-sm text-slate-400 bg-slate-800/80 backdrop-blur-sm hover:bg-slate-700/80 border border-slate-700/50 px-3 py-1.5 rounded-md transition-colors"
|
||||
>
|
||||
<span className="text-slate-300 truncate max-w-[140px]">{label}</span>
|
||||
<ChevronDown size={14} className={`text-slate-500 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full right-0 mt-1.5 bg-slate-800 border border-slate-700 rounded-lg shadow-xl shadow-black/40 py-1.5 min-w-[220px] max-h-[320px] overflow-y-auto z-20">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MonitoringPageProps {
|
||||
events: DockerEvent[];
|
||||
token: string;
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
export function MonitoringPage({ events, token, services }: MonitoringPageProps) {
|
||||
const { t } = useT();
|
||||
const sorted = [...events].reverse();
|
||||
const [statsRange, setStatsRange] = useState<StatsRange>("1h");
|
||||
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(new Set());
|
||||
const [selectedServices, setSelectedServices] = useState<Set<string>>(new Set());
|
||||
const [expandedService, setExpandedService] = useState<string | null>(null);
|
||||
const [configService, setConfigService] = useState<string | null>(null);
|
||||
const [projectFilterOpen, setProjectFilterOpen] = useState(false);
|
||||
const [serviceFilterOpen, setServiceFilterOpen] = useState(false);
|
||||
const projectRef = useRef<HTMLDivElement>(null);
|
||||
const serviceRef = useRef<HTMLDivElement>(null);
|
||||
const { data: allHistory, loading: historyLoading } = useAllStatsHistory(statsRange, token);
|
||||
const [containerSettings, setContainerSettings] = useState<Record<string, ContainerSettings>>({});
|
||||
const [globalThresholds, setGlobalThresholds] = useState<{ cpu: number; mem: number }>({ cpu: 80, mem: 90 });
|
||||
const [discordEnabled, setDiscordEnabled] = useState(false);
|
||||
|
||||
// Load thresholds
|
||||
useEffect(() => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
fetch("/api/container-settings", { headers })
|
||||
.then((r) => r.ok ? r.json() : {})
|
||||
.then((data: Record<string, ContainerSettings>) => setContainerSettings(data))
|
||||
.catch(() => {});
|
||||
fetch("/api/discord-config", { headers })
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data: DiscordConfig | null) => {
|
||||
if (data) {
|
||||
setGlobalThresholds({ cpu: data.thresholds.cpuPercent, mem: data.thresholds.memPercent });
|
||||
setDiscordEnabled(data.enabled && !!data.webhookUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
// Save a single container's settings
|
||||
const saveContainerSetting = useCallback(async (uid: string, settings: ContainerSettings) => {
|
||||
setContainerSettings((prev) => ({ ...prev, [uid]: settings }));
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
await fetch("/api/container-settings", {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify({ uid, settings }),
|
||||
});
|
||||
} catch {}
|
||||
}, [token]);
|
||||
|
||||
// Auto-save container settings on drag (debounced)
|
||||
const csSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const debouncedSave = useCallback((uid: string, settings: ContainerSettings) => {
|
||||
if (csSaveTimer.current) clearTimeout(csSaveTimer.current);
|
||||
csSaveTimer.current = setTimeout(() => {
|
||||
saveContainerSetting(uid, settings);
|
||||
}, 400);
|
||||
}, [saveContainerSetting]);
|
||||
|
||||
// Close dropdowns on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (projectRef.current && !projectRef.current.contains(e.target as HTMLElement)) {
|
||||
setProjectFilterOpen(false);
|
||||
}
|
||||
if (serviceRef.current && !serviceRef.current.contains(e.target as HTMLElement)) {
|
||||
setServiceFilterOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
// Build all known services from services prop + events + history
|
||||
const allServiceNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
for (const s of services) names.add(s.uid);
|
||||
for (const ev of events) names.add(ev.service);
|
||||
for (const svc of Object.keys(allHistory)) names.add(svc);
|
||||
return [...names].sort();
|
||||
}, [services, events, allHistory]);
|
||||
|
||||
// Extract unique projects
|
||||
const allProjects = useMemo(() => {
|
||||
const projects = new Set<string>();
|
||||
for (const svc of allServiceNames) {
|
||||
const slash = svc.indexOf("/");
|
||||
projects.add(slash >= 0 ? svc.slice(0, slash) : "standalone");
|
||||
}
|
||||
return [...projects].sort();
|
||||
}, [allServiceNames]);
|
||||
|
||||
// Services filtered by selected projects
|
||||
const projectFilteredServices = useMemo(() => {
|
||||
if (selectedProjects.size === 0) return allServiceNames;
|
||||
return allServiceNames.filter((svc) => {
|
||||
const slash = svc.indexOf("/");
|
||||
const project = slash >= 0 ? svc.slice(0, slash) : "standalone";
|
||||
return selectedProjects.has(project);
|
||||
});
|
||||
}, [allServiceNames, selectedProjects]);
|
||||
|
||||
// Final filtered set (project filter + service filter)
|
||||
// When no service is explicitly selected, show nothing (require selection)
|
||||
const hasActiveFilter = selectedServices.size > 0 || selectedProjects.size > 0;
|
||||
const finalFilteredServices = useMemo(() => {
|
||||
if (selectedServices.size > 0) return new Set(projectFilteredServices.filter((svc) => selectedServices.has(svc)));
|
||||
if (selectedProjects.size > 0) return new Set(projectFilteredServices);
|
||||
return new Set<string>();
|
||||
}, [projectFilteredServices, selectedServices, selectedProjects]);
|
||||
|
||||
// Filtered data
|
||||
const filteredHistory = useMemo(() => {
|
||||
const result: Record<string, typeof allHistory[string]> = {};
|
||||
for (const [svc, points] of Object.entries(allHistory)) {
|
||||
if (finalFilteredServices.has(svc)) result[svc] = points;
|
||||
}
|
||||
return result;
|
||||
}, [allHistory, finalFilteredServices]);
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
const sorted = [...events].reverse();
|
||||
if (!hasActiveFilter) return sorted;
|
||||
return sorted.filter((ev) => finalFilteredServices.has(ev.service));
|
||||
}, [events, finalFilteredServices, hasActiveFilter]);
|
||||
|
||||
const historyServiceNames = Object.keys(filteredHistory).sort();
|
||||
|
||||
// Toggle helpers
|
||||
const toggleProject = (project: string) => {
|
||||
setSelectedProjects((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(project)) next.delete(project);
|
||||
else next.add(project);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleService = (svc: string) => {
|
||||
setSelectedServices((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(svc)) next.delete(svc);
|
||||
else next.add(svc);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Labels
|
||||
const projectLabel = selectedProjects.size === 0
|
||||
? t("monitoring.filterProject")
|
||||
: selectedProjects.size === allProjects.length
|
||||
? t("monitoring.allProjects")
|
||||
: selectedProjects.size === 1
|
||||
? [...selectedProjects][0]
|
||||
: `${selectedProjects.size} ${t("filter.projects").toLowerCase()}`;
|
||||
|
||||
const serviceLabel = selectedServices.size === 0
|
||||
? t("monitoring.filterService")
|
||||
: selectedServices.size === projectFilteredServices.length
|
||||
? t("monitoring.allServices")
|
||||
: selectedServices.size === 1
|
||||
? ([...selectedServices][0].split("/").pop() || [...selectedServices][0])
|
||||
: `${selectedServices.size} ${t("footer.containers")}`;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Activity size={24} className="text-cyan-400" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">{t("monitoring.title")}</h1>
|
||||
<p className="text-sm text-slate-500">{t("monitoring.subtitle")}</p>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity size={24} className="text-cyan-400" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">{t("monitoring.title")}</h1>
|
||||
<p className="text-sm text-slate-500">{t("monitoring.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{allServiceNames.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Project filter */}
|
||||
{allProjects.length > 1 && (
|
||||
<FilterDropdown
|
||||
label={projectLabel}
|
||||
open={projectFilterOpen}
|
||||
onToggle={() => { setProjectFilterOpen((v) => !v); setServiceFilterOpen(false); }}
|
||||
dropdownRef={projectRef}
|
||||
>
|
||||
<button
|
||||
onClick={() => { setSelectedProjects(new Set(allProjects)); }}
|
||||
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
|
||||
selectedProjects.size === allProjects.length ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
|
||||
}`}>
|
||||
{selectedProjects.size === allProjects.length && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-slate-300 font-medium">{t("monitoring.allProjects")}</span>
|
||||
</button>
|
||||
<div className="border-t border-slate-700/50 my-1" />
|
||||
{allProjects.map((project) => {
|
||||
const isSelected = selectedProjects.has(project);
|
||||
return (
|
||||
<button
|
||||
key={project}
|
||||
onClick={() => toggleProject(project)}
|
||||
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 ${
|
||||
isSelected ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
|
||||
}`}>
|
||||
{isSelected && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<span className={isSelected ? "text-slate-200" : "text-slate-400"}>{project}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</FilterDropdown>
|
||||
)}
|
||||
|
||||
{/* Service filter */}
|
||||
<FilterDropdown
|
||||
label={serviceLabel}
|
||||
open={serviceFilterOpen}
|
||||
onToggle={() => { setServiceFilterOpen((v) => !v); setProjectFilterOpen(false); }}
|
||||
dropdownRef={serviceRef}
|
||||
>
|
||||
<button
|
||||
onClick={() => { setSelectedServices(new Set(projectFilteredServices)); }}
|
||||
className="flex items-center gap-2.5 w-full px-3.5 py-2 text-sm hover:bg-slate-700/60 transition-colors"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
|
||||
selectedServices.size === projectFilteredServices.length ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
|
||||
}`}>
|
||||
{selectedServices.size === projectFilteredServices.length && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-slate-300 font-medium">{t("monitoring.allServices")}</span>
|
||||
</button>
|
||||
<div className="border-t border-slate-700/50 my-1" />
|
||||
{projectFilteredServices.map((svc) => {
|
||||
const shortName = svc.split("/").pop() || svc;
|
||||
const isSelected = selectedServices.has(svc);
|
||||
return (
|
||||
<button
|
||||
key={svc}
|
||||
onClick={() => toggleService(svc)}
|
||||
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 ${
|
||||
isSelected ? "bg-cyan-500 border-cyan-500" : "border-slate-600"
|
||||
}`}>
|
||||
{isSelected && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<ServiceIcon uid={svc} services={services} />
|
||||
<span className={isSelected ? "text-slate-200" : "text-slate-400"}>{shortName}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</FilterDropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resource Usage History */}
|
||||
<div className="bg-slate-800/50 border border-slate-700/60 rounded-xl overflow-hidden mb-6">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700/40">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 size={16} className="text-cyan-400" />
|
||||
<span className="text-sm font-medium text-slate-200">{t("monitoring.statsHistory")}</span>
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
{(["1h", "6h", "24h", "7d"] as StatsRange[]).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setStatsRange(r)}
|
||||
className={`px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${
|
||||
statsRange === r
|
||||
? "bg-cyan-500/20 text-cyan-300"
|
||||
: "text-slate-500 hover:text-slate-300 hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!hasActiveFilter ? (
|
||||
<div className="px-6 py-10 text-center">
|
||||
<BarChart3 size={28} className="mx-auto mb-3 text-slate-600 opacity-50" />
|
||||
<p className="text-sm text-slate-500 mb-3">{t("monitoring.selectFilter")}</p>
|
||||
<button
|
||||
onClick={() => { setSelectedProjects(new Set(allProjects)); }}
|
||||
className="px-4 py-1.5 text-xs font-medium bg-cyan-500/15 text-cyan-400 rounded-lg hover:bg-cyan-500/25 transition-colors"
|
||||
>
|
||||
{t("monitoring.loadAll")}
|
||||
</button>
|
||||
</div>
|
||||
) : historyLoading ? (
|
||||
<div className="px-6 py-8 text-center text-slate-500 text-sm">
|
||||
{t("monitoring.loadingHistory")}
|
||||
</div>
|
||||
) : historyServiceNames.length === 0 ? (
|
||||
<div className="px-6 py-8 text-center text-slate-500 text-sm">
|
||||
{t("monitoring.noHistoryData")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-700/40">
|
||||
{historyServiceNames.map((svc) => {
|
||||
const points = filteredHistory[svc] || [];
|
||||
const shortName = svc.split("/").pop() || svc;
|
||||
const cs = containerSettings[svc];
|
||||
const svcNotifs = discordEnabled && (cs?.notificationsEnabled !== false);
|
||||
const cpuThreshold = svcNotifs ? (cs?.cpuThreshold ?? globalThresholds.cpu) : undefined;
|
||||
const memThreshold = svcNotifs ? (cs?.memThreshold ?? globalThresholds.mem) : undefined;
|
||||
const latest = points.length > 0 ? points[points.length - 1] : null;
|
||||
const isExpanded = expandedService === svc;
|
||||
const chartHeight = isExpanded ? 120 : 56;
|
||||
const svcData = services.find((s) => s.uid === svc);
|
||||
const cpuLimit = svcData && svcData.cpu_quota > 0 ? `${(svcData.cpu_quota / 1000).toFixed(0)}%` : undefined;
|
||||
const memLimit = svcData && svcData.memory_limit > 0 ? `${(svcData.memory_limit / 1024 / 1024).toFixed(0)} MB` : undefined;
|
||||
return (
|
||||
<div key={svc} className="px-5 py-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ServiceIcon uid={svc} services={services} />
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs text-slate-300 font-medium truncate block">{shortName}</span>
|
||||
{svc.includes("/") && (
|
||||
<span className="text-[10px] text-slate-500 truncate block leading-tight">{svc.split("/")[0]}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
{discordEnabled && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const opening = configService !== svc;
|
||||
setConfigService(opening ? svc : null);
|
||||
if (opening) setExpandedService(svc);
|
||||
else setExpandedService(null);
|
||||
}}
|
||||
className={`p-1 rounded hover:bg-slate-700/60 transition-colors ${configService === svc ? "text-cyan-400" : "text-slate-500 hover:text-slate-300"}`}
|
||||
title={t("detail.config")}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setExpandedService(isExpanded ? null : svc)}
|
||||
className="p-1 rounded hover:bg-slate-700/60 text-slate-500 hover:text-slate-300 transition-colors"
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{isExpanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{/* Inline config panel */}
|
||||
{configService === svc && (() => {
|
||||
const settings = cs || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null };
|
||||
const cpuTh = settings.cpuThreshold ?? globalThresholds.cpu;
|
||||
const memTh = settings.memThreshold ?? globalThresholds.mem;
|
||||
const cpuVal = latest?.cpu ?? 0;
|
||||
const memVal = latest?.mem_percent ?? 0;
|
||||
const memMb = latest?.mem_mb ?? 0;
|
||||
return (
|
||||
<div className="mb-2 bg-slate-900/90 border border-slate-700/40 rounded-lg px-4 py-3 space-y-3">
|
||||
{/* Notifications toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-300">{t("detail.notifications")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveContainerSetting(svc, { ...settings, notificationsEnabled: !settings.notificationsEnabled })}
|
||||
className={`relative w-9 h-5 rounded-full transition-colors ${settings.notificationsEnabled ? "bg-cyan-600" : "bg-slate-600"}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${settings.notificationsEnabled ? "translate-x-4" : "translate-x-0"}`} />
|
||||
</button>
|
||||
</div>
|
||||
{settings.notificationsEnabled && (
|
||||
<>
|
||||
<ThresholdBar
|
||||
label={t("detail.cpuUsage")}
|
||||
value={cpuVal}
|
||||
threshold={cpuTh}
|
||||
isCustom={settings.cpuThreshold !== null}
|
||||
showThreshold={true}
|
||||
thresholdLabel={t("detail.cpuThreshold")}
|
||||
tagLabel={settings.cpuThreshold !== null ? t("detail.custom") : t("detail.global")}
|
||||
hintLabel={t("detail.thresholdHint")}
|
||||
onThresholdChange={(v) => {
|
||||
setContainerSettings((prev) => {
|
||||
const cur = prev[svc] || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null };
|
||||
const updated = { ...cur, cpuThreshold: v };
|
||||
debouncedSave(svc, updated);
|
||||
return { ...prev, [svc]: updated };
|
||||
});
|
||||
}}
|
||||
onReset={() => saveContainerSetting(svc, { ...(cs || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }), cpuThreshold: null })}
|
||||
formatValue={(v) => `${v.toFixed(1)}%`}
|
||||
baseColor="cyan"
|
||||
/>
|
||||
<ThresholdBar
|
||||
label={t("detail.memoryUsage")}
|
||||
value={memVal}
|
||||
threshold={memTh}
|
||||
isCustom={settings.memThreshold !== null}
|
||||
showThreshold={true}
|
||||
thresholdLabel={t("detail.memThreshold")}
|
||||
tagLabel={settings.memThreshold !== null ? t("detail.custom") : t("detail.global")}
|
||||
hintLabel={t("detail.thresholdHint")}
|
||||
onThresholdChange={(v) => {
|
||||
setContainerSettings((prev) => {
|
||||
const cur = prev[svc] || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null };
|
||||
const updated = { ...cur, memThreshold: v };
|
||||
debouncedSave(svc, updated);
|
||||
return { ...prev, [svc]: updated };
|
||||
});
|
||||
}}
|
||||
onReset={() => saveContainerSetting(svc, { ...(cs || { notificationsEnabled: true, cpuThreshold: null, memThreshold: null }), memThreshold: null })}
|
||||
formatValue={() => `${memMb.toFixed(0)} MB (${memVal.toFixed(1)}%)`}
|
||||
formatThreshold={svcData && svcData.memory_limit > 0 ? (th) => `${((th / 100) * svcData.memory_limit / 1024 / 1024).toFixed(0)} MB` : undefined}
|
||||
baseColor="purple"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className={isExpanded ? "space-y-2" : "grid grid-cols-2 gap-2"}>
|
||||
<StatsCard
|
||||
label="CPU"
|
||||
value={latest ? `${latest.cpu.toFixed(1)}%` : "—"}
|
||||
limit={cpuLimit}
|
||||
data={points.map((p) => p.cpu)}
|
||||
timestamps={points.map((p) => p.timestamp)}
|
||||
hoverValues={points.map((p) => p.cpu)}
|
||||
color="#06b6d4"
|
||||
threshold={cpuThreshold}
|
||||
sparklineHeight={chartHeight}
|
||||
formatHoverValue={(v) => `${v.toFixed(2)}%`}
|
||||
showAverage
|
||||
formatAverage={(v) => `${v.toFixed(2)}%`}
|
||||
avgLabel={t("detail.avg")}
|
||||
/>
|
||||
<StatsCard
|
||||
label="MEM"
|
||||
value={latest ? `${latest.mem_mb.toFixed(0)} MB` : "—"}
|
||||
limit={memLimit}
|
||||
data={points.map((p) => p.mem_percent)}
|
||||
timestamps={points.map((p) => p.timestamp)}
|
||||
hoverValues={points.map((p) => p.mem_mb)}
|
||||
color="#a78bfa"
|
||||
threshold={memThreshold}
|
||||
sparklineHeight={chartHeight}
|
||||
formatHoverValue={(v) => `${v.toFixed(0)} MB`}
|
||||
showAverage
|
||||
formatAverage={(v) => `${v.toFixed(0)} MB`}
|
||||
avgLabel={t("detail.avg")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Events list */}
|
||||
<div className="bg-slate-800/50 border border-slate-700/60 rounded-xl overflow-hidden">
|
||||
{sorted.length === 0 ? (
|
||||
{filteredEvents.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center text-slate-500">
|
||||
<Activity size={32} className="mx-auto mb-3 opacity-40" />
|
||||
<p>{t("monitoring.noEvents")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-700/40">
|
||||
{sorted.map((ev, i) => (
|
||||
{filteredEvents.map((ev, i) => (
|
||||
<div key={`${ev.service}-${ev.time}-${i}`} className="flex items-center gap-4 px-5 py-3 hover:bg-slate-700/30 transition-colors">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-700/60 flex items-center justify-center flex-shrink-0">
|
||||
{eventIcon(ev.action)}
|
||||
</div>
|
||||
<ServiceIcon uid={ev.service} services={services} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm text-slate-200 font-medium truncate block">{ev.service}</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-sm text-slate-200 font-medium truncate">
|
||||
{ev.service.split("/").pop() || ev.service}
|
||||
</span>
|
||||
{ev.service.includes("/") && (
|
||||
<span className="text-[10px] text-slate-500 truncate">
|
||||
{ev.service.split("/")[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs font-mono ${actionColor(ev.action)}`}>{ev.action}</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 font-mono flex-shrink-0">{timeAgo(ev.time)}</span>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function SettingsPage({ projects, servicesCount, token }: SettingsPagePro
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto p-6">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Settings size={24} className="text-cyan-400" />
|
||||
|
||||
+181
-150
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo, startTransition } from "react";
|
||||
import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info as InfoIcon, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink, Pencil, HelpCircle } from "lucide-react";
|
||||
import type { Service, Stats, LogLine, WSMessage, Connection, DockerEvent, ContainerSettings } from "../../shared/types";
|
||||
import { X, Pause, Play, Square, RotateCw, Hammer, Trash2, Terminal, Network, Globe, Info as InfoIcon, Activity, Variable, Settings, ChevronUp, ChevronDown, Eye, EyeOff, Copy, Check, Loader2, AlertTriangle, Maximize2, ExternalLink, Pencil, HelpCircle, Save } from "lucide-react";
|
||||
import type { Service, Stats, LogLine, WSMessage, Connection, DockerEvent, ContainerSettings, DiscordConfig, StatsRange } from "../../shared/types";
|
||||
import { useT } from "../i18n";
|
||||
import { useStatsHistory } from "../hooks/useStatsHistory";
|
||||
import { StatsCard } from "../components/StatsCard";
|
||||
import { ThresholdBar } from "../components/ThresholdBar";
|
||||
|
||||
type Tab = "info" | "config" | "env" | "stats";
|
||||
|
||||
@@ -87,6 +90,10 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
const [csLoaded, setCsLoaded] = useState(false);
|
||||
const [csSaving, setCsSaving] = useState(false);
|
||||
const [csSaved, setCsSaved] = useState(false);
|
||||
const [globalThresholds, setGlobalThresholds] = useState<{ cpu: number; mem: number }>({ cpu: 80, mem: 90 });
|
||||
const [discordEnabled, setDiscordEnabled] = useState(false);
|
||||
const [statsRange, setStatsRange] = useState<StatsRange>("1h");
|
||||
const { data: historyData, loading: historyLoading } = useStatsHistory(service.uid, statsRange, token);
|
||||
|
||||
useEffect(() => {
|
||||
const headers: Record<string, string> = {};
|
||||
@@ -98,24 +105,40 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
setCsLoaded(true);
|
||||
})
|
||||
.catch(() => setCsLoaded(true));
|
||||
fetch("/api/discord-config", { headers })
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((cfg: DiscordConfig | null) => {
|
||||
if (cfg) {
|
||||
setGlobalThresholds({ cpu: cfg.thresholds.cpuPercent, mem: cfg.thresholds.memPercent });
|
||||
setDiscordEnabled(cfg.enabled && !!cfg.webhookUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [service.uid, token]);
|
||||
|
||||
const saveContainerSettings = useCallback(async () => {
|
||||
setCsSaving(true);
|
||||
setCsSaved(false);
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
await fetch("/api/container-settings", {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify({ uid: service.uid, settings: containerSettings }),
|
||||
});
|
||||
setCsSaved(true);
|
||||
setTimeout(() => setCsSaved(false), 2000);
|
||||
} catch {}
|
||||
setCsSaving(false);
|
||||
}, [service.uid, token, containerSettings]);
|
||||
// Auto-save container settings on change (debounced)
|
||||
const csLoadedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!csLoaded) return;
|
||||
// Skip the first render after loading
|
||||
if (!csLoadedRef.current) { csLoadedRef.current = true; return; }
|
||||
const timer = setTimeout(async () => {
|
||||
setCsSaving(true);
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
await fetch("/api/container-settings", {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: JSON.stringify({ uid: service.uid, settings: containerSettings }),
|
||||
});
|
||||
setCsSaved(true);
|
||||
setTimeout(() => setCsSaved(false), 1500);
|
||||
} catch {}
|
||||
setCsSaving(false);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [containerSettings, csLoaded, service.uid, token]);
|
||||
|
||||
// Scroll modal to bottom when opened or when logs arrive
|
||||
useEffect(() => {
|
||||
@@ -881,68 +904,142 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
|
||||
{/* Notifications toggle */}
|
||||
{csLoaded && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-300">{t("detail.notifications")}</span>
|
||||
<span className={`text-xs ${discordEnabled ? "text-slate-300" : "text-slate-500"}`}>{t("detail.notifications")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setContainerSettings((s) => ({ ...s, notificationsEnabled: !s.notificationsEnabled }))}
|
||||
className={`relative w-9 h-5 rounded-full transition-colors ${containerSettings.notificationsEnabled ? "bg-cyan-600" : "bg-slate-600"}`}
|
||||
disabled={!discordEnabled}
|
||||
onClick={() => { if (discordEnabled) setContainerSettings((s) => ({ ...s, notificationsEnabled: !s.notificationsEnabled })); }}
|
||||
className={`relative w-9 h-5 rounded-full transition-colors ${!discordEnabled ? "bg-slate-700 opacity-50 cursor-not-allowed" : containerSettings.notificationsEnabled ? "bg-cyan-600" : "bg-slate-600"}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${containerSettings.notificationsEnabled ? "translate-x-4" : "translate-x-0"}`} />
|
||||
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${discordEnabled && containerSettings.notificationsEnabled ? "translate-x-4" : "translate-x-0"}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatCard label={t("node.cpu")} value={`${stats.cpu.toFixed(1)}%`} color={stats.cpu > 80 ? "text-red-400" : stats.cpu > 50 ? "text-yellow-400" : "text-emerald-400"} />
|
||||
<StatCard label={t("detail.memory")} value={`${stats.mem_mb.toFixed(0)} MB`} extra={`${stats.mem_percent.toFixed(1)}%`} color={stats.mem_percent > 80 ? "text-red-400" : stats.mem_percent > 50 ? "text-yellow-400" : "text-emerald-400"} />
|
||||
<div className="grid grid-cols-2 gap-3 mb-2">
|
||||
<StatCard
|
||||
label={t("node.cpu")}
|
||||
value={`${stats.cpu.toFixed(1)}%`}
|
||||
color={stats.cpu > (discordEnabled && containerSettings.notificationsEnabled ? (containerSettings.cpuThreshold ?? globalThresholds.cpu) : 80) ? "text-amber-400" : "text-cyan-400"}
|
||||
limit={service.cpu_quota > 0 ? `${(service.cpu_quota / 1000).toFixed(0)}%` : undefined}
|
||||
threshold={discordEnabled && containerSettings.notificationsEnabled ? `${containerSettings.cpuThreshold ?? globalThresholds.cpu}%` : undefined}
|
||||
thresholdLabel={t("detail.threshold")}
|
||||
limitLabel={t("detail.limit")}
|
||||
thresholdTooltip={t("detail.thresholdTooltip")}
|
||||
limitTooltip={t("detail.limitTooltip")}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("detail.memory")}
|
||||
value={`${stats.mem_mb.toFixed(0)} MB`}
|
||||
extra={`${stats.mem_percent.toFixed(1)}%`}
|
||||
color={stats.mem_percent > (discordEnabled && containerSettings.notificationsEnabled ? (containerSettings.memThreshold ?? globalThresholds.mem) : 80) ? "text-amber-400" : "text-purple-400"}
|
||||
limit={service.memory_limit > 0 ? `${(service.memory_limit / 1024 / 1024).toFixed(0)} MB` : undefined}
|
||||
threshold={discordEnabled && containerSettings.notificationsEnabled ? `${containerSettings.memThreshold ?? globalThresholds.mem}%` : undefined}
|
||||
thresholdLabel={t("detail.threshold")}
|
||||
limitLabel={t("detail.limit")}
|
||||
thresholdTooltip={t("detail.thresholdTooltip")}
|
||||
limitTooltip={t("detail.limitTooltip")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CPU bar with draggable threshold */}
|
||||
<ThresholdBar
|
||||
label={t("detail.cpuUsage")}
|
||||
value={stats.cpu}
|
||||
threshold={containerSettings.cpuThreshold ?? 80}
|
||||
threshold={containerSettings.cpuThreshold ?? globalThresholds.cpu}
|
||||
isCustom={containerSettings.cpuThreshold !== null}
|
||||
showThreshold={containerSettings.notificationsEnabled}
|
||||
showThreshold={discordEnabled && containerSettings.notificationsEnabled}
|
||||
thresholdLabel={t("detail.cpuThreshold")}
|
||||
tagLabel={containerSettings.cpuThreshold !== null ? t("detail.custom") : t("detail.global")}
|
||||
hintLabel={t("detail.thresholdHint")}
|
||||
onThresholdChange={(v) => setContainerSettings((s) => ({ ...s, cpuThreshold: v }))}
|
||||
onReset={() => setContainerSettings((s) => ({ ...s, cpuThreshold: null }))}
|
||||
formatValue={(v) => `${v.toFixed(1)}%`}
|
||||
baseColor="cyan"
|
||||
/>
|
||||
|
||||
{/* Memory bar with draggable threshold */}
|
||||
{/* Memory bar with draggable threshold — extra top margin for drag handle clearance */}
|
||||
<div className="mt-2" />
|
||||
<ThresholdBar
|
||||
label={t("detail.memoryUsage")}
|
||||
value={stats.mem_percent}
|
||||
threshold={containerSettings.memThreshold ?? 90}
|
||||
threshold={containerSettings.memThreshold ?? globalThresholds.mem}
|
||||
isCustom={containerSettings.memThreshold !== null}
|
||||
showThreshold={containerSettings.notificationsEnabled}
|
||||
showThreshold={discordEnabled && containerSettings.notificationsEnabled}
|
||||
thresholdLabel={t("detail.memThreshold")}
|
||||
tagLabel={containerSettings.memThreshold !== null ? t("detail.custom") : t("detail.global")}
|
||||
hintLabel={t("detail.thresholdHint")}
|
||||
onThresholdChange={(v) => setContainerSettings((s) => ({ ...s, memThreshold: v }))}
|
||||
onReset={() => setContainerSettings((s) => ({ ...s, memThreshold: null }))}
|
||||
formatValue={() => `${stats.mem_mb.toFixed(0)} MB (${stats.mem_percent.toFixed(1)}%)`}
|
||||
formatThreshold={service.memory_limit > 0 ? (th) => `${((th / 100) * service.memory_limit / 1024 / 1024).toFixed(0)} MB` : undefined}
|
||||
baseColor="purple"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-slate-500 text-sm text-center py-8">{t("detail.noStats")}</div>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
{csLoaded && (
|
||||
<button
|
||||
onClick={saveContainerSettings}
|
||||
disabled={csSaving}
|
||||
className="w-full px-3 py-1.5 rounded text-xs font-medium text-white bg-cyan-700 hover:bg-cyan-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{csSaving ? t("detail.savingSettings") : csSaved ? t("detail.settingsSaved") : t("detail.saveSettings")}
|
||||
</button>
|
||||
)}
|
||||
{/* History sparklines */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400 font-medium">{t("detail.cpuHistory")}</span>
|
||||
<div className="flex gap-0.5">
|
||||
{(["1h", "6h", "24h", "7d"] as StatsRange[]).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setStatsRange(r)}
|
||||
className={`px-2 py-0.5 rounded-full text-[10px] font-medium transition-colors ${
|
||||
statsRange === r
|
||||
? "bg-cyan-500/20 text-cyan-300"
|
||||
: "text-slate-500 hover:text-slate-300 hover:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{historyLoading ? (
|
||||
<div className="text-slate-500 text-[11px] text-center py-4">{t("detail.loadingHistory")}</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<StatsCard
|
||||
label="CPU"
|
||||
value={stats ? `${stats.cpu.toFixed(1)}%` : "—"}
|
||||
limit={service.cpu_quota > 0 ? `${(service.cpu_quota / 1000).toFixed(0)}%` : undefined}
|
||||
data={historyData.map((p) => p.cpu)}
|
||||
timestamps={historyData.map((p) => p.timestamp)}
|
||||
hoverValues={historyData.map((p) => p.cpu)}
|
||||
color="#06b6d4"
|
||||
threshold={discordEnabled && containerSettings.notificationsEnabled ? (containerSettings.cpuThreshold ?? globalThresholds.cpu) : undefined}
|
||||
sparklineHeight={60}
|
||||
formatHoverValue={(v) => `${v.toFixed(2)}%`}
|
||||
showAverage
|
||||
formatAverage={(v) => `${v.toFixed(2)}%`}
|
||||
avgLabel={t("detail.avg")}
|
||||
/>
|
||||
<StatsCard
|
||||
label="MEM"
|
||||
value={stats ? `${stats.mem_mb.toFixed(0)} MB` : "—"}
|
||||
limit={service.memory_limit > 0 ? `${(service.memory_limit / 1024 / 1024).toFixed(0)} MB` : undefined}
|
||||
data={historyData.map((p) => p.mem_percent)}
|
||||
timestamps={historyData.map((p) => p.timestamp)}
|
||||
hoverValues={historyData.map((p) => p.mem_mb)}
|
||||
color="#a78bfa"
|
||||
threshold={discordEnabled && containerSettings.notificationsEnabled ? (containerSettings.memThreshold ?? globalThresholds.mem) : undefined}
|
||||
sparklineHeight={60}
|
||||
formatHoverValue={(v) => `${v.toFixed(0)} MB`}
|
||||
showAverage
|
||||
formatAverage={(v) => `${v.toFixed(0)} MB`}
|
||||
avgLabel={t("detail.avg")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1185,124 +1282,58 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
|
||||
);
|
||||
}
|
||||
|
||||
function ThresholdBar({ label, value, threshold, isCustom, showThreshold, thresholdLabel, tagLabel, hintLabel, onThresholdChange, onReset, formatValue }: {
|
||||
label: string;
|
||||
value: number;
|
||||
threshold: number;
|
||||
isCustom: boolean;
|
||||
showThreshold: boolean;
|
||||
thresholdLabel: string;
|
||||
tagLabel: string;
|
||||
hintLabel: string;
|
||||
onThresholdChange: (v: number) => void;
|
||||
onReset: () => void;
|
||||
formatValue: (v: number) => string;
|
||||
}) {
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [hovering, setHovering] = useState(false);
|
||||
|
||||
const calcPercent = useCallback((clientX: number) => {
|
||||
if (!barRef.current) return threshold;
|
||||
const rect = barRef.current.getBoundingClientRect();
|
||||
const pct = Math.round(((clientX - rect.left) / rect.width) * 100);
|
||||
return Math.max(5, Math.min(100, pct));
|
||||
}, [threshold]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const onMove = (e: MouseEvent) => { onThresholdChange(calcPercent(e.clientX)); };
|
||||
const onUp = () => { setDragging(false); };
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
return () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); };
|
||||
}, [dragging, calcPercent, onThresholdChange]);
|
||||
|
||||
// Touch support
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const onMove = (e: TouchEvent) => { if (e.touches[0]) onThresholdChange(calcPercent(e.touches[0].clientX)); };
|
||||
const onEnd = () => { setDragging(false); };
|
||||
window.addEventListener("touchmove", onMove);
|
||||
window.addEventListener("touchend", onEnd);
|
||||
return () => { window.removeEventListener("touchmove", onMove); window.removeEventListener("touchend", onEnd); };
|
||||
}, [dragging, calcPercent, onThresholdChange]);
|
||||
|
||||
const barColor = showThreshold
|
||||
? (value > threshold ? "bg-red-500" : value > 50 ? "bg-yellow-500" : "bg-emerald-500")
|
||||
: (value > 80 ? "bg-red-500" : value > 50 ? "bg-yellow-500" : "bg-emerald-500");
|
||||
const showTooltip = dragging || hovering;
|
||||
|
||||
function Tooltip({ text }: { text: string }) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-slate-500 mb-1">
|
||||
<span>{label}</span>
|
||||
<span>{formatValue(value)}</span>
|
||||
</div>
|
||||
<div
|
||||
ref={barRef}
|
||||
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)); }}
|
||||
<span className="relative inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => setShow(true)}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
onClick={() => setShow((v) => !v)}
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
{/* Usage fill */}
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 rounded-full transition-all duration-500 ${barColor}`}
|
||||
style={{ width: `${Math.min(value, 100)}%` }}
|
||||
/>
|
||||
{/* Threshold handle — only when notifications enabled */}
|
||||
{showThreshold && (
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 z-10 select-none touch-none"
|
||||
style={{ left: `${threshold}%` }}
|
||||
onMouseDown={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onTouchStart={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
>
|
||||
{/* Vertical line */}
|
||||
<div className={`w-0.5 h-5 rounded-full transition-colors ${dragging ? "bg-amber-300" : "bg-amber-400/80 group-hover:bg-amber-400"}`} />
|
||||
{/* Drag handle diamond */}
|
||||
<div className={`absolute -top-1 left-1/2 -translate-x-1/2 w-2.5 h-2.5 rotate-45 rounded-[1px] border transition-colors cursor-grab active:cursor-grabbing ${
|
||||
dragging ? "bg-amber-300 border-amber-200" : "bg-amber-400/90 border-amber-500/50 group-hover:bg-amber-400"
|
||||
}`} />
|
||||
{/* Tooltip */}
|
||||
{showTooltip && (
|
||||
<div className="absolute -top-7 left-1/2 -translate-x-1/2 px-1.5 py-0.5 bg-slate-700 rounded text-[10px] font-mono text-amber-300 whitespace-nowrap shadow-lg">
|
||||
{threshold}%
|
||||
<HelpCircle size={10} />
|
||||
</button>
|
||||
{show && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-xs text-slate-200 w-48 text-left shadow-xl z-50 leading-relaxed">
|
||||
{text}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-slate-700" />
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<div className="bg-slate-800/80 rounded-lg px-4 py-3">
|
||||
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">{label}</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className={`text-xl font-mono font-semibold ${color}`}>{value}</span>
|
||||
{extra && <span className="text-xs text-slate-500 ml-2">{extra}</span>}
|
||||
</div>
|
||||
{(threshold || limit) && (
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
{threshold && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-slate-500">{thresholdLabel}:</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">{threshold}</span>
|
||||
{thresholdTooltip && <Tooltip text={thresholdTooltip} />}
|
||||
</div>
|
||||
)}
|
||||
{limit && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-slate-500">{limitLabel}:</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono">{limit}</span>
|
||||
{limitTooltip && <Tooltip text={limitTooltip} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Label row — only when notifications enabled */}
|
||||
{showThreshold && (
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-[10px] text-slate-600">{hintLabel}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-amber-400/70 font-mono">{threshold}%</span>
|
||||
{isCustom && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="text-[9px] text-slate-500 hover:text-slate-300 transition-colors"
|
||||
title="Reset to global"
|
||||
>
|
||||
reset
|
||||
</button>
|
||||
)}
|
||||
<span className="text-[9px] text-slate-600">{tagLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, extra, color }: { label: string; value: string; extra?: string; color: string }) {
|
||||
return (
|
||||
<div className="bg-slate-800/80 rounded-lg px-4 py-3">
|
||||
<span className="text-[11px] uppercase tracking-wider text-slate-500 block mb-1">{label}</span>
|
||||
<span className={`text-xl font-mono font-semibold ${color}`}>{value}</span>
|
||||
{extra && <span className="text-xs text-slate-500 ml-2">{extra}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+87
-27
@@ -73,9 +73,13 @@ export function checkDownServices(config: DiscordConfig): void {
|
||||
if (!config.enabled || !config.events.containerStateChanges) return;
|
||||
const now = Date.now();
|
||||
for (const [service, downSince] of downServices) {
|
||||
// Skip services that are still in debounce window (might be restarting)
|
||||
if (pendingDown.has(service)) continue;
|
||||
const downMinutes = Math.floor((now - downSince) / 60_000);
|
||||
// Don't send "Still Down" for less than 1 minute
|
||||
if (downMinutes < 1) continue;
|
||||
const cooldownKey = `down:${service}`;
|
||||
if (!isOnCooldown(cooldownKey, config.downReminderMinutes)) {
|
||||
const downMinutes = Math.floor((now - downSince) / 60_000);
|
||||
setCooldown(cooldownKey);
|
||||
queueWebhook(config.webhookUrl, {
|
||||
username: "ContainerFlow",
|
||||
@@ -146,45 +150,101 @@ function sendEmbed(config: DiscordConfig, embed: any, cooldownKey?: string): voi
|
||||
|
||||
// ── Notification functions ──
|
||||
|
||||
const STATE_COLORS: Record<string, number> = {
|
||||
start: 0x22c55e, // green
|
||||
stop: 0xef4444, // red
|
||||
die: 0xef4444,
|
||||
restart: 0xf59e0b, // orange
|
||||
health_status: 0xf59e0b,
|
||||
create: 0x3b82f6, // blue
|
||||
destroy: 0xef4444,
|
||||
};
|
||||
const STATE_DEBOUNCE_MS = 15_000; // Wait 15s before notifying stop/die to detect restarts
|
||||
|
||||
const STATE_TITLES: Record<string, string> = {
|
||||
start: "Container Started",
|
||||
stop: "Container Stopped",
|
||||
die: "Container Crashed",
|
||||
restart: "Container Restarted",
|
||||
health_status: "Health Status Changed",
|
||||
create: "Container Created",
|
||||
destroy: "Container Destroyed",
|
||||
};
|
||||
// Pending stop/die events waiting to be flushed or cancelled
|
||||
const pendingDown = new Map<string, { action: string; timer: ReturnType<typeof setTimeout>; config: DiscordConfig }>();
|
||||
|
||||
function flushPendingDown(service: string): void {
|
||||
const pending = pendingDown.get(service);
|
||||
if (!pending) return;
|
||||
pendingDown.delete(service);
|
||||
clearTimeout(pending.timer);
|
||||
|
||||
const { action, config } = pending;
|
||||
|
||||
// Now we know it's a real stop/crash (no start followed within the debounce window)
|
||||
downServices.set(service, Date.now());
|
||||
// Set cooldown for down reminders so the first "Still Down" doesn't fire immediately
|
||||
setCooldown(`down:${service}`);
|
||||
|
||||
const cooldownKey = `state:${action}:${service}`;
|
||||
const title = action === "die" ? "Container Crashed" : "Container Stopped";
|
||||
sendEmbed(config, {
|
||||
title,
|
||||
color: 0xef4444,
|
||||
description: `**${service}**\n\nAction: \`${action}\``,
|
||||
footer: { text: "ContainerFlow" },
|
||||
}, cooldownKey);
|
||||
}
|
||||
|
||||
function cancelPendingDown(service: string): void {
|
||||
const pending = pendingDown.get(service);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pendingDown.delete(service);
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyStateChange(service: string, action: string, config: DiscordConfig): void {
|
||||
if (!config.events.containerStateChanges) return;
|
||||
|
||||
// Track down services for re-alerting
|
||||
// Ignore create/destroy — they're internal Docker lifecycle noise
|
||||
if (action === "create" || action === "destroy") return;
|
||||
|
||||
if (action === "die" || action === "stop") {
|
||||
downServices.set(service, Date.now());
|
||||
} else if (action === "start") {
|
||||
// Service recovered — stop tracking and clear die/stop cooldowns
|
||||
// Don't notify immediately — buffer to detect restart sequences
|
||||
// If there's already a pending event for this service, keep the first one
|
||||
if (pendingDown.has(service)) return;
|
||||
const timer = setTimeout(() => flushPendingDown(service), STATE_DEBOUNCE_MS);
|
||||
pendingDown.set(service, { action, timer, config });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "start") {
|
||||
const wasPending = pendingDown.has(service);
|
||||
cancelPendingDown(service);
|
||||
|
||||
// Service recovered — stop tracking and clear cooldowns
|
||||
downServices.delete(service);
|
||||
clearCooldown(`state:die:${service}`);
|
||||
clearCooldown(`state:stop:${service}`);
|
||||
|
||||
if (wasPending) {
|
||||
// stop/die → start within debounce window = restart/redeploy, send single message
|
||||
const cooldownKey = `state:restart:${service}`;
|
||||
sendEmbed(config, {
|
||||
title: "Container Restarted",
|
||||
color: 0xf59e0b,
|
||||
description: `**${service}**\n\nAction: \`redeployed\``,
|
||||
footer: { text: "ContainerFlow" },
|
||||
}, cooldownKey);
|
||||
} else {
|
||||
// Fresh start (no preceding stop/die)
|
||||
const cooldownKey = `state:start:${service}`;
|
||||
sendEmbed(config, {
|
||||
title: "Container Started",
|
||||
color: 0x22c55e,
|
||||
description: `**${service}**\n\nAction: \`start\``,
|
||||
footer: { text: "ContainerFlow" },
|
||||
}, cooldownKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cooldown per action per service
|
||||
// Other events (restart, health_status)
|
||||
const titles: Record<string, string> = {
|
||||
restart: "Container Restarted",
|
||||
health_status: "Health Status Changed",
|
||||
};
|
||||
const colors: Record<string, number> = {
|
||||
restart: 0xf59e0b,
|
||||
health_status: 0xf59e0b,
|
||||
};
|
||||
const cooldownKey = `state:${action}:${service}`;
|
||||
const title = STATE_TITLES[action] || `Container ${action}`;
|
||||
sendEmbed(config, {
|
||||
title,
|
||||
color: STATE_COLORS[action] || 0x94a3b8,
|
||||
title: titles[action] || `Container ${action}`,
|
||||
color: colors[action] || 0x94a3b8,
|
||||
description: `**${service}**\n\nAction: \`${action}\``,
|
||||
footer: { text: "ContainerFlow" },
|
||||
}, cooldownKey);
|
||||
|
||||
+22
-1
@@ -8,7 +8,8 @@ import { docker, discoverServices, discoverConnections, getContainerLogs, stream
|
||||
import { pollStats, watchDockerEvents } from "./watcher";
|
||||
import { loadDiscordConfig, saveDiscordConfig, notifyStateChange, notifyResourceAlert, notifyUIAction, notifyActionError, testWebhook, checkDownServices } from "./discord";
|
||||
import { loadContainerSettings, saveContainerSettings } from "./container-settings";
|
||||
import type { Service, WSMessage, DiscordConfig, ContainerSettings } from "../shared/types";
|
||||
import { initStatsDB, insertStats, getStatsHistory, getAllServicesStatsHistory } from "./stats-db";
|
||||
import type { Service, WSMessage, DiscordConfig, ContainerSettings, StatsRange } from "../shared/types";
|
||||
|
||||
/** Directory for persistent data files (positions, env overrides) */
|
||||
const DATA_DIR = process.env.DATA_DIR || process.cwd();
|
||||
@@ -471,6 +472,22 @@ app.put("/api/container-settings", async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Stats history ──
|
||||
const VALID_RANGES = new Set(["1h", "6h", "24h", "7d"]);
|
||||
|
||||
app.get("/api/stats/history/:uid{.+}", (c) => {
|
||||
const uid = c.req.param("uid");
|
||||
const range = (c.req.query("range") || "1h") as StatsRange;
|
||||
if (!VALID_RANGES.has(range)) return c.json({ error: "Invalid range" }, 400);
|
||||
return c.json(getStatsHistory(uid, range));
|
||||
});
|
||||
|
||||
app.get("/api/stats/history", (c) => {
|
||||
const range = (c.req.query("range") || "1h") as StatsRange;
|
||||
if (!VALID_RANGES.has(range)) return c.json({ error: "Invalid range" }, 400);
|
||||
return c.json(getAllServicesStatsHistory(range));
|
||||
});
|
||||
|
||||
// ── Cache headers for static assets ──
|
||||
app.use("/*", async (c, next) => {
|
||||
await next();
|
||||
@@ -560,6 +577,7 @@ async function refreshStats(services: Service[]) {
|
||||
try {
|
||||
const stats = await pollStats(services);
|
||||
broadcast({ type: "stats", data: stats });
|
||||
try { insertStats(stats); } catch {}
|
||||
// Check resource thresholds and down services for Discord alerts
|
||||
try {
|
||||
const discordConfig = loadDiscordConfig();
|
||||
@@ -627,6 +645,9 @@ let lastConnectionsHash = "";
|
||||
|
||||
setInterval(refreshServices, POLL_INTERVAL_MS);
|
||||
|
||||
// ── Init stats DB ──
|
||||
initStatsDB();
|
||||
|
||||
// ── Start ──
|
||||
const server = Bun.serve({
|
||||
hostname: HOST,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import path from "path";
|
||||
import type { Stats, StatsHistoryPoint, StatsRange } from "../shared/types";
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || process.cwd();
|
||||
const DB_PATH = path.join(DATA_DIR, ".dockerflow-stats.db");
|
||||
|
||||
let db: Database;
|
||||
|
||||
const RANGE_BUCKET: Record<StatsRange, number> = {
|
||||
"1h": 30,
|
||||
"6h": 60,
|
||||
"24h": 300,
|
||||
"7d": 1800,
|
||||
};
|
||||
|
||||
const RANGE_SECONDS: Record<StatsRange, number> = {
|
||||
"1h": 3600,
|
||||
"6h": 21600,
|
||||
"24h": 86400,
|
||||
"7d": 604800,
|
||||
};
|
||||
|
||||
export function initStatsDB() {
|
||||
db = new Database(DB_PATH);
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA synchronous = NORMAL");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS stats_raw (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
service TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
cpu REAL NOT NULL,
|
||||
mem_mb REAL NOT NULL,
|
||||
mem_percent REAL NOT NULL
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_service_time ON stats_raw (service, timestamp)
|
||||
`);
|
||||
|
||||
// Initial cleanup
|
||||
cleanupOldStats();
|
||||
|
||||
// Schedule cleanup every hour
|
||||
setInterval(cleanupOldStats, 3600_000);
|
||||
}
|
||||
|
||||
export function insertStats(stats: Stats[]) {
|
||||
if (!db || stats.length === 0) return;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const stmt = db.prepare(
|
||||
"INSERT INTO stats_raw (service, timestamp, cpu, mem_mb, mem_percent) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
const transaction = db.transaction(() => {
|
||||
for (const s of stats) {
|
||||
stmt.run(s.service, now, s.cpu, s.mem_mb, s.mem_percent);
|
||||
}
|
||||
});
|
||||
transaction();
|
||||
}
|
||||
|
||||
export function getStatsHistory(service: string, range: StatsRange): StatsHistoryPoint[] {
|
||||
if (!db) return [];
|
||||
const bucket = RANGE_BUCKET[range];
|
||||
const since = Math.floor(Date.now() / 1000) - RANGE_SECONDS[range];
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
(timestamp / ?) * ? AS ts,
|
||||
AVG(cpu) AS cpu,
|
||||
AVG(mem_mb) AS mem_mb,
|
||||
AVG(mem_percent) AS mem_percent
|
||||
FROM stats_raw
|
||||
WHERE service = ? AND timestamp >= ?
|
||||
GROUP BY ts
|
||||
ORDER BY ts ASC
|
||||
`).all(bucket, bucket, service, since) as { ts: number; cpu: number; mem_mb: number; mem_percent: number }[];
|
||||
|
||||
return rows.map((r) => ({
|
||||
timestamp: r.ts,
|
||||
cpu: r.cpu,
|
||||
mem_mb: r.mem_mb,
|
||||
mem_percent: r.mem_percent,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getAllServicesStatsHistory(range: StatsRange): Record<string, StatsHistoryPoint[]> {
|
||||
if (!db) return {};
|
||||
const bucket = RANGE_BUCKET[range];
|
||||
const since = Math.floor(Date.now() / 1000) - RANGE_SECONDS[range];
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
service,
|
||||
(timestamp / ?) * ? AS ts,
|
||||
AVG(cpu) AS cpu,
|
||||
AVG(mem_mb) AS mem_mb,
|
||||
AVG(mem_percent) AS mem_percent
|
||||
FROM stats_raw
|
||||
WHERE timestamp >= ?
|
||||
GROUP BY service, ts
|
||||
ORDER BY service, ts ASC
|
||||
`).all(bucket, bucket, since) as { service: string; ts: number; cpu: number; mem_mb: number; mem_percent: number }[];
|
||||
|
||||
const result: Record<string, StatsHistoryPoint[]> = {};
|
||||
for (const r of rows) {
|
||||
if (!result[r.service]) result[r.service] = [];
|
||||
result[r.service].push({
|
||||
timestamp: r.ts,
|
||||
cpu: r.cpu,
|
||||
mem_mb: r.mem_mb,
|
||||
mem_percent: r.mem_percent,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function cleanupOldStats() {
|
||||
if (!db) return;
|
||||
const cutoff = Math.floor(Date.now() / 1000) - RANGE_SECONDS["7d"];
|
||||
db.prepare("DELETE FROM stats_raw WHERE timestamp < ?").run(cutoff);
|
||||
try {
|
||||
db.exec("VACUUM");
|
||||
} catch {}
|
||||
}
|
||||
@@ -76,6 +76,15 @@ export interface ContainerSettings {
|
||||
memThreshold: number | null;
|
||||
}
|
||||
|
||||
export interface StatsHistoryPoint {
|
||||
timestamp: number;
|
||||
cpu: number;
|
||||
mem_mb: number;
|
||||
mem_percent: number;
|
||||
}
|
||||
|
||||
export type StatsRange = "1h" | "6h" | "24h" | "7d";
|
||||
|
||||
export type WSMessage =
|
||||
| { type: "services"; data: Service[] }
|
||||
| { type: "connections"; data: Connection[] }
|
||||
|
||||
Reference in New Issue
Block a user