refactor(decnet_web/CanaryTokens): extract list views
Lift the three tab bodies — tokens, blobs, file drops — into their own files. Each takes plain props (data + the operations its rows need), so the page shell stops mixing tab markup with data plumbing. - New CanaryTokens/TokenListView.tsx (text search + state/scope filter selectors + flat row grid; visibleTokens memo lives here now). Exports StateFilter / ScopeFilter union types so the page can declare its filter useState with the right shape. - New CanaryTokens/BlobListView.tsx (delete refused while a token references a blob; ref count badge reuses the disabled button). - New CanaryTokens/FileDropListView.tsx (CLEAR LIST hidden when the local log is empty). - Three companion tests cover empty states, filter behavior, delete refused-vs-allowed, and the per-tab callback wiring. Wiring into CanaryTokens.tsx + the hook lands next.
This commit is contained in:
50
decnet_web/src/components/CanaryTokens/BlobListView.test.tsx
Normal file
50
decnet_web/src/components/CanaryTokens/BlobListView.test.tsx
Normal file
@@ -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(<BlobListView blobs={[]} onDelete={() => {}} />);
|
||||
expect(screen.getByText(/No uploaded artifacts/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders DELETE for blobs with no token refs and the ref count otherwise', () => {
|
||||
render(
|
||||
<BlobListView
|
||||
blobs={[
|
||||
makeCanaryBlob({ uuid: 'b1', filename: 'free.bin', token_count: 0 }),
|
||||
makeCanaryBlob({ uuid: 'b2', filename: 'used.bin', token_count: 3 }),
|
||||
]}
|
||||
onDelete={() => {}}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<BlobListView
|
||||
blobs={[makeCanaryBlob({ uuid: 'b-abc', token_count: 0 })]}
|
||||
onDelete={onDelete}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText('DELETE'));
|
||||
expect(onDelete).toHaveBeenCalledWith('b-abc');
|
||||
});
|
||||
|
||||
it('refused DELETE button is disabled when token_count > 0', () => {
|
||||
render(
|
||||
<BlobListView
|
||||
blobs={[makeCanaryBlob({ uuid: 'b-locked', token_count: 2 })]}
|
||||
onDelete={() => {}}
|
||||
/>,
|
||||
);
|
||||
const refsBtn = screen.getByText('2 REFS') as HTMLButtonElement;
|
||||
expect(refsBtn.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
58
decnet_web/src/components/CanaryTokens/BlobListView.tsx
Normal file
58
decnet_web/src/components/CanaryTokens/BlobListView.tsx
Normal file
@@ -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<Props> = ({ blobs, onDelete }) => (
|
||||
<>
|
||||
{blobs.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', opacity: 0.6, fontSize: '0.85rem' }}>
|
||||
No uploaded artifacts. Click UPLOAD ARTIFACT to add one.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{blobs.map((b) => (
|
||||
<div
|
||||
key={b.uuid}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 220px 90px 100px 80px',
|
||||
alignItems: 'center', gap: '12px',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
background: 'var(--matrix-tint-5)',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{b.filename}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7, fontFamily: 'monospace' }}>{b.content_type}</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>{fmtBytes(b.size_bytes)}</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>{fmt(b.uploaded_at)}</span>
|
||||
<button
|
||||
onClick={() => onDelete(b.uuid)}
|
||||
disabled={b.token_count > 0}
|
||||
title={b.token_count > 0 ? `${b.token_count} token(s) still reference this blob` : 'Delete'}
|
||||
style={{
|
||||
background: 'transparent', color: b.token_count > 0 ? 'var(--dim-color)' : '#ff5555',
|
||||
border: `1px solid ${b.token_count > 0 ? 'var(--dim-color)' : '#ff5555'}`,
|
||||
padding: '4px 8px', fontSize: '0.7rem',
|
||||
cursor: b.token_count > 0 ? 'not-allowed' : 'pointer',
|
||||
opacity: b.token_count > 0 ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{b.token_count > 0 ? `${b.token_count} REFS` : 'DELETE'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -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> = {}): 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(<FileDropListView fileDrops={[]} onClear={() => {}} />);
|
||||
expect(screen.getByText(/No file drops in this browser yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides CLEAR LIST when there are no entries', () => {
|
||||
render(<FileDropListView fileDrops={[]} onClear={() => {}} />);
|
||||
expect(screen.queryByText('CLEAR LIST')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders one row per drop with its path + decky name', () => {
|
||||
render(
|
||||
<FileDropListView fileDrops={[entry()]} onClear={() => {}} />,
|
||||
);
|
||||
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(
|
||||
<FileDropListView fileDrops={[entry()]} onClear={onClear} />,
|
||||
);
|
||||
await user.click(screen.getByText('CLEAR LIST'));
|
||||
expect(onClear).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
85
decnet_web/src/components/CanaryTokens/FileDropListView.tsx
Normal file
85
decnet_web/src/components/CanaryTokens/FileDropListView.tsx
Normal file
@@ -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<Props> = ({ fileDrops, onClear }) => (
|
||||
<>
|
||||
<div style={{
|
||||
display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '12px',
|
||||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
||||
Local log only — the server doesn't persist file drops.
|
||||
Cleared when you clear browser storage.
|
||||
</div>
|
||||
{fileDrops.length > 0 && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
border: '1px solid var(--dim-color)',
|
||||
background: 'transparent', color: 'var(--dim-color)',
|
||||
fontSize: '0.7rem', cursor: 'pointer',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
CLEAR LIST
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{fileDrops.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', opacity: 0.6, fontSize: '0.85rem' }}>
|
||||
No file drops in this browser yet. Click DROP FILE to send bytes to a decky.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{fileDrops.map((fd) => (
|
||||
<div
|
||||
key={fd.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '80px 140px 1fr 90px 80px 140px',
|
||||
alignItems: 'center', gap: '12px',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
background: 'var(--matrix-tint-5)',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
title={fd.topology_id ? `topology ${fd.topology_id}` : 'fleet'}
|
||||
style={{
|
||||
fontSize: '0.65rem', letterSpacing: '0.05em',
|
||||
padding: '2px 6px',
|
||||
border: `1px solid ${fd.topology_id ? 'var(--accent-color, #00ff88)' : 'var(--dim-color)'}`,
|
||||
color: fd.topology_id ? 'var(--accent-color, #00ff88)' : 'var(--dim-color)',
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{fd.topology_id ? 'topology' : 'fleet'}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace' }}>{fd.decky_name}</span>
|
||||
<span
|
||||
title={`${fd.filename} → ${fd.path}`}
|
||||
style={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{fd.path}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>{fmtBytes(fd.size_bytes)}</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7, fontFamily: 'monospace' }}>{fd.mode.toString(8)}</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>{fmt(fd.dropped_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -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(
|
||||
<TokenListView
|
||||
{...baseProps}
|
||||
tokens={[
|
||||
makeCanaryToken({ uuid: 't1', decky_name: 'decoy-01' }),
|
||||
makeCanaryToken({ uuid: 't2', decky_name: 'decoy-02' }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('decoy-01')).toBeInTheDocument();
|
||||
expect(screen.getByText('decoy-02')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty-fleet hint when tokens is []', () => {
|
||||
render(<TokenListView {...baseProps} tokens={[]} />);
|
||||
expect(screen.getByText(/No canary tokens yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the filtered-empty hint when filter excludes all rows', () => {
|
||||
render(
|
||||
<TokenListView
|
||||
{...baseProps}
|
||||
tokens={[makeCanaryToken({ decky_name: 'decoy-01' })]}
|
||||
filter="zzzz-no-match"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('No tokens match the current filter.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('respects stateFilter', () => {
|
||||
render(
|
||||
<TokenListView
|
||||
{...baseProps}
|
||||
tokens={[
|
||||
makeCanaryToken({ uuid: 't1', decky_name: 'planted-d', state: 'planted' }),
|
||||
makeCanaryToken({ uuid: 't2', decky_name: 'revoked-d', state: 'revoked' }),
|
||||
]}
|
||||
stateFilter="planted"
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<TokenListView
|
||||
{...baseProps}
|
||||
tokens={[makeCanaryToken({ uuid: 't1', decky_name: 'decoy-01' })]}
|
||||
onPick={onPick}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText('decoy-01'));
|
||||
expect(onPick).toHaveBeenCalled();
|
||||
expect(onPick.mock.calls[0][0].uuid).toBe('t1');
|
||||
});
|
||||
});
|
||||
148
decnet_web/src/components/CanaryTokens/TokenListView.tsx
Normal file
148
decnet_web/src/components/CanaryTokens/TokenListView.tsx
Normal file
@@ -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<Props> = ({
|
||||
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 (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ position: 'relative', flex: '1 1 300px' }}>
|
||||
<Search size={14} style={{ position: 'absolute', left: '10px', top: '50%', transform: 'translateY(-50%)', opacity: 0.5 }} />
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter by decky / path / slug / generator…"
|
||||
style={{ ...INPUT_STYLE, paddingLeft: '32px', marginBottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={stateFilter}
|
||||
onChange={(e) => setStateFilter(e.target.value as StateFilter)}
|
||||
style={{ ...INPUT_STYLE, marginBottom: 0, width: 'auto' }}
|
||||
>
|
||||
<option value="all">all states</option>
|
||||
<option value="planted">planted</option>
|
||||
<option value="revoked">revoked</option>
|
||||
<option value="failed">failed</option>
|
||||
</select>
|
||||
<select
|
||||
value={scopeFilter}
|
||||
onChange={(e) => setScopeFilter(e.target.value as ScopeFilter)}
|
||||
style={{ ...INPUT_STYLE, marginBottom: 0, width: 'auto' }}
|
||||
>
|
||||
<option value="all">all scopes</option>
|
||||
<option value="fleet">fleet only</option>
|
||||
<option value="topology">topology only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading && <div style={{ opacity: 0.6 }}>loading…</div>}
|
||||
{error && <div style={{ color: '#ff5555', marginBottom: '16px' }}>{error}</div>}
|
||||
{!loading && visibleTokens.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', opacity: 0.6, fontSize: '0.85rem' }}>
|
||||
{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.'}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{visibleTokens.map((t) => (
|
||||
<button
|
||||
key={t.uuid}
|
||||
onClick={() => onPick(t)}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '110px 80px 140px 1fr 100px 110px 80px',
|
||||
alignItems: 'center', gap: '12px',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--border-color, #30363d)',
|
||||
background: 'var(--matrix-tint-5)',
|
||||
color: 'var(--text-color)',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
color: STATE_COLOR[t.state], fontFamily: 'monospace',
|
||||
fontSize: '0.7rem', letterSpacing: '0.05em',
|
||||
}}>
|
||||
● {t.state.toUpperCase()}
|
||||
</span>
|
||||
<span
|
||||
title={t.topology_id ? `topology ${t.topology_id}` : 'fleet'}
|
||||
style={{
|
||||
fontSize: '0.65rem', letterSpacing: '0.05em',
|
||||
padding: '2px 6px',
|
||||
border: `1px solid ${t.topology_id ? 'var(--accent-color, #00ff88)' : 'var(--dim-color)'}`,
|
||||
color: t.topology_id ? 'var(--accent-color, #00ff88)' : 'var(--dim-color)',
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{t.topology_id ? 'topology' : 'fleet'}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace' }}>{t.decky_name}</span>
|
||||
<span style={{ fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{t.placement_path}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7 }}>
|
||||
{t.kind === 'aws_passive' ? 'aws-passive' : t.kind}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', opacity: 0.7, fontFamily: 'monospace' }}>
|
||||
{t.generator || t.instrumenter || '?'}
|
||||
</span>
|
||||
<span style={{ textAlign: 'right', fontFamily: 'monospace', color: t.trigger_count > 0 ? '#00ff88' : 'var(--dim-color)' }}>
|
||||
{t.trigger_count} {t.trigger_count === 1 ? 'hit' : 'hits'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user