diff --git a/decnet_web/src/components/CanaryTokens/BlobListView.test.tsx b/decnet_web/src/components/CanaryTokens/BlobListView.test.tsx new file mode 100644 index 00000000..5f8c94be --- /dev/null +++ b/decnet_web/src/components/CanaryTokens/BlobListView.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BlobListView } from './BlobListView'; +import { makeCanaryBlob } from '../../test/fixtures'; + +describe('BlobListView', () => { + it('shows the empty hint when blobs is []', () => { + render( {}} />); + expect(screen.getByText(/No uploaded artifacts/)).toBeInTheDocument(); + }); + + it('renders DELETE for blobs with no token refs and the ref count otherwise', () => { + render( + {}} + />, + ); + expect(screen.getByText('DELETE')).toBeInTheDocument(); + expect(screen.getByText('3 REFS')).toBeInTheDocument(); + }); + + it('clicking DELETE invokes onDelete with the blob uuid', async () => { + const onDelete = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByText('DELETE')); + expect(onDelete).toHaveBeenCalledWith('b-abc'); + }); + + it('refused DELETE button is disabled when token_count > 0', () => { + render( + {}} + />, + ); + const refsBtn = screen.getByText('2 REFS') as HTMLButtonElement; + expect(refsBtn.disabled).toBe(true); + }); +}); diff --git a/decnet_web/src/components/CanaryTokens/BlobListView.tsx b/decnet_web/src/components/CanaryTokens/BlobListView.tsx new file mode 100644 index 00000000..c38ca8dd --- /dev/null +++ b/decnet_web/src/components/CanaryTokens/BlobListView.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import type { BlobRow } from './types'; +import { fmt, fmtBytes } from './helpers'; + +interface Props { + blobs: BlobRow[]; + onDelete: (uuid: string) => void; +} + +/** Blobs tab: flat row grid. The DELETE button stays disabled while + * any token still references the blob (the server would refuse the + * request anyway; we surface that to the operator preemptively). */ +export const BlobListView: React.FC = ({ blobs, onDelete }) => ( + <> + {blobs.length === 0 && ( +
+ No uploaded artifacts. Click UPLOAD ARTIFACT to add one. +
+ )} +
+ {blobs.map((b) => ( +
+ + {b.filename} + + {b.content_type} + {fmtBytes(b.size_bytes)} + {fmt(b.uploaded_at)} + +
+ ))} +
+ +); diff --git a/decnet_web/src/components/CanaryTokens/FileDropListView.test.tsx b/decnet_web/src/components/CanaryTokens/FileDropListView.test.tsx new file mode 100644 index 00000000..6bffcb3a --- /dev/null +++ b/decnet_web/src/components/CanaryTokens/FileDropListView.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FileDropListView } from './FileDropListView'; +import type { FileDropEntry } from './FileDropModal'; + +const entry = (overrides: Partial = {}): FileDropEntry => ({ + id: 'fd-1', + decky_name: 'decoy-99', + topology_id: null, + path: '/tmp/payload.bin', + size_bytes: 4096, + filename: 'payload.bin', + mode: 0o644, + mtime_offset: 0, + dropped_at: '2026-05-09T11:00:00Z', + ...overrides, +}); + +describe('FileDropListView', () => { + it('shows the empty hint when fileDrops is []', () => { + render( {}} />); + expect(screen.getByText(/No file drops in this browser yet/)).toBeInTheDocument(); + }); + + it('hides CLEAR LIST when there are no entries', () => { + render( {}} />); + expect(screen.queryByText('CLEAR LIST')).not.toBeInTheDocument(); + }); + + it('renders one row per drop with its path + decky name', () => { + render( + {}} />, + ); + expect(screen.getByText('decoy-99')).toBeInTheDocument(); + expect(screen.getByText('/tmp/payload.bin')).toBeInTheDocument(); + }); + + it('CLEAR LIST invokes onClear', async () => { + const onClear = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByText('CLEAR LIST')); + expect(onClear).toHaveBeenCalled(); + }); +}); diff --git a/decnet_web/src/components/CanaryTokens/FileDropListView.tsx b/decnet_web/src/components/CanaryTokens/FileDropListView.tsx new file mode 100644 index 00000000..56e94f12 --- /dev/null +++ b/decnet_web/src/components/CanaryTokens/FileDropListView.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import type { FileDropEntry } from './FileDropModal'; +import { fmt, fmtBytes } from './helpers'; + +interface Props { + fileDrops: FileDropEntry[]; + onClear: () => void; +} + +/** File-drops tab: local-only log of past drops (the server does not + * persist these; this is purely an operator memory aid). The CLEAR + * LIST button only wipes the browser-side history — actual dropped + * files remain on the targeted decky. */ +export const FileDropListView: React.FC = ({ fileDrops, onClear }) => ( + <> +
+
+ Local log only — the server doesn't persist file drops. + Cleared when you clear browser storage. +
+ {fileDrops.length > 0 && ( + + )} +
+ {fileDrops.length === 0 && ( +
+ No file drops in this browser yet. Click DROP FILE to send bytes to a decky. +
+ )} +
+ {fileDrops.map((fd) => ( +
+ + {fd.topology_id ? 'topology' : 'fleet'} + + {fd.decky_name} + + {fd.path} + + {fmtBytes(fd.size_bytes)} + {fd.mode.toString(8)} + {fmt(fd.dropped_at)} +
+ ))} +
+ +); diff --git a/decnet_web/src/components/CanaryTokens/TokenListView.test.tsx b/decnet_web/src/components/CanaryTokens/TokenListView.test.tsx new file mode 100644 index 00000000..1de97ba8 --- /dev/null +++ b/decnet_web/src/components/CanaryTokens/TokenListView.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TokenListView } from './TokenListView'; +import { makeCanaryToken } from '../../test/fixtures'; + +const baseProps = { + loading: false, + error: null, + filter: '', + setFilter: () => {}, + stateFilter: 'all' as const, + setStateFilter: () => {}, + scopeFilter: 'all' as const, + setScopeFilter: () => {}, + onPick: () => {}, +}; + +describe('TokenListView', () => { + it('renders one row per token', () => { + render( + , + ); + expect(screen.getByText('decoy-01')).toBeInTheDocument(); + expect(screen.getByText('decoy-02')).toBeInTheDocument(); + }); + + it('shows the empty-fleet hint when tokens is []', () => { + render(); + expect(screen.getByText(/No canary tokens yet/)).toBeInTheDocument(); + }); + + it('shows the filtered-empty hint when filter excludes all rows', () => { + render( + , + ); + expect(screen.getByText('No tokens match the current filter.')).toBeInTheDocument(); + }); + + it('respects stateFilter', () => { + render( + , + ); + expect(screen.getByText('planted-d')).toBeInTheDocument(); + expect(screen.queryByText('revoked-d')).not.toBeInTheDocument(); + }); + + it('clicking a row invokes onPick with the token', async () => { + const onPick = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByText('decoy-01')); + expect(onPick).toHaveBeenCalled(); + expect(onPick.mock.calls[0][0].uuid).toBe('t1'); + }); +}); diff --git a/decnet_web/src/components/CanaryTokens/TokenListView.tsx b/decnet_web/src/components/CanaryTokens/TokenListView.tsx new file mode 100644 index 00000000..54ab6749 --- /dev/null +++ b/decnet_web/src/components/CanaryTokens/TokenListView.tsx @@ -0,0 +1,148 @@ +import React, { useMemo } from 'react'; +import { Search } from '../../icons'; +import type { CanaryTokenRow } from '../CanaryTokenDrawer'; +import { STATE_COLOR } from './types'; +import { INPUT_STYLE } from './ui'; + +export type StateFilter = 'all' | 'planted' | 'revoked' | 'failed'; +export type ScopeFilter = 'all' | 'fleet' | 'topology'; + +interface Props { + tokens: CanaryTokenRow[]; + loading: boolean; + error: string | null; + filter: string; + setFilter: (s: string) => void; + stateFilter: StateFilter; + setStateFilter: (s: StateFilter) => void; + scopeFilter: ScopeFilter; + setScopeFilter: (s: ScopeFilter) => void; + onPick: (t: CanaryTokenRow) => void; +} + +/** Tokens tab: text search + state/scope filter selectors over a + * flat row grid. Clicking a row passes the token up to the page, + * which opens the CanaryTokenDrawer. */ +export const TokenListView: React.FC = ({ + tokens, loading, error, + filter, setFilter, + stateFilter, setStateFilter, + scopeFilter, setScopeFilter, + onPick, +}) => { + const visibleTokens = useMemo(() => { + return tokens.filter((t) => { + if (stateFilter !== 'all' && t.state !== stateFilter) return false; + if (scopeFilter === 'fleet' && t.topology_id) return false; + if (scopeFilter === 'topology' && !t.topology_id) return false; + if (!filter) return true; + const f = filter.toLowerCase(); + return ( + t.decky_name.toLowerCase().includes(f) || + t.placement_path.toLowerCase().includes(f) || + t.callback_token.toLowerCase().includes(f) || + (t.generator || '').toLowerCase().includes(f) || + (t.instrumenter || '').toLowerCase().includes(f) || + (t.topology_id || '').toLowerCase().includes(f) + ); + }); + }, [tokens, filter, stateFilter, scopeFilter]); + + return ( + <> +
+
+ + setFilter(e.target.value)} + placeholder="Filter by decky / path / slug / generator…" + style={{ ...INPUT_STYLE, paddingLeft: '32px', marginBottom: 0 }} + /> +
+ + +
+ + {loading &&
loading…
} + {error &&
{error}
} + {!loading && visibleTokens.length === 0 && ( +
+ {tokens.length === 0 + ? 'No canary tokens yet. Click NEW TOKEN to plant one, or UPLOAD ARTIFACT to start with an operator-supplied document.' + : 'No tokens match the current filter.'} +
+ )} +
+ {visibleTokens.map((t) => ( + + ))} +
+ + ); +};