From dbc819583becc0786aaea61a03d3b6162f211039 Mon Sep 17 00:00:00 2001 From: KodekaTeam Date: Thu, 18 Jun 2026 15:34:56 +0700 Subject: [PATCH] perf: reduce container runtime resource spikes across builds --- src/app/containers/page.tsx | 20 +- .../containers/[containerId]/page.tsx | 437 ++++++++++++++---- src/lib/api.ts | 22 + src/server/routes/containers.ts | 434 ++++++++++++++++- tests/backend/container-metrics-cache.test.ts | 121 +++++ .../container-runtime-regressions.test.ts | 82 ++++ 6 files changed, 1010 insertions(+), 106 deletions(-) create mode 100644 tests/backend/container-metrics-cache.test.ts create mode 100644 tests/backend/container-runtime-regressions.test.ts diff --git a/src/app/containers/page.tsx b/src/app/containers/page.tsx index 08c2152..42d1387 100644 --- a/src/app/containers/page.tsx +++ b/src/app/containers/page.tsx @@ -78,7 +78,9 @@ function createFailedRebuildTimeline(activeStep: number): ProcessLogStep[] { export default function ContainersPage() { const [data, setData] = useState([]); const [serverList, setServerList] = useState([]); - const [selectedServerId, setSelectedServerId] = useState(""); + const [selectedServerId, setSelectedServerId] = useState(() => + readStoredServerSelection(PAGE_KEY), + ); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); @@ -169,13 +171,15 @@ export default function ContainersPage() { useEffect(() => { const storedServerId = readStoredServerSelection(PAGE_KEY); - setSelectedServerId(storedServerId); - const hasCache = applyCachedState(storedServerId); - void load({ - sync: true, - serverId: storedServerId, - silent: hasCache, + const frame = window.requestAnimationFrame(() => { + const hasCache = applyCachedState(storedServerId); + void load({ + serverId: storedServerId, + silent: hasCache, + }); }); + + return () => window.cancelAnimationFrame(frame); }, [applyCachedState, load]); useEffect(() => { @@ -198,7 +202,7 @@ export default function ContainersPage() { storeServerSelection(PAGE_KEY, serverId); setSelectedServerId(serverId); const hasCache = applyCachedState(serverId); - await load({ sync: true, serverId, silent: hasCache }); + await load({ serverId, silent: hasCache }); }; const runAction = async ( diff --git a/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/page.tsx b/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/page.tsx index 96391bf..c925a3d 100644 --- a/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/page.tsx +++ b/src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/page.tsx @@ -15,7 +15,9 @@ import { projectsApi, type Container, type ContainerDetails, + type ContainerProcess, type ContainerProjectEnvFile, + type ContainerRuntimeStats, type Domain, type ProjectEnvironmentRecord, type ProjectRecord, @@ -64,6 +66,10 @@ type PendingConfirmAction = { onConfirm: () => void; }; +const LOGS_REFRESH_INTERVAL_MS = 10000; +const LOGS_REFRESH_TAIL_LINES = 50; +const LOGS_STREAM_LIMIT = 50; + function formatStatus(value: string) { return value.charAt(0) + value.slice(1).toLowerCase(); } @@ -215,8 +221,58 @@ function buildFallbackMetrics(container: Container): AppMetric[] { ]; } +type RuntimeMetricSource = { + stats: ContainerRuntimeStats; + processes?: ContainerProcess[]; +}; + +function createEmptyRuntimeStats(): ContainerRuntimeStats { + return { + cpuPercent: 0, + memoryPercent: 0, + pids: 0, + memory: { raw: "" }, + network: { raw: "" }, + io: { raw: "" }, + }; +} + +function createRuntimeDetailSnapshot({ + container, + inspect = {}, + logs = "", + processes = [], + stats, +}: { + container: Container; + inspect?: Record; + logs?: string; + processes?: ContainerProcess[]; + stats: ContainerRuntimeStats; +}): ContainerDetails { + return { + container: { + id: container.id, + name: container.name, + image: container.image, + status: container.status, + dockerId: container.dockerId, + serverId: container.serverId, + }, + server: { + id: container.serverId, + name: container.server?.name ?? "-", + ip: container.server?.ip ?? "-", + }, + logs, + inspect, + stats, + processes, + }; +} + function buildMetrics( - detail: ContainerDetails | null, + detail: RuntimeMetricSource | null, container: Container, ): AppMetric[] { if (!detail) return buildFallbackMetrics(container); @@ -259,7 +315,9 @@ function buildMetrics( { label: "Processes", value: String(detail.stats.pids), - subvalue: `${detail.processes.length} visible processes`, + subvalue: detail.processes + ? `${detail.processes.length} visible processes` + : `${detail.stats.pids} running PIDs`, tone: "green", points: sparkline(Math.max(8, detail.stats.pids)), }, @@ -275,6 +333,7 @@ function buildAppDetail({ runtimeNotice, fallbackLogs, projectEnv, + metrics, }: { project: ProjectRecord; environment: ProjectEnvironmentRecord; @@ -284,15 +343,17 @@ function buildAppDetail({ runtimeNotice?: string; fallbackLogs?: string; projectEnv: ContainerProjectEnvFile | null; + metrics?: RuntimeMetricSource | null; }): AppDetail { const status = formatStatus(container.status); const isRunning = container.status === "RUNNING"; + const metricSource = metrics ?? detail; const serverName = detail?.server.name ?? container.server?.name ?? "-"; const serverIp = detail?.server.ip ?? container.server?.ip ?? "-"; - const cpu = detail - ? `${detail.stats.cpuPercent.toFixed(2)}%` + const cpu = metricSource + ? `${metricSource.stats.cpuPercent.toFixed(2)}%` : (container.cpuUsage ?? "0%"); - const memory = detail?.stats.memory.used || container.ramUsage || "-"; + const memory = metricSource?.stats.memory.used || container.ramUsage || "-"; const rawLogs = detail ? detail.logs : fallbackLogs || runtimeNotice || ""; const logs = parseRecentLogs(rawLogs, 8); const logStream = parseRecentLogs(rawLogs, 100); @@ -328,7 +389,7 @@ function buildAppDetail({ recentLogs: logStream, }), terminal: createTerminalData(container, detail), - metrics: buildMetrics(detail, container), + metrics: buildMetrics(metricSource, container), deployment: { status: isRunning ? "Success" : status, commit: "-", @@ -428,6 +489,17 @@ export default function AppContainerDetailPage() { const rebuildJobIdRef = useRef(null); const rebuildCancelRequestedRef = useRef(false); const runtimeMetricsInFlightRef = useRef(false); + const runtimeDetailRef = useRef(null); + const environmentRecordRef = useRef(null); + const projectEnvRef = useRef(null); + const domainsLoadedRef = useRef(false); + const domainsLoadingRef = useRef(false); + const runtimeDetailLoadedRef = useRef(false); + const runtimeDetailLoadingRef = useRef(false); + const projectEnvLoadedRef = useRef(false); + const projectEnvLoadingRef = useRef(false); + const logsLoadedRef = useRef(false); + const logsInFlightRef = useRef(false); const environmentContainersHref = `/projects/${params.projectId}/environments/${params.environmentId}`; const appDetailId = appDetail?.id; @@ -440,59 +512,40 @@ export default function AppContainerDetailPage() { setError(""); setActionError(""); setRuntimeNotice(""); + runtimeDetailRef.current = null; + projectEnvRef.current = null; + domainsLoadedRef.current = false; + runtimeDetailLoadedRef.current = false; + projectEnvLoadedRef.current = false; + logsLoadedRef.current = false; try { - const [ - projectResponse, - containerResponse, - detailResult, - domainsResponse, - projectEnvResponse, - ] = await Promise.all([ - projectsApi.detail(params.projectId), - containersApi.get(params.containerId), - containersApi - .details(params.containerId, 300) - .then((response) => ({ - detail: response.data ?? null, - logs: "", - notice: "", - })) - .catch(async (detailsError) => { - const message = - detailsError instanceof Error - ? detailsError.message - : "Runtime detail is unavailable."; - let logs = ""; + const [projectResponse, containerResponse, metricsResult] = + await Promise.all([ + projectsApi.detail(params.projectId), + containersApi.get(params.containerId), + containersApi + .metrics(params.containerId) + .then((response) => ({ + metrics: response.data ?? null, + notice: "", + })) + .catch((metricsError) => { + const message = + metricsError instanceof Error + ? metricsError.message + : "Runtime metrics are unavailable."; - try { - const logResponse = await containersApi.logs( - params.containerId, - 100, - ); - logs = logResponse.data?.logs ?? ""; - } catch { - logs = ""; - } - - return { - detail: null, - logs, - notice: message, - }; - }), - domainsApi - .list() - .catch(() => ({ success: false, data: [] as Domain[] })), - containersApi.projectEnv(params.containerId).catch(() => ({ - success: false, - data: null as ContainerProjectEnvFile | null, - })), - ]); + return { + metrics: null, + notice: message, + }; + }), + ]); const project = projectResponse.data; const container = containerResponse.data; - const detail = detailResult.detail; + const metrics = metricsResult.metrics; const environment = project?.environments.find( (item) => item.id === params.environmentId, ); @@ -516,17 +569,24 @@ export default function AppContainerDetailPage() { project, environment, container, - detail, - domains: domainsResponse.data ?? [], - runtimeNotice: detailResult.notice, - fallbackLogs: detailResult.logs, - projectEnv: projectEnvResponse.data ?? null, + detail: null, + domains: [], + runtimeNotice: metricsResult.notice, + projectEnv: null, + metrics, }), ); - if (detailResult.notice) { - setRuntimeNotice(detailResult.notice); + if (metricsResult.notice) { + setRuntimeNotice(metricsResult.notice); } setContainerRecord(container); + environmentRecordRef.current = environment; + runtimeDetailRef.current = metrics + ? createRuntimeDetailSnapshot({ + container, + stats: metrics.stats, + }) + : null; } catch (loadError) { setAppDetail(null); setContainerRecord(null); @@ -563,10 +623,16 @@ export default function AppContainerDetailPage() { runtimeMetricsInFlightRef.current = true; try { - const response = await containersApi.details(params.containerId, 20); - const detail = response.data; - const nextCpu = `${detail.stats.cpuPercent.toFixed(2)}%`; - const nextMemory = detail.stats.memory.used || container.ramUsage || "-"; + const response = await containersApi.metrics(params.containerId); + const metrics = response.data; + const nextCpu = `${metrics.stats.cpuPercent.toFixed(2)}%`; + const nextMemory = metrics.stats.memory.used || container.ramUsage || "-"; + runtimeDetailRef.current = runtimeDetailRef.current + ? { ...runtimeDetailRef.current, stats: metrics.stats } + : createRuntimeDetailSnapshot({ + container, + stats: metrics.stats, + }); setContainerRecord((current) => current @@ -582,11 +648,7 @@ export default function AppContainerDetailPage() { current ? { ...current, - metrics: buildMetrics(detail, container), - runtime: createRuntimeData(container, detail), - storage: createStorageData(container, detail), - advanced: createAdvancedData(container, detail), - terminal: createTerminalData(container, detail), + metrics: buildMetrics(metrics, container), health: { ...current.health, status: "Healthy", @@ -613,14 +675,187 @@ export default function AppContainerDetailPage() { } }, [params.containerId]); + const hydrateDomains = useCallback(async () => { + const container = containerRecordRef.current; + if (!container || domainsLoadedRef.current || domainsLoadingRef.current) { + return; + } + + domainsLoadingRef.current = true; + + try { + const response = await domainsApi.list(); + const domains = response.data ?? []; + domainsLoadedRef.current = true; + + setAppDetail((current) => + current + ? { + ...current, + domains: getLinkedDomains(domains, container), + openUrl: + getDomainUrl(domains, container) ?? + getContainerWebUiUrl(container), + } + : current, + ); + } catch { + domainsLoadedRef.current = true; + } finally { + domainsLoadingRef.current = false; + } + }, []); + + const hydrateRuntimeDetail = useCallback(async () => { + const container = containerRecordRef.current; + if ( + !container || + runtimeDetailLoadedRef.current || + runtimeDetailLoadingRef.current + ) { + return; + } + + runtimeDetailLoadingRef.current = true; + + try { + const [inspectResult, metricsResult, processesResult] = + await Promise.allSettled([ + containersApi.inspect(params.containerId), + containersApi.metrics(params.containerId), + containersApi.processes(params.containerId), + ]); + const previous = runtimeDetailRef.current; + const stats = + metricsResult.status === "fulfilled" + ? metricsResult.value.data.stats + : (previous?.stats ?? createEmptyRuntimeStats()); + const detail = createRuntimeDetailSnapshot({ + container, + stats, + inspect: + inspectResult.status === "fulfilled" + ? inspectResult.value.data + : (previous?.inspect ?? {}), + processes: + processesResult.status === "fulfilled" + ? processesResult.value.data + : (previous?.processes ?? []), + logs: previous?.logs ?? "", + }); + runtimeDetailRef.current = detail; + runtimeDetailLoadedRef.current = true; + setRuntimeNotice(""); + + setAppDetail((current) => { + const environment = environmentRecordRef.current; + if (!current || !environment) return current; + + return { + ...current, + serverName: detail.server.name, + serverIp: detail.server.ip, + metrics: buildMetrics(detail, container), + runtime: createRuntimeData(container, detail), + deployments: createDeploymentsData(container, detail), + advanced: createAdvancedData(container, detail), + environment: createEnvironmentData({ + container, + detail, + environment, + projectEnv: projectEnvRef.current, + }), + storage: createStorageData(container, detail), + logsDetail: createLogsData({ + container, + detail, + recentLogs: current.logsDetail.streams.map((log) => ({ + time: log.time, + level: + log.level === "DEBUG" + ? ("INFO" as const) + : (log.level as RecentLogLine["level"]), + message: log.message, + })), + }), + terminal: createTerminalData(container, detail), + }; + }); + } catch (runtimeError) { + setRuntimeNotice( + runtimeError instanceof Error + ? runtimeError.message + : "Runtime detail is unavailable.", + ); + } finally { + runtimeDetailLoadingRef.current = false; + } + }, [params.containerId]); + + const hydrateProjectEnv = useCallback(async () => { + const container = containerRecordRef.current; + const environment = environmentRecordRef.current; + if ( + !container || + !environment || + projectEnvLoadedRef.current || + projectEnvLoadingRef.current + ) { + return; + } + + projectEnvLoadingRef.current = true; + + try { + const response = await containersApi.projectEnv(params.containerId); + const projectEnv = response.data ?? null; + projectEnvRef.current = projectEnv; + projectEnvLoadedRef.current = true; + + setAppDetail((current) => + current + ? { + ...current, + environment: createEnvironmentData({ + container, + detail: runtimeDetailRef.current, + environment, + projectEnv, + }), + } + : current, + ); + } catch { + projectEnvLoadedRef.current = true; + } finally { + projectEnvLoadingRef.current = false; + } + }, [params.containerId]); + const refreshLogs = useCallback(async () => { + if (logsInFlightRef.current) return; + + logsInFlightRef.current = true; setLogsRefreshing(true); try { - const response = await containersApi.logs(params.containerId, 100); + const response = await containersApi.logs( + params.containerId, + LOGS_REFRESH_TAIL_LINES, + ); const rawLogs = response.data?.logs ?? ""; const nextLogs = parseRecentLogs(rawLogs, 8); - const nextLogStream = parseRecentLogs(rawLogs, 100); + const nextLogStream = parseRecentLogs(rawLogs, LOGS_STREAM_LIMIT); + logsLoadedRef.current = true; + runtimeDetailRef.current = runtimeDetailRef.current + ? { ...runtimeDetailRef.current, logs: rawLogs } + : containerRecord + ? createRuntimeDetailSnapshot({ + container: containerRecord, + stats: createEmptyRuntimeStats(), + logs: rawLogs, + }) + : null; setAppDetail((current) => current @@ -628,11 +863,11 @@ export default function AppContainerDetailPage() { ...current, logs: nextLogs, logsDetail: containerRecord - ? createLogsData({ - container: containerRecord, - detail: null, - recentLogs: nextLogStream, - }) + ? createLogsData({ + container: containerRecord, + detail: runtimeDetailRef.current, + recentLogs: nextLogStream, + }) : current.logsDetail, health: { ...current.health, @@ -663,6 +898,7 @@ export default function AppContainerDetailPage() { : current, ); } finally { + logsInFlightRef.current = false; setLogsRefreshing(false); } }, [containerRecord, params.containerId]); @@ -1099,6 +1335,43 @@ export default function AppContainerDetailPage() { return () => window.cancelAnimationFrame(frame); }, [load]); + useEffect(() => { + if (!appDetailId) return; + + const frame = window.requestAnimationFrame(() => { + if (activeTab === "overview" || activeTab === "domains") { + void hydrateDomains(); + } + + if ( + activeTab === "runtime" || + activeTab === "storage" || + activeTab === "advanced" || + activeTab === "terminal" || + activeTab === "deployments" + ) { + void hydrateRuntimeDetail(); + } + + if (activeTab === "environment") { + void hydrateProjectEnv(); + } + + if (activeTab === "logs" && !logsLoadedRef.current) { + void refreshLogs(); + } + }); + + return () => window.cancelAnimationFrame(frame); + }, [ + activeTab, + appDetailId, + hydrateDomains, + hydrateProjectEnv, + hydrateRuntimeDetail, + refreshLogs, + ]); + useEffect(() => { if (!appDetailId || containerRecord?.status !== "RUNNING") return; @@ -1124,17 +1397,21 @@ export default function AppContainerDetailPage() { if (!logsAutoRefresh) return; const frame = window.requestAnimationFrame(() => { - void refreshLogs(); + if (activeTab === "logs" && !document.hidden) { + void refreshLogs(); + } }); const intervalId = window.setInterval(() => { - void refreshLogs(); - }, 5000); + if (activeTab === "logs" && !document.hidden) { + void refreshLogs(); + } + }, LOGS_REFRESH_INTERVAL_MS); return () => { window.cancelAnimationFrame(frame); window.clearInterval(intervalId); }; - }, [logsAutoRefresh, refreshLogs]); + }, [activeTab, logsAutoRefresh, refreshLogs]); return ( + get<{ success: boolean; data: ContainerProcess[] }>( + `/containers/${id}/processes`, + { timeoutMs: 20000 }, + ), details: (id: string, lines?: number) => get<{ success: boolean; data: ContainerDetails }>( `/containers/${id}/details${lines ? `?lines=${lines}` : ""}`, { timeoutMs: 30000 }, ), + metrics: (id: string) => + get<{ success: boolean; data: ContainerMetrics }>( + `/containers/${id}/metrics`, + { timeoutMs: 15000 }, + ), listFiles: (id: string, path?: string) => { const qs = new URLSearchParams(); if (path) qs.set("path", path); diff --git a/src/server/routes/containers.ts b/src/server/routes/containers.ts index 92c1e91..368ebfb 100644 --- a/src/server/routes/containers.ts +++ b/src/server/routes/containers.ts @@ -124,6 +124,222 @@ const ContainerRenamePathSchema = z.object({ const CONTAINER_UPLOAD_CONTENT_BASE64_MAX_LENGTH = 8_000_000; const CONTAINER_UPLOAD_BODY_LIMIT_BYTES = 10 * 1024 * 1024; +const CONTAINER_METRICS_CACHE_TTL_MS = 5_000; +const DOCKER_TIMING_SAMPLE_LIMIT = 100; + +type DockerCommandCategory = "list" | "logs" | "inspect" | "stats" | "top"; +type DockerCommandStatus = "success" | "error"; +type DockerCommandTimingEvent = { + endpoint: string; + category: DockerCommandCategory; + serverId: string; + serverName?: string; + containerId?: string; + status: DockerCommandStatus; + durationMs: number; + timedOut: boolean; +}; +type DockerCommandTimingSink = (event: DockerCommandTimingEvent) => void; + +const dockerTimingStats = new Map< + string, + { count: number; samples: number[] } +>(); + +function isLikelyTimeout(error: unknown) { + const message = error instanceof Error ? error.message : String(error ?? ""); + return /timed?\s*out|timeout|aborted|abort/i.test(message); +} + +function percentile95(samples: number[]) { + if (samples.length === 0) return 0; + const sorted = [...samples].sort((left, right) => left - right); + const index = Math.min( + sorted.length - 1, + Math.ceil(sorted.length * 0.95) - 1, + ); + return sorted[index] ?? 0; +} + +function recordDockerCommandTiming( + app: FastifyInstance, + event: DockerCommandTimingEvent, +) { + const key = `${event.endpoint}:${event.category}:${event.status}`; + const current = dockerTimingStats.get(key) ?? { count: 0, samples: [] }; + const samples = [...current.samples, event.durationMs].slice( + -DOCKER_TIMING_SAMPLE_LIMIT, + ); + const count = current.count + 1; + dockerTimingStats.set(key, { count, samples }); + + app.log.info( + { + event: "docker_command_timing", + endpoint: event.endpoint, + category: event.category, + serverId: event.serverId, + serverName: event.serverName, + containerId: event.containerId, + status: event.status, + durationMs: event.durationMs, + timedOut: event.timedOut, + count, + p95DurationMs: percentile95(samples), + sampleSize: samples.length, + }, + "Docker command timing", + ); +} + +async function measureDockerCommand( + sink: DockerCommandTimingSink | undefined, + event: Omit, + operation: () => Promise, +) { + const startedAt = Date.now(); + + try { + const result = await operation(); + sink?.({ + ...event, + status: "success", + durationMs: Date.now() - startedAt, + timedOut: false, + }); + return result; + } catch (error) { + sink?.({ + ...event, + status: "error", + durationMs: Date.now() - startedAt, + timedOut: isLikelyTimeout(error), + }); + throw error; + } +} + +export type ContainerMetricsPayload = { + container: { + id: string; + name: string; + status: ContainerStatus; + dockerId: string | null; + serverId: string; + }; + stats: Awaited>; + sampledAt: string; +}; + +type ContainerMetricsCacheEntry = { + expiresAt: number; + promise: Promise; +}; + +const containerMetricsCache = new Map(); + +type ReadContainerMetricsWithCacheArgs = { + organizationId?: string; + serverId: string; + serverName?: string; + containerId: string; + containerName: string; + containerStatus: ContainerStatus; + dockerId: string | null; + runtimeId: string; + loadStats: () => Promise>>; +}; + +function getContainerMetricsCacheKey(args: { + organizationId?: string; + serverId: string; + containerId: string; + runtimeId: string; +}) { + return [ + args.organizationId ?? "unknown-organization", + args.serverId, + args.containerId, + args.runtimeId, + ].join(":"); +} + +export function invalidateContainerMetricsCache(args: { + organizationId?: string; + serverId: string; + containerId: string; +}) { + const prefix = [ + args.organizationId ?? "unknown-organization", + args.serverId, + args.containerId, + "", + ].join(":"); + + for (const key of containerMetricsCache.keys()) { + if (key.startsWith(prefix)) { + containerMetricsCache.delete(key); + } + } +} + +export function clearContainerMetricsCacheForTest() { + containerMetricsCache.clear(); +} + +export async function readContainerMetricsWithCache({ + organizationId, + serverId, + containerId, + containerName, + containerStatus, + dockerId, + runtimeId, + loadStats, +}: ReadContainerMetricsWithCacheArgs): Promise { + const cacheKey = getContainerMetricsCacheKey({ + organizationId, + serverId, + containerId, + runtimeId, + }); + const now = Date.now(); + const cached = containerMetricsCache.get(cacheKey); + + if (cached && cached.expiresAt > now) { + return cached.promise; + } + + let entry: ContainerMetricsCacheEntry; + const promise = loadStats().then((stats): ContainerMetricsPayload => { + entry.expiresAt = Date.now() + CONTAINER_METRICS_CACHE_TTL_MS; + + return { + container: { + id: containerId, + name: containerName, + status: containerStatus, + dockerId, + serverId, + }, + stats, + sampledAt: new Date().toISOString(), + }; + }); + entry = { + expiresAt: Number.POSITIVE_INFINITY, + promise, + }; + + containerMetricsCache.set(cacheKey, entry); + + try { + return await promise; + } catch (error) { + containerMetricsCache.delete(cacheKey); + throw error; + } +} const ContainerUploadFileSchema = z.object({ directoryPath: z.string().min(1).max(2048), @@ -801,6 +1017,7 @@ async function syncContainersForServers( updatedAt: Date; }>, userId?: string, + timingSink?: DockerCommandTimingSink, ) { const summary: Array<{ serverId: string; @@ -812,7 +1029,16 @@ async function syncContainersForServers( const pendingNotifications: Array> = []; let actualContainers; try { - actualContainers = await ssh.listDockerContainers(server); + actualContainers = await measureDockerCommand( + timingSink, + { + endpoint: "containers.sync", + category: "list", + serverId: server.id, + serverName: server.name, + }, + () => ssh.listDockerContainers(server), + ); } catch (error) { throw new Error( error instanceof Error @@ -1292,7 +1518,11 @@ export async function containerRoutes(app: FastifyInstance) { } try { - const summary = await syncContainersForServers(servers, req.userId); + const summary = await syncContainersForServers( + servers, + req.userId, + (event) => recordDockerCommandTiming(app, event), + ); const containers = await prisma.container.findMany({ where: { @@ -2081,6 +2311,54 @@ export async function containerRoutes(app: FastifyInstance) { return reply.send({ success: true, data: container }); }); + // GET /containers/:id/metrics - lightweight runtime metrics for polling. + app.get( + "/:id/metrics", + { preHandler: containerReadAccess }, + async (req, reply) => { + const { id } = req.params as { id: string }; + const container = await getAccessibleContainer(id, req.organizationId); + if (!container) + return reply + .status(404) + .send({ success: false, error: "Container not found" }); + + const runtimeId = container.dockerId || container.name; + + try { + const data = await readContainerMetricsWithCache({ + organizationId: req.organizationId, + serverId: container.serverId, + serverName: container.server.name, + containerId: container.id, + containerName: container.name, + containerStatus: container.status, + dockerId: container.dockerId, + runtimeId, + loadStats: () => + measureDockerCommand( + (event) => recordDockerCommandTiming(app, event), + { + endpoint: "containers.metrics", + category: "stats", + serverId: container.serverId, + serverName: container.server.name, + containerId: container.id, + }, + () => ssh.dockerStats(container.server, runtimeId), + ), + }); + + return reply.send({ success: true, data }); + } catch (err: unknown) { + return reply.status(500).send({ + success: false, + error: err instanceof Error ? err.message : "Failed to load metrics", + }); + } + }, + ); + // GET /containers/:id/details — logs, inspect, runtime stats and processes app.get( "/:id/details", @@ -2100,14 +2378,55 @@ export async function containerRoutes(app: FastifyInstance) { try { const [logsResult, inspectResult, statsResult, processesResult] = await Promise.allSettled([ - ssh.dockerLogs( - container.server, - runtimeId, - parseInt(lines, 10) || 200, + measureDockerCommand( + (event) => recordDockerCommandTiming(app, event), + { + endpoint: "containers.details", + category: "logs", + serverId: container.serverId, + serverName: container.server.name, + containerId: container.id, + }, + () => + ssh.dockerLogs( + container.server, + runtimeId, + parseInt(lines, 10) || 200, + ), + ), + measureDockerCommand( + (event) => recordDockerCommandTiming(app, event), + { + endpoint: "containers.details", + category: "inspect", + serverId: container.serverId, + serverName: container.server.name, + containerId: container.id, + }, + () => ssh.dockerInspect(container.server, runtimeId), + ), + measureDockerCommand( + (event) => recordDockerCommandTiming(app, event), + { + endpoint: "containers.details", + category: "stats", + serverId: container.serverId, + serverName: container.server.name, + containerId: container.id, + }, + () => ssh.dockerStats(container.server, runtimeId), + ), + measureDockerCommand( + (event) => recordDockerCommandTiming(app, event), + { + endpoint: "containers.details", + category: "top", + serverId: container.serverId, + serverName: container.server.name, + containerId: container.id, + }, + () => ssh.dockerTop(container.server, runtimeId), ), - ssh.dockerInspect(container.server, runtimeId), - ssh.dockerStats(container.server, runtimeId), - ssh.dockerTop(container.server, runtimeId), ]); const logs = logsResult.status === "fulfilled" ? logsResult.value : ""; const inspect = @@ -2169,7 +2488,17 @@ export async function containerRoutes(app: FastifyInstance) { const runtimeId = container.dockerId || container.name; try { - const inspect = await ssh.dockerInspect(container.server, runtimeId); + const inspect = await measureDockerCommand( + (event) => recordDockerCommandTiming(app, event), + { + endpoint: "containers.inspect", + category: "inspect", + serverId: container.serverId, + serverName: container.server.name, + containerId: container.id, + }, + () => ssh.dockerInspect(container.server, runtimeId), + ); return reply.send({ success: true, data: inspect }); } catch (err: any) { return reply.status(500).send({ success: false, error: err.message }); @@ -2177,7 +2506,44 @@ export async function containerRoutes(app: FastifyInstance) { }, ); - // POST /containers — deploy a new container + // GET /containers/:id/processes - fetch only the runtime process table. + app.get( + "/:id/processes", + { preHandler: containerReadAccess }, + async (req, reply) => { + const { id } = req.params as { id: string }; + const container = await getAccessibleContainer(id, req.organizationId); + if (!container) + return reply + .status(404) + .send({ success: false, error: "Container not found" }); + + const runtimeId = container.dockerId || container.name; + + try { + const processes = await measureDockerCommand( + (event) => recordDockerCommandTiming(app, event), + { + endpoint: "containers.processes", + category: "top", + serverId: container.serverId, + serverName: container.server.name, + containerId: container.id, + }, + () => ssh.dockerTop(container.server, runtimeId), + ); + return reply.send({ success: true, data: processes }); + } catch (err: unknown) { + return reply.status(500).send({ + success: false, + error: + err instanceof Error ? err.message : "Failed to load processes", + }); + } + }, + ); + + // POST /containers - deploy a new container app.post( "/", { preHandler: containerWriteAccess, bodyLimit: 1024 * 1024 }, @@ -2485,7 +2851,9 @@ export async function containerRoutes(app: FastifyInstance) { deploymentPath = result.deploymentPath; } - await syncContainersForServers([server], req.userId); + await syncContainersForServers([server], req.userId, (event) => + recordDockerCommandTiming(app, event), + ); const serverContainers = await prisma.container.findMany({ where: { serverId: server.id }, @@ -2650,7 +3018,17 @@ export async function containerRoutes(app: FastifyInstance) { // Read actual runtime status after action so DB reflects reality. let inspect: any = null; try { - inspect = await ssh.dockerInspect(container.server, runtimeRefUsed); + inspect = await measureDockerCommand( + (event) => recordDockerCommandTiming(app, event), + { + endpoint: "containers.action", + category: "inspect", + serverId: container.serverId, + serverName: container.server.name, + containerId: container.id, + }, + () => ssh.dockerInspect(container.server, runtimeRefUsed), + ); } catch { inspect = null; } @@ -2680,6 +3058,12 @@ export async function containerRoutes(app: FastifyInstance) { message: `Container "${container.name}" → ${action}`, }); + invalidateContainerMetricsCache({ + organizationId: req.organizationId, + serverId: container.serverId, + containerId: container.id, + }); + return reply.send({ success: true, data: updatedContainer ?? undefined, @@ -2897,7 +3281,9 @@ export async function containerRoutes(app: FastifyInstance) { ), }); - await syncContainersForServers([container.server], req.userId); + await syncContainersForServers([container.server], req.userId, (event) => + recordDockerCommandTiming(app, event), + ); const serverContainers = await prisma.container.findMany({ where: { serverId: container.serverId }, @@ -2990,12 +3376,24 @@ export async function containerRoutes(app: FastifyInstance) { return reply .status(400) .send({ success: false, error: "Container has no Docker ID" }); + const runtimeId = container.dockerId; try { - const logs = await ssh.dockerLogs( - container.server, - container.dockerId, - parseInt(lines), + const logs = await measureDockerCommand( + (event) => recordDockerCommandTiming(app, event), + { + endpoint: "containers.logs", + category: "logs", + serverId: container.serverId, + serverName: container.server.name, + containerId: container.id, + }, + () => + ssh.dockerLogs( + container.server, + runtimeId, + parseInt(lines), + ), ); return reply.send({ success: true, diff --git a/tests/backend/container-metrics-cache.test.ts b/tests/backend/container-metrics-cache.test.ts new file mode 100644 index 0000000..a328ec6 --- /dev/null +++ b/tests/backend/container-metrics-cache.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + clearContainerMetricsCacheForTest, + invalidateContainerMetricsCache, + readContainerMetricsWithCache, +} from "../../src/server/routes/containers"; + +const stats = { + cpuPercent: 12.5, + memoryPercent: 32.1, + pids: 4, + memory: { + raw: "128MiB / 512MiB", + used: "128MiB", + limit: "512MiB", + usedBytes: 134217728, + limitBytes: 536870912, + }, + network: { + raw: "1kB / 2kB", + read: "1kB", + write: "2kB", + totalBytes: null, + readBytes: null, + writeBytes: null, + }, + io: { + raw: "3kB / 4kB", + read: "3kB", + write: "4kB", + totalBytes: null, + readBytes: null, + writeBytes: null, + }, +}; + +function readMetrics(loadStats: () => Promise) { + return readContainerMetricsWithCache({ + organizationId: "org-1", + serverId: "server-1", + containerId: "container-1", + containerName: "app", + containerStatus: "RUNNING", + dockerId: "abc123", + runtimeId: "abc123", + loadStats, + }); +} + +test("container metrics cache dedupes concurrent samples and reuses short-lived results", async () => { + clearContainerMetricsCacheForTest(); + let loadCount = 0; + let resolveStats: (value: typeof stats) => void = () => undefined; + + const loadStats = async () => { + loadCount += 1; + return new Promise((resolve) => { + resolveStats = resolve; + }); + }; + + const first = readMetrics(loadStats); + const second = readMetrics(loadStats); + + assert.equal(loadCount, 1); + resolveStats(stats); + + const [firstResult, secondResult] = await Promise.all([first, second]); + + assert.deepEqual(firstResult.stats, stats); + assert.deepEqual(secondResult.stats, stats); + assert.equal(loadCount, 1); + + const cachedResult = await readMetrics(loadStats); + + assert.deepEqual(cachedResult.stats, stats); + assert.equal(loadCount, 1); +}); + +test("container metrics cache invalidation forces a fresh sample", async () => { + clearContainerMetricsCacheForTest(); + let loadCount = 0; + const loadStats = async () => { + loadCount += 1; + return stats; + }; + + await readMetrics(loadStats); + await readMetrics(loadStats); + assert.equal(loadCount, 1); + + invalidateContainerMetricsCache({ + organizationId: "org-1", + serverId: "server-1", + containerId: "container-1", + }); + + await readMetrics(loadStats); + assert.equal(loadCount, 2); +}); + +test("container metrics cache does not retain failed samples", async () => { + clearContainerMetricsCacheForTest(); + let loadCount = 0; + const loadStats = async () => { + loadCount += 1; + if (loadCount === 1) { + throw new Error("docker stats failed"); + } + return stats; + }; + + await assert.rejects(() => readMetrics(loadStats), /docker stats failed/); + const result = await readMetrics(loadStats); + + assert.deepEqual(result.stats, stats); + assert.equal(loadCount, 2); +}); + diff --git a/tests/backend/container-runtime-regressions.test.ts b/tests/backend/container-runtime-regressions.test.ts new file mode 100644 index 0000000..83118a6 --- /dev/null +++ b/tests/backend/container-runtime-regressions.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const CONTAINER_DETAIL_PAGE = + "src/app/projects/[projectId]/environments/[environmentId]/containers/[containerId]/page.tsx"; +const CONTAINER_ROUTES = "src/server/routes/containers.ts"; + +function readSource(path: string) { + return readFileSync(path, "utf8"); +} + +function sourceBlock(source: string, startPattern: string, endPattern: string) { + const start = source.indexOf(startPattern); + assert.notEqual(start, -1, `Missing source block start: ${startPattern}`); + + const end = source.indexOf(endPattern, start); + assert.notEqual(end, -1, `Missing source block end: ${endPattern}`); + + return source.slice(start, end); +} + +test("container detail runtime polling uses the lightweight metrics API", () => { + const source = readSource(CONTAINER_DETAIL_PAGE); + const refreshBlock = sourceBlock( + source, + "const refreshRuntimeMetrics = useCallback", + "const hydrateDomains = useCallback", + ); + + assert.match(refreshBlock, /containersApi\.metrics\(params\.containerId\)/); + assert.doesNotMatch(refreshBlock, /containersApi\.details/); +}); + +test("metrics endpoint does not call heavy Docker detail commands", () => { + const source = readSource(CONTAINER_ROUTES); + const metricsRouteBlock = sourceBlock( + source, + '"/:id/metrics"', + "GET /containers/:id/details", + ); + + assert.match(metricsRouteBlock, /readContainerMetricsWithCache/); + assert.match(metricsRouteBlock, /dockerStats/); + assert.doesNotMatch(metricsRouteBlock, /dockerLogs/); + assert.doesNotMatch(metricsRouteBlock, /dockerInspect/); + assert.doesNotMatch(metricsRouteBlock, /dockerTop/); +}); + +test("processes endpoint is isolated to Docker top", () => { + const source = readSource(CONTAINER_ROUTES); + const processesRouteBlock = sourceBlock( + source, + '"/:id/processes"', + "POST /containers - deploy a new container", + ); + + assert.match(processesRouteBlock, /dockerTop/); + assert.doesNotMatch(processesRouteBlock, /dockerLogs/); + assert.doesNotMatch(processesRouteBlock, /dockerInspect/); + assert.doesNotMatch(processesRouteBlock, /dockerStats/); +}); + +test("logs auto refresh keeps tab visibility and in-flight guards", () => { + const source = readSource(CONTAINER_DETAIL_PAGE); + const refreshLogsBlock = sourceBlock( + source, + "const refreshLogs = useCallback", + "const pageHeaderActions = useMemo", + ); + const autoRefreshBlock = sourceBlock( + source, + "if (!logsAutoRefresh) return", + "return (", + ); + + assert.match(refreshLogsBlock, /logsInFlightRef\.current/); + assert.match(refreshLogsBlock, /LOGS_REFRESH_TAIL_LINES/); + assert.match(autoRefreshBlock, /activeTab === "logs"/); + assert.match(autoRefreshBlock, /!document\.hidden/); + assert.match(autoRefreshBlock, /LOGS_REFRESH_INTERVAL_MS/); +});