mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
feat(resources): add image details sheet with layer history (#925)
Adds a read-only inspect panel for Docker images. Click the eye icon on any image row to open a sheet showing: - Overview: ID (with copy), size, created date, arch/OS, author, tags - Config: Cmd, Entrypoint, WorkingDir, User, exposed ports, env (collapsible), labels (collapsible) - Layers: ordered history list with size, age, and build command per layer. Empty layers (metadata-only) are dimmed. Backend adds DockerController.inspectImage(id) which combines image.inspect() and image.history() in parallel, exposed via GET /api/system/images/:id. The route accepts both bare hex IDs and sha256-prefixed IDs, since the list endpoint surfaces the prefixed form. Returns 400 for malformed IDs and 404 for missing images. Documents the new panel in docs/features/resources.mdx under Images.
This commit is contained in:
@@ -562,6 +562,59 @@ describe('DockerController - inspectNetwork edge cases', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- inspectImage --------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('DockerController - inspectImage', () => {
|
||||||
|
it('returns combined inspect + history payload', async () => {
|
||||||
|
const inspectData = {
|
||||||
|
Id: 'sha256:abc12345',
|
||||||
|
RepoTags: ['nginx:1.27'],
|
||||||
|
Size: 187_000_000,
|
||||||
|
Architecture: 'amd64',
|
||||||
|
Os: 'linux',
|
||||||
|
Config: { Cmd: ['nginx', '-g', 'daemon off;'] },
|
||||||
|
};
|
||||||
|
const historyData = [
|
||||||
|
{ Id: 'layer1', Created: 1700000000, CreatedBy: '/bin/sh -c #(nop) ADD file:abc', Size: 72_000_000, Tags: null, Comment: '' },
|
||||||
|
{ Id: 'layer2', Created: 1700000010, CreatedBy: 'ENV NGINX_VERSION=1.27', Size: 0, Tags: null, Comment: '' },
|
||||||
|
];
|
||||||
|
mockDocker.getImage.mockReturnValue({
|
||||||
|
inspect: vi.fn().mockResolvedValue(inspectData),
|
||||||
|
history: vi.fn().mockResolvedValue(historyData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dc = DockerController.getInstance(1);
|
||||||
|
const result = await dc.inspectImage('sha256:abc12345');
|
||||||
|
|
||||||
|
expect(result.inspect).toEqual(inspectData);
|
||||||
|
expect(result.history).toEqual(historyData);
|
||||||
|
expect(mockDocker.getImage).toHaveBeenCalledWith('sha256:abc12345');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates 404 from Dockerode when image is missing', async () => {
|
||||||
|
mockDocker.getImage.mockReturnValue({
|
||||||
|
inspect: vi.fn().mockRejectedValue(Object.assign(new Error('No such image: missing'), { statusCode: 404 })),
|
||||||
|
history: vi.fn().mockResolvedValue([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dc = DockerController.getInstance(1);
|
||||||
|
await expect(dc.inspectImage('missing')).rejects.toThrow('No such image');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty history when an image has none', async () => {
|
||||||
|
mockDocker.getImage.mockReturnValue({
|
||||||
|
inspect: vi.fn().mockResolvedValue({ Id: 'sha256:empty', Size: 0 }),
|
||||||
|
history: vi.fn().mockResolvedValue([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dc = DockerController.getInstance(1);
|
||||||
|
const result = await dc.inspectImage('sha256:empty');
|
||||||
|
|
||||||
|
expect(result.history).toEqual([]);
|
||||||
|
expect(result.inspect.Id).toBe('sha256:empty');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// --- createNetwork validation --------------------------------------------------
|
// --- createNetwork validation --------------------------------------------------
|
||||||
|
|
||||||
describe('createNetwork', () => {
|
describe('createNetwork', () => {
|
||||||
|
|||||||
@@ -135,6 +135,25 @@ systemMaintenanceRouter.get('/networks', async (req: Request, res: Response) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
systemMaintenanceRouter.get('/images/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const rawId = req.params.id as string;
|
||||||
|
if (!rawId) return res.status(400).json({ error: 'Invalid image ID format' });
|
||||||
|
const hexId = rawId.startsWith('sha256:') ? rawId.slice('sha256:'.length) : rawId;
|
||||||
|
if (!isValidDockerResourceId(hexId)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid image ID format' });
|
||||||
|
}
|
||||||
|
const result = await DockerController.getInstance(req.nodeId).inspectImage(hexId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Failed to inspect image:', error);
|
||||||
|
const err = error as Record<string, unknown>;
|
||||||
|
const is404 = (typeof err.statusCode === 'number' && err.statusCode === 404)
|
||||||
|
|| (error instanceof Error && error.message.includes('404'));
|
||||||
|
res.status(is404 ? 404 : 500).json({ error: is404 ? 'Image not found' : 'Failed to inspect image' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
systemMaintenanceRouter.post('/images/delete', async (req: Request, res: Response) => {
|
systemMaintenanceRouter.post('/images/delete', async (req: Request, res: Response) => {
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -435,6 +435,12 @@ class DockerController {
|
|||||||
await image.remove({ force: true });
|
await image.remove({ force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async inspectImage(id: string) {
|
||||||
|
const image = this.docker.getImage(id);
|
||||||
|
const [inspect, history] = await Promise.all([image.inspect(), image.history()]);
|
||||||
|
return { inspect, history };
|
||||||
|
}
|
||||||
|
|
||||||
public async removeVolume(name: string) {
|
public async removeVolume(name: string) {
|
||||||
const volume = this.docker.getVolume(name);
|
const volume = this.docker.getVolume(name);
|
||||||
await volume.remove({ force: true });
|
await volume.remove({ force: true });
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ Lists all Docker images on the host with their ID, repository tag, size, and sta
|
|||||||
|
|
||||||
Click the trash icon on any row to delete an individual image. Sencho will warn you if the image is in use.
|
Click the trash icon on any row to delete an individual image. Sencho will warn you if the image is in use.
|
||||||
|
|
||||||
|
#### Inspect image
|
||||||
|
|
||||||
|
Click the eye icon on any image row to open a detail panel showing:
|
||||||
|
|
||||||
|
- **Overview** - ID, size, creation date, architecture and OS, author, and all repository tags
|
||||||
|
- **Config** - Default `Cmd`, `Entrypoint`, `WorkingDir`, `User`, exposed ports, environment variables, and labels (env and labels are collapsible)
|
||||||
|
- **Layers** - Ordered layer history. Each row shows layer index, size, age, and the build command (`CreatedBy`). Empty layers (metadata-only, zero bytes) are dimmed.
|
||||||
|
|
||||||
|
The inspect panel is read-only and available to all users.
|
||||||
|
|
||||||
### Volumes
|
### Volumes
|
||||||
|
|
||||||
Lists all Docker volumes. Columns: name, driver, mount point, size, and managed status.
|
Lists all Docker volumes. Columns: name, driver, mount point, size, and managed status.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type { SenchoOpenLogsDetail } from '@/lib/events';
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
import { ReclaimHero } from './resources/ReclaimHero';
|
import { ReclaimHero } from './resources/ReclaimHero';
|
||||||
import { FootprintTreemap } from './resources/FootprintTreemap';
|
import { FootprintTreemap } from './resources/FootprintTreemap';
|
||||||
|
import { ImageDetailsSheet } from './resources/ImageDetailsSheet';
|
||||||
import { TabLanding, type TabLandingEntry } from './resources/TabLanding';
|
import { TabLanding, type TabLandingEntry } from './resources/TabLanding';
|
||||||
|
|
||||||
const NetworkTopologyView = lazy(() => import('./NetworkTopologyView'));
|
const NetworkTopologyView = lazy(() => import('./NetworkTopologyView'));
|
||||||
@@ -392,6 +393,7 @@ export default function ResourcesView() {
|
|||||||
const [isCreatingNetwork, setIsCreatingNetwork] = useState(false);
|
const [isCreatingNetwork, setIsCreatingNetwork] = useState(false);
|
||||||
const [inspectNetwork, setInspectNetwork] = useState<NetworkInspectData | null>(null);
|
const [inspectNetwork, setInspectNetwork] = useState<NetworkInspectData | null>(null);
|
||||||
const [inspectLoadingId, setInspectLoadingId] = useState<string | null>(null);
|
const [inspectLoadingId, setInspectLoadingId] = useState<string | null>(null);
|
||||||
|
const [inspectImageId, setInspectImageId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Unmanaged container state
|
// Unmanaged container state
|
||||||
const [selectedOrphans, setSelectedOrphans] = useState<string[]>([]);
|
const [selectedOrphans, setSelectedOrphans] = useState<string[]>([]);
|
||||||
@@ -858,6 +860,16 @@ export default function ResourcesView() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => setInspectImageId(img.Id)}
|
||||||
|
title="Inspect image"
|
||||||
|
aria-label={`Inspect ${img.RepoTags?.[0] || 'image'}`}
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||||
|
</Button>
|
||||||
{trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>' && (
|
{trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>' && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -1333,6 +1345,9 @@ export default function ResourcesView() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Image Details Sheet */}
|
||||||
|
<ImageDetailsSheet imageId={inspectImageId} onClose={() => setInspectImageId(null)} />
|
||||||
|
|
||||||
{/* Network Inspect Sheet */}
|
{/* Network Inspect Sheet */}
|
||||||
<Sheet open={!!inspectNetwork} onOpenChange={open => !open && setInspectNetwork(null)}>
|
<Sheet open={!!inspectNetwork} onOpenChange={open => !open && setInspectNetwork(null)}>
|
||||||
<SheetContent className="sm:max-w-lg">
|
<SheetContent className="sm:max-w-lg">
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
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';
|
||||||
|
|
||||||
|
interface ImageInspect {
|
||||||
|
Id: string;
|
||||||
|
RepoTags?: string[] | null;
|
||||||
|
RepoDigests?: string[] | null;
|
||||||
|
Created: string;
|
||||||
|
Size: number;
|
||||||
|
Architecture?: string;
|
||||||
|
Os?: string;
|
||||||
|
Author?: string;
|
||||||
|
Config?: {
|
||||||
|
Cmd?: string[] | null;
|
||||||
|
Entrypoint?: string[] | null;
|
||||||
|
WorkingDir?: string;
|
||||||
|
User?: string;
|
||||||
|
Env?: string[] | null;
|
||||||
|
Labels?: Record<string, string> | null;
|
||||||
|
ExposedPorts?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
RootFS?: { Type?: string; Layers?: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageHistoryEntry {
|
||||||
|
Id: string;
|
||||||
|
Created: number;
|
||||||
|
CreatedBy: string;
|
||||||
|
Tags?: string[] | null;
|
||||||
|
Size: number;
|
||||||
|
Comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageDetails {
|
||||||
|
inspect: ImageInspect;
|
||||||
|
history: ImageHistoryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageDetailsSheetProps {
|
||||||
|
imageId: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeAge(timestampSec: number): string {
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const diff = now - timestampSec;
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
if (diff < 86400 * 30) return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
if (diff < 86400 * 365) return `${Math.floor(diff / (86400 * 30))}mo ago`;
|
||||||
|
return `${Math.floor(diff / (86400 * 365))}y ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDigest(id: string): string {
|
||||||
|
const colon = id.indexOf(':');
|
||||||
|
const hex = colon >= 0 ? id.slice(colon + 1) : id;
|
||||||
|
return hex.substring(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageDetailsSheet({ imageId, onClose }: ImageDetailsSheetProps) {
|
||||||
|
const [data, setData] = useState<ImageDetails | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageId) {
|
||||||
|
setData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setData(null);
|
||||||
|
|
||||||
|
apiFetch(`/system/images/${encodeURIComponent(imageId)}`)
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(res.status === 404 ? 'Image not found.' : 'Failed to load image details.');
|
||||||
|
}
|
||||||
|
return res.json() as Promise<ImageDetails>;
|
||||||
|
})
|
||||||
|
.then((details) => {
|
||||||
|
if (!cancelled) setData(details);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const msg = err instanceof Error ? err.message : 'Failed to load image details.';
|
||||||
|
toast.error(msg);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [imageId]);
|
||||||
|
|
||||||
|
const inspect = data?.inspect;
|
||||||
|
const history = data?.history ?? [];
|
||||||
|
const totalLayers = history.length;
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children, span }: { label: string; children: React.ReactNode; span?: 1 | 2 }) {
|
||||||
|
return (
|
||||||
|
<div className={span === 2 ? 'col-span-2' : undefined}>
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigRow({ label, value }: { label: string; value?: string }) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[100px_1fr] gap-3 items-baseline">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span className="font-mono text-[11px] break-all">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleList({ label, count, items }: { label: string; count: number; items: string[] }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[100px_1fr] gap-3 items-baseline">
|
||||||
|
<span className="text-xs text-muted-foreground">{label} ({count})</span>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors underline-offset-2 hover:underline"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{open ? 'Collapse' : 'Expand'}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<ol className="mt-1.5 space-y-0.5">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<li key={i} className="font-mono text-[11px] break-all">{item}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user