mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 19:57:12 +00:00
feat(stacks): copy stack anatomy as Markdown (#1323)
* feat(stacks): copy stack anatomy as Markdown Add a "copy md" action to the Stack Anatomy panel that exports the parsed compose summary as a deterministic Markdown document: services, ports and volumes tables, restart policy, env file with its variable count, missing variables, network, and source. Useful for pasting a stack's wiring into a README, a Git repository, Obsidian, BookStack, or a support thread. The export carries only env variable names and counts, never the values stored in .env files. * fix(stacks): escape backslashes in anatomy Markdown table cells escapeCell escaped pipes but not the backslash itself, so a backslash in a cell value (for example a Windows path) could defeat the pipe escaping and break the rendered table row. Escape the backslash first, then pipes, then collapse newlines. Adds a regression test. * fix(stacks): correct git source attribution and CR handling in anatomy export Gate the Anatomy panel's git source on its owning stack so a slow git-source response for a previously selected stack can no longer render or be exported under a different stack. Also collapse lone carriage returns (not just CRLF and LF) in Markdown table cells so a stray CR cannot break a table row.
This commit is contained in:
@@ -67,7 +67,7 @@ When the container has a compose service name attached, an extra `⋮` button ap
|
|||||||
|
|
||||||
The right column shows the **Anatomy panel** by default: a read-only summary of the compose file with a tab for the stack's activity timeline.
|
The right column shows the **Anatomy panel** by default: a read-only summary of the compose file with a tab for the stack's activity timeline.
|
||||||
|
|
||||||
The header strip carries two tabs (**Anatomy** and **Activity**) plus shortcuts to open the **files** tab and enter **edit** mode. The shortcuts belong to the strip itself and remain available regardless of which tab is active.
|
The header strip carries two tabs (**Anatomy** and **Activity**) plus shortcuts to **copy md** (export the summary as Markdown), open the **files** tab, and enter **edit** mode. The shortcuts belong to the strip itself and remain available regardless of which tab is active.
|
||||||
|
|
||||||
The Anatomy tab lists:
|
The Anatomy tab lists:
|
||||||
|
|
||||||
@@ -81,6 +81,12 @@ The Anatomy tab lists:
|
|||||||
|
|
||||||
When an image update is available, an inline banner appears at the top of the panel. Its tone follows the version-bump severity: `safe to apply` (patch), `review recommended` (minor), `breaking changes possible` (major), or `review required` when the bump cannot be classified. The banner has an inline **apply** button that runs the same operation as the action bar's **Update**; it is hidden for roles that lack the `stack:edit` permission and when the bump is flagged as blocked.
|
When an image update is available, an inline banner appears at the top of the panel. Its tone follows the version-bump severity: `safe to apply` (patch), `review recommended` (minor), `breaking changes possible` (major), or `review required` when the bump cannot be classified. The banner has an inline **apply** button that runs the same operation as the action bar's **Update**; it is hidden for roles that lack the `stack:edit` permission and when the bump is flagged as blocked.
|
||||||
|
|
||||||
|
### Copy as Markdown
|
||||||
|
|
||||||
|
The **copy md** shortcut copies the current anatomy to your clipboard as a Markdown document: the stack name, services, a ports table, a volumes table, the restart policy, the env file with its variable count, any missing variables, the network, and the source. Paste it straight into a README, a Git repository, Obsidian, BookStack, or a support thread to keep a written record of how a stack is wired.
|
||||||
|
|
||||||
|
Empty sections render cleanly as `none`, and the export carries only variable **names** and **counts**, never the values stored in your `.env` file.
|
||||||
|
|
||||||
The **Activity** tab streams every operational event scoped to this stack. See [Stack Activity](/features/stack-activity) for the full event catalog and attribution rules.
|
The **Activity** tab streams every operational event scoped to this stack. See [Stack Activity](/features/stack-activity) for the full event catalog and attribution rules.
|
||||||
|
|
||||||
## Editor mode
|
## Editor mode
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { parse as parseYaml } from 'yaml';
|
import { parse as parseYaml } from 'yaml';
|
||||||
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } from 'lucide-react';
|
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen, Copy } from 'lucide-react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { copyToClipboard } from '@/lib/clipboard';
|
||||||
|
import { toast } from '@/components/ui/toast-store';
|
||||||
|
import { buildStackAnatomyMarkdown, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown';
|
||||||
import { StackActivityTimeline } from './stack/StackActivityTimeline';
|
import { StackActivityTimeline } from './stack/StackActivityTimeline';
|
||||||
import type { NotificationItem } from '@/components/dashboard/types';
|
import type { NotificationItem } from '@/components/dashboard/types';
|
||||||
|
|
||||||
@@ -46,17 +49,6 @@ interface GitSourceInfo {
|
|||||||
compose_path?: string;
|
compose_path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PortRow {
|
|
||||||
host: string;
|
|
||||||
container: string;
|
|
||||||
proto: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VolumeRow {
|
|
||||||
host: string;
|
|
||||||
container: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Anatomy {
|
interface Anatomy {
|
||||||
services: string[];
|
services: string[];
|
||||||
ports: Record<string, PortRow[]>;
|
ports: Record<string, PortRow[]>;
|
||||||
@@ -249,7 +241,7 @@ export default function StackAnatomyPanel({
|
|||||||
|
|
||||||
const envVarCount = envKeys.size;
|
const envVarCount = envKeys.size;
|
||||||
|
|
||||||
const [gitSource, setGitSource] = useState<GitSourceInfo | null>(null);
|
const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo } | null>(null);
|
||||||
const [updatePreview, setUpdatePreview] = useState<UpdatePreview | null>(null);
|
const [updatePreview, setUpdatePreview] = useState<UpdatePreview | null>(null);
|
||||||
const [scanStatus, setScanStatus] = useState<{
|
const [scanStatus, setScanStatus] = useState<{
|
||||||
status: 'ok' | 'partial' | 'failed' | 'skipped' | null;
|
status: 'ok' | 'partial' | 'failed' | 'skipped' | null;
|
||||||
@@ -270,7 +262,10 @@ export default function StackAnatomyPanel({
|
|||||||
if (data && data.linked === false) {
|
if (data && data.linked === false) {
|
||||||
setGitSource(null);
|
setGitSource(null);
|
||||||
} else {
|
} else {
|
||||||
setGitSource({ repo_url: data.repo_url, branch: data.branch, compose_path: data.compose_path });
|
setGitSource({
|
||||||
|
stack: stackName,
|
||||||
|
info: { repo_url: data.repo_url, branch: data.branch, compose_path: data.compose_path },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setGitSource(null);
|
setGitSource(null);
|
||||||
@@ -358,6 +353,9 @@ export default function StackAnatomyPanel({
|
|||||||
? anatomy.networks[0]
|
? anatomy.networks[0]
|
||||||
: `${stackName}_default`;
|
: `${stackName}_default`;
|
||||||
const firstEnvFile = anatomy?.envFiles[0] ?? selectedEnvFile ?? null;
|
const firstEnvFile = anatomy?.envFiles[0] ?? selectedEnvFile ?? null;
|
||||||
|
// Only treat the fetched source as current when it belongs to the selected stack, so a
|
||||||
|
// slow /git-source response for a previously selected stack cannot render or be exported here.
|
||||||
|
const activeGitSource = gitSource?.stack === stackName ? gitSource.info : null;
|
||||||
const primaryHostPort = useMemo(() => {
|
const primaryHostPort = useMemo(() => {
|
||||||
if (!anatomy) return null;
|
if (!anatomy) return null;
|
||||||
for (const svc of anatomy.services) {
|
for (const svc of anatomy.services) {
|
||||||
@@ -367,6 +365,28 @@ export default function StackAnatomyPanel({
|
|||||||
return null;
|
return null;
|
||||||
}, [anatomy]);
|
}, [anatomy]);
|
||||||
|
|
||||||
|
const handleCopyMarkdown = async () => {
|
||||||
|
if (!anatomy) return;
|
||||||
|
const markdown = buildStackAnatomyMarkdown({
|
||||||
|
stackName,
|
||||||
|
services: anatomy.services,
|
||||||
|
ports: anatomy.ports,
|
||||||
|
volumes: anatomy.volumes,
|
||||||
|
restart: anatomy.restart,
|
||||||
|
envFile: firstEnvFile,
|
||||||
|
envVarCount,
|
||||||
|
missingVars,
|
||||||
|
networkName,
|
||||||
|
gitSource: activeGitSource ? formatGitSource(activeGitSource) : null,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await copyToClipboard(markdown);
|
||||||
|
toast.success('Stack anatomy copied as Markdown.');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy to clipboard.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const bump = updatePreview?.summary.semver_bump ?? 'none';
|
const bump = updatePreview?.summary.semver_bump ?? 'none';
|
||||||
const hasUpdate = Boolean(updatePreview?.summary.has_update);
|
const hasUpdate = Boolean(updatePreview?.summary.has_update);
|
||||||
const blocked = Boolean(updatePreview?.summary.blocked);
|
const blocked = Boolean(updatePreview?.summary.blocked);
|
||||||
@@ -403,6 +423,17 @@ export default function StackAnatomyPanel({
|
|||||||
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
|
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{anatomy && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="anatomy-copy-md-btn"
|
||||||
|
onClick={() => { void handleCopyMarkdown(); }}
|
||||||
|
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" strokeWidth={1.5} />
|
||||||
|
copy md
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onOpenFiles && (
|
{onOpenFiles && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -521,8 +552,8 @@ export default function StackAnatomyPanel({
|
|||||||
className="inline-flex items-center gap-1.5 font-mono text-[11px] text-left hover:text-brand transition-colors"
|
className="inline-flex items-center gap-1.5 font-mono text-[11px] text-left hover:text-brand transition-colors"
|
||||||
>
|
>
|
||||||
<GitBranch className="h-3 w-3 shrink-0" strokeWidth={1.5} />
|
<GitBranch className="h-3 w-3 shrink-0" strokeWidth={1.5} />
|
||||||
{gitSource ? (
|
{activeGitSource ? (
|
||||||
<span className="truncate">git <span className="text-stat-subtitle">·</span> {formatGitSource(gitSource)}</span>
|
<span className="truncate">git <span className="text-stat-subtitle">·</span> {formatGitSource(activeGitSource)}</span>
|
||||||
) : (
|
) : (
|
||||||
<span>local</span>
|
<span>local</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildStackAnatomyMarkdown, type AnatomyMarkdownInput } from './anatomyMarkdown';
|
||||||
|
|
||||||
|
const fullInput: AnatomyMarkdownInput = {
|
||||||
|
stackName: 'mediawiki',
|
||||||
|
services: ['web', 'db'],
|
||||||
|
ports: {
|
||||||
|
web: [
|
||||||
|
{ host: '8080', container: '80', proto: 'tcp' },
|
||||||
|
{ host: '8443', container: '443', proto: 'tcp' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
volumes: {
|
||||||
|
db: [{ host: './data', container: '/var/lib/mysql' }],
|
||||||
|
},
|
||||||
|
restart: 'unless-stopped',
|
||||||
|
envFile: '.env',
|
||||||
|
envVarCount: 12,
|
||||||
|
missingVars: ['DB_PASSWORD', 'SECRET_KEY'],
|
||||||
|
networkName: 'mediawiki_default',
|
||||||
|
gitSource: 'github.com/me/wiki#main',
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyInput: AnatomyMarkdownInput = {
|
||||||
|
stackName: 'blank',
|
||||||
|
services: [],
|
||||||
|
ports: {},
|
||||||
|
volumes: {},
|
||||||
|
restart: null,
|
||||||
|
envFile: null,
|
||||||
|
envVarCount: 0,
|
||||||
|
missingVars: [],
|
||||||
|
networkName: 'blank_default',
|
||||||
|
gitSource: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildStackAnatomyMarkdown', () => {
|
||||||
|
it('renders a full anatomy as tables, headings, and lists', () => {
|
||||||
|
expect(buildStackAnatomyMarkdown(fullInput)).toBe(
|
||||||
|
[
|
||||||
|
'# mediawiki',
|
||||||
|
'',
|
||||||
|
'## Services',
|
||||||
|
'- `web`',
|
||||||
|
'- `db`',
|
||||||
|
'',
|
||||||
|
'## Ports',
|
||||||
|
'| Service | Host | Container | Protocol |',
|
||||||
|
'| --- | --- | --- | --- |',
|
||||||
|
'| web | 8080 | 80 | tcp |',
|
||||||
|
'| web | 8443 | 443 | tcp |',
|
||||||
|
'',
|
||||||
|
'## Volumes',
|
||||||
|
'| Service | Host | Container |',
|
||||||
|
'| --- | --- | --- |',
|
||||||
|
'| db | ./data | /var/lib/mysql |',
|
||||||
|
'',
|
||||||
|
'## Restart policy',
|
||||||
|
'`unless-stopped`',
|
||||||
|
'',
|
||||||
|
'## Environment',
|
||||||
|
'- File: `.env`',
|
||||||
|
'- Variables: 12',
|
||||||
|
'- Missing: `DB_PASSWORD`, `SECRET_KEY`',
|
||||||
|
'',
|
||||||
|
'## Network',
|
||||||
|
'`mediawiki_default` (bridge)',
|
||||||
|
'',
|
||||||
|
'## Source',
|
||||||
|
'git · github.com/me/wiki#main',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders clean empty-state placeholders', () => {
|
||||||
|
expect(buildStackAnatomyMarkdown(emptyInput)).toBe(
|
||||||
|
[
|
||||||
|
'# blank',
|
||||||
|
'',
|
||||||
|
'## Services',
|
||||||
|
'_none defined_',
|
||||||
|
'',
|
||||||
|
'## Ports',
|
||||||
|
'_none_',
|
||||||
|
'',
|
||||||
|
'## Volumes',
|
||||||
|
'_none_',
|
||||||
|
'',
|
||||||
|
'## Restart policy',
|
||||||
|
'_default_',
|
||||||
|
'',
|
||||||
|
'## Environment',
|
||||||
|
'- File: _none_',
|
||||||
|
'',
|
||||||
|
'## Network',
|
||||||
|
'`blank_default` (bridge)',
|
||||||
|
'',
|
||||||
|
'## Source',
|
||||||
|
'local',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps missing env vars visible', () => {
|
||||||
|
const md = buildStackAnatomyMarkdown(fullInput);
|
||||||
|
expect(md).toContain('- Missing: `DB_PASSWORD`, `SECRET_KEY`');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits only the env variable count and key names, never values', () => {
|
||||||
|
const md = buildStackAnatomyMarkdown(fullInput);
|
||||||
|
expect(md).toContain('- Variables: 12');
|
||||||
|
expect(md).toContain('`DB_PASSWORD`');
|
||||||
|
// The builder has no access to env values, so no `KEY=value` assignment can appear.
|
||||||
|
expect(md).not.toMatch(/DB_PASSWORD\s*=/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes pipe characters in volume table cells', () => {
|
||||||
|
const md = buildStackAnatomyMarkdown({
|
||||||
|
...emptyInput,
|
||||||
|
services: ['svc'],
|
||||||
|
volumes: { svc: [{ host: '/data|weird', container: '/mnt' }] },
|
||||||
|
});
|
||||||
|
expect(md).toContain('| svc | /data\\|weird | /mnt |');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes pipe characters in port table cells', () => {
|
||||||
|
const md = buildStackAnatomyMarkdown({
|
||||||
|
...emptyInput,
|
||||||
|
services: ['svc'],
|
||||||
|
ports: { svc: [{ host: '127.0.0.1|x', container: '80', proto: 'tcp' }] },
|
||||||
|
});
|
||||||
|
expect(md).toContain('| svc | 127.0.0.1\\|x | 80 | tcp |');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes backslashes before pipes so the escaping cannot be defeated', () => {
|
||||||
|
const md = buildStackAnatomyMarkdown({
|
||||||
|
...emptyInput,
|
||||||
|
services: ['svc'],
|
||||||
|
// host `C:\data` -> `C:\\data`; container `a\|b` -> backslash doubled then pipe escaped -> `a\\\|b`
|
||||||
|
volumes: { svc: [{ host: 'C:\\data', container: 'a\\|b' }] },
|
||||||
|
});
|
||||||
|
expect(md).toContain('| svc | C:\\\\data | a\\\\\\|b |');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses newlines in a table cell to a single space', () => {
|
||||||
|
const md = buildStackAnatomyMarkdown({
|
||||||
|
...emptyInput,
|
||||||
|
services: ['svc'],
|
||||||
|
volumes: { svc: [{ host: 'a\nb', container: '/mnt' }] },
|
||||||
|
});
|
||||||
|
expect(md).toContain('| svc | a b | /mnt |');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses CRLF and lone carriage returns in a table cell', () => {
|
||||||
|
const md = buildStackAnatomyMarkdown({
|
||||||
|
...emptyInput,
|
||||||
|
services: ['svc'],
|
||||||
|
volumes: { svc: [{ host: 'a\r\nb', container: 'c\rd' }] },
|
||||||
|
});
|
||||||
|
expect(md).toContain('| svc | a b | c d |');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flattens rows from multiple services into one table', () => {
|
||||||
|
const md = buildStackAnatomyMarkdown({
|
||||||
|
...emptyInput,
|
||||||
|
services: ['web', 'db'],
|
||||||
|
ports: {
|
||||||
|
web: [{ host: '8080', container: '80', proto: 'tcp' }],
|
||||||
|
db: [{ host: '5432', container: '5432', proto: 'tcp' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(md).toContain('| web | 8080 | 80 | tcp |');
|
||||||
|
expect(md).toContain('| db | 5432 | 5432 | tcp |');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the Missing line when an env file has no missing vars', () => {
|
||||||
|
const md = buildStackAnatomyMarkdown({
|
||||||
|
...emptyInput,
|
||||||
|
envFile: '.env',
|
||||||
|
envVarCount: 3,
|
||||||
|
missingVars: [],
|
||||||
|
});
|
||||||
|
expect(md).toContain('## Environment\n- File: `.env`\n- Variables: 3');
|
||||||
|
expect(md).not.toContain('- Missing:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic across distinct but equal inputs', () => {
|
||||||
|
const a = buildStackAnatomyMarkdown(fullInput);
|
||||||
|
const b = buildStackAnatomyMarkdown({
|
||||||
|
...fullInput,
|
||||||
|
ports: { ...fullInput.ports },
|
||||||
|
volumes: { ...fullInput.volumes },
|
||||||
|
});
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic Markdown export for the Stack Anatomy panel.
|
||||||
|
*
|
||||||
|
* Turns the already-parsed compose anatomy into a clean Markdown document so an
|
||||||
|
* operator can paste a stack summary into Git, Obsidian, BookStack, a README, or
|
||||||
|
* a support thread. The builder is pure and side-effect free: the same input
|
||||||
|
* always yields byte-identical output.
|
||||||
|
*
|
||||||
|
* It only ever receives env variable names and a count, never `.env` values, so
|
||||||
|
* no secret can leak into the exported text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PortRow {
|
||||||
|
host: string;
|
||||||
|
container: string;
|
||||||
|
proto: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VolumeRow {
|
||||||
|
host: string;
|
||||||
|
container: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnatomyMarkdownInput {
|
||||||
|
stackName: string;
|
||||||
|
services: string[];
|
||||||
|
ports: Record<string, PortRow[]>;
|
||||||
|
volumes: Record<string, VolumeRow[]>;
|
||||||
|
restart: string | null;
|
||||||
|
/** Selected env file path, or null when the stack declares none. */
|
||||||
|
envFile: string | null;
|
||||||
|
envVarCount: number;
|
||||||
|
/** Referenced `${VAR}` names with no entry in the env file. */
|
||||||
|
missingVars: string[];
|
||||||
|
networkName: string;
|
||||||
|
/** Formatted Git label (typically `host/repo#branch`) when Git-linked, else null (local). */
|
||||||
|
gitSource: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = (s: string): string => `\`${s}\``;
|
||||||
|
|
||||||
|
// Escape the backslash first (so it cannot defeat the pipe escaping), then pipes
|
||||||
|
// that would break a table row, then collapse line breaks (CRLF, LF, or a lone CR)
|
||||||
|
// onto the same line.
|
||||||
|
function escapeCell(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/\|/g, '\\|')
|
||||||
|
.replace(/\r\n?|\n/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function servicesSection(services: string[]): string {
|
||||||
|
if (services.length === 0) return '## Services\n_none defined_';
|
||||||
|
return `## Services\n${services.map(s => `- ${code(s)}`).join('\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function portsSection(ports: Record<string, PortRow[]>): string {
|
||||||
|
const rows = Object.entries(ports).flatMap(([svc, list]) =>
|
||||||
|
list.map(r => `| ${escapeCell(svc)} | ${escapeCell(r.host)} | ${escapeCell(r.container)} | ${escapeCell(r.proto)} |`),
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return '## Ports\n_none_';
|
||||||
|
return ['## Ports', '| Service | Host | Container | Protocol |', '| --- | --- | --- | --- |', ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function volumesSection(volumes: Record<string, VolumeRow[]>): string {
|
||||||
|
const rows = Object.entries(volumes).flatMap(([svc, list]) =>
|
||||||
|
list.map(r => `| ${escapeCell(svc)} | ${escapeCell(r.host)} | ${escapeCell(r.container)} |`),
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return '## Volumes\n_none_';
|
||||||
|
return ['## Volumes', '| Service | Host | Container |', '| --- | --- | --- |', ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function environmentSection(envFile: string | null, envVarCount: number, missingVars: string[]): string {
|
||||||
|
if (!envFile) return '## Environment\n- File: _none_';
|
||||||
|
const lines = [`- File: ${code(envFile)}`, `- Variables: ${envVarCount}`];
|
||||||
|
if (missingVars.length > 0) {
|
||||||
|
lines.push(`- Missing: ${missingVars.map(code).join(', ')}`);
|
||||||
|
}
|
||||||
|
return `## Environment\n${lines.join('\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStackAnatomyMarkdown(input: AnatomyMarkdownInput): string {
|
||||||
|
const restart = input.restart ? code(input.restart) : '_default_';
|
||||||
|
const source = input.gitSource ? `git · ${input.gitSource}` : 'local';
|
||||||
|
|
||||||
|
return [
|
||||||
|
`# ${input.stackName}`,
|
||||||
|
servicesSection(input.services),
|
||||||
|
portsSection(input.ports),
|
||||||
|
volumesSection(input.volumes),
|
||||||
|
`## Restart policy\n${restart}`,
|
||||||
|
environmentSection(input.envFile, input.envVarCount, input.missingVars),
|
||||||
|
`## Network\n${code(input.networkName)} (bridge)`,
|
||||||
|
`## Source\n${source}`,
|
||||||
|
].join('\n\n');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user