From 907e7427e5983a3a59d7dad831d2bb4e1302f15f Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 6 May 2026 23:23:25 -0400 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20migrate=20resources,=20app=20?= =?UTF-8?q?store,=20and=20blueprint=20sheets=20to=20=C2=A79.11=20chrome=20?= =?UTF-8?q?(#962)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/AppStoreView.tsx | 508 +++++++++--------- frontend/src/components/ResourcesView.tsx | 188 +------ .../components/blueprints/BlueprintDetail.tsx | 174 +++--- .../resources/ImageDetailsSheet.tsx | 280 +++++----- .../resources/NetworkDetailSheet.tsx | 193 +++++++ .../resources/VolumeBrowserSheet.tsx | 119 ++-- frontend/src/components/ui/system-sheet.tsx | 18 +- 7 files changed, 735 insertions(+), 745 deletions(-) create mode 100644 frontend/src/components/resources/NetworkDetailSheet.tsx diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index f85841b4..7b037774 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -2,11 +2,10 @@ import { useState, useEffect, useMemo } from 'react'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from '@/components/ui/sheet'; +import { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Search, Rocket, Loader2, Info, ExternalLink, Star, ShieldCheck } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { cn } from '@/lib/utils'; @@ -324,277 +323,260 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { - - - {selectedTemplate && ( -
- - {activeNode?.type === 'remote' && ( -
- - Deploying to: {activeNode.name} - -
- )} -
-
- {selectedTemplate.logo && !imgErrors[selectedTemplate.logo] ? ( - {selectedTemplate.title} setImgErrors(prev => ({ ...prev, [selectedTemplate.logo!]: true }))} /> - ) : ( - - )} -
-
- {selectedTemplate.title} -
- - {selectedTemplate.description} - - setIsDescExpanded(!isDescExpanded)}> - {isDescExpanded ? 'Read less' : 'Read more'} - -
- - {(selectedTemplate.architectures || selectedTemplate.stars !== undefined || selectedTemplate.github_url || selectedTemplate.docs_url) && ( -
- {selectedTemplate.architectures && selectedTemplate.architectures.length > 0 && ( -
- {selectedTemplate.architectures.map(arch => ( - - {arch} - - ))} -
- )} - -
- {selectedTemplate.stars !== undefined && ( -
- - {selectedTemplate.stars?.toLocaleString()} -
- )} - {selectedTemplate.github_url && ( - - - Source - - )} - {selectedTemplate.docs_url && ( - - - Docs - - )} -
-
- )} -
+ setSheetTab(id as 'essentials' | 'advanced')} + primaryAction={selectedTemplate ? { + label: isDeploying ? 'Deploying…' : `Deploy ${selectedTemplate.title}`, + icon: isDeploying ? Loader2 : Rocket, + onClick: handleDeploy, + disabled: isDeploying || !stackName.trim() || !can('stack:create'), + } : undefined} + footerContext={isDeploying ? 'This may take a few minutes for large images.' : undefined} + size="md" + > + {selectedTemplate && ( + <> + +
+
+ {selectedTemplate.logo && !imgErrors[selectedTemplate.logo] ? ( + {selectedTemplate.title} setImgErrors(prev => ({ ...prev, [selectedTemplate.logo!]: true }))} /> + ) : ( + + )}
- +
+

+ {selectedTemplate.description} +

+ - setSheetTab(v as 'essentials' | 'advanced')} className="flex flex-col flex-1 min-h-0"> - - Essentials - Advanced - - - - -
-
- - setStackName(e.target.value)} - placeholder="e.g. my-app" - /> -

- This determines the directory name and docker project name. -

-
- -
- Deploy with defaults: the template's recommended ports, volumes, and environment variables. - Use the Advanced tab to customize. -
-
-
-
- - - -
- {selectedTemplate.ports && selectedTemplate.ports.length > 0 && ( -
-

Ports (Host : Container)

- {selectedTemplate.ports.map((p, idx) => { - const parts = p.split(':'); - if (parts.length < 2) return null; - const hostPort = portVars[p] || ''; - const conflict = hostPort ? portsInUse[hostPort] : undefined; - return ( -
- { - const val = e.target.value.replace(/[^0-9]/g, ''); - setPortVars(prev => ({ ...prev, [p]: val })); - }} - className={cn('w-24 text-center font-mono', hostPort && !isValidPort(hostPort) && 'border-destructive')} - /> - {conflict ? ( - - - : {parts[1]} - - - -
- - -
- - {conflict.stack !== null - ? <>Port {hostPort} used by {conflict.stack} - : <>Port {hostPort} used by an external app - } - -
-
- - ) : ( - : {parts[1]} - )} -
- ); - })} -
- )} - - {selectedTemplate.volumes && selectedTemplate.volumes.length > 0 && ( -
-

Volumes (Host : Container)

- {selectedTemplate.volumes.map((v, idx) => { - const containerPath = v.container; - if (!containerPath) return null; - return ( -
- - setVolVars(prev => ({ ...prev, [containerPath]: e.target.value }))} - placeholder="/path/to/host/dir" - /> -
- ); - })} -
- )} - - {selectedTemplate.env && selectedTemplate.env.length > 0 && ( -
-

Environment Variables

- {selectedTemplate.env.map((e, idx) => ( -
- - setEnvVars(prev => ({ ...prev, [e.name]: ev.target.value }))} - placeholder={e.default || `Enter value for ${e.name}`} - /> -

- {e.name} -

-
+ {(selectedTemplate.architectures || selectedTemplate.stars !== undefined || selectedTemplate.github_url || selectedTemplate.docs_url) && ( +
+ {selectedTemplate.architectures && selectedTemplate.architectures.length > 0 && ( +
+ {selectedTemplate.architectures.map(arch => ( + + {arch} + ))}
)} - -
-

Add Custom Variable

- {customEnvs.map((ce, idx) => ( -
- - - +
+ {selectedTemplate.stars !== undefined && ( +
+ + {selectedTemplate.stars?.toLocaleString()}
- ))} -
- setNewEnvKey(e.target.value)} className="w-1/3 font-mono text-xs" /> - setNewEnvVal(e.target.value)} className="flex-1 font-mono text-xs" /> - -
+ )} + {selectedTemplate.github_url && ( + + + Source + + )} + {selectedTemplate.docs_url && ( + + + Docs + + )}
- - {trivyAvailable && ( -
- setAutoScan(!!checked)} - /> - -
- )}
- - - - - -
- - {isDeploying && ( -

- This may take a few minutes for large images. -

)}
-
-
- )} - - +
+ + + {sheetTab === 'essentials' && ( + +
+ + setStackName(e.target.value)} + placeholder="e.g. my-app" + /> +

+ This determines the directory name and docker project name. +

+
+ +
+ Deploy with defaults: the template's recommended ports, volumes, and environment variables. + Use the Advanced tab to customize. +
+
+ )} + + {sheetTab === 'advanced' && ( + <> + {selectedTemplate.ports && selectedTemplate.ports.length > 0 && ( + +
+ {selectedTemplate.ports.map((p, idx) => { + const parts = p.split(':'); + if (parts.length < 2) return null; + const hostPort = portVars[p] || ''; + const conflict = hostPort ? portsInUse[hostPort] : undefined; + return ( +
+ { + const val = e.target.value.replace(/[^0-9]/g, ''); + setPortVars(prev => ({ ...prev, [p]: val })); + }} + className={cn('w-24 text-center font-mono', hostPort && !isValidPort(hostPort) && 'border-destructive')} + /> + {conflict ? ( + + + : {parts[1]} + + + +
+ + +
+ + {conflict.stack !== null + ? <>Port {hostPort} used by {conflict.stack} + : <>Port {hostPort} used by an external app + } + +
+
+ + ) : ( + : {parts[1]} + )} +
+ ); + })} +
+ + )} + + {selectedTemplate.volumes && selectedTemplate.volumes.length > 0 && ( + +
+ {selectedTemplate.volumes.map((v, idx) => { + const containerPath = v.container; + if (!containerPath) return null; + return ( +
+ + setVolVars(prev => ({ ...prev, [containerPath]: e.target.value }))} + placeholder="/path/to/host/dir" + /> +
+ ); + })} +
+
+ )} + + {selectedTemplate.env && selectedTemplate.env.length > 0 && ( + +
+ {selectedTemplate.env.map((e, idx) => ( +
+ + setEnvVars(prev => ({ ...prev, [e.name]: ev.target.value }))} + placeholder={e.default || `Enter value for ${e.name}`} + /> +

+ {e.name} +

+
+ ))} +
+
+ )} + + +
+ {customEnvs.map((ce, idx) => ( +
+ + + +
+ ))} +
+ setNewEnvKey(e.target.value)} className="w-1/3 font-mono text-xs" /> + setNewEnvVal(e.target.value)} className="flex-1 font-mono text-xs" /> + +
+
+
+ + {trivyAvailable && ( + +
+ setAutoScan(!!checked)} + /> + +
+
+ )} + + )} + + )} +
); } diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 464cc15c..6700720f 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -7,7 +7,6 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from "@/components/ui/modal"; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Combobox } from "@/components/ui/combobox"; import { Input } from "@/components/ui/input"; @@ -15,9 +14,8 @@ import { Label } from "@/components/ui/label"; import { TogglePill } from "@/components/ui/toggle-pill"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { apiFetch } from '@/lib/api'; -import { copyToClipboard } from '@/lib/clipboard'; import { toast } from '@/components/ui/toast-store'; -import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Copy, Container, Loader2, History, FolderOpen } from 'lucide-react'; +import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Loader2, History, FolderOpen } from 'lucide-react'; import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; @@ -38,6 +36,7 @@ import { ReclaimHero } from './resources/ReclaimHero'; import { FootprintTreemap } from './resources/FootprintTreemap'; import { ImageDetailsSheet } from './resources/ImageDetailsSheet'; import { VolumeBrowserSheet } from './resources/VolumeBrowserSheet'; +import { NetworkDetailSheet, type NetworkInspectData } from './resources/NetworkDetailSheet'; import { TabLanding, type TabLandingEntry } from './resources/TabLanding'; const NetworkTopologyView = lazy(() => import('./NetworkTopologyView')); @@ -108,28 +107,7 @@ interface UnmanagedContainer { Image: string; } -interface NetworkInspectData { - Id: string; - Name: string; - Created: string; - Scope: string; - Driver: string; - Internal: boolean; - Attachable: boolean; - Labels: Record; - IPAM: { - Driver: string; - Config: Array<{ Subnet?: string; Gateway?: string; IPRange?: string }>; - }; - Containers: Record; - Options: Record; -} +// NetworkInspectData is re-exported from ./resources/NetworkDetailSheet type ResourceFilter = 'all' | 'managed' | 'unmanaged'; type PruneTarget = 'containers' | 'images' | 'networks' | 'volumes'; @@ -1365,161 +1343,11 @@ export default function ResourcesView() { setBrowseVolume(null)} /> - {/* Network Inspect Sheet */} - !open && setInspectNetwork(null)}> - - - - - - {inspectNetwork?.Name} - - - {inspectNetwork && ( -
- {/* Overview */} -
-

Overview

-
-
- ID -

- {inspectNetwork.Id.substring(0, 12)} - -

-
-
- Driver - {inspectNetwork.Driver} -
-
- Scope - {inspectNetwork.Scope} -
-
- Created -

{new Date(inspectNetwork.Created).toLocaleString()}

-
-
- Internal -

{inspectNetwork.Internal ? 'Yes' : 'No'}

-
-
- Attachable -

{inspectNetwork.Attachable ? 'Yes' : 'No'}

-
-
-
- - {/* IPAM Config */} - {inspectNetwork.IPAM?.Config?.length > 0 && ( -
-

IPAM Configuration

-
- {inspectNetwork.IPAM.Config.map((cfg, i) => ( -
- {cfg.Subnet && ( -
- Subnet - {cfg.Subnet} -
- )} - {cfg.Gateway && ( -
- Gateway - {cfg.Gateway} -
- )} - {cfg.IPRange && ( -
- IP Range - {cfg.IPRange} -
- )} -
- ))} -
-
- )} - - {/* Options */} - {inspectNetwork.Options && Object.keys(inspectNetwork.Options).length > 0 && ( -
-

Options

-
- - - {Object.entries(inspectNetwork.Options).map(([key, val]) => ( - - {key} - {val} - - ))} - -
-
-
- )} - - {/* Connected Containers */} -
-

- Connected Containers ({Object.keys(inspectNetwork.Containers || {}).length}) -

- {Object.keys(inspectNetwork.Containers || {}).length === 0 ? ( -

No containers connected to this network.

- ) : ( -
- {Object.entries(inspectNetwork.Containers).map(([id, container]) => ( -
-
- - {container.Name} -
-
-
- IPv4 -

{container.IPv4Address || 'N/A'}

-
-
- MAC -

{container.MacAddress || 'N/A'}

-
-
-
- ))} -
- )} -
- - {/* Labels */} - {inspectNetwork.Labels && Object.keys(inspectNetwork.Labels).length > 0 && ( -
-

Labels

-
- - - {Object.entries(inspectNetwork.Labels).map(([key, val]) => ( - - {key} - {val} - - ))} - -
-
-
- )} -
- )} -
-
-
+ {/* Network detail sheet */} + setInspectNetwork(null)} + /> - - -
- - {blueprint && ( -
- - {canEdit && !editMode && ( - - )} - {canEdit && ( - - - - - - - {blueprint.enabled ? 'Disable reconciler' : 'Enable reconciler'} - - - setDeleteOpen(true)} className="text-destructive"> - - Delete blueprint - - - - )} -
- )} -
-
- - Blueprint - - - {blueprint?.name ?? } - - {blueprint?.description && ( -

{blueprint.description}

- )} -
-
+ const meta = blueprint + ? `${describeSelector(blueprint.selector)} · ${blueprint.drift_mode} · rev ${blueprint.revision}` + : (loading ? 'Loading…' : ''); -
- {loading || !blueprint || !summary ? ( -
- - - -
- ) : editMode ? ( + const footerContext = blueprint + ? `Updated ${formatTimeAgo(blueprint.updated_at)}${blueprint.enabled ? '' : ' · reconciler disabled'}` + : undefined; + + const secondaryActions = blueprint && canEdit + ? [ + ...(!editMode ? [{ + label: 'Edit', + icon: Pencil, + onClick: () => setEditMode(true), + disabled: submitting, + }] : []), + { + label: blueprint.enabled ? 'Disable' : 'Enable', + icon: Power, + onClick: handleToggleEnabled, + disabled: submitting, + }, + ] + : undefined; + + return ( + <> + } + meta={meta} + primaryAction={blueprint ? { + label: 'Apply now', + icon: Play, + onClick: handleApply, + disabled: submitting || !blueprint.enabled || editMode, + } : undefined} + secondaryActions={secondaryActions} + destructiveAction={blueprint && canEdit ? { + label: 'Delete', + icon: Trash2, + onClick: () => setDeleteOpen(true), + disabled: submitting || editMode, + } : undefined} + footerContext={footerContext} + size="lg" + > + {loading || !blueprint || !summary ? ( +
+ + + +
+ ) : editMode ? ( + - ) : ( - <> -
- - - - -
+
+ ) : ( + <> + {blueprint.description && ( + +

{blueprint.description}

+
+ )} + + -
- - Compose + +
+ + Show compose source -
+                                
                                     {blueprint.compose_content}
                                 
- - )} -
+
+ + )} + - {evictTarget && blueprint && ( + {evictTarget && blueprint && ( { if (!o) setEvictTarget(null); }} @@ -366,16 +360,6 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca /> )} - - - ); -} - -function Stat({ label, value }: { label: string; value: string }) { - return ( -
- {label} -

{value}

-
+ ); } diff --git a/frontend/src/components/resources/ImageDetailsSheet.tsx b/frontend/src/components/resources/ImageDetailsSheet.tsx index 4fda073d..eebb41d4 100644 --- a/frontend/src/components/resources/ImageDetailsSheet.tsx +++ b/frontend/src/components/resources/ImageDetailsSheet.tsx @@ -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 ( - !open && onClose()}> - - - - - - {inspect?.RepoTags?.[0] || (inspect ? shortDigest(inspect.Id) : 'Image details')} - - + !open && onClose()} + crumb={['Resources', 'Images', name]} + name={name} + meta={meta} + footerContext={footerContext} + size="md" + > + {loading && ( +
+ + + + +
+ )} - {loading && ( -
- - - - -
- )} - - {!loading && inspect && ( -
-
-
- -

- {shortDigest(inspect.Id)} - -

-
- -

{formatBytes(inspect.Size)}

-
- -

- {new Date(inspect.Created).toLocaleDateString()} -

-
- -

- {inspect.Architecture ?? 'unknown'} / {inspect.Os ?? 'unknown'} -

-
- {inspect.Author && ( - -

{inspect.Author}

-
- )} - {inspect.RepoTags && inspect.RepoTags.length > 0 && ( - -
- {inspect.RepoTags.map((t) => ( - {t} - ))} -
-
- )} -
-
- - {inspect.Config && ( -
-
- - - - - - {inspect.Config.Env && inspect.Config.Env.length > 0 && ( - - )} - {inspect.Config.Labels && Object.keys(inspect.Config.Labels).length > 0 && ( - `${k}=${v}`)} - /> - )} -
-
+ {!loading && inspect && ( + <> + +
+ +

+ {shortDigest(inspect.Id)} + +

+
+ +

{formatBytes(inspect.Size)}

+
+ +

+ {new Date(inspect.Created).toLocaleDateString()} +

+
+ +

+ {inspect.Architecture ?? 'unknown'} / {inspect.Os ?? 'unknown'} +

+
+ {inspect.Author && ( + +

{inspect.Author}

+
+ )} + {inspect.RepoTags && inspect.RepoTags.length > 0 && ( + +
+ {inspect.RepoTags.map((t) => ( + {t} + ))} +
+
)} - -
- {totalLayers === 0 ? ( -

No layer history available.

- ) : ( -
    - {history.map((h, idx) => { - const empty = h.Size === 0; - return ( -
  1. -
    - #{totalLayers - idx} - {formatBytes(h.Size)} - {formatRelativeAge(h.Created)} -
    -

    - {h.CreatedBy || '(no command)'} -

    - {h.Comment && ( -

    {h.Comment}

    - )} -
  2. - ); - })} -
- )} -
- )} - - - - ); -} +
-function Section({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

- {children} -
+ {inspect.Config && ( + +
+ + + + + + {inspect.Config.Env && inspect.Config.Env.length > 0 && ( + + )} + {inspect.Config.Labels && Object.keys(inspect.Config.Labels).length > 0 && ( + `${k}=${v}`)} + /> + )} +
+
+ )} + + + {totalLayers === 0 ? ( +

No layer history available.

+ ) : ( +
    + {history.map((h, idx) => { + const empty = h.Size === 0; + return ( +
  1. +
    + #{totalLayers - idx} + {formatBytes(h.Size)} + {formatRelativeAge(h.Created)} +
    +

    + {h.CreatedBy || '(no command)'} +

    + {h.Comment && ( +

    {h.Comment}

    + )} +
  2. + ); + })} +
+ )} +
+ + )} + ); } diff --git a/frontend/src/components/resources/NetworkDetailSheet.tsx b/frontend/src/components/resources/NetworkDetailSheet.tsx new file mode 100644 index 00000000..582132c2 --- /dev/null +++ b/frontend/src/components/resources/NetworkDetailSheet.tsx @@ -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; + IPAM: { + Driver: string; + Config: Array<{ Subnet?: string; Gateway?: string; IPRange?: string }>; + }; + Containers: Record; + Options: Record; +} + +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 ( + { if (!open) onClose(); }} + crumb={['Resources', 'Networks', network?.Name ?? '—']} + name={network?.Name ?? 'Network'} + meta={meta} + footerContext={footerContext} + size="md" + > + {network && ( + <> + +
+
+ ID +

+ {network.Id.substring(0, 12)} + +

+
+
+ Driver + + {network.Driver} + +
+
+ Scope + + {network.Scope} + +
+
+ Created +

{new Date(network.Created).toLocaleString()}

+
+
+ Internal +

{network.Internal ? 'Yes' : 'No'}

+
+
+ Attachable +

{network.Attachable ? 'Yes' : 'No'}

+
+
+
+ + {network.IPAM?.Config?.length > 0 && ( + +
+ {network.IPAM.Config.map((cfg, i) => ( +
+ {cfg.Subnet && ( +
+ Subnet + {cfg.Subnet} +
+ )} + {cfg.Gateway && ( +
+ Gateway + {cfg.Gateway} +
+ )} + {cfg.IPRange && ( +
+ IP Range + {cfg.IPRange} +
+ )} +
+ ))} +
+
+ )} + + {network.Options && Object.keys(network.Options).length > 0 && ( + + + + {Object.entries(network.Options).map(([key, val]) => ( + + {key} + {val} + + ))} + +
+
+ )} + + + {containerCount === 0 ? ( +

No containers connected to this network.

+ ) : ( +
+ {Object.entries(network.Containers).map(([id, c]) => ( +
+
+ + {c.Name} +
+
+
+ IPv4 +

{c.IPv4Address || 'N/A'}

+
+
+ MAC +

{c.MacAddress || 'N/A'}

+
+
+
+ ))} +
+ )} +
+ + {network.Labels && Object.keys(network.Labels).length > 0 && ( + + + + {Object.entries(network.Labels).map(([key, val]) => ( + + {key} + {val} + + ))} + +
+
+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/resources/VolumeBrowserSheet.tsx b/frontend/src/components/resources/VolumeBrowserSheet.tsx index f600367d..488d406f 100644 --- a/frontend/src/components/resources/VolumeBrowserSheet.tsx +++ b/frontend/src/components/resources/VolumeBrowserSheet.tsx @@ -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 ( - - - - - - {volumeName} - - - - - {volumeName && ( -
-
- -
- -
- {!selectedPath && ( -
- Select a file to preview. -
- )} - {selectedPath && fileLoading && ( -
- - - - -
- )} - {selectedPath && !fileLoading && fileResult && ( - - )} -
+ setRefreshKey((k) => k + 1), + }} + footerContext="File reads are recorded in the audit log." + size="lg" + noScroll + > + {volumeName && ( +
+
+
- )} -

- File reads are recorded in the audit log. -

- - +
+ {!selectedPath && ( +
+ Select a file to preview. +
+ )} + {selectedPath && fileLoading && ( +
+ + + + +
+ )} + {selectedPath && !fileLoading && fileResult && ( + + )} +
+
+ )} +
); } @@ -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.
)} -
-        {decoded}
-      
+ +
+          {decoded}
+        
+
); } diff --git a/frontend/src/components/ui/system-sheet.tsx b/frontend/src/components/ui/system-sheet.tsx index 1db3b17e..bd175439 100644 --- a/frontend/src/components/ui/system-sheet.tsx +++ b/frontend/src/components/ui/system-sheet.tsx @@ -53,6 +53,13 @@ export interface SystemSheetProps { footerContext?: React.ReactNode; size?: SystemSheetSize; + /** + * Skip wrapping the body in ``. Use when the sheet body manages its own + * scroll regions (e.g. multi-pane file browsers). The caller is responsible for + * keeping scrollbars to `` per §10 Scrollbars, AND for adding their own + * body padding (`noScroll` skips the default `px-6 py-5` wrapper). + */ + noScroll?: boolean; children?: React.ReactNode; } @@ -70,6 +77,7 @@ export function SystemSheet({ onTabChange, footerContext, size = 'md', + noScroll = false, children, }: SystemSheetProps) { const hasToolbar = !!(primaryAction || (secondaryActions && secondaryActions.length > 0) || destructiveAction); @@ -111,9 +119,13 @@ export function SystemSheet({ /> )} - -
{children}
-
+ {noScroll ? ( +
{children}
+ ) : ( + +
{children}
+
+ )} {hasFooter && }