mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
feat(stacks): add Stack Dossier tab with operator notes and Markdown export (#1326)
* feat(stacks): add Stack Dossier tab with operator notes and Markdown export Add a Dossier tab beside Anatomy and Activity on the stack detail panel. It shows a read-only summary auto-derived from the stack's Compose anatomy (services, ports, volumes, network, restart policy, env file, source) plus an editable form for the context Sencho cannot infer: purpose, owner, access URLs, static IP, VLAN, and firewall, reverse-proxy, backup, upgrade, recovery, and custom notes. Notes persist per stack and per node in a new stack_dossiers table, reached transparently through the remote-node proxy so a remote stack's dossier round-trips to the node that owns it. Reading a dossier needs stack read permission; saving needs stack edit. The tab exports a single Markdown document combining the generated facts and the operator notes, with copy-to-clipboard and download actions; env values are never exported, only variable names and counts. Available on all tiers. The standalone anatomy copy-as-Markdown shortcut is removed since the Dossier export supersedes it. * fix(stacks): gate dossier reads/writes on stack existence; clear dossier on node delete A dossier endpoint validated the stack name but not that the stack exists, so an editor could PUT a dossier for a name with no stack, leaving an orphan row that a later stack of the same name would inherit. Require the stack to exist (existing requireStackExists guard) on the dossier GET and PUT, returning 404 otherwise. Also clear a node's stack_dossiers rows when the node is deleted, alongside the other node-scoped cleanup, so removing a node leaves no orphan dossiers. Docs: scope the "no secret exported" statement to the generated facts (which only ever carry variable names and counts) and clarify that operator notes are exported exactly as written.
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
"features/editor",
|
||||
"features/stack-file-explorer",
|
||||
"features/stack-activity",
|
||||
"features/stack-dossier",
|
||||
"features/stack-labels",
|
||||
"features/sidebar"
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 `<stack>-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
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="The Save button stays greyed out">
|
||||
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.
|
||||
</Accordion>
|
||||
<Accordion title="The generated facts say 'Unable to parse compose.yaml'">
|
||||
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.
|
||||
</Accordion>
|
||||
<Accordion title="My notes for the same stack differ between two nodes">
|
||||
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.
|
||||
</Accordion>
|
||||
<Accordion title="An access URL spilled onto one line in the export">
|
||||
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.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -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<AnatomyMarkdownInput | null>(() => {
|
||||
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({
|
||||
<TabsList className="h-7 gap-0.5 bg-transparent border-none p-0">
|
||||
<TabsTrigger value="anatomy" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
|
||||
<TabsTrigger value="dossier" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Dossier</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"
|
||||
@@ -636,6 +621,9 @@ export default function StackAnatomyPanel({
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="dossier" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<StackDossierPanel stackName={stackName} anatomy={anatomyInput} canEdit={canEdit} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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(<StackDossierPanel stackName="web" anatomy={anatomy} canEdit />);
|
||||
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(<StackDossierPanel stackName="web" anatomy={anatomy} canEdit />);
|
||||
|
||||
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(<StackDossierPanel stackName="web" anatomy={anatomy} canEdit={false} />);
|
||||
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(<StackDossierPanel stackName="web" anatomy={null} canEdit />);
|
||||
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(<StackDossierPanel stackName="web" anatomy={anatomy} canEdit />);
|
||||
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(<StackDossierPanel stackName="web" anatomy={anatomy} canEdit />);
|
||||
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(<StackDossierPanel stackName="web" anatomy={anatomy} canEdit />);
|
||||
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(<StackDossierPanel stackName="web" anatomy={anatomy} canEdit />);
|
||||
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'));
|
||||
});
|
||||
});
|
||||
@@ -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<keyof StackDossierFields>;
|
||||
|
||||
// `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<string, unknown>;
|
||||
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 (
|
||||
<div className="grid grid-cols-[72px_1fr] gap-3 border-t border-muted py-2 first:border-t-0">
|
||||
<span className={cn(LABEL_CLASS, 'pt-0.5')}>{label}</span>
|
||||
<div className="min-w-0 text-[12px] text-foreground/90">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
|
||||
<Row label="services">
|
||||
{anatomy.services.length === 0 ? (
|
||||
<span className="text-stat-subtitle">none defined</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{anatomy.services.map(s => (
|
||||
<span key={s} className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">{s}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Row>
|
||||
<Row label="ports">
|
||||
{portRows.length === 0 ? (
|
||||
<span className="text-stat-subtitle">none</span>
|
||||
) : (
|
||||
<span className={MONO_FACT_CLASS}>
|
||||
{portRows.length} published <span className="text-stat-subtitle">· {portRows.map(r => `:${r.host}`).join(' ')}</span>
|
||||
</span>
|
||||
)}
|
||||
</Row>
|
||||
<Row label="volumes">
|
||||
<span className={MONO_FACT_CLASS}>{volumeCount === 0 ? <span className="text-stat-subtitle">none</span> : volumeCount}</span>
|
||||
</Row>
|
||||
<Row label="network">
|
||||
<span className={MONO_FACT_CLASS}>{anatomy.networkName} <span className="text-stat-subtitle">· bridge</span></span>
|
||||
</Row>
|
||||
<Row label="restart">
|
||||
<span className={MONO_FACT_CLASS}>{anatomy.restart ?? <span className="text-stat-subtitle">default</span>}</span>
|
||||
</Row>
|
||||
<Row label="env_file">
|
||||
{!anatomy.envFile ? (
|
||||
<span className="text-stat-subtitle">none</span>
|
||||
) : (
|
||||
<span className={MONO_FACT_CLASS}>
|
||||
{anatomy.envFile} <span className="text-stat-subtitle">· {anatomy.envVarCount} var{anatomy.envVarCount === 1 ? '' : 's'}</span>
|
||||
{anatomy.missingVars.length > 0 && (
|
||||
<span className="text-destructive"> · {anatomy.missingVars.length} missing</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Row>
|
||||
<Row label="source">
|
||||
<span className={MONO_FACT_CLASS}>
|
||||
{anatomy.gitSource ? <>git <span className="text-stat-subtitle">·</span> {anatomy.gitSource}</> : 'local'}
|
||||
</span>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StackDossierPanel({ stackName, anatomy, canEdit }: StackDossierPanelProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const nodeId = activeNode?.id;
|
||||
const [fields, setFields] = useState<StackDossierFields>(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<StackDossierFields>(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 (
|
||||
<div data-testid="dossier-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={LABEL_CLASS}>export</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" data-testid="dossier-copy-btn" onClick={() => { void handleCopy(); }} disabled={!anatomy || loadError} className={ACTION_CLASS}>
|
||||
<Copy className="h-3 w-3" strokeWidth={1.5} /> copy md
|
||||
</button>
|
||||
<button type="button" data-testid="dossier-download-btn" onClick={handleDownload} disabled={!anatomy || loadError} className={ACTION_CLASS}>
|
||||
<Download className="h-3 w-3" strokeWidth={1.5} /> download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5')}>generated facts</div>
|
||||
{anatomy ? (
|
||||
<GeneratedFacts anatomy={anatomy} />
|
||||
) : (
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-3 font-mono text-[11px] text-stat-subtitle">
|
||||
Unable to parse compose.yaml.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5')}>operator notes</div>
|
||||
{loadError ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/40 bg-destructive/[0.06] px-3 py-3">
|
||||
<span className="font-mono text-[11px] text-destructive">Could not load this dossier.</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dossier-retry-btn"
|
||||
onClick={() => setReloadKey(k => k + 1)}
|
||||
className="font-mono text-[10px] uppercase tracking-wide text-destructive hover:underline"
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{TEXT_FIELDS.map(({ key, label, placeholder, max }) => (
|
||||
<label key={key} className="flex flex-col gap-1">
|
||||
<span className={LABEL_CLASS}>{label}</span>
|
||||
<Input
|
||||
data-testid={`dossier-field-${key}`}
|
||||
value={fields[key]}
|
||||
onChange={e => setField(key, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={!canEdit}
|
||||
maxLength={max}
|
||||
className="h-8 text-[12px]"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{TEXTAREA_FIELDS.map(({ key, label, placeholder, rows, max }) => (
|
||||
<label key={key} className="flex flex-col gap-1">
|
||||
<span className={LABEL_CLASS}>{label}</span>
|
||||
<textarea
|
||||
data-testid={`dossier-field-${key}`}
|
||||
value={fields[key]}
|
||||
onChange={e => setField(key, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={!canEdit}
|
||||
rows={rows}
|
||||
maxLength={max}
|
||||
className={TEXTAREA_CLASS}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
{canEdit && (
|
||||
<div className="flex items-center justify-end gap-3 pt-1">
|
||||
{dirty && <span className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle">unsaved changes</span>}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dossier-save-btn"
|
||||
onClick={() => { void handleSave(); }}
|
||||
disabled={saving || !dirty}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-brand/40 px-3 py-1.5 font-mono text-[10px] uppercase tracking-wide text-brand transition-colors hover:bg-brand/10 disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Save className={cn('h-3 w-3', saving && 'animate-pulse')} strokeWidth={1.5} />
|
||||
{saving ? 'saving…' : 'save'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildStackDossierMarkdown, EMPTY_DOSSIER_FIELDS, type StackDossierFields } from './dossierMarkdown';
|
||||
import type { AnatomyMarkdownInput } from './anatomyMarkdown';
|
||||
|
||||
const anatomy: AnatomyMarkdownInput = {
|
||||
stackName: 'plex',
|
||||
services: ['plex'],
|
||||
ports: { plex: [{ host: '32400', container: '32400', proto: 'tcp' }] },
|
||||
volumes: {},
|
||||
restart: 'unless-stopped',
|
||||
envFile: '.env',
|
||||
envVarCount: 4,
|
||||
missingVars: ['CLAIM_TOKEN'],
|
||||
networkName: 'plex_default',
|
||||
gitSource: null,
|
||||
};
|
||||
|
||||
const fields = (over: Partial<StackDossierFields> = {}): StackDossierFields => ({ ...EMPTY_DOSSIER_FIELDS, ...over });
|
||||
|
||||
describe('buildStackDossierMarkdown', () => {
|
||||
it('returns only the anatomy markdown when no operator notes are set', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields());
|
||||
expect(md).toContain('# plex');
|
||||
expect(md).toContain('## Services');
|
||||
expect(md).not.toContain('## Operator notes');
|
||||
});
|
||||
|
||||
it('appends an Operator notes section with the filled fields', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields({
|
||||
purpose: 'Media server',
|
||||
owner: 'home',
|
||||
static_ip: '10.0.10.4',
|
||||
backup_notes: 'rsync config nightly',
|
||||
}));
|
||||
expect(md).toContain('## Operator notes');
|
||||
expect(md).toContain('- **Purpose:** Media server');
|
||||
expect(md).toContain('- **Owner:** home');
|
||||
expect(md).toContain('- **Static IP:** 10.0.10.4');
|
||||
expect(md).toContain('### Backup\nrsync config nightly');
|
||||
});
|
||||
|
||||
it('omits empty operator fields', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields({ purpose: 'only this' }));
|
||||
expect(md).toContain('- **Purpose:** only this');
|
||||
expect(md).not.toContain('- **Owner:**');
|
||||
expect(md).not.toContain('### Firewall');
|
||||
});
|
||||
|
||||
it('keeps the generated anatomy facts in the combined export', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields({ purpose: 'p' }));
|
||||
expect(md).toContain('| plex | 32400 | 32400 | tcp |');
|
||||
expect(md).toContain('- Variables: 4');
|
||||
expect(md).toContain('- Missing: `CLAIM_TOKEN`');
|
||||
});
|
||||
|
||||
it('preserves multi-line access URLs as a block', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields({ access_urls: 'https://a.example\nhttps://b.example' }));
|
||||
expect(md).toContain('### Access URLs\nhttps://a.example\nhttps://b.example');
|
||||
});
|
||||
|
||||
it('collapses stray newlines in a single-line field into one bullet', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields({ purpose: 'line one\nline two' }));
|
||||
expect(md).toContain('- **Purpose:** line one line two');
|
||||
});
|
||||
|
||||
it('the generated facts never emit a .env assignment, only variable names and counts', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields({ custom_notes: 'see runbook' }));
|
||||
expect(md).toContain('- Variables: 4');
|
||||
expect(md).toContain('- Missing: `CLAIM_TOKEN`');
|
||||
expect(md).not.toMatch(/CLAIM_TOKEN\s*=/);
|
||||
});
|
||||
|
||||
it('exports operator notes verbatim (user-authored content is not redacted)', () => {
|
||||
const md = buildStackDossierMarkdown(anatomy, fields({ custom_notes: 'DB_PASSWORD=hunter2' }));
|
||||
expect(md).toContain('DB_PASSWORD=hunter2');
|
||||
});
|
||||
|
||||
it('is deterministic across distinct but equal inputs', () => {
|
||||
const f = fields({ purpose: 'x', firewall_notes: 'y' });
|
||||
expect(buildStackDossierMarkdown(anatomy, f)).toBe(buildStackDossierMarkdown(anatomy, { ...f }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Deterministic Markdown export for the Stack Dossier.
|
||||
*
|
||||
* Combines the generated stack anatomy (via the shared anatomy builder) with the
|
||||
* operator-authored notes into one document an operator can paste into Git,
|
||||
* Obsidian, BookStack, a README, or store alongside backups. Pure and
|
||||
* side-effect free: the same input always yields byte-identical output.
|
||||
*
|
||||
* Like the anatomy builder it reuses, this never receives `.env` values, so no
|
||||
* secret can leak into the exported text.
|
||||
*/
|
||||
|
||||
import { buildStackAnatomyMarkdown, type AnatomyMarkdownInput } from './anatomyMarkdown';
|
||||
|
||||
/**
|
||||
* Operator-authored dossier fields. Mirrors the backend `StackDossierFields`
|
||||
* shape (the operator-authored subset of a persisted dossier row); this is the
|
||||
* single frontend source of truth shared by the editor form, the API calls, and
|
||||
* this Markdown builder.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
export const EMPTY_DOSSIER_FIELDS: StackDossierFields = {
|
||||
purpose: '',
|
||||
owner: '',
|
||||
access_urls: '',
|
||||
static_ip: '',
|
||||
vlan: '',
|
||||
firewall_notes: '',
|
||||
reverse_proxy_notes: '',
|
||||
backup_notes: '',
|
||||
upgrade_notes: '',
|
||||
recovery_notes: '',
|
||||
custom_notes: '',
|
||||
};
|
||||
|
||||
// Single-line facts render as bullets; their values get any stray line breaks
|
||||
// collapsed so a bullet can never spill into a broken list.
|
||||
const SHORT_FIELDS: Array<[keyof StackDossierFields, string]> = [
|
||||
['purpose', 'Purpose'],
|
||||
['owner', 'Owner'],
|
||||
['static_ip', 'Static IP'],
|
||||
['vlan', 'VLAN'],
|
||||
];
|
||||
|
||||
// Multi-line fields render as their own heading + body block, preserving the
|
||||
// operator's line structure (e.g. one access URL per line).
|
||||
const BLOCK_FIELDS: Array<[keyof StackDossierFields, string]> = [
|
||||
['access_urls', 'Access URLs'],
|
||||
['firewall_notes', 'Firewall'],
|
||||
['reverse_proxy_notes', 'Reverse proxy'],
|
||||
['backup_notes', 'Backup'],
|
||||
['upgrade_notes', 'Upgrade'],
|
||||
['recovery_notes', 'Recovery'],
|
||||
['custom_notes', 'Notes'],
|
||||
];
|
||||
|
||||
function operatorNotesSection(d: StackDossierFields): string | null {
|
||||
const bullets = SHORT_FIELDS
|
||||
.filter(([k]) => d[k].trim() !== '')
|
||||
.map(([k, label]) => `- **${label}:** ${d[k].trim().replace(/\s*\r?\n\s*/g, ' ')}`);
|
||||
const blocks = BLOCK_FIELDS
|
||||
.filter(([k]) => d[k].trim() !== '')
|
||||
.map(([k, label]) => `### ${label}\n${d[k].trim()}`);
|
||||
if (bullets.length === 0 && blocks.length === 0) return null;
|
||||
const parts = ['## Operator notes'];
|
||||
if (bullets.length > 0) parts.push(bullets.join('\n'));
|
||||
parts.push(...blocks);
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildStackDossierMarkdown(
|
||||
anatomy: AnatomyMarkdownInput,
|
||||
dossier: StackDossierFields,
|
||||
): string {
|
||||
const anatomyMarkdown = buildStackAnatomyMarkdown(anatomy);
|
||||
const notes = operatorNotesSection(dossier);
|
||||
return notes ? `${anatomyMarkdown}\n\n${notes}` : anatomyMarkdown;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Trigger a browser download of in-memory text as a file, using the standard
|
||||
* object-URL + anchor-click idiom (the same pattern used elsewhere for exports,
|
||||
* e.g. AuditLogView). Suited to client-side text such as a Markdown export.
|
||||
*/
|
||||
export function downloadTextFile(filename: string, text: string, mime = 'text/markdown'): void {
|
||||
const blob = new Blob([text], { type: `${mime};charset=utf-8` });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
Reference in New Issue
Block a user