diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx index 8a3453b0..7e3992b3 100644 --- a/docs/features/editor.mdx +++ b/docs/features/editor.mdx @@ -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 diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index 0f541d09..ff53c4da 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -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; @@ -249,7 +241,7 @@ export default function StackAnatomyPanel({ const envVarCount = envKeys.size; - const [gitSource, setGitSource] = useState(null); + const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo } | null>(null); const [updatePreview, setUpdatePreview] = useState(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({ Activity
+ {anatomy && ( + + )} {onOpenFiles && (