feat(frontend): migrate resources, app store, and blueprint sheets to §9.11 chrome (#962)

Final PR of the System Sheet (§9.11) chrome rollout, stacked on PR 2.
Migrates the last five sheet consumers and extracts the inline network
detail sheet from ResourcesView into its own file.

Sheets migrated:
* VolumeBrowserSheet: crumb Resources > Volumes > {name}, Refresh tree
  primary, footer audit-log notice. Uses the new SystemSheet noScroll
  prop because the body is a 2-pane file browser that manages its own
  scroll regions; each pane (file tree, file preview) wraps its scroll
  region in ScrollArea per §10 Scrollbars.
* ImageDetailsSheet: crumb Resources > Images > {name}. Three sections
  (Overview, Config, Layers) flush against hairlines. Removed the
  icon-prefixed title and per-layer card wrapping (replaced with
  divide-y dividers).
* NetworkDetailSheet: NEW file extracted from the inline 150-line
  network sheet that lived inside ResourcesView.tsx. Crumb Resources >
  Networks > {name}. Five sections (Overview, IPAM, Options, Connected,
  Labels). Re-exports NetworkInspectData so ResourcesView can import
  the type. ResourcesView now renders the extracted component and drops
  its now-unused Sheet, ScrollArea, copyToClipboard, Copy, and Container
  imports.
* AppStoreView template detail sheet: crumb App store > {template}.
  Tabs Essentials | Advanced. Deploy lifted from SheetFooter into the
  toolbar primary slot. The remote-target signal (was a Badge in the
  header) collapses into the meta line as "→ {remoteName}".
* BlueprintDetail: the §9.11 reference implementation, intentionally
  migrated last. Crumb Blueprints > {name}. The kebab dropdown
  dissolves into individual toolbar actions: Apply now (primary), Edit
  (secondary, when not in editMode), Enable/Disable (secondary), Delete
  (destructive). Body has Description, Deployments, Compose sections.

Primitive enhancement:
* Added noScroll?: boolean to SystemSheet. When true, the body is
  rendered as a flex container instead of being wrapped in ScrollArea.
  Caller manages its own scroll regions and body padding.

Final state: frontend/src/components/ui/sheet.tsx is now imported only
by TopBar.tsx (mobile nav drawer, intentionally out of scope per the
plan) and SystemSheet itself. The §9.11 rollout is complete.
This commit is contained in:
Anso
2026-05-06 23:23:25 -04:00
committed by GitHub
parent 3ec0a45ff0
commit 907e7427e5
7 changed files with 735 additions and 745 deletions
@@ -1,13 +1,12 @@
import { useEffect, useState } from 'react';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { formatBytes } from '@/lib/utils';
import { copyToClipboard } from '@/lib/clipboard';
import { Image as ImageIcon, Copy } from 'lucide-react';
import { Copy } from 'lucide-react';
interface ImageInspect {
Id: string;
@@ -105,150 +104,147 @@ export function ImageDetailsSheet({ imageId, onClose }: ImageDetailsSheetProps)
const history = data?.history ?? [];
const totalLayers = history.length;
const name = inspect?.RepoTags?.[0] || (inspect ? shortDigest(inspect.Id) : 'Image details');
const meta = inspect
? `${formatBytes(inspect.Size)} · ${inspect.Architecture ?? '?'}/${inspect.Os ?? '?'} · ${totalLayers} layers`
: (loading ? 'Loading…' : '');
const footerContext = inspect?.Created
? `Created ${formatRelativeAge(new Date(inspect.Created).getTime() / 1000)}`
: undefined;
return (
<Sheet open={!!imageId} onOpenChange={(open) => !open && onClose()}>
<SheetContent className="sm:max-w-lg">
<ScrollArea className="h-full">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
{inspect?.RepoTags?.[0] || (inspect ? shortDigest(inspect.Id) : 'Image details')}
</SheetTitle>
</SheetHeader>
<SystemSheet
open={!!imageId}
onOpenChange={(open) => !open && onClose()}
crumb={['Resources', 'Images', name]}
name={name}
meta={meta}
footerContext={footerContext}
size="md"
>
{loading && (
<div className="space-y-3">
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-3/4" />
</div>
)}
{loading && (
<div className="space-y-3 mt-6">
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-3/4" />
</div>
)}
{!loading && inspect && (
<div className="space-y-6 mt-6 pb-6">
<Section title="Overview">
<div className="grid grid-cols-2 gap-3 text-sm">
<Field label="ID">
<p className="font-mono text-xs mt-0.5 flex items-center gap-1.5">
{shortDigest(inspect.Id)}
<button
className="text-muted-foreground hover:text-foreground transition-colors"
onClick={async () => {
try { await copyToClipboard(inspect.Id); toast.success('ID copied'); }
catch { toast.error('Copy failed.'); }
}}
aria-label="Copy image ID"
>
<Copy className="w-3 h-3" strokeWidth={1.5} />
</button>
</p>
</Field>
<Field label="Size">
<p className="font-mono text-xs mt-0.5 tabular-nums">{formatBytes(inspect.Size)}</p>
</Field>
<Field label="Created">
<p className="text-xs mt-0.5" title={new Date(inspect.Created).toLocaleString()}>
{new Date(inspect.Created).toLocaleDateString()}
</p>
</Field>
<Field label="Arch / OS">
<p className="text-xs mt-0.5">
<Badge variant="outline" className="text-[10px] h-5">{inspect.Architecture ?? 'unknown'} / {inspect.Os ?? 'unknown'}</Badge>
</p>
</Field>
{inspect.Author && (
<Field label="Author" span={2}>
<p className="text-xs mt-0.5">{inspect.Author}</p>
</Field>
)}
{inspect.RepoTags && inspect.RepoTags.length > 0 && (
<Field label="Tags" span={2}>
<div className="flex flex-wrap gap-1 mt-1">
{inspect.RepoTags.map((t) => (
<Badge key={t} variant="outline" className="text-[10px] h-5 font-mono">{t}</Badge>
))}
</div>
</Field>
)}
</div>
</Section>
{inspect.Config && (
<Section title="Config">
<div className="space-y-2 text-sm">
<ConfigRow label="Cmd" value={inspect.Config.Cmd?.join(' ')} />
<ConfigRow label="Entrypoint" value={inspect.Config.Entrypoint?.join(' ')} />
<ConfigRow label="WorkingDir" value={inspect.Config.WorkingDir} />
<ConfigRow label="User" value={inspect.Config.User} />
<ConfigRow
label="Ports"
value={
inspect.Config.ExposedPorts
? Object.keys(inspect.Config.ExposedPorts).join(', ')
: undefined
}
/>
{inspect.Config.Env && inspect.Config.Env.length > 0 && (
<CollapsibleList label="Env" count={inspect.Config.Env.length} items={inspect.Config.Env} />
)}
{inspect.Config.Labels && Object.keys(inspect.Config.Labels).length > 0 && (
<CollapsibleList
label="Labels"
count={Object.keys(inspect.Config.Labels).length}
items={Object.entries(inspect.Config.Labels).map(([k, v]) => `${k}=${v}`)}
/>
)}
</div>
</Section>
{!loading && inspect && (
<>
<SheetSection title="Overview">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<Field label="ID">
<p className="font-mono text-xs mt-0.5 flex items-center gap-1.5">
{shortDigest(inspect.Id)}
<button
className="text-muted-foreground hover:text-foreground transition-colors"
onClick={async () => {
try { await copyToClipboard(inspect.Id); toast.success('ID copied'); }
catch { toast.error('Copy failed.'); }
}}
aria-label="Copy image ID"
>
<Copy className="w-3 h-3" strokeWidth={1.5} />
</button>
</p>
</Field>
<Field label="Size">
<p className="font-mono text-xs mt-0.5 tabular-nums">{formatBytes(inspect.Size)}</p>
</Field>
<Field label="Created">
<p className="text-xs mt-0.5" title={new Date(inspect.Created).toLocaleString()}>
{new Date(inspect.Created).toLocaleDateString()}
</p>
</Field>
<Field label="Arch / OS">
<p className="text-xs mt-0.5">
<Badge variant="outline" className="text-[10px] h-5">{inspect.Architecture ?? 'unknown'} / {inspect.Os ?? 'unknown'}</Badge>
</p>
</Field>
{inspect.Author && (
<Field label="Author" span={2}>
<p className="text-xs mt-0.5">{inspect.Author}</p>
</Field>
)}
{inspect.RepoTags && inspect.RepoTags.length > 0 && (
<Field label="Tags" span={2}>
<div className="flex flex-wrap gap-1 mt-1">
{inspect.RepoTags.map((t) => (
<Badge key={t} variant="outline" className="text-[10px] h-5 font-mono">{t}</Badge>
))}
</div>
</Field>
)}
<Section title={`Layers (${totalLayers})`}>
{totalLayers === 0 ? (
<p className="text-xs text-muted-foreground italic">No layer history available.</p>
) : (
<ol className="space-y-1.5">
{history.map((h, idx) => {
const empty = h.Size === 0;
return (
<li
key={`${h.Id}-${idx}`}
className={`rounded-md border border-card-border bg-card px-3 py-2 shadow-card-bevel ${empty ? 'opacity-60' : ''}`}
>
<div className="flex items-baseline justify-between gap-3 text-[11px] text-muted-foreground tabular-nums">
<span>#{totalLayers - idx}</span>
<span className="font-mono">{formatBytes(h.Size)}</span>
<span>{formatRelativeAge(h.Created)}</span>
</div>
<p
className="font-mono text-[11px] mt-1 break-all"
title={h.CreatedBy}
>
{h.CreatedBy || '(no command)'}
</p>
{h.Comment && (
<p className="text-[11px] text-muted-foreground italic mt-0.5">{h.Comment}</p>
)}
</li>
);
})}
</ol>
)}
</Section>
</div>
)}
</ScrollArea>
</SheetContent>
</Sheet>
);
}
</SheetSection>
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{title}</h4>
{children}
</div>
{inspect.Config && (
<SheetSection title="Config">
<div className="space-y-2 text-sm">
<ConfigRow label="Cmd" value={inspect.Config.Cmd?.join(' ')} />
<ConfigRow label="Entrypoint" value={inspect.Config.Entrypoint?.join(' ')} />
<ConfigRow label="WorkingDir" value={inspect.Config.WorkingDir} />
<ConfigRow label="User" value={inspect.Config.User} />
<ConfigRow
label="Ports"
value={
inspect.Config.ExposedPorts
? Object.keys(inspect.Config.ExposedPorts).join(', ')
: undefined
}
/>
{inspect.Config.Env && inspect.Config.Env.length > 0 && (
<CollapsibleList label="Env" count={inspect.Config.Env.length} items={inspect.Config.Env} />
)}
{inspect.Config.Labels && Object.keys(inspect.Config.Labels).length > 0 && (
<CollapsibleList
label="Labels"
count={Object.keys(inspect.Config.Labels).length}
items={Object.entries(inspect.Config.Labels).map(([k, v]) => `${k}=${v}`)}
/>
)}
</div>
</SheetSection>
)}
<SheetSection title={`Layers · ${totalLayers}`}>
{totalLayers === 0 ? (
<p className="text-xs text-muted-foreground italic">No layer history available.</p>
) : (
<ol className="divide-y divide-card-border/40">
{history.map((h, idx) => {
const empty = h.Size === 0;
return (
<li
key={`${h.Id}-${idx}`}
className={`py-2 ${empty ? 'opacity-60' : ''}`}
>
<div className="flex items-baseline justify-between gap-3 text-[11px] text-muted-foreground tabular-nums">
<span>#{totalLayers - idx}</span>
<span className="font-mono">{formatBytes(h.Size)}</span>
<span>{formatRelativeAge(h.Created)}</span>
</div>
<p
className="font-mono text-[11px] mt-1 break-all"
title={h.CreatedBy}
>
{h.CreatedBy || '(no command)'}
</p>
{h.Comment && (
<p className="text-[11px] text-muted-foreground italic mt-0.5">{h.Comment}</p>
)}
</li>
);
})}
</ol>
)}
</SheetSection>
</>
)}
</SystemSheet>
);
}
@@ -0,0 +1,193 @@
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
import { toast } from '@/components/ui/toast-store';
import { copyToClipboard } from '@/lib/clipboard';
import { Container, Copy } from 'lucide-react';
export interface NetworkInspectData {
Id: string;
Name: string;
Created: string;
Scope: string;
Driver: string;
Internal: boolean;
Attachable: boolean;
Labels: Record<string, string>;
IPAM: {
Driver: string;
Config: Array<{ Subnet?: string; Gateway?: string; IPRange?: string }>;
};
Containers: Record<string, {
Name: string;
EndpointID: string;
MacAddress: string;
IPv4Address: string;
IPv6Address: string;
}>;
Options: Record<string, string>;
}
interface NetworkDetailSheetProps {
network: NetworkInspectData | null;
onClose: () => void;
}
export function NetworkDetailSheet({ network, onClose }: NetworkDetailSheetProps) {
const containerCount = network ? Object.keys(network.Containers || {}).length : 0;
const subnet = network?.IPAM?.Config?.[0]?.Subnet;
const meta = network
? `${network.Driver} · ${network.Scope}${subnet ? ` · ${subnet}` : ''} · ${containerCount} container${containerCount === 1 ? '' : 's'}`
: '';
const footerContext = network?.Created
? `Created ${new Date(network.Created).toLocaleString()}`
: undefined;
return (
<SystemSheet
open={!!network}
onOpenChange={(open) => { if (!open) onClose(); }}
crumb={['Resources', 'Networks', network?.Name ?? '—']}
name={network?.Name ?? 'Network'}
meta={meta}
footerContext={footerContext}
size="md"
>
{network && (
<>
<SheetSection title="Overview">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<span className="text-xs text-muted-foreground">ID</span>
<p className="font-mono text-xs mt-0.5 flex items-center gap-1.5">
{network.Id.substring(0, 12)}
<button
className="text-muted-foreground hover:text-foreground transition-colors"
onClick={async () => {
try { await copyToClipboard(network.Id); toast.success('ID copied'); }
catch { toast.error('Copy failed.'); }
}}
aria-label="Copy network ID"
>
<Copy className="w-3 h-3" strokeWidth={1.5} />
</button>
</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Driver</span>
<span className="text-xs mt-0.5 block">
<Badge variant="outline" className="text-[10px] h-5">{network.Driver}</Badge>
</span>
</div>
<div>
<span className="text-xs text-muted-foreground">Scope</span>
<span className="text-xs mt-0.5 block">
<Badge variant="outline" className="text-[10px] h-5">{network.Scope}</Badge>
</span>
</div>
<div>
<span className="text-xs text-muted-foreground">Created</span>
<p className="text-xs mt-0.5">{new Date(network.Created).toLocaleString()}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Internal</span>
<p className="text-xs mt-0.5">{network.Internal ? 'Yes' : 'No'}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">Attachable</span>
<p className="text-xs mt-0.5">{network.Attachable ? 'Yes' : 'No'}</p>
</div>
</div>
</SheetSection>
{network.IPAM?.Config?.length > 0 && (
<SheetSection title="IPAM configuration">
<div className="divide-y divide-card-border/40">
{network.IPAM.Config.map((cfg, i) => (
<div key={i} className="py-2 space-y-1.5">
{cfg.Subnet && (
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Subnet</span>
<span className="font-mono text-xs tabular-nums">{cfg.Subnet}</span>
</div>
)}
{cfg.Gateway && (
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">Gateway</span>
<span className="font-mono text-xs tabular-nums">{cfg.Gateway}</span>
</div>
)}
{cfg.IPRange && (
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground">IP Range</span>
<span className="font-mono text-xs tabular-nums">{cfg.IPRange}</span>
</div>
)}
</div>
))}
</div>
</SheetSection>
)}
{network.Options && Object.keys(network.Options).length > 0 && (
<SheetSection title="Options">
<Table>
<TableBody>
{Object.entries(network.Options).map(([key, val]) => (
<TableRow key={key} className="hover:bg-muted/30">
<TableCell className="font-mono text-xs py-1.5 text-muted-foreground">{key}</TableCell>
<TableCell className="font-mono text-xs py-1.5 text-right">{val}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SheetSection>
)}
<SheetSection title={`Connected · ${containerCount} container${containerCount === 1 ? '' : 's'}`}>
{containerCount === 0 ? (
<p className="text-xs text-muted-foreground py-4 text-center">No containers connected to this network.</p>
) : (
<div className="divide-y divide-card-border/40">
{Object.entries(network.Containers).map(([id, c]) => (
<div key={id} className="py-2 space-y-1.5">
<div className="flex items-center gap-2">
<Container className="w-3.5 h-3.5 text-muted-foreground" strokeWidth={1.5} />
<span className="text-sm font-medium truncate">{c.Name}</span>
</div>
<div className="grid grid-cols-2 gap-2 pl-5">
<div>
<span className="text-[10px] text-muted-foreground">IPv4</span>
<p className="font-mono text-xs tabular-nums">{c.IPv4Address || 'N/A'}</p>
</div>
<div>
<span className="text-[10px] text-muted-foreground">MAC</span>
<p className="font-mono text-xs tabular-nums">{c.MacAddress || 'N/A'}</p>
</div>
</div>
</div>
))}
</div>
)}
</SheetSection>
{network.Labels && Object.keys(network.Labels).length > 0 && (
<SheetSection title="Labels">
<Table>
<TableBody>
{Object.entries(network.Labels).map(([key, val]) => (
<TableRow key={key} className="hover:bg-muted/30">
<TableCell className="font-mono text-xs py-1.5 text-muted-foreground">{key}</TableCell>
<TableCell className="font-mono text-xs py-1.5 text-right">{val}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SheetSection>
)}
</>
)}
</SystemSheet>
);
}
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { HardDrive, RefreshCw } from 'lucide-react';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { RefreshCw } from 'lucide-react';
import { SystemSheet } from '@/components/ui/system-sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/toast-store';
import { FileTree } from '@/components/files/FileTree';
import type { FileEntry } from '@/lib/stackFilesApi';
@@ -66,65 +66,58 @@ export function VolumeBrowserSheet({ volumeName, onClose }: VolumeBrowserSheetPr
[volumeName]
);
const meta = selectedPath || 'No file selected';
return (
<Sheet open={!!volumeName} onOpenChange={handleClose}>
<SheetContent className="sm:max-w-3xl">
<SheetHeader className="pr-10">
<SheetTitle className="flex items-center gap-2 min-w-0">
<HardDrive className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="font-mono text-sm truncate">{volumeName}</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 ml-auto shrink-0"
onClick={() => setRefreshKey((k) => k + 1)}
title="Refresh tree"
aria-label="Refresh tree"
>
<RefreshCw className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</SheetTitle>
</SheetHeader>
{volumeName && (
<div className="grid grid-cols-[260px_1fr] gap-3 mt-4 h-[calc(100vh-180px)]">
<div className="rounded-md border border-card-border bg-card overflow-hidden">
<FileTree
key={`${volumeName}:${refreshKey}`}
sourceKey={volumeName}
loadDir={loadDir}
refreshKey={refreshKey}
selectedPath={selectedPath}
onSelectFile={handleSelectFile}
/>
</div>
<div className="rounded-md border border-card-border bg-card overflow-hidden flex flex-col">
{!selectedPath && (
<div className="flex-1 flex items-center justify-center p-6 text-xs text-muted-foreground italic">
Select a file to preview.
</div>
)}
{selectedPath && fileLoading && (
<div className="p-3 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
)}
{selectedPath && !fileLoading && fileResult && (
<FileResultPanel path={selectedPath} result={fileResult} />
)}
</div>
<SystemSheet
open={!!volumeName}
onOpenChange={handleClose}
crumb={['Resources', 'Volumes', volumeName ?? '—']}
name={volumeName ?? 'Volume'}
meta={meta}
primaryAction={{
label: 'Refresh tree',
icon: RefreshCw,
onClick: () => setRefreshKey((k) => k + 1),
}}
footerContext="File reads are recorded in the audit log."
size="lg"
noScroll
>
{volumeName && (
<div className="grid grid-cols-[260px_1fr] gap-3 px-6 py-5 flex-1 min-h-0">
<div className="rounded-md border border-card-border bg-card overflow-hidden">
<FileTree
key={`${volumeName}:${refreshKey}`}
sourceKey={volumeName}
loadDir={loadDir}
refreshKey={refreshKey}
selectedPath={selectedPath}
onSelectFile={handleSelectFile}
/>
</div>
)}
<p className="mt-3 text-[11px] text-muted-foreground">
File reads are recorded in the audit log.
</p>
</SheetContent>
</Sheet>
<div className="rounded-md border border-card-border bg-card overflow-hidden flex flex-col">
{!selectedPath && (
<div className="flex-1 flex items-center justify-center p-6 text-xs text-muted-foreground italic">
Select a file to preview.
</div>
)}
{selectedPath && fileLoading && (
<div className="p-3 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
)}
{selectedPath && !fileLoading && fileResult && (
<FileResultPanel path={selectedPath} result={fileResult} />
)}
</div>
</div>
)}
</SystemSheet>
);
}
@@ -143,9 +136,11 @@ function FileResultPanel({ path, result }: { path: string; result: VolumeFileRes
Showing first {formatBytes(5 * 1024 * 1024)}. Larger files cannot be downloaded from this view.
</div>
)}
<pre className="flex-1 overflow-auto p-3 font-mono text-[11px] whitespace-pre-wrap break-all leading-relaxed">
{decoded}
</pre>
<ScrollArea className="flex-1">
<pre className="p-3 font-mono text-[11px] whitespace-pre-wrap break-all leading-relaxed">
{decoded}
</pre>
</ScrollArea>
</div>
);
}