mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(resources): render anonymous volume names readably in the volume browser (#1429)
Anonymous Docker volumes carry a raw 64-character hex name, which is correct data but unreadable in the volume browser crumb, sheet title, and table row. Detect 64-hex names and show a short prefix (e.g. 079dfda49f2c…) in the table cell and sheet title, with the full name available on hover and an "anonymous" chip so the truncation is self-explanatory. The volume browser sheet adds a band that keeps the full hash visible and copyable. Named volumes still display in full. The name is passed verbatim to the API; only the rendered label changes.
This commit is contained in:
@@ -38,6 +38,7 @@ import { ReclaimHero } from './resources/ReclaimHero';
|
||||
import { FootprintTreemap } from './resources/FootprintTreemap';
|
||||
import { ImageDetailsSheet } from './resources/ImageDetailsSheet';
|
||||
import { VolumeBrowserSheet } from './resources/VolumeBrowserSheet';
|
||||
import { VolumeNameLabel } from './resources/VolumeNameLabel';
|
||||
import { NetworkDetailSheet, type NetworkInspectData } from './resources/NetworkDetailSheet';
|
||||
|
||||
const NetworkTopologyView = lazy(() => import('./NetworkTopologyView'));
|
||||
@@ -1021,7 +1022,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
|
||||
className="animate-in fade-in-0 duration-200 hover:bg-muted/30 transition-colors"
|
||||
style={{ animationDelay: `${Math.min(i * 20, 200)}ms` }}
|
||||
>
|
||||
<TableCell className="font-mono text-xs max-w-[200px] truncate">{vol.Name}</TableCell>
|
||||
<TableCell className="font-mono text-xs max-w-[200px]"><VolumeNameLabel name={vol.Name} showChip /></TableCell>
|
||||
<TableCell><Badge variant="outline" className="text-[10px] h-5">{vol.Driver}</Badge></TableCell>
|
||||
<TableCell className="hidden md:table-cell text-xs text-muted-foreground truncate max-w-[300px]">{vol.Mountpoint}</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Copy } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
|
||||
// Anonymous volumes show a truncated name in the crumb and title; this band keeps
|
||||
// the full 64-char hash visible and copyable for operators who need the real name.
|
||||
export function AnonymousNameBand({ name }: { name: string }) {
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copyToClipboard(name);
|
||||
toast.success('Volume name copied.');
|
||||
} catch {
|
||||
toast.error('Copy failed.');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-6 pt-4 text-xs">
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded border border-border bg-muted/40 text-muted-foreground text-[10px] font-medium shrink-0">
|
||||
anonymous
|
||||
</span>
|
||||
<span className="font-mono text-muted-foreground break-all min-w-0">{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy full volume name"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import type { FileEntry } from '@/lib/stackFilesApi';
|
||||
import { listVolumeDirectory, readVolumeFile } from '@/lib/volumeApi';
|
||||
import type { VolumeFileResult } from '@/lib/volumeApi';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { isAnonymousVolumeName, shortVolumeLabel } from '@/lib/volumeName';
|
||||
import { VolumeNameLabel } from './VolumeNameLabel';
|
||||
import { AnonymousNameBand } from './AnonymousNameBand';
|
||||
|
||||
interface VolumeBrowserSheetProps {
|
||||
volumeName: string | null;
|
||||
@@ -72,8 +75,8 @@ export function VolumeBrowserSheet({ volumeName, onClose }: VolumeBrowserSheetPr
|
||||
<SystemSheet
|
||||
open={!!volumeName}
|
||||
onOpenChange={handleClose}
|
||||
crumb={['Resources', 'Volumes', volumeName ?? '—']}
|
||||
name={volumeName ?? 'Volume'}
|
||||
crumb={['Resources', 'Volumes', volumeName ? shortVolumeLabel(volumeName) : '-']}
|
||||
name={volumeName ? <VolumeNameLabel name={volumeName} /> : 'Volume'}
|
||||
meta={meta}
|
||||
primaryAction={{
|
||||
label: 'Refresh tree',
|
||||
@@ -85,37 +88,40 @@ export function VolumeBrowserSheet({ volumeName, onClose }: VolumeBrowserSheetPr
|
||||
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>
|
||||
<>
|
||||
{isAnonymousVolumeName(volumeName) && <AnonymousNameBand name={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>
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SystemSheet>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { isAnonymousVolumeName, shortVolumeLabel } from '@/lib/volumeName';
|
||||
|
||||
interface VolumeNameLabelProps {
|
||||
name: string;
|
||||
/** Append a small `anonymous` chip when the name is an anonymous volume. */
|
||||
showChip?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a volume name truncated to a readable prefix when anonymous, with the
|
||||
* full name available on hover. Named volumes display in full. Used in the
|
||||
* volumes table cell and the volume browser sheet title.
|
||||
*/
|
||||
export function VolumeNameLabel({ name, showChip = false }: VolumeNameLabelProps) {
|
||||
const anonymous = isAnonymousVolumeName(name);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 min-w-0 max-w-full align-middle">
|
||||
<span className="truncate" title={name} data-testid="volume-name-text">
|
||||
{shortVolumeLabel(name)}
|
||||
</span>
|
||||
{showChip && anonymous && (
|
||||
<span
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded border border-border bg-muted/40 text-muted-foreground text-[10px] font-medium shrink-0"
|
||||
data-testid="anon-volume-chip"
|
||||
>
|
||||
anonymous
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
const { copyMock, toastMock } = vi.hoisted(() => ({
|
||||
copyMock: vi.fn(),
|
||||
toastMock: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: copyMock }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({ toast: toastMock }));
|
||||
|
||||
import { AnonymousNameBand } from '../AnonymousNameBand';
|
||||
|
||||
const ANON = '079dfda49f2c483f80f1d4f6b1865be55af54a0298507a0e588aae551134ba62';
|
||||
|
||||
describe('AnonymousNameBand', () => {
|
||||
beforeEach(() => {
|
||||
copyMock.mockReset();
|
||||
toastMock.success.mockReset();
|
||||
toastMock.error.mockReset();
|
||||
});
|
||||
|
||||
it('shows the full hash and an anonymous chip', () => {
|
||||
render(<AnonymousNameBand name={ANON} />);
|
||||
expect(screen.getByText(ANON)).toBeInTheDocument();
|
||||
expect(screen.getByText('anonymous')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies the full hash, not the truncated label, and toasts success', async () => {
|
||||
copyMock.mockResolvedValue(undefined);
|
||||
render(<AnonymousNameBand name={ANON} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy full volume name' }));
|
||||
await waitFor(() => expect(copyMock).toHaveBeenCalledWith(ANON));
|
||||
await waitFor(() => expect(toastMock.success).toHaveBeenCalled());
|
||||
expect(toastMock.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toasts an error when the copy fails', async () => {
|
||||
copyMock.mockRejectedValue(new Error('clipboard unavailable'));
|
||||
render(<AnonymousNameBand name={ANON} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy full volume name' }));
|
||||
await waitFor(() => expect(toastMock.error).toHaveBeenCalled());
|
||||
expect(toastMock.success).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { VolumeNameLabel } from '../VolumeNameLabel';
|
||||
|
||||
const ANON = '079dfda49f2c483f80f1d4f6b1865be55af54a0298507a0e588aae551134ba62';
|
||||
|
||||
describe('VolumeNameLabel', () => {
|
||||
it('truncates an anonymous name and exposes the full hash on hover', () => {
|
||||
render(<VolumeNameLabel name={ANON} showChip />);
|
||||
const text = screen.getByTestId('volume-name-text');
|
||||
expect(text).toHaveTextContent('079dfda49f2c…');
|
||||
expect(text).toHaveAttribute('title', ANON);
|
||||
expect(screen.getByTestId('anon-volume-chip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a named volume in full with no chip', () => {
|
||||
render(<VolumeNameLabel name="app_pgdata" showChip />);
|
||||
expect(screen.getByTestId('volume-name-text')).toHaveTextContent('app_pgdata');
|
||||
expect(screen.queryByTestId('anon-volume-chip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the chip when showChip is not set, but still truncates', () => {
|
||||
render(<VolumeNameLabel name={ANON} />);
|
||||
expect(screen.getByTestId('volume-name-text')).toHaveTextContent('079dfda49f2c…');
|
||||
expect(screen.queryByTestId('anon-volume-chip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAnonymousVolumeName, shortVolumeLabel } from '../volumeName';
|
||||
|
||||
const ANON = '079dfda49f2c483f80f1d4f6b1865be55af54a0298507a0e588aae551134ba62';
|
||||
|
||||
describe('isAnonymousVolumeName', () => {
|
||||
it('treats a 64 lowercase hex name as anonymous', () => {
|
||||
expect(isAnonymousVolumeName(ANON)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a friendly named volume as not anonymous', () => {
|
||||
expect(isAnonymousVolumeName('app_pgdata')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects names that are not exactly 64 chars', () => {
|
||||
expect(isAnonymousVolumeName(ANON.slice(0, 63))).toBe(false);
|
||||
expect(isAnonymousVolumeName(ANON + 'a')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects uppercase hex and non-hex characters', () => {
|
||||
expect(isAnonymousVolumeName(ANON.toUpperCase())).toBe(false);
|
||||
expect(isAnonymousVolumeName('z'.repeat(64))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortVolumeLabel', () => {
|
||||
it('truncates an anonymous name to a 12-char prefix plus an ellipsis', () => {
|
||||
expect(shortVolumeLabel(ANON)).toBe('079dfda49f2c…');
|
||||
});
|
||||
|
||||
it('returns a named volume verbatim', () => {
|
||||
expect(shortVolumeLabel('app_pgdata')).toBe('app_pgdata');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// Helpers for rendering Docker volume names readably in the Resources Hub.
|
||||
//
|
||||
// A standard Docker anonymous volume name is exactly 64 lowercase hex characters
|
||||
// (the same shape as a SHA-256 digest). Named volumes are arbitrary strings.
|
||||
//
|
||||
// Note: a user who deliberately names a volume with 64 lowercase hex characters
|
||||
// is also treated as anonymous for display only. Nothing is lost: the full name
|
||||
// stays available via the title tooltip (and the copy button in the volume
|
||||
// browser sheet), and it is always passed verbatim to the API.
|
||||
|
||||
const ANON_VOLUME_RE = /^[0-9a-f]{64}$/;
|
||||
|
||||
/** True when the name looks like an anonymous Docker volume (64 lowercase hex chars). */
|
||||
export function isAnonymousVolumeName(name: string): boolean {
|
||||
return ANON_VOLUME_RE.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, readable label for a volume name. Anonymous names are truncated to a
|
||||
* 12-character prefix with an ellipsis (e.g. `079dfda49f2c…`); named volumes are
|
||||
* returned verbatim so friendly names keep displaying in full.
|
||||
*/
|
||||
export function shortVolumeLabel(name: string): string {
|
||||
return isAnonymousVolumeName(name) ? `${name.slice(0, 12)}…` : name;
|
||||
}
|
||||
Reference in New Issue
Block a user