/**
* Host and system formatting utilities
*
* Provides consistent markdown formatting for host status,
* resources, and port mappings.
*/
import type { HostResources } from "../services/ssh.js";
import { getTimestamp } from "./utils.js";
// Re-export HostResources so consumers can import from formatters
export type { HostResources } from "../services/ssh.js";
/**
* Host status entry type
*/
export interface HostStatusEntry {
name: string;
connected: boolean;
containerCount: number;
runningCount: number;
error?: string;
}
/**
* Format host status as markdown table
*
* Title format per STYLE.md 3.1: Plain text, no markdown ## prefix
* Summary per STYLE.md 3.2: Pipe-separated host counts
* Legend per STYLE.md 3.3: Shows symbols for mixed states
* Symbols per STYLE.md 4.1: ● for online, ○ for offline
* Sorting per STYLE.md Section 12: Severity-first (offline → online)
*
* @param status - Array of host status entries
* @returns Formatted markdown string with status table
*
* @example
* ```typescript
* const output = formatHostStatusMarkdown([
* { name: "squirts", connected: true, containerCount: 10, runningCount: 8 },
* { name: "boops", connected: false, containerCount: 0, runningCount: 0, error: "Timeout" }
* ]);
* // Homelab Host Status
* // Hosts: 2 | Online: 1 | Offline: 1
* // Legend: ● online ○ offline
* //
* // | Host | Status | Containers | Running |
* // |--------|-----------------------|------------|---------|
* // | boops | ○ Offline (Timeout) | 0 | 0 |
* // | squirts| ● Online | 10 | 8 |
* ```
*/
export function formatHostStatusMarkdown(status: HostStatusEntry[]): string {
// STYLE.md Section 12: Severity-first sorting (offline → online)
const sorted = [...status].sort((a, b) => {
if (!a.connected && b.connected) return -1;
if (a.connected && !b.connected) return 1;
return 0;
});
// STYLE.md 3.2: Summary line with counts
const totalHosts = sorted.length;
const onlineCount = sorted.filter((h) => h.connected).length;
const offlineCount = sorted.filter((h) => !h.connected).length;
// STYLE.md 3.3: Legend for mixed states
const hasMixedStates = onlineCount > 0 && offlineCount > 0;
const lines = [
// STYLE.md 3.1: Plain text title (no markdown ##)
"Homelab Host Status",
`Hosts: ${totalHosts} | Online: ${onlineCount} | Offline: ${offlineCount}`,
];
if (hasMixedStates) {
lines.push("Legend: ● online ○ offline");
}
lines.push(
"",
"| Host | Status | Containers | Running |",
"|------|--------|------------|---------|"
);
for (const h of sorted) {
// STYLE.md 4.1: Canonical symbols (● for online, ○ for offline)
const statusSymbol = h.connected ? "●" : "○";
const statusText = h.connected ? "Online" : `Offline (${h.error || "Unknown"})`;
lines.push(
`| ${h.name} | ${statusSymbol} ${statusText} | ${h.containerCount} | ${h.runningCount} |`
);
}
return lines.join("\n");
}
/**
* Format host resources as markdown
*
* Title format per STYLE.md 3.1: Plain text, no markdown ## prefix
* Symbols per STYLE.md 4.1: ✗ for errors (no ❌ emoji)
* Freshness per STYLE.md 3.6: Resources are volatile (CPU, memory, disk)
* Warning per STYLE.md 10.2: MANDATORY thresholds - CPU>90%, MEM>90%, DISK>85%
*
* @param results - Array of host resource results (success or error)
* @returns Formatted markdown string with resource details
*
* @example
* ```typescript
* const output = formatHostResourcesMarkdown([
* {
* host: "squirts",
* resources: {
* hostname: "squirts.local",
* uptime: "10 days",
* loadAverage: [1.0, 1.5, 2.0],
* cpu: { cores: 8, usagePercent: 95 },
* memory: { totalMB: 16384, usedMB: 15360, usagePercent: 94 },
* disk: [{ mount: "/", totalGB: 100, usedGB: 90, usagePercent: 90 }]
* }
* }
* ]);
* // Host Resources
* // As of (EST): 11:45:30 | 02/13/2026
* //
* // ### squirts
* // - **Hostname:** squirts.local
* // - **Uptime:** 10 days
* // - **Load:** 1.0, 1.5, 2.0
* // - **CPU:** 8 cores @ 95% ⚠
* // - **Memory:** 15360 MB / 16384 MB (94% ⚠)
* //
* // **Disks:**
* // - /: 90G / 100G (90% ⚠)
* ```
*/
export function formatHostResourcesMarkdown(
results: Array<{ host: string; resources: HostResources | null; error?: string }>
): string {
// STYLE.md 3.1: Plain text title (no markdown ##)
// STYLE.md 3.6: Freshness timestamp for volatile resource data
const lines = ["Host Resources", getTimestamp(), ""];
for (const { host, resources, error } of results) {
lines.push(`### ${host}`);
if (error || !resources) {
// STYLE.md 4.1: Canonical ✗ symbol (no ❌ emoji)
lines.push(`✗ ${error || "Unknown error"}`);
lines.push("");
continue;
}
lines.push(`- **Hostname:** ${resources.hostname}`);
lines.push(`- **Uptime:** ${resources.uptime}`);
lines.push(`- **Load:** ${resources.loadAverage.join(", ")}`);
// STYLE.md 10.2: CPU warning threshold >90%
const cpuWarning = resources.cpu.usagePercent > 90 ? " ⚠" : "";
lines.push(
`- **CPU:** ${resources.cpu.cores} cores @ ${resources.cpu.usagePercent}%${cpuWarning}`
);
// STYLE.md 10.2: Memory warning threshold >90%
const memWarning = resources.memory.usagePercent > 90 ? " ⚠" : "";
lines.push(
`- **Memory:** ${resources.memory.usedMB} MB / ${resources.memory.totalMB} MB (${resources.memory.usagePercent}%${memWarning})`
);
if (resources.disk.length > 0) {
lines.push("", "**Disks:**");
for (const d of resources.disk) {
// STYLE.md 10.2: Disk warning threshold >85%
const diskWarning = d.usagePercent > 85 ? " ⚠" : "";
lines.push(`- ${d.mount}: ${d.usedGB}G / ${d.totalGB}G (${d.usagePercent}%${diskWarning})`);
}
}
lines.push("");
}
return lines.join("\n");
}
/**
* Port mapping info type for formatting
*/
export interface HostPortMapping {
port: number;
protocol: "tcp" | "udp";
state: "listening" | "bound" | "reserved";
source: "host" | "docker" | "compose";
containerName?: string;
containerPort?: number;
}
/**
* Format host port mappings as markdown
*
* Title format per STYLE.md 3.1: Plain text, no markdown ## prefix
* Symbols per STYLE.md 4.1: Canonical → for port mapping notation (no emoji)
* Pagination per STYLE.md Section 12: Standard footer format
*
* @param ports - Array of port mapping information
* @param host - Host name
* @param total - Total count of ports (for pagination)
* @param offset - Current pagination offset
* @param hasMore - Whether more results are available
* @returns Formatted markdown string with port mappings
*
* @example
* ```typescript
* const output = formatHostPortsMarkdown(
* [{ port: 8080, protocol: "tcp", state: "listening", source: "docker", containerName: "nginx", containerPort: 80 }],
* "squirts",
* 10,
* 0,
* true
* );
* // Port Mappings - squirts
* //
* // | Port | Protocol | State | Source | Container | Mapping |
* // |------|----------|-----------|--------|-----------|------------|
* // | 8080 | tcp | listening | docker | nginx | 8080 → 80 |
* //
* // Showing 1–1 of 10 | next_offset: 1
* ```
*/
export function formatHostPortsMarkdown(
ports: HostPortMapping[],
host: string,
total: number,
offset: number,
hasMore: boolean
): string {
// STYLE.md 3.1: Plain text title (no markdown ##)
if (ports.length === 0) {
return `Port Mappings - ${host}\n\nNo ports found matching the specified criteria.`;
}
const lines = [
`Port Mappings - ${host}`,
"",
"| Port | Protocol | State | Source | Container | Mapping |",
"|------|----------|-------|--------|-----------|---------|",
];
for (const p of ports) {
const container = p.containerName || "—";
// STYLE.md 4.1: Use canonical → for port mapping notation
const mapping = p.containerPort ? `${p.port} → ${p.containerPort}` : "—";
// No emoji per STYLE.md 4.1 - just text
lines.push(
`| ${p.port} | ${p.protocol} | ${p.state} | ${p.source} | ${container} | ${mapping} |`
);
}
// STYLE.md Section 12: Standard pagination footer format
if (hasMore) {
const endOffset = offset + ports.length;
lines.push("", `Showing ${offset + 1}–${endOffset} of ${total} | next_offset: ${endOffset}`);
}
return lines.join("\n");
}