mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 12:48:10 +00:00
feat: match the Security masthead to the primary-page hero and make History search realtime (#1365)
- Masthead: add `size` and `subtitle` to PageMasthead and render the Security masthead at the hero title size with a one-line posture summary, so its height and weight match the Home and Fleet mastheads. The compact size with no subtitle stays the default, so Settings, Console, and Logs are unchanged. - History search now applies as you type (a short debounce, no Enter press), matching the Fleet overview search. It stays server-side so the query searches every completed scan, not just the loaded page.
This commit is contained in:
@@ -171,7 +171,11 @@ export function SecurityView({ activeTab, onTabChange }: SecurityViewProps) {
|
|||||||
state={state}
|
state={state}
|
||||||
tone={tone}
|
tone={tone}
|
||||||
pulsing={pulsing}
|
pulsing={pulsing}
|
||||||
|
size="hero"
|
||||||
className="rounded-lg mb-4"
|
className="rounded-lg mb-4"
|
||||||
|
subtitle={overview
|
||||||
|
? `${overview.scannedImages} ${overview.scannedImages === 1 ? 'image' : 'images'} scanned · scanner ${overview.scanner.available ? 'ready' : 'not installed'}`
|
||||||
|
: undefined}
|
||||||
metadata={overview ? [
|
metadata={overview ? [
|
||||||
{ label: 'CRITICAL', value: String(overview.critical), tone: overview.critical > 0 ? 'error' : 'value' },
|
{ label: 'CRITICAL', value: String(overview.critical), tone: overview.critical > 0 ? 'error' : 'value' },
|
||||||
{ label: 'HIGH', value: String(overview.high), tone: overview.high > 0 ? 'warn' : 'value' },
|
{ label: 'HIGH', value: String(overview.high), tone: overview.high > 0 ? 'warn' : 'value' },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -98,6 +98,18 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
|
|||||||
|
|
||||||
useEffect(() => { void load(safePage, search); }, [load, safePage, search, nodeId]);
|
useEffect(() => { void load(safePage, search); }, [load, safePage, search, nodeId]);
|
||||||
|
|
||||||
|
// Realtime search: apply the draft automatically a beat after typing stops (no
|
||||||
|
// Enter), then refetch from page 0. Debounced rather than per-keystroke because
|
||||||
|
// the list is server-paginated, so the query searches every completed scan, not
|
||||||
|
// just the loaded page. Skip the initial mount so it does not reset pagination.
|
||||||
|
const prevSearchDraftRef = useRef(searchDraft);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevSearchDraftRef.current === searchDraft) return;
|
||||||
|
prevSearchDraftRef.current = searchDraft;
|
||||||
|
const t = setTimeout(() => { setSearch(searchDraft); setPage(0); }, 250);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [searchDraft]);
|
||||||
|
|
||||||
const toggleSelect = (scanId: number) => {
|
const toggleSelect = (scanId: number) => {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
if (prev.includes(scanId)) return prev.filter((x) => x !== scanId);
|
if (prev.includes(scanId)) return prev.filter((x) => x !== scanId);
|
||||||
@@ -157,7 +169,6 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
|
|||||||
placeholder="Search by image..."
|
placeholder="Search by image..."
|
||||||
value={searchDraft}
|
value={searchDraft}
|
||||||
onChange={(e) => setSearchDraft(e.target.value)}
|
onChange={(e) => setSearchDraft(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { setPage(0); setSearch(searchDraft); } }}
|
|
||||||
className="pl-8"
|
className="pl-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -125,11 +125,11 @@ describe('HistoryTab', () => {
|
|||||||
expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('searches by image on Enter, adding imageRefLike to the request', async () => {
|
it('searches by image as you type (no Enter), adding imageRefLike to the request', async () => {
|
||||||
mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })]));
|
mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })]));
|
||||||
render(<HistoryTab onInspect={vi.fn()} />);
|
render(<HistoryTab onInspect={vi.fn()} />);
|
||||||
await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument());
|
||||||
await userEvent.type(screen.getByPlaceholderText('Search by image...'), 'redis{Enter}');
|
await userEvent.type(screen.getByPlaceholderText('Search by image...'), 'redis');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const calls = mockedFetch.mock.calls.map((c) => c[0] as string);
|
const calls = mockedFetch.mock.calls.map((c) => c[0] as string);
|
||||||
expect(calls.some((u) => u.includes('imageRefLike=redis'))).toBe(true);
|
expect(calls.some((u) => u.includes('imageRefLike=redis'))).toBe(true);
|
||||||
|
|||||||
@@ -15,8 +15,16 @@ export interface PageMastheadProps {
|
|||||||
tone: MastheadTone;
|
tone: MastheadTone;
|
||||||
pulsing?: boolean;
|
pulsing?: boolean;
|
||||||
metadata?: MastheadMetadataItem[];
|
metadata?: MastheadMetadataItem[];
|
||||||
|
/** Optional meta line under the state word (e.g. a one-line posture summary). */
|
||||||
|
subtitle?: ReactNode;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/**
|
||||||
|
* `hero` matches the larger title of the primary nav pages (Home, Fleet);
|
||||||
|
* `default` is the compact title used by the secondary tool pages (Settings,
|
||||||
|
* Console, Logs).
|
||||||
|
*/
|
||||||
|
size?: 'default' | 'hero';
|
||||||
}
|
}
|
||||||
|
|
||||||
const toneConfig: Record<MastheadTone, {
|
const toneConfig: Record<MastheadTone, {
|
||||||
@@ -59,8 +67,10 @@ export function PageMasthead({
|
|||||||
tone,
|
tone,
|
||||||
pulsing = false,
|
pulsing = false,
|
||||||
metadata,
|
metadata,
|
||||||
|
subtitle,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
size = 'default',
|
||||||
}: PageMastheadProps) {
|
}: PageMastheadProps) {
|
||||||
const config = toneConfig[tone];
|
const config = toneConfig[tone];
|
||||||
const shouldPulse = pulsing && (tone === 'live' || tone === 'warn');
|
const shouldPulse = pulsing && (tone === 'live' || tone === 'warn');
|
||||||
@@ -88,9 +98,20 @@ export function PageMasthead({
|
|||||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">
|
||||||
{kicker}
|
{kicker}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn('font-display italic text-[22px] leading-7 tracking-[-0.01em]', config.stateTextClass)}>
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-display italic tracking-[-0.01em]',
|
||||||
|
size === 'hero' ? 'text-3xl leading-none' : 'text-[22px] leading-7',
|
||||||
|
config.stateTextClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{state}
|
{state}
|
||||||
</span>
|
</span>
|
||||||
|
{subtitle ? (
|
||||||
|
<span className="font-mono text-[11px] leading-tight text-stat-subtitle/90 truncate">
|
||||||
|
{subtitle}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{children ? <div className="ml-2 min-w-0">{children}</div> : null}
|
{children ? <div className="ml-2 min-w-0">{children}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user