perf: reduce container runtime resource spikes across builds

This commit is contained in:
KodekaTeam committed 2026-06-18 15:34:56 +07:00
1 parent 9c21c86fd5
commit dbc819583b
6 files changed
+1010 -106

No files matched your search

+12 -8
View File
@@ -78,7 +78,9 @@ function createFailedRebuildTimeline(activeStep: number): ProcessLogStep[] {
export default function ContainersPage() {
const [data, setData] = useState<Container[]>([]);
const [serverList, setServerList] = useState<Server[]>([]);
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 (
@@ -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<string, unknown>;
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<string | null>(null);
const rebuildCancelRequestedRef = useRef(false);
const runtimeMetricsInFlightRef = useRef(false);
const runtimeDetailRef = useRef<ContainerDetails | null>(null);
const environmentRecordRef = useRef<ProjectEnvironmentRecord | null>(null);
const projectEnvRef = useRef<ContainerProjectEnvFile | null>(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 (
<DashboardLayout
+22
View File
@@ -1581,6 +1581,18 @@ export interface ContainerDetails {
processes: ContainerProcess[];
}
export interface ContainerMetrics {
container: {
id: string;
name: string;
status: string;
dockerId: string | null;
serverId: string;
};
stats: ContainerRuntimeStats;
sampledAt: string;
}
export interface ContainerFileEntry {
name: string;
path: string;
@@ -1855,11 +1867,21 @@ export const containers = {
`/containers/${id}/inspect`,
{ timeoutMs: 20000 },
),
processes: (id: string) =>
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);
+416 -18
View File
@@ -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<T>(
sink: DockerCommandTimingSink | undefined,
event: Omit<DockerCommandTimingEvent, "durationMs" | "status" | "timedOut">,
operation: () => Promise<T>,
) {
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<ReturnType<typeof ssh.dockerStats>>;
sampledAt: string;
};
type ContainerMetricsCacheEntry = {
expiresAt: number;
promise: Promise<ContainerMetricsPayload>;
};
const containerMetricsCache = new Map<string, ContainerMetricsCacheEntry>();
type ReadContainerMetricsWithCacheArgs = {
organizationId?: string;
serverId: string;
serverName?: string;
containerId: string;
containerName: string;
containerStatus: ContainerStatus;
dockerId: string | null;
runtimeId: string;
loadStats: () => Promise<Awaited<ReturnType<typeof ssh.dockerStats>>>;
};
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<ContainerMetricsPayload> {
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<Promise<unknown>> = [];
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,
@@ -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<typeof stats>) {
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<typeof stats>((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);
});
@@ -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/);
});