From 50149b3c179dd6ff083d8a52dbffa23d83021ce3 Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Mon, 10 Aug 2026 01:59:59 +0700 Subject: [PATCH] Add interactive device registry search --- web/app/globals.css | 14 ++++++++++++ web/components/AdminPortal.tsx | 25 ++++++++++++++++++--- web/lib/device-search.test.ts | 41 ++++++++++++++++++++++++++++++++++ web/lib/device-search.ts | 11 +++++++++ 4 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 web/lib/device-search.test.ts create mode 100644 web/lib/device-search.ts diff --git a/web/app/globals.css b/web/app/globals.css index 1f7a8396..b1b6de4e 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -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; } @@ -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; } diff --git a/web/components/AdminPortal.tsx b/web/components/AdminPortal.tsx index 45901041..9130eff9 100644 --- a/web/components/AdminPortal.tsx +++ b/web/components/AdminPortal.tsx @@ -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 = { @@ -200,6 +201,7 @@ export function AdminPortal() { function Dashboard({ username, setNotice, notice, onLogout }: { username: string; setNotice: (notice: Notice) => void; notice: Notice; onLogout: () => void }) { const [devices, setDevices] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); const [selectedId, setSelectedId] = useState(null); const [selected, setSelected] = useState(null); const [loading, setLoading] = useState(true); @@ -207,6 +209,8 @@ function Dashboard({ username, setNotice, notice, onLogout }: { username: string const [prefillDeviceId, setPrefillDeviceId] = useState(''); const [pendingRequests, setPendingRequests] = useState([]); 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 { @@ -250,7 +254,22 @@ function Dashboard({ username, setNotice, notice, onLogout }: { username: string
CONFIG PROFILES

Devices {devices.length.toString().padStart(2, '0')}

{ setPrefillDeviceId(deviceId); setShowAdd(true); }} /> {showAdd && { 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 ?
Loading registry...
: devices.length === 0 ? setShowAdd(true)} /> :
PROFILEMODELVERSIONUPDATED
{devices.map((device) => )}
} + {!loading && devices.length > 0 &&
+ + {isSearching ? `${visibleDevices.length} of ${devices.length}` : `${devices.length} total`} +
} + {loading ?
Loading registry...
: devices.length === 0 ? setShowAdd(true)} /> : visibleDevices.length === 0 ?

No matching devices

Try a different Device ID or display label.

:
PROFILEMODELVERSIONUPDATED
{visibleDevices.map((device) => )}
} {selectedId && { 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}` })} />} } diff --git a/web/lib/device-search.test.ts b/web/lib/device-search.test.ts new file mode 100644 index 00000000..5b0dc453 --- /dev/null +++ b/web/lib/device-search.test.ts @@ -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([]); + }); +}); diff --git a/web/lib/device-search.ts b/web/lib/device-search.ts new file mode 100644 index 00000000..e6455b42 --- /dev/null +++ b/web/lib/device-search.ts @@ -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)); + }); +}