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:
Anso
2026-06-06 22:21:30 -04:00
committed by GitHub
parent af4083175c
commit 57fe430db8
12 changed files with 1034 additions and 33 deletions
+96
View File
@@ -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);