mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
feat: flag documentation drift in the stack dossier (#1349)
* feat: flag documentation drift in the stack dossier Warn in the Dossier tab when a port written into a stack's access_urls is not published by the stack's compose, so operator documentation stays aligned with what Sencho can observe. The check is deterministic, read-only, and frontend-only: it compares ports parsed from access_urls against the published ports the Anatomy panel already shows, never interprets prose, and stays quiet for port-less URLs, scheme-default ports (:80/:443), and ports published through a variable, to avoid false positives. Community tier, no gating. * test: pin doc-drift handling of bare hosts and mixed variable ports Make two deterministic-drift behaviors intentional and regression-proof after review: a scheme-less single-label host (plex:32400) is not checked, since it cannot be told apart from a plain note (add a scheme to opt in), and a stack mixing a variable-published port with fixed ports suppresses the whole check. Adds tests for both, a clarifying code comment, and a docs note with the http:// workaround. No behavior change.
This commit is contained in:
@@ -126,4 +126,67 @@ describe('StackDossierPanel', () => {
|
||||
fireEvent.click(screen.getByTestId('dossier-download-btn'));
|
||||
expect(downloadTextFile).toHaveBeenCalledWith('web-dossier.md', expect.stringContaining('# web'));
|
||||
});
|
||||
|
||||
// Anatomy that publishes a single TCP host port, for documentation-drift tests.
|
||||
const anatomyPublishing = (host: string): AnatomyMarkdownInput => ({
|
||||
...anatomy,
|
||||
ports: { web: [{ host, container: '80', proto: 'tcp' }] },
|
||||
});
|
||||
|
||||
it('warns when an access URL names a port the stack does not publish', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:32400' }));
|
||||
render(<StackDossierPanel stackName="web" anatomy={anatomyPublishing('32401')} canEdit />);
|
||||
expect(await screen.findByTestId('dossier-doc-drift')).toHaveTextContent(':32400');
|
||||
});
|
||||
|
||||
it('does not warn when the access URL port is published', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:32400' }));
|
||||
render(<StackDossierPanel stackName="web" anatomy={anatomyPublishing('32400')} canEdit />);
|
||||
await screen.findByTestId('dossier-panel');
|
||||
expect(screen.queryByTestId('dossier-doc-drift')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the drift warning to read-only viewers', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:32400' }));
|
||||
render(<StackDossierPanel stackName="web" anatomy={anatomyPublishing('32401')} canEdit={false} />);
|
||||
expect(await screen.findByTestId('dossier-doc-drift')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates the warning live as access_urls is edited', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS }));
|
||||
render(<StackDossierPanel stackName="web" anatomy={anatomyPublishing('8080')} canEdit />);
|
||||
await screen.findByTestId('dossier-panel');
|
||||
expect(screen.queryByTestId('dossier-doc-drift')).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByTestId('dossier-field-access_urls'), { target: { value: 'http://host:9000' } });
|
||||
expect(await screen.findByTestId('dossier-doc-drift')).toHaveTextContent(':9000');
|
||||
});
|
||||
|
||||
it('clears the warning when the URL is edited to a published port', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:9000' }));
|
||||
render(<StackDossierPanel stackName="web" anatomy={anatomyPublishing('8080')} canEdit />);
|
||||
await screen.findByTestId('dossier-doc-drift');
|
||||
fireEvent.change(screen.getByTestId('dossier-field-access_urls'), { target: { value: 'http://host:8080' } });
|
||||
await waitFor(() => expect(screen.queryByTestId('dossier-doc-drift')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders one row per unpublished port', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:9000\nhttp://host:9001' }));
|
||||
render(<StackDossierPanel stackName="web" anatomy={anatomyPublishing('8080')} canEdit />);
|
||||
const section = await screen.findByTestId('dossier-doc-drift');
|
||||
expect(section).toHaveTextContent(':9000');
|
||||
expect(section).toHaveTextContent(':9001');
|
||||
});
|
||||
|
||||
it('suppresses the warning when a reload fails, never showing the previous stack stale', async () => {
|
||||
vi.mocked(apiFetch)
|
||||
.mockResolvedValueOnce(jsonRes({ ...EMPTY_DOSSIER_FIELDS, access_urls: 'http://host:9000' }))
|
||||
.mockResolvedValueOnce(jsonRes({ error: 'down' }, false));
|
||||
const { rerender } = render(<StackDossierPanel stackName="web" anatomy={anatomyPublishing('8080')} canEdit />);
|
||||
expect(await screen.findByTestId('dossier-doc-drift')).toHaveTextContent(':9000');
|
||||
// Switch stacks: the reload fails, so the prior stack's drifting fields stay
|
||||
// resident but must not keep a warning on screen.
|
||||
rerender(<StackDossierPanel stackName="web2" anatomy={anatomyPublishing('8080')} canEdit />);
|
||||
await screen.findByTestId('dossier-retry-btn');
|
||||
expect(screen.queryByTestId('dossier-doc-drift')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Copy, Download, Save } from 'lucide-react';
|
||||
import { Copy, Download, Save, TriangleAlert } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type StackDossierFields,
|
||||
} from '@/lib/dossierMarkdown';
|
||||
import type { AnatomyMarkdownInput } from '@/lib/anatomyMarkdown';
|
||||
import { computeDocDrift, type DocDriftFinding } from '@/lib/docDrift';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
interface StackDossierPanelProps {
|
||||
@@ -124,9 +125,37 @@ function GeneratedFacts({ anatomy }: { anatomy: AnatomyMarkdownInput }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Documentation drift: ports the operator documented in access_urls that the
|
||||
// stack does not publish. Read-only and advisory; it never edits notes or
|
||||
// compose. Visual language matches the Drift tab's warning findings.
|
||||
function DocDriftWarnings({ findings }: { findings: DocDriftFinding[] }) {
|
||||
return (
|
||||
<section data-testid="dossier-doc-drift">
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5')}>documentation drift</div>
|
||||
<div className="rounded-lg border border-warning/40 bg-warning/[0.06] px-3 py-1">
|
||||
{findings.map(f => (
|
||||
<div key={f.port} className="border-t border-warning/20 py-2 first:border-t-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<TriangleAlert className="h-3.5 w-3.5 shrink-0 text-warning" strokeWidth={1.5} />
|
||||
<span className="rounded-md bg-warning/15 px-1.5 py-0.5 font-mono text-[11px] text-warning">:{f.port}</span>
|
||||
<span className="font-mono text-[10px] uppercase tracking-wide text-stat-subtitle">not published</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-foreground/90">{f.detail}</div>
|
||||
<div className="mt-1 truncate font-mono text-[11px] text-stat-subtitle" title={f.source}>{f.source}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StackDossierPanel({ stackName, anatomy, canEdit }: StackDossierPanelProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const nodeId = activeNode?.id;
|
||||
// Identifies the dossier currently in view. Doc-drift renders only once the
|
||||
// load for *this* key has succeeded (see loadedKey), so a switch-in-flight or
|
||||
// a failed load never diffs new anatomy against the prior stack's fields.
|
||||
const currentKey = `${nodeId ?? ''}::${stackName}`;
|
||||
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.
|
||||
@@ -134,6 +163,10 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
// The (node, stack) whose dossier has successfully loaded. Set only on a
|
||||
// successful fetch, so it lags during a switch and stays behind on a failed
|
||||
// load, which is exactly when doc-drift must stay hidden.
|
||||
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
||||
|
||||
// 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
|
||||
@@ -151,10 +184,11 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
|
||||
toast.error('Failed to load the stack dossier.');
|
||||
return;
|
||||
}
|
||||
const loaded = pickFields(await res.json());
|
||||
setServerFields(loaded);
|
||||
setFields(loaded);
|
||||
const next = pickFields(await res.json());
|
||||
setServerFields(next);
|
||||
setFields(next);
|
||||
setLoadError(false);
|
||||
setLoadedKey(`${nodeId ?? ''}::${stackName}`);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLoadError(true);
|
||||
@@ -171,6 +205,13 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
|
||||
[fields, serverFields],
|
||||
);
|
||||
|
||||
// Recomputes live as the operator edits access_urls; depends on that single
|
||||
// field so unrelated edits do not re-run the comparison.
|
||||
const docDrift = useMemo(
|
||||
() => computeDocDrift(anatomy, fields.access_urls),
|
||||
[anatomy, fields.access_urls],
|
||||
);
|
||||
|
||||
const setField = (key: keyof StackDossierFields, value: string) =>
|
||||
setFields(prev => ({ ...prev, [key]: value }));
|
||||
|
||||
@@ -249,6 +290,8 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
|
||||
)}
|
||||
</section>
|
||||
|
||||
{loadedKey === currentKey && docDrift.length > 0 && <DocDriftWarnings findings={docDrift} />}
|
||||
|
||||
<section>
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5')}>operator notes</div>
|
||||
{loadError ? (
|
||||
|
||||
Reference in New Issue
Block a user