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 && }