diff --git a/backend/src/__tests__/stack-dossier-api.test.ts b/backend/src/__tests__/stack-dossier-api.test.ts new file mode 100644 index 00000000..5deeb05f --- /dev/null +++ b/backend/src/__tests__/stack-dossier-api.test.ts @@ -0,0 +1,151 @@ +/** + * Integration tests for the per-stack Dossier endpoints: blank-on-first-read, + * upsert persistence, full-document save semantics, field validation, RBAC, and + * per (node, stack) scoping at the DAO layer. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import fs from 'fs'; +import path from 'path'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let adminCookie: string; +let viewerCookie: string; + +const FIELD_KEYS = [ + 'purpose', 'owner', 'access_urls', 'static_ip', 'vlan', + 'firewall_notes', 'reverse_proxy_notes', 'backup_notes', + 'upgrade_notes', 'recovery_notes', 'custom_notes', +] as const; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + // The dossier routes require the stack to exist on disk, so seed a compose + // file for every stack name these tests drive through the HTTP API. + const composeDir = process.env.COMPOSE_DIR as string; + for (const stack of ['web', 'upd', 'clr']) { + fs.mkdirSync(path.join(composeDir, stack), { recursive: true }); + fs.writeFileSync(path.join(composeDir, stack, 'compose.yaml'), 'services:\n app:\n image: nginx\n'); + } + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); + + const viewerHash = await bcrypt.hash('viewerpass', 1); + DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: viewerHash, role: 'viewer' }); + const viewerRes = await request(app).post('/api/auth/login').send({ username: 'viewer', password: 'viewerpass' }); + const cookies = viewerRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +describe('GET /api/stacks/:stackName/dossier', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).get('/api/stacks/web/dossier'); + expect(res.status).toBe(401); + }); + + it('returns a blank dossier (200, all fields empty) when none has been saved', async () => { + const res = await request(app).get('/api/stacks/web/dossier').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.stack_name).toBe('web'); + for (const k of FIELD_KEYS) expect(res.body[k]).toBe(''); + }); +}); + +describe('PUT /api/stacks/:stackName/dossier', () => { + it('persists operator fields and returns the saved row', async () => { + const res = await request(app) + .put('/api/stacks/web/dossier') + .set('Cookie', adminCookie) + .send({ purpose: 'Reverse proxy', owner: 'ops', static_ip: '10.0.20.5', vlan: '20', firewall_notes: '443 open' }); + expect(res.status).toBe(200); + expect(res.body.purpose).toBe('Reverse proxy'); + expect(res.body.static_ip).toBe('10.0.20.5'); + + const get = await request(app).get('/api/stacks/web/dossier').set('Cookie', adminCookie); + expect(get.body.owner).toBe('ops'); + expect(get.body.firewall_notes).toBe('443 open'); + }); + + it('updates in place: a second PUT keeps a single row and preserves created_at', async () => { + const first = await request(app).put('/api/stacks/upd/dossier').set('Cookie', adminCookie).send({ purpose: 'one' }); + const createdAt = first.body.created_at as number; + await new Promise(r => setTimeout(r, 20)); + const second = await request(app).put('/api/stacks/upd/dossier').set('Cookie', adminCookie).send({ purpose: 'two' }); + expect(second.body.purpose).toBe('two'); + expect(second.body.created_at).toBe(createdAt); + expect(second.body.updated_at).toBeGreaterThanOrEqual(createdAt); + + const row = DatabaseService.getInstance().getDb() + .prepare("SELECT COUNT(*) AS n FROM stack_dossiers WHERE stack_name = 'upd'").get() as { n: number }; + expect(row.n).toBe(1); + }); + + it('treats a save as a full document: omitted short and block fields are cleared', async () => { + await request(app).put('/api/stacks/clr/dossier').set('Cookie', adminCookie) + .send({ purpose: 'p', owner: 'o', firewall_notes: '443 open' }); + const res = await request(app).put('/api/stacks/clr/dossier').set('Cookie', adminCookie) + .send({ purpose: 'only purpose' }); + expect(res.body.purpose).toBe('only purpose'); + expect(res.body.owner).toBe(''); + expect(res.body.firewall_notes).toBe(''); + }); + + it('rejects an over-long field with 400', async () => { + const res = await request(app) + .put('/api/stacks/web/dossier') + .set('Cookie', adminCookie) + .send({ static_ip: 'x'.repeat(300) }); + expect(res.status).toBe(400); + }); +}); + +describe('Dossier requires the stack to exist', () => { + it('returns 404 for a GET on a stack that does not exist', async () => { + const res = await request(app).get('/api/stacks/ghost/dossier').set('Cookie', adminCookie); + expect(res.status).toBe(404); + }); + + it('returns 404 for a PUT on a missing stack and creates no orphan row', async () => { + const res = await request(app).put('/api/stacks/ghost/dossier').set('Cookie', adminCookie).send({ purpose: 'orphan' }); + expect(res.status).toBe(404); + expect(DatabaseService.getInstance().getStackDossier(1, 'ghost')).toBeUndefined(); + }); +}); + +describe('Dossier RBAC', () => { + it('lets a viewer read the dossier', async () => { + const res = await request(app).get('/api/stacks/web/dossier').set('Cookie', viewerCookie); + expect(res.status).toBe(200); + }); + + it('forbids a viewer from saving the dossier (403)', async () => { + const res = await request(app).put('/api/stacks/web/dossier').set('Cookie', viewerCookie).send({ purpose: 'nope' }); + expect(res.status).toBe(403); + }); +}); + +describe('Dossier scoping (DAO)', () => { + it('keeps dossiers isolated per (node, stack)', () => { + const db = DatabaseService.getInstance(); + const fields = (purpose: string) => ({ + purpose, owner: '', access_urls: '', static_ip: '', vlan: '', + firewall_notes: '', reverse_proxy_notes: '', backup_notes: '', + upgrade_notes: '', recovery_notes: '', custom_notes: '', + }); + db.upsertStackDossier(1, 'shared', fields('node-1')); + db.upsertStackDossier(2, 'shared', fields('node-2')); + expect(db.getStackDossier(1, 'shared')?.purpose).toBe('node-1'); + expect(db.getStackDossier(2, 'shared')?.purpose).toBe('node-2'); + + db.deleteStackDossier(1, 'shared'); + expect(db.getStackDossier(1, 'shared')).toBeUndefined(); + expect(db.getStackDossier(2, 'shared')?.purpose).toBe('node-2'); + }); +}); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 9e752ce0..28aa9b9c 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -1,11 +1,12 @@ import { Router, type Request, type Response, type NextFunction } from 'express'; +import { z } from 'zod'; import path from 'path'; import YAML from 'yaml'; import multer from 'multer'; import { FileSystemService } from '../services/FileSystemService'; import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService'; import DockerController from '../services/DockerController'; -import { DatabaseService } from '../services/DatabaseService'; +import { DatabaseService, type StackDossierFields } from '../services/DatabaseService'; import { MeshService } from '../services/MeshService'; import { CacheService } from '../services/CacheService'; import { UpdatePreviewService } from '../services/UpdatePreviewService'; @@ -651,6 +652,58 @@ stacksRouter.put('/:stackName/env', async (req: Request, res: Response) => { } }); +// Stack Dossier: operator-authored documentation persisted per (node, stack). +// All fields default to '' so a PUT is a full-document save (an omitted field +// clears it) and a GET for a stack with no dossier yet returns a clean blank. +const dossierField = (max: number) => z.string().max(max).default(''); +const StackDossierUpdateSchema = z.object({ + purpose: dossierField(1000), + owner: dossierField(1000), + access_urls: dossierField(2000), + static_ip: dossierField(255), + vlan: dossierField(255), + firewall_notes: dossierField(8000), + reverse_proxy_notes: dossierField(8000), + backup_notes: dossierField(8000), + upgrade_notes: dossierField(8000), + recovery_notes: dossierField(8000), + custom_notes: dossierField(8000), +}); +const emptyDossierFields = (): StackDossierFields => StackDossierUpdateSchema.parse({}); + +stacksRouter.get('/:stackName/dossier', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; + if (!(await requireStackExists(req.nodeId, stackName, res))) return; + try { + const row = DatabaseService.getInstance().getStackDossier(req.nodeId, stackName); + // No dossier yet: answer 200 with a blank document so the editor loads clean + // rather than forcing the client to special-case a 404. + res.json(row ?? { node_id: req.nodeId, stack_name: stackName, ...emptyDossierFields(), created_at: 0, updated_at: 0 }); + } catch (error) { + console.error('[Stacks] Failed to read dossier:', sanitizeForLog(getErrorMessage(error, 'unknown'))); + res.status(500).json({ error: 'Failed to read dossier' }); + } +}); + +stacksRouter.put('/:stackName/dossier', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; + if (!(await requireStackExists(req.nodeId, stackName, res))) return; + const parsed = StackDossierUpdateSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }); + return; + } + try { + const row = DatabaseService.getInstance().upsertStackDossier(req.nodeId, stackName, parsed.data); + res.json(row); + } catch (error) { + console.error('[Stacks] Failed to save dossier:', sanitizeForLog(getErrorMessage(error, 'unknown'))); + res.status(500).json({ error: 'Failed to save dossier' }); + } +}); + stacksRouter.post('/', async (req: Request, res: Response) => { if (!requirePermission(req, res, 'stack:create')) return; try { @@ -884,6 +937,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => { DatabaseService.getInstance().clearStackScanAttempts(req.nodeId, stackName); DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName); DatabaseService.getInstance().deleteGitSource(stackName); + DatabaseService.getInstance().deleteStackDossier(req.nodeId, stackName); if (debug) console.debug(`[Stacks:debug] Delete: db OK`, { stackName: sanitizedName }); } catch (dbErr) { console.error('[Stacks] Database cleanup failed for %s; files already removed:', sanitizeForLog(stackName), dbErr); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 7d86e9b8..7c74429b 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -65,6 +65,30 @@ export interface AutoHealHistoryEntry { timestamp: number; } +/** Operator-authored fields of a stack dossier (everything the user types). */ +export interface StackDossierFields { + purpose: string; + owner: string; + access_urls: string; + static_ip: string; + vlan: string; + firewall_notes: string; + reverse_proxy_notes: string; + backup_notes: string; + upgrade_notes: string; + recovery_notes: string; + custom_notes: string; +} + +/** A persisted stack dossier row: operator fields plus identity and timestamps. */ +export interface StackDossier extends StackDossierFields { + id?: number; + node_id: number; + stack_name: string; + created_at: number; + updated_at: number; +} + export interface Node { id: number; name: string; @@ -1131,6 +1155,26 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_auto_heal_history_policy_ts ON auto_heal_history(policy_id, timestamp DESC); + CREATE TABLE IF NOT EXISTS stack_dossiers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL, + stack_name TEXT NOT NULL, + purpose TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL DEFAULT '', + access_urls TEXT NOT NULL DEFAULT '', + static_ip TEXT NOT NULL DEFAULT '', + vlan TEXT NOT NULL DEFAULT '', + firewall_notes TEXT NOT NULL DEFAULT '', + reverse_proxy_notes TEXT NOT NULL DEFAULT '', + backup_notes TEXT NOT NULL DEFAULT '', + upgrade_notes TEXT NOT NULL DEFAULT '', + recovery_notes TEXT NOT NULL DEFAULT '', + custom_notes TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(node_id, stack_name) + ); + CREATE TABLE IF NOT EXISTS secrets ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -2014,6 +2058,57 @@ export class DatabaseService { this.db.prepare('UPDATE auto_heal_policies SET enabled = ?, updated_at = ? WHERE id = ?').run(enabled ? 1 : 0, Date.now(), policyId); } + // --- Stack Dossiers --- + + public getStackDossier(nodeId: number, stackName: string): StackDossier | undefined { + return this.db.prepare('SELECT * FROM stack_dossiers WHERE node_id = ? AND stack_name = ?').get(nodeId, stackName) as StackDossier | undefined; + } + + public upsertStackDossier(nodeId: number, stackName: string, fields: StackDossierFields): StackDossier { + const now = Date.now(); + this.db.prepare( + `INSERT INTO stack_dossiers ( + node_id, stack_name, purpose, owner, access_urls, static_ip, vlan, + firewall_notes, reverse_proxy_notes, backup_notes, upgrade_notes, + recovery_notes, custom_notes, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(node_id, stack_name) DO UPDATE SET + purpose = excluded.purpose, + owner = excluded.owner, + access_urls = excluded.access_urls, + static_ip = excluded.static_ip, + vlan = excluded.vlan, + firewall_notes = excluded.firewall_notes, + reverse_proxy_notes = excluded.reverse_proxy_notes, + backup_notes = excluded.backup_notes, + upgrade_notes = excluded.upgrade_notes, + recovery_notes = excluded.recovery_notes, + custom_notes = excluded.custom_notes, + updated_at = excluded.updated_at` + ).run( + nodeId, + stackName, + fields.purpose, + fields.owner, + fields.access_urls, + fields.static_ip, + fields.vlan, + fields.firewall_notes, + fields.reverse_proxy_notes, + fields.backup_notes, + fields.upgrade_notes, + fields.recovery_notes, + fields.custom_notes, + now, + now + ); + return this.getStackDossier(nodeId, stackName) as StackDossier; + } + + public deleteStackDossier(nodeId: number, stackName: string): void { + this.db.prepare('DELETE FROM stack_dossiers WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName); + } + // --- Notification History --- private mapNotificationRow(row: any): NotificationHistory { @@ -2350,6 +2445,7 @@ export class DatabaseService { this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(id); this.db.prepare('DELETE FROM stack_label_assignments WHERE node_id = ?').run(id); this.db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(id); + this.db.prepare('DELETE FROM stack_dossiers WHERE node_id = ?').run(id); this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id); this.deleteRoleAssignmentsByResource('node', String(id)); this.db.prepare('DELETE FROM fleet_sync_status WHERE node_id = ?').run(id); diff --git a/docs/docs.json b/docs/docs.json index 7874d59d..218703db 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -103,6 +103,7 @@ "features/editor", "features/stack-file-explorer", "features/stack-activity", + "features/stack-dossier", "features/stack-labels", "features/sidebar" ] diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx index 7e3992b3..34433733 100644 --- a/docs/features/editor.mdx +++ b/docs/features/editor.mdx @@ -65,9 +65,9 @@ When the container has a compose service name attached, an extra `⋮` button ap ## Anatomy panel -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, alongside tabs for the stack's activity timeline and its dossier. -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 header strip carries three tabs (**Anatomy**, **Activity**, and **Dossier**) 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 Anatomy tab lists: @@ -81,11 +81,9 @@ 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 +### Markdown export -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 **Dossier** tab turns this same anatomy into an exportable Markdown document, combined with your own operator notes, with copy-to-clipboard and download actions. Empty sections render cleanly as *none*. The generated facts carry only variable **names** and **counts**, never the values stored in your `.env` file; your own notes are exported as written. See [Stack Dossier](/features/stack-dossier). 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. diff --git a/docs/features/stack-dossier.mdx b/docs/features/stack-dossier.mdx new file mode 100644 index 00000000..221334ca --- /dev/null +++ b/docs/features/stack-dossier.mdx @@ -0,0 +1,76 @@ +--- +title: Stack Dossier +description: Turn each Compose stack into living documentation by pairing the facts Sencho already derives with operator notes it cannot infer, then export the whole thing as Markdown. +--- + +The **Dossier** tab in the right-hand **Anatomy** panel turns a stack into living documentation. Sencho fills in the operational facts it already knows from your Compose file, and you add the context it cannot infer: what the stack is for, who owns it, where it lives on your network, and how to recover it. The `files` and `edit` actions on the same strip belong to the panel as a whole and stay available regardless of which tab is active. + +The goal is to stop scattering this information across spreadsheets, `.env` comments, and memory. Sencho is already close to the source of truth, so it keeps the generated half in sync while you maintain the rest. + +## Generated facts + +The top of the tab shows a read-only summary derived live from the stack's current Compose anatomy. It is never copied into a field you have to maintain, so it can never drift: + +| Fact | Source | +|------|--------| +| **Services** | Service names declared in the Compose file | +| **Ports** | Published host ports across all services | +| **Volumes** | Mounted volumes count | +| **Network** | The stack's network | +| **Restart** | The restart policy | +| **Env file** | The env file, its variable count, and any referenced `${VAR}` with no value | +| **Source** | Git source when the stack is linked, otherwise local | + +Secret values are never read or shown. Only env variable **names** and **counts** appear, exactly as in the Anatomy tab. + +## Operator notes + +Below the generated facts is a form for the details Sencho cannot derive. Every field is optional: + +| Field | What to record | +|-------|----------------| +| **Purpose** | What this stack is for | +| **Owner** | Who maintains it | +| **Access URLs** | Where it is reached (one URL per line) | +| **Static IP** | The stack's static or LAN IP | +| **VLAN** | The VLAN it sits on | +| **Firewall** | Ports opened, rules, zones | +| **Reverse proxy** | Hostnames, upstreams, TLS notes | +| **Backup** | What to back up and how | +| **Upgrade** | Upgrade steps and gotchas | +| **Recovery** | How to rebuild the stack from scratch | +| **Notes** | Anything else worth recording | + +Notes are saved per stack and per node, so the same stack name on two different nodes keeps its own dossier. Anyone can read a dossier; saving changes requires stack edit permission, so read-only users see the notes but not a Save button. + +## Markdown export + +Two actions in the tab header produce a single Markdown document combining the generated facts and your notes: + +- **copy md** copies it to the clipboard. +- **download** saves it as `-dossier.md`. + +The export is clean Markdown that reads well in Git, Obsidian, BookStack, a README, or stored alongside a backup. The generated facts never include `.env` values, only variable names and counts. Your operator notes are exported exactly as you write them, so treat them like any document you might share and avoid pasting secrets you do not want in the export. + +## Accessing the Dossier tab + +1. Click any stack in the left sidebar to open it. +2. Switch to the **Dossier** tab in the Anatomy panel header. +3. Fill in any fields you want and click **Save**. An **unsaved changes** marker appears while edits are pending. + +## Troubleshooting + + + + Save is enabled only when there are unsaved changes. Edit any field and it activates. If it never activates, you may be signed in as a read-only user: editing a dossier requires stack edit permission, while reading it does not. + + + The summary is built from the stack's Compose file. If the file is empty or not valid YAML, the facts cannot be generated and the export buttons are disabled. Fix the Compose file in the editor and the facts reappear. Your operator notes are unaffected and still save. + + + That is expected. A dossier is stored per stack and per node, so a stack with the same name on two nodes has an independent dossier on each. Edit the dossier while that node is the active node to update the right one. + + + The Access URLs field treats each line as a separate URL. Put one URL per line in the form and they are preserved as separate lines in the exported Markdown. + + diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index ff53c4da..70d010e0 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -1,14 +1,13 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { parse as parseYaml } from 'yaml'; -import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen, Copy } from 'lucide-react'; +import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } 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 { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown'; import { StackActivityTimeline } from './stack/StackActivityTimeline'; +import StackDossierPanel from './stack/StackDossierPanel'; import type { NotificationItem } from '@/components/dashboard/types'; interface StackAnatomyPanelProps { @@ -365,9 +364,11 @@ export default function StackAnatomyPanel({ return null; }, [anatomy]); - const handleCopyMarkdown = async () => { - if (!anatomy) return; - const markdown = buildStackAnatomyMarkdown({ + // Assembled facts for this stack, passed to the Dossier tab for its read-only + // summary and Markdown export. Null until compose parses. + const anatomyInput = useMemo(() => { + if (!anatomy) return null; + return { stackName, services: anatomy.services, ports: anatomy.ports, @@ -378,14 +379,8 @@ export default function StackAnatomyPanel({ 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.'); - } - }; + }; + }, [anatomy, stackName, firstEnvFile, envVarCount, missingVars, networkName, activeGitSource]); const bump = updatePreview?.summary.semver_bump ?? 'none'; const hasUpdate = Boolean(updatePreview?.summary.has_update); @@ -421,19 +416,9 @@ export default function StackAnatomyPanel({ Anatomy Activity + Dossier
- {anatomy && ( - - )} {onOpenFiles && (
); diff --git a/frontend/src/components/stack/StackDossierPanel.test.tsx b/frontend/src/components/stack/StackDossierPanel.test.tsx new file mode 100644 index 00000000..1fcd2e62 --- /dev/null +++ b/frontend/src/components/stack/StackDossierPanel.test.tsx @@ -0,0 +1,129 @@ +/** + * Covers the Dossier editor: it loads saved fields, enables Save only after an + * edit (then PUTs the document), renders read-only for users who cannot edit, + * surfaces a distinct retry state on load failure (without blanking), keeps the + * form dirty when a save fails, coerces non-string payloads, and wires the + * copy/download exports. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 } }) })); +vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) })); +vi.mock('@/lib/download', () => ({ downloadTextFile: vi.fn() })); + +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { copyToClipboard } from '@/lib/clipboard'; +import { downloadTextFile } from '@/lib/download'; +import StackDossierPanel from './StackDossierPanel'; +import { EMPTY_DOSSIER_FIELDS } from '@/lib/dossierMarkdown'; +import type { AnatomyMarkdownInput } from '@/lib/anatomyMarkdown'; + +const anatomy: AnatomyMarkdownInput = { + stackName: 'web', services: ['web'], ports: {}, volumes: {}, restart: null, + envFile: null, envVarCount: 0, missingVars: [], networkName: 'web_default', gitSource: null, +}; + +function jsonRes(body: unknown, ok = true) { + return { ok, status: ok ? 200 : 500, json: async () => body, text: async () => '' } as unknown as Response; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('StackDossierPanel', () => { + it('loads saved fields into the form', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, purpose: 'Reverse proxy' })); + render(); + await waitFor(() => + expect((screen.getByTestId('dossier-field-purpose') as HTMLInputElement).value).toBe('Reverse proxy'), + ); + }); + + it('enables save only after an edit and PUTs the document', async () => { + vi.mocked(apiFetch).mockImplementation(async (_endpoint: string, opts?: { method?: string }) => + opts?.method === 'PUT' + ? jsonRes({ ...EMPTY_DOSSIER_FIELDS, owner: 'ops' }) + : jsonRes({ ...EMPTY_DOSSIER_FIELDS }), + ); + render(); + + const saveBtn = await screen.findByTestId('dossier-save-btn'); + expect(saveBtn).toBeDisabled(); + + fireEvent.change(screen.getByTestId('dossier-field-owner'), { target: { value: 'ops' } }); + expect(saveBtn).not.toBeDisabled(); + fireEvent.click(saveBtn); + + await waitFor(() => { + const putCall = vi.mocked(apiFetch).mock.calls.find(([, o]) => (o as { method?: string } | undefined)?.method === 'PUT'); + expect(putCall).toBeTruthy(); + }); + }); + + it('renders read-only (no save button, disabled inputs) for users who cannot edit', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS })); + render(); + await screen.findByTestId('dossier-panel'); + expect(screen.queryByTestId('dossier-save-btn')).not.toBeInTheDocument(); + expect(screen.getByTestId('dossier-field-purpose')).toBeDisabled(); + }); + + it('disables the export buttons when compose cannot be parsed', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS })); + render(); + await screen.findByTestId('dossier-panel'); + expect(screen.getByTestId('dossier-copy-btn')).toBeDisabled(); + expect(screen.getByTestId('dossier-download-btn')).toBeDisabled(); + }); + + it('shows a retry state (not a blank form) when the load fails', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes({ error: 'down' }, false)); + render(); + await screen.findByTestId('dossier-retry-btn'); + // The form must NOT render blank fields that could be mistaken for "no notes". + expect(screen.queryByTestId('dossier-field-purpose')).not.toBeInTheDocument(); + expect(toast.error).toHaveBeenCalled(); + }); + + it('keeps the form dirty when a save fails', async () => { + vi.mocked(apiFetch).mockImplementation(async (_endpoint: string, opts?: { method?: string }) => + opts?.method === 'PUT' + ? jsonRes({ error: 'boom' }, false) + : jsonRes({ ...EMPTY_DOSSIER_FIELDS }), + ); + render(); + const saveBtn = await screen.findByTestId('dossier-save-btn'); + fireEvent.change(screen.getByTestId('dossier-field-owner'), { target: { value: 'ops' } }); + fireEvent.click(saveBtn); + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + expect(saveBtn).not.toBeDisabled(); // still dirty: the failed save did not advance the baseline + }); + + it('coerces non-string payload values to empty strings', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes({ purpose: null, owner: 5, custom_notes: 'keep' })); + render(); + await waitFor(() => + expect((screen.getByTestId('dossier-field-custom_notes') as HTMLTextAreaElement).value).toBe('keep'), + ); + expect((screen.getByTestId('dossier-field-purpose') as HTMLInputElement).value).toBe(''); + expect((screen.getByTestId('dossier-field-owner') as HTMLInputElement).value).toBe(''); + }); + + it('wires copy and download to the combined Markdown', async () => { + vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS })); + render(); + await screen.findByTestId('dossier-panel'); + + fireEvent.click(screen.getByTestId('dossier-copy-btn')); + await waitFor(() => expect(copyToClipboard).toHaveBeenCalled()); + expect(vi.mocked(copyToClipboard).mock.calls[0][0]).toContain('# web'); + + fireEvent.click(screen.getByTestId('dossier-download-btn')); + expect(downloadTextFile).toHaveBeenCalledWith('web-dossier.md', expect.stringContaining('# web')); + }); +}); diff --git a/frontend/src/components/stack/StackDossierPanel.tsx b/frontend/src/components/stack/StackDossierPanel.tsx new file mode 100644 index 00000000..e94ac194 --- /dev/null +++ b/frontend/src/components/stack/StackDossierPanel.tsx @@ -0,0 +1,319 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Copy, Download, Save } from 'lucide-react'; +import { apiFetch } from '@/lib/api'; +import { cn } from '@/lib/utils'; +import { copyToClipboard } from '@/lib/clipboard'; +import { downloadTextFile } from '@/lib/download'; +import { toast } from '@/components/ui/toast-store'; +import { Input } from '@/components/ui/input'; +import { + buildStackDossierMarkdown, + EMPTY_DOSSIER_FIELDS, + type StackDossierFields, +} from '@/lib/dossierMarkdown'; +import type { AnatomyMarkdownInput } from '@/lib/anatomyMarkdown'; +import { useNodes } from '@/context/NodeContext'; + +interface StackDossierPanelProps { + stackName: string; + /** Generated anatomy for this stack, or null when compose.yaml cannot be parsed. */ + anatomy: AnatomyMarkdownInput | null; + canEdit: boolean; +} + +const FIELD_KEYS = Object.keys(EMPTY_DOSSIER_FIELDS) as Array; + +// `max` caps mirror the backend dossier validation schema (routes/stacks.ts), so +// the input stops at the limit instead of letting a save fail with a 400. +const TEXT_FIELDS: Array<{ key: keyof StackDossierFields; label: string; placeholder: string; max: number }> = [ + { key: 'purpose', label: 'purpose', placeholder: 'What this stack is for', max: 1000 }, + { key: 'owner', label: 'owner', placeholder: 'Who maintains it', max: 1000 }, + { key: 'static_ip', label: 'static ip', placeholder: 'e.g. 10.0.20.5', max: 255 }, + { key: 'vlan', label: 'vlan', placeholder: 'e.g. 20 / iot', max: 255 }, +]; + +const TEXTAREA_FIELDS: Array<{ key: keyof StackDossierFields; label: string; placeholder: string; rows: number; max: number }> = [ + { key: 'access_urls', label: 'access urls', placeholder: 'One URL per line', rows: 2, max: 2000 }, + { key: 'firewall_notes', label: 'firewall', placeholder: 'Ports opened, rules, zones', rows: 2, max: 8000 }, + { key: 'reverse_proxy_notes', label: 'reverse proxy', placeholder: 'Hostnames, upstreams, TLS', rows: 2, max: 8000 }, + { key: 'backup_notes', label: 'backup', placeholder: 'What to back up and how', rows: 2, max: 8000 }, + { key: 'upgrade_notes', label: 'upgrade', placeholder: 'Upgrade steps and gotchas', rows: 2, max: 8000 }, + { key: 'recovery_notes', label: 'recovery', placeholder: 'How to rebuild from scratch', rows: 2, max: 8000 }, + { key: 'custom_notes', label: 'notes', placeholder: 'Anything else worth recording', rows: 3, max: 8000 }, +]; + +const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle'; +const MONO_FACT_CLASS = 'font-mono text-[11px]'; +const TEXTAREA_CLASS = + 'w-full rounded-md border border-glass-border bg-input px-3 py-2 text-[12px] shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 resize-y'; +const ACTION_CLASS = + 'inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors disabled:opacity-40 disabled:hover:text-stat-subtitle'; + +function pickFields(data: unknown): StackDossierFields { + const out = { ...EMPTY_DOSSIER_FIELDS }; + if (data && typeof data === 'object') { + const obj = data as Record; + for (const k of FIELD_KEYS) { + if (typeof obj[k] === 'string') out[k] = obj[k] as string; + } + } + return out; +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +function GeneratedFacts({ anatomy }: { anatomy: AnatomyMarkdownInput }) { + const portRows = Object.values(anatomy.ports).flat(); + const volumeCount = Object.values(anatomy.volumes).reduce((n, list) => n + list.length, 0); + return ( +
+ + {anatomy.services.length === 0 ? ( + none defined + ) : ( +
+ {anatomy.services.map(s => ( + {s} + ))} +
+ )} +
+ + {portRows.length === 0 ? ( + none + ) : ( + + {portRows.length} published · {portRows.map(r => `:${r.host}`).join(' ')} + + )} + + + {volumeCount === 0 ? none : volumeCount} + + + {anatomy.networkName} · bridge + + + {anatomy.restart ?? default} + + + {!anatomy.envFile ? ( + none + ) : ( + + {anatomy.envFile} · {anatomy.envVarCount} var{anatomy.envVarCount === 1 ? '' : 's'} + {anatomy.missingVars.length > 0 && ( + · {anatomy.missingVars.length} missing + )} + + )} + + + + {anatomy.gitSource ? <>git · {anatomy.gitSource} : 'local'} + + +
+ ); +} + +export default function StackDossierPanel({ stackName, anatomy, canEdit }: StackDossierPanelProps) { + const { activeNode } = useNodes(); + const nodeId = activeNode?.id; + const [fields, setFields] = useState(EMPTY_DOSSIER_FIELDS); + // The last-saved values, kept in state so dirty-tracking compares against them + // without reading a ref during render. + const [serverFields, setServerFields] = useState(EMPTY_DOSSIER_FIELDS); + const [saving, setSaving] = useState(false); + const [loadError, setLoadError] = useState(false); + const [reloadKey, setReloadKey] = useState(0); + + // Reload when the stack OR the active node changes: the same stack name can + // exist on two nodes with independent dossiers, and apiFetch scopes by the + // active node, so a node switch must refetch. On failure we keep the existing + // values and show a distinct error state rather than blanking the form, so a + // failed load can never be mistaken for an empty dossier or saved as one. + useEffect(() => { + let cancelled = false; + const run = async () => { + try { + const res = await apiFetch(`/stacks/${stackName}/dossier`); + if (cancelled) return; + if (!res.ok) { + setLoadError(true); + toast.error('Failed to load the stack dossier.'); + return; + } + const loaded = pickFields(await res.json()); + setServerFields(loaded); + setFields(loaded); + setLoadError(false); + } catch { + if (!cancelled) { + setLoadError(true); + toast.error('Failed to load the stack dossier.'); + } + } + }; + void run(); + return () => { cancelled = true; }; + }, [stackName, nodeId, reloadKey]); + + const dirty = useMemo( + () => FIELD_KEYS.some(k => fields[k] !== serverFields[k]), + [fields, serverFields], + ); + + const setField = (key: keyof StackDossierFields, value: string) => + setFields(prev => ({ ...prev, [key]: value })); + + const handleSave = async () => { + setSaving(true); + try { + const res = await apiFetch(`/stacks/${stackName}/dossier`, { + method: 'PUT', + body: JSON.stringify(fields), + }); + if (!res.ok) { + const err = await res.json().catch((parseErr) => { + console.error('[Dossier] save error response was not JSON:', parseErr); + return {}; + }); + toast.error(err?.error || 'Failed to save the dossier.'); + return; + } + const saved = pickFields(await res.json()); + setServerFields(saved); + setFields(saved); + toast.success('Stack dossier saved.'); + } catch { + // apiFetch throws a sentinel ('Unauthorized') or a network error here; show + // a fixed friendly message rather than echoing an internal error string. + toast.error('Failed to save the dossier. Check your connection and try again.'); + } finally { + setSaving(false); + } + }; + + const handleCopy = async () => { + if (!anatomy) return; + try { + await copyToClipboard(buildStackDossierMarkdown(anatomy, fields)); + toast.success('Stack dossier copied as Markdown.'); + } catch { + toast.error('Failed to copy to clipboard.'); + } + }; + + const handleDownload = () => { + if (!anatomy) return; + try { + // Stack names are already constrained, but sanitize defensively so the + // file always has a coherent, safe name ending in .md. + const base = stackName.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '') || 'stack'; + downloadTextFile(`${base}-dossier.md`, buildStackDossierMarkdown(anatomy, fields)); + } catch { + toast.error('Failed to download the dossier.'); + } + }; + + return ( +
+
+ export +
+ + +
+
+ +
+
generated facts
+ {anatomy ? ( + + ) : ( +
+ Unable to parse compose.yaml. +
+ )} +
+ +
+
operator notes
+ {loadError ? ( +
+ Could not load this dossier. + +
+ ) : ( +
+
+ {TEXT_FIELDS.map(({ key, label, placeholder, max }) => ( + + ))} +
+ {TEXTAREA_FIELDS.map(({ key, label, placeholder, rows, max }) => ( +