/**
* Container formatting utilities
*
* Provides consistent markdown formatting for container operations:
* listing, logs, stats, inspection, and search.
*/
import { formatBytes } from "../services/docker/utils/formatters.js";
import type { ContainerInfo, ContainerStats, DockerRawContainerInspectInfo } from "../types.js";
import { CONTAINER_SEVERITY, sortBySeverity } from "../utils/sorting.js";
import { getTimestamp } from "./utils.js";
/**
* Get canonical state symbol for container status
* STYLE.md Section 4.1: Status and State symbols
*/
function getContainerStatusSymbol(state: string): string {
if (state === "running") return "●"; // running/active
if (state === "paused" || state === "restarting") return "◐"; // degraded/transitioning
return "○"; // stopped/exited/dead
}
/**
* Format container list as markdown following STYLE.md section 7.3
*/
export function formatContainersMarkdown(
containers: ContainerInfo[],
total: number,
offset: number,
hasMore: boolean,
filters?: Record<string, string | number | boolean>
): string {
if (containers.length === 0) {
return "No containers found matching the specified criteria.";
}
// Title - STYLE.md Section 3.1 (plain text, no markdown ##)
const title = "Docker Containers";
// Summary - STYLE.md Section 3.2
const summary = `Showing ${containers.length} of ${total} containers`;
// Request Echo - STYLE.md Section 3.5 (filters affect output)
const filterLine = filters
? `Filters: ${Object.entries(filters)
.map(([k, v]) => `${k}=${v}`)
.join(", ")}`
: "";
// Legend - STYLE.md Section 3.3 (for mixed states)
const states = new Set(containers.map((c) => c.state));
const hasMultipleStates = states.size > 1;
const legend = hasMultipleStates ? "Legend: ● running ◐ restarting ○ stopped" : "";
const lines = [title, summary];
if (filterLine) lines.push(filterLine);
if (legend) lines.push(legend);
lines.push(""); // blank line before table
// Table header - STYLE.md Section 5.1
lines.push(" Container Host Ports Project");
lines.push(" ------------------------- ---------------- ------------------- ---------------");
// Sort by severity - STYLE.md Section 12
// Note: Handler also sorts before pagination to ensure critical containers appear on page 1
// This defensive sort ensures correct order even if formatter is called with unsorted data
const sorted = sortBySeverity(
containers,
(c) => c.state,
CONTAINER_SEVERITY,
(c) => c.name
);
for (const c of sorted) {
const symbol = getContainerStatusSymbol(c.state);
// Format ports: show first 3 host ports then +N notation
const ports = c.ports.filter((p) => p.hostPort).map((p) => p.hostPort!.toString());
const portsDisplay =
ports.length === 0
? "—"
: ports.length <= 3
? ports.join(",")
: `${ports.slice(0, 3).join(",")}+${ports.length - 3}`;
// Extract project from labels (com.docker.compose.project)
const project = c.labels["com.docker.compose.project"] || "—";
// Format row with aligned columns
const nameCol = c.name.length > 25 ? `${c.name.slice(0, 24)}…` : c.name.padEnd(25);
const hostDisplay = c.hostName || "—";
const hostCol =
hostDisplay.length > 16 ? `${hostDisplay.slice(0, 15)}…` : hostDisplay.padEnd(16);
const portsCol =
portsDisplay.length > 19 ? `${portsDisplay.slice(0, 18)}…` : portsDisplay.padEnd(19);
const projectCol = project.length > 15 ? `${project.slice(0, 14)}…` : project;
lines.push(`${symbol} ${nameCol} ${hostCol} ${portsCol} ${projectCol}`);
}
// Pagination footer - STYLE.md Section 12
if (hasMore) {
lines.push("");
lines.push(
`Showing ${offset + 1}–${offset + containers.length} of ${total} | next_offset: ${offset + containers.length}`
);
}
return lines.join("\n");
}
/** Preview threshold for log output - show preview format when exceeding this */
const PREVIEW_THRESHOLD = 10;
/**
* Format container logs as markdown following STYLE.md section 7.5
*
* Uses preview format (first 5 + last 5) for outputs >10 lines
*/
export function formatLogsMarkdown(
logs: Array<{ stream: "stdout" | "stderr"; message: string }>,
container: string,
host: string,
requested: number,
truncated: boolean,
follow: boolean
): string {
if (logs.length === 0) {
return `No logs found for container '${container}' on ${host}.`;
}
// Title - STYLE.md Section 3.1 (plain text, no markdown ##)
const title = `Container Logs for ${container} on ${host}`;
// Summary - STYLE.md Section 3.2
const summary = [
`Lines returned: ${logs.length} (requested: ${requested})`,
`truncated: ${truncated ? "yes" : "no"}`,
`follow: ${follow ? "yes" : "no"}`,
].join(" | ");
// STYLE.md Section 3.6: Freshness timestamp for volatile data (logs)
const timestamp = getTimestamp();
const formatLogLine = (log: { stream: string; message: string }) =>
` [${log.stream}] ${log.message}`;
let logOutput: string;
if (logs.length <= PREVIEW_THRESHOLD) {
// Show all lines
logOutput = logs.map(formatLogLine).join("\n");
} else {
// Preview format per STYLE.md 7.5
const first5 = logs.slice(0, 5).map(formatLogLine).join("\n");
const last5 = logs.slice(-5).map(formatLogLine).join("\n");
logOutput = `Preview (first 5):\n${first5}\n ...\n\nPreview (last 5):\n${last5}\n ...`;
}
return `${title}\n${summary}\n${timestamp}\n\n${logOutput}`;
}
/**
* Format single container stats as markdown following STYLE.md
*/
export function formatStatsMarkdown(stats: ContainerStats[], host: string): string {
if (stats.length === 0) return "No stats available.";
const s = stats[0];
// STYLE.md Section 3.6: Freshness timestamp for volatile data (stats)
const timestamp = getTimestamp();
// STYLE.md 10.2: Warning thresholds for CPU>90%, MEM>90%
const cpuWarning = s.cpuPercent > 90 ? " ⚠" : "";
const memWarning = s.memoryPercent > 90 ? " ⚠" : "";
// STYLE.md 3.1: Plain text title (no markdown ##)
return `Stats: ${s.containerName} (${host})
${timestamp}
| Metric | Value |
|--------|-------|
| CPU | ${s.cpuPercent.toFixed(1)}%${cpuWarning} |
| Memory | ${formatBytes(s.memoryUsage)} / ${formatBytes(s.memoryLimit)} (${s.memoryPercent.toFixed(1)}%${memWarning}) |
| Network RX | ${formatBytes(s.networkRx)} |
| Network TX | ${formatBytes(s.networkTx)} |
| Block Read | ${formatBytes(s.blockRead)} |
| Block Write | ${formatBytes(s.blockWrite)} |`;
}
/**
* Format multiple container stats as markdown table
* STYLE.md Section 3.1: Plain text title
* STYLE.md Section 3.2: Summary with rollup counts
* STYLE.md Section 3.6: Freshness timestamp for volatile data
* STYLE.md Section 10.2: Warning thresholds (CPU>90%, MEM>90%)
* STYLE.md Section 12: Severity-first sorting
*/
export function formatMultiStatsMarkdown(
allStats: Array<{ stats: ContainerStats; host: string }>
): string {
if (allStats.length === 0) return "No running containers found.";
// STYLE.md 3.1: Plain text title (no markdown ##)
const title = "Container Resource Usage";
// STYLE.md 3.2: Summary with container count
const summary = `Showing ${allStats.length} containers`;
// STYLE.md Section 3.6: Freshness timestamp for volatile data (stats)
const timestamp = getTimestamp();
// STYLE.md Section 12: Severity-first sorting (highest CPU/MEM first)
const sorted = [...allStats].sort((a, b) => {
// Sort by max(CPU%, MEM%) descending - highest resource usage first
const aMax = Math.max(a.stats.cpuPercent, a.stats.memoryPercent);
const bMax = Math.max(b.stats.cpuPercent, b.stats.memoryPercent);
return bMax - aMax;
});
const lines = [
title,
summary,
timestamp,
"",
"| Container | Host | CPU% | Memory | Mem% |",
"|-----------|------|------|--------|------|",
];
for (const { stats, host } of sorted) {
// STYLE.md 10.2: Warning thresholds
const cpuWarning = stats.cpuPercent > 90 ? " ⚠" : "";
const memWarning = stats.memoryPercent > 90 ? " ⚠" : "";
lines.push(
`| ${stats.containerName} | ${host} | ${stats.cpuPercent.toFixed(1)}%${cpuWarning} | ${formatBytes(stats.memoryUsage)} | ${stats.memoryPercent.toFixed(1)}%${memWarning} |`
);
}
return lines.join("\n");
}
/**
* Format container inspection as markdown
* STYLE.md Section 3.1: Plain text title (no markdown ##/###)
* STYLE.md Section 4.1: Canonical → for port mapping
*/
export function formatInspectMarkdown(info: DockerRawContainerInspectInfo, host: string): string {
const config = info.Config;
const state = info.State;
const mounts = info.Mounts || [];
const network = info.NetworkSettings;
// STYLE.md 3.1: Plain text title (no markdown ##)
const lines = [
`Container: ${info.Name.replace(/^\//, "")} (${host})`,
"",
"**State**",
`- Status: ${state.Status}`,
`- Running: ${state.Running}`,
`- Started: ${state.StartedAt}`,
`- Restart Count: ${info.RestartCount}`,
"",
"**Configuration**",
`- Image: ${config.Image}`,
`- Command: ${(config.Cmd || []).join(" ")}`,
`- Working Dir: ${config.WorkingDir || "/"}`,
"",
];
if (config.Env && config.Env.length > 0) {
lines.push("**Environment Variables**");
for (const env of config.Env.slice(0, 20)) {
const [key] = env.split("=");
const isSensitive = /password|secret|key|token|api/i.test(key);
lines.push(`- ${isSensitive ? `${key}=****` : env}`);
}
if (config.Env.length > 20) lines.push(`- ... and ${config.Env.length - 20} more`);
lines.push("");
}
if (mounts.length > 0) {
lines.push("**Mounts**");
for (const m of mounts) {
// STYLE.md 4.1: Canonical → for mapping notation
lines.push(`- ${m.Source} → ${m.Destination} (${m.Mode || "rw"})`);
}
lines.push("");
}
if (network.Ports) {
lines.push("**Ports**");
for (const [containerPort, bindings] of Object.entries(network.Ports)) {
if (bindings && bindings.length > 0) {
for (const b of bindings) {
// STYLE.md 4.1: Canonical → for port mapping
lines.push(`- ${b.HostIp || "0.0.0.0"}:${b.HostPort} → ${containerPort}`);
}
}
}
lines.push("");
}
if (network.Networks && Object.keys(network.Networks).length > 0) {
lines.push("**Networks**");
for (const networkName of Object.keys(network.Networks)) {
lines.push(`- ${networkName}`);
}
}
return lines.join("\n");
}
/**
* Format search results as markdown
*/
export function formatSearchResultsMarkdown(
containers: ContainerInfo[],
query: string,
total: number
): string {
if (containers.length === 0) return `No containers found matching '${query}'.`;
const lines = [`## Search Results for '${query}' (${total} matches)`, ""];
for (const c of containers) {
const stateEmoji = c.state === "running" ? "🟢" : c.state === "paused" ? "🟡" : "🔴";
lines.push(`${stateEmoji} **${c.name}** (${c.hostName})`);
lines.push(` Image: ${c.image} | State: ${c.state}`);
lines.push("");
}
return lines.join("\n");
}
/**
* Container inspect summary type
*/
export interface ContainerInspectSummary {
id: string;
name: string;
image: string;
state: string;
created: string;
started: string;
restartCount: number;
ports: string[];
mounts: Array<{ src?: string; dst?: string; type?: string }>;
networks: string[];
env_count: number;
labels_count: number;
host: string;
}
/**
* Format container inspect summary as markdown (condensed version)
*/
export function formatInspectSummaryMarkdown(summary: ContainerInspectSummary): string {
const lines = [
`## ${summary.name} (${summary.host})`,
"",
"| Field | Value |",
"|-------|-------|",
`| ID | ${summary.id} |`,
`| Image | ${summary.image} |`,
`| State | ${summary.state} |`,
`| Started | ${summary.started?.slice(0, 19) || "-"} |`,
`| Restarts | ${summary.restartCount} |`,
`| Networks | ${summary.networks.join(", ") || "-"} |`,
`| Ports | ${summary.ports.join(", ") || "-"} |`,
`| Mounts | ${summary.mounts.length} |`,
`| Env Vars | ${summary.env_count} |`,
`| Labels | ${summary.labels_count} |`,
];
if (summary.mounts.length > 0 && summary.mounts.length <= 5) {
lines.push("", "**Mounts:**");
for (const m of summary.mounts) {
lines.push(`- ${m.src} → ${m.dst} (${m.type})`);
}
}
return lines.join("\n");
}
/**
* Format container lifecycle operations as one-liners
* Simple success messages per STYLE.md - no elaborate formatting needed
*/
export function formatContainerStartMarkdown(container: string, host: string): string {
return `Container ${container} started on ${host}`;
}
export function formatContainerStopMarkdown(container: string, host: string): string {
return `Container ${container} stopped on ${host}`;
}
export function formatContainerRestartMarkdown(container: string, host: string): string {
return `Container ${container} restarted on ${host}`;
}
export function formatContainerPauseMarkdown(container: string, host: string): string {
return `Container ${container} paused on ${host}`;
}
export function formatContainerResumeMarkdown(container: string, host: string): string {
return `Container ${container} resumed on ${host}`;
}
export function formatContainerExecMarkdown(
container: string,
host: string,
command: string,
output: string,
exitCode: number
): string {
const status = exitCode === 0 ? "✓" : "✗";
const outputLines = output.split("\n");
if (outputLines.length <= PREVIEW_THRESHOLD) {
return `${status} Executed in ${container} on ${host}: ${command}\nExit code: ${exitCode}\n\n${output}`;
}
const first5 = outputLines.slice(0, 5).join("\n");
const last5 = outputLines.slice(-5).join("\n");
const preview = `Preview (first 5):\n${first5}\n...\n\nPreview (last 5):\n${last5}\n...`;
return `${status} Executed in ${container} on ${host}: ${command}\nExit code: ${exitCode}\nOutput lines: ${outputLines.length} (previewed)\n\n${preview}`;
}
export function formatContainerTopMarkdown(
container: string,
host: string,
processes: string
): string {
const timestamp = getTimestamp();
return `Processes in ${container} (${host})\n${timestamp}\n\n${processes}`;
}