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:
Anso
2026-06-06 11:02:39 -04:00
committed by GitHub
parent 7df5ffde54
commit 13b6acab16
4 changed files with 346 additions and 17 deletions
+7 -1
View File
@@ -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 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:
@@ -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.
### 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.
## Editor mode
+47 -16
View File
@@ -1,10 +1,13 @@
import { useEffect, useMemo, useRef, useState } from 'react';
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 { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { apiFetch } from '@/lib/api';
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 type { NotificationItem } from '@/components/dashboard/types';
@@ -46,17 +49,6 @@ interface GitSourceInfo {
compose_path?: string;
}
interface PortRow {
host: string;
container: string;
proto: string;
}
interface VolumeRow {
host: string;
container: string;
}
interface Anatomy {
services: string[];
ports: Record<string, PortRow[]>;
@@ -249,7 +241,7 @@ export default function StackAnatomyPanel({
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 [scanStatus, setScanStatus] = useState<{
status: 'ok' | 'partial' | 'failed' | 'skipped' | null;
@@ -270,7 +262,10 @@ export default function StackAnatomyPanel({
if (data && data.linked === false) {
setGitSource(null);
} 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 {
setGitSource(null);
@@ -358,6 +353,9 @@ export default function StackAnatomyPanel({
? anatomy.networks[0]
: `${stackName}_default`;
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(() => {
if (!anatomy) return null;
for (const svc of anatomy.services) {
@@ -367,6 +365,28 @@ export default function StackAnatomyPanel({
return null;
}, [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 hasUpdate = Boolean(updatePreview?.summary.has_update);
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>
</TabsList>
<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 && (
<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"
>
<GitBranch className="h-3 w-3 shrink-0" strokeWidth={1.5} />
{gitSource ? (
<span className="truncate">git <span className="text-stat-subtitle">·</span> {formatGitSource(gitSource)}</span>
{activeGitSource ? (
<span className="truncate">git <span className="text-stat-subtitle">·</span> {formatGitSource(activeGitSource)}</span>
) : (
<span>local</span>
)}
+196
View File
@@ -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);
});
});
+96
View File
@@ -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');
}