From 401980ffa375f70a3433c784bb992262e23328f0 Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 24 Jun 2026 19:05:40 -0400 Subject: [PATCH] feat(resources): render anonymous volume names readably in the volume browser (#1429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/ResourcesView.tsx | 3 +- .../resources/AnonymousNameBand.tsx | 32 +++++++++ .../resources/VolumeBrowserSheet.tsx | 68 ++++++++++--------- .../components/resources/VolumeNameLabel.tsx | 31 +++++++++ .../__tests__/AnonymousNameBand.test.tsx | 44 ++++++++++++ .../__tests__/VolumeNameLabel.test.tsx | 27 ++++++++ frontend/src/lib/__tests__/volumeName.test.ts | 34 ++++++++++ frontend/src/lib/volumeName.ts | 25 +++++++ 8 files changed, 232 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/resources/AnonymousNameBand.tsx create mode 100644 frontend/src/components/resources/VolumeNameLabel.tsx create mode 100644 frontend/src/components/resources/__tests__/AnonymousNameBand.test.tsx create mode 100644 frontend/src/components/resources/__tests__/VolumeNameLabel.test.tsx create mode 100644 frontend/src/lib/__tests__/volumeName.test.ts create mode 100644 frontend/src/lib/volumeName.ts diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index a3a9d6e6..a1832585 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -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` }} > - {vol.Name} + {vol.Driver} {vol.Mountpoint} diff --git a/frontend/src/components/resources/AnonymousNameBand.tsx b/frontend/src/components/resources/AnonymousNameBand.tsx new file mode 100644 index 00000000..09263b5e --- /dev/null +++ b/frontend/src/components/resources/AnonymousNameBand.tsx @@ -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 ( +
+ + anonymous + + {name} + +
+ ); +} diff --git a/frontend/src/components/resources/VolumeBrowserSheet.tsx b/frontend/src/components/resources/VolumeBrowserSheet.tsx index 488d406f..12522325 100644 --- a/frontend/src/components/resources/VolumeBrowserSheet.tsx +++ b/frontend/src/components/resources/VolumeBrowserSheet.tsx @@ -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 : 'Volume'} meta={meta} primaryAction={{ label: 'Refresh tree', @@ -85,37 +88,40 @@ export function VolumeBrowserSheet({ volumeName, onClose }: VolumeBrowserSheetPr noScroll > {volumeName && ( -
-
- -
+ <> + {isAnonymousVolumeName(volumeName) && } +
+
+ +
-
- {!selectedPath && ( -
- Select a file to preview. -
- )} - {selectedPath && fileLoading && ( -
- - - - -
- )} - {selectedPath && !fileLoading && fileResult && ( - - )} +
+ {!selectedPath && ( +
+ Select a file to preview. +
+ )} + {selectedPath && fileLoading && ( +
+ + + + +
+ )} + {selectedPath && !fileLoading && fileResult && ( + + )} +
-
+ )} ); diff --git a/frontend/src/components/resources/VolumeNameLabel.tsx b/frontend/src/components/resources/VolumeNameLabel.tsx new file mode 100644 index 00000000..5189dca6 --- /dev/null +++ b/frontend/src/components/resources/VolumeNameLabel.tsx @@ -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 ( + + + {shortVolumeLabel(name)} + + {showChip && anonymous && ( + + anonymous + + )} + + ); +} diff --git a/frontend/src/components/resources/__tests__/AnonymousNameBand.test.tsx b/frontend/src/components/resources/__tests__/AnonymousNameBand.test.tsx new file mode 100644 index 00000000..268bccf5 --- /dev/null +++ b/frontend/src/components/resources/__tests__/AnonymousNameBand.test.tsx @@ -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(); + 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(); + 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(); + fireEvent.click(screen.getByRole('button', { name: 'Copy full volume name' })); + await waitFor(() => expect(toastMock.error).toHaveBeenCalled()); + expect(toastMock.success).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/resources/__tests__/VolumeNameLabel.test.tsx b/frontend/src/components/resources/__tests__/VolumeNameLabel.test.tsx new file mode 100644 index 00000000..7ced12f1 --- /dev/null +++ b/frontend/src/components/resources/__tests__/VolumeNameLabel.test.tsx @@ -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(); + 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(); + 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(); + expect(screen.getByTestId('volume-name-text')).toHaveTextContent('079dfda49f2c…'); + expect(screen.queryByTestId('anon-volume-chip')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/__tests__/volumeName.test.ts b/frontend/src/lib/__tests__/volumeName.test.ts new file mode 100644 index 00000000..3fe4dd18 --- /dev/null +++ b/frontend/src/lib/__tests__/volumeName.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/volumeName.ts b/frontend/src/lib/volumeName.ts new file mode 100644 index 00000000..75c8a6f7 --- /dev/null +++ b/frontend/src/lib/volumeName.ts @@ -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; +}