Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ input:focus, select:focus, textarea:focus { border-color: var(--cyan); box-shado
.pending-identity small { color: var(--muted); font-size: 11px; }
.heading-count { color: var(--muted-2); font-size: 13px; font-weight: 500; margin-left: 6px; }
.panel-actions, .editor-actions { display: flex; align-items: center; gap: 8px; }
.registry-search { display: flex; align-items: center; gap: 12px; padding: 14px 22px; border-bottom: 1px solid var(--line-soft); background: rgba(8, 21, 34, .25); }
.search-field { position: relative; min-width: 0; flex: 1; }
.search-field > svg { position: absolute; top: 50%; left: 12px; z-index: 1; color: var(--muted-2); pointer-events: none; transform: translateY(-50%); }
.search-field input { height: 38px; padding-left: 38px; padding-right: 40px; }
.search-field input::-webkit-search-cancel-button { appearance: none; }
.search-clear { position: absolute; top: 50%; right: 7px; display: grid; place-items: center; width: 27px; height: 27px; padding: 0; border: 0; border-radius: 2px; color: var(--muted); background: transparent; transform: translateY(-50%); }
.search-clear:hover { color: var(--text); background: rgba(97, 198, 219, .08); }
.search-summary { flex: 0 0 auto; color: var(--muted-2); font-size: 10px; letter-spacing: .06em; white-space: nowrap; }
.search-empty { min-height: 280px; display: grid; place-content: center; justify-items: center; gap: 10px; padding: 25px; color: var(--muted-2); text-align: center; }
.search-empty h3 { margin: 2px 0 0; color: var(--text); font-size: 16px; }
.search-empty p { margin: 0 0 6px; color: var(--muted); font-size: 12px; }
.sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; }
.device-table { width: 100%; }
.table-head, .device-row { display: grid; grid-template-columns: minmax(150px, 1.5fr) minmax(80px, .9fr) 70px minmax(122px, 1.25fr) 22px; align-items: center; gap: 14px; padding: 0 22px; }
.table-head { min-height: 38px; color: var(--muted-2); font-size: 9px; letter-spacing: .12em; font-weight: 700; }
Expand Down Expand Up @@ -243,6 +255,8 @@ fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
.workspace-grid > .surface, .registry-panel, .editor-panel { min-width: 0; width: 100%; }
.panel-heading, .editor-heading { padding: 18px 15px; align-items: flex-start; flex-wrap: wrap; }
.panel-actions, .editor-actions { margin-left: auto; flex-wrap: wrap; }
.registry-search { align-items: stretch; flex-direction: column; padding: 13px 15px; }
.search-summary { align-self: flex-end; }
.pending-requests { margin: 15px; }
.pending-heading { align-items: flex-start; flex-direction: column; }
.pending-row { padding-left: 12px; }
Expand Down
25 changes: 22 additions & 3 deletions web/components/AdminPortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

import {
Activity, AlertCircle, Check, ChevronRight, Copy, KeyRound, LayoutDashboard,
LogOut, Plus, RadioTower, RefreshCw, Save, Settings2, ShieldCheck, Trash2,
LogOut, Plus, RadioTower, RefreshCw, Save, Search, Settings2, ShieldCheck, Trash2,
X
} from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import type { DeviceSummary, MinimumConfig, PendingDeviceRequestSummary } from '@/lib/types';
import { applyModelProfile, emptyConfig } from '@/lib/default-config';
import { MODEL_PROFILES, type ModelProfile } from '@/lib/model-profiles';
import { filterDevices } from '@/lib/device-search';
import { ConfigEditorForm } from './ConfigEditorForm';

type SessionState = {
Expand Down Expand Up @@ -200,13 +201,16 @@ export function AdminPortal() {

function Dashboard({ username, setNotice, notice, onLogout }: { username: string; setNotice: (notice: Notice) => void; notice: Notice; onLogout: () => void }) {
const [devices, setDevices] = useState<DeviceSummary[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selected, setSelected] = useState<DeviceRecord | null>(null);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [prefillDeviceId, setPrefillDeviceId] = useState('');
const [pendingRequests, setPendingRequests] = useState<PendingDeviceRequestSummary[]>([]);
const [activeNav, setActiveNav] = useState('Devices');
const visibleDevices = useMemo(() => filterDevices(devices, searchQuery), [devices, searchQuery]);
const isSearching = searchQuery.trim().length > 0;
const loadDevices = async () => {
setLoading(true);
try {
Expand Down Expand Up @@ -250,7 +254,22 @@ function Dashboard({ username, setNotice, notice, onLogout }: { username: string
<div className="panel-heading"><div><span className="eyebrow">CONFIG PROFILES</span><h2>Devices <span className="heading-count">{devices.length.toString().padStart(2, '0')}</span></h2></div><div className="panel-actions"><button className="icon-button" onClick={() => void loadDevices()} title="Refresh devices" aria-label="Refresh devices"><RefreshCw size={17} /></button><button className="primary-button" onClick={() => setShowAdd(true)}><Plus size={17} /> Add device</button></div></div>
<PendingRequests requests={pendingRequests} onRegister={(deviceId) => { setPrefillDeviceId(deviceId); setShowAdd(true); }} />
{showAdd && <AddDeviceForm key={prefillDeviceId || 'new'} initialDeviceId={prefillDeviceId} onClose={() => { setShowAdd(false); setPrefillDeviceId(''); }} onSaved={(token, device) => { setShowAdd(false); setPrefillDeviceId(''); setNotice({ tone: 'success', text: `Device ${device.deviceId} created. Copy this one-time token now: ${token}` }); void loadDevices(); setSelectedId(device.deviceId); setSelected({ ...device, config: emptyConfig(device.deviceId, device.model) }); }} />}
{loading ? <div className="loading-row"><RefreshCw size={17} className="spin" />Loading registry...</div> : devices.length === 0 ? <EmptyState onAdd={() => setShowAdd(true)} /> : <div className="device-table"><div className="table-head"><span>PROFILE</span><span>MODEL</span><span>VERSION</span><span>UPDATED</span><span /></div>{devices.map((device) => <button className={`device-row ${selectedId === device.deviceId ? 'selected' : ''}`} key={device.deviceId} onClick={() => void chooseDevice(device.deviceId)}><span className="device-identity"><span className="device-mark"><RadioTower size={16} /></span><span><strong>{device.deviceId}</strong><small>{device.label}</small></span></span><span className="muted mono">{device.model}</span><span className="version-pill">v{device.configVersion}</span><span className="muted">{formatDate(device.updatedAt)}</span><ChevronRight size={16} className="row-chevron" /></button>)}</div>}
{!loading && devices.length > 0 && <div className="registry-search">
<label className="search-field">
<span className="sr-only">Search devices by Device ID or display label</span>
<Search size={16} aria-hidden="true" />
<input
type="search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search Device ID or display label"
autoComplete="off"
/>
{isSearching && <button type="button" className="search-clear" onClick={() => setSearchQuery('')} aria-label="Clear device search" title="Clear search"><X size={15} /></button>}
</label>
<span className="search-summary" aria-live="polite">{isSearching ? `${visibleDevices.length} of ${devices.length}` : `${devices.length} total`}</span>
</div>}
{loading ? <div className="loading-row"><RefreshCw size={17} className="spin" />Loading registry...</div> : devices.length === 0 ? <EmptyState onAdd={() => setShowAdd(true)} /> : visibleDevices.length === 0 ? <div className="search-empty"><Search size={24} /><h3>No matching devices</h3><p>Try a different Device ID or display label.</p><button className="quiet-button" type="button" onClick={() => setSearchQuery('')}>Clear search</button></div> : <div className="device-table"><div className="table-head"><span>PROFILE</span><span>MODEL</span><span>VERSION</span><span>UPDATED</span><span /></div>{visibleDevices.map((device) => <button className={`device-row ${selectedId === device.deviceId ? 'selected' : ''}`} key={device.deviceId} onClick={() => void chooseDevice(device.deviceId)}><span className="device-identity"><span className="device-mark"><RadioTower size={16} /></span><span><strong>{device.deviceId}</strong><small>{device.label}</small></span></span><span className="muted mono">{device.model}</span><span className="version-pill">v{device.configVersion}</span><span className="muted">{formatDate(device.updatedAt)}</span><ChevronRight size={16} className="row-chevron" /></button>)}</div>}
</section>
{selectedId && <DeviceEditor key={selectedId} device={selected} onSaved={(updated) => { setSelected(updated); setNotice({ tone: 'success', text: `${updated.deviceId} saved at config v${updated.config.configVersion}.` }); void loadDevices(); }} onDeleted={() => { setSelected(null); setSelectedId(null); setNotice({ tone: 'success', text: 'Device removed.' }); void loadDevices(); }} onToken={(token) => setNotice({ tone: 'success', text: `New device token: ${token}` })} />}
</section>}
Expand Down
41 changes: 41 additions & 0 deletions web/lib/device-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import type { DeviceSummary } from './types';
import { filterDevices } from './device-search';

function device(deviceId: string, label: string): DeviceSummary {
return {
deviceId,
label,
model: 't99',
configVersion: 1,
tokenHint: 'token',
tokenCreatedAt: '2026-08-10T00:00:00.000Z',
createdAt: '2026-08-10T00:00:00.000Z',
updatedAt: '2026-08-10T00:00:00.000Z'
};
}

const devices = [
device('GYZ3DE', 'North Command'),
device('T56-OPS-02', 'Rescue Team Two'),
device('T99-HQ-01', 'Headquarters')
];

describe('device registry search', () => {
it('matches device IDs without case sensitivity', () => {
expect(filterDevices(devices, 't56-ops').map((item) => item.deviceId)).toEqual(['T56-OPS-02']);
});

it('matches display labels without case sensitivity', () => {
expect(filterDevices(devices, 'north command').map((item) => item.deviceId)).toEqual(['GYZ3DE']);
});

it('supports multiple terms across device ID and display label', () => {
expect(filterDevices(devices, 't99 headquarters').map((item) => item.deviceId)).toEqual(['T99-HQ-01']);
});

it('returns the original list for an empty query and no matches otherwise', () => {
expect(filterDevices(devices, ' ')).toBe(devices);
expect(filterDevices(devices, 'missing')).toEqual([]);
});
});
11 changes: 11 additions & 0 deletions web/lib/device-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { DeviceSummary } from './types';

export function filterDevices(devices: DeviceSummary[], query: string): DeviceSummary[] {
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
if (!terms.length) return devices;

return devices.filter((device) => {
const searchableText = `${device.deviceId} ${device.label}`.toLocaleLowerCase();
return terms.every((term) => searchableText.includes(term));
});
}
Loading