import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { query } from '../api/client'; import { useQuery } from '../lib/hooks'; import { daysAgo, num, when } from '../lib/format'; import { Banner, Button, Card, Empty, EmptyRow, Field, Grid, Loading, PageHead, Segments, TableWrap, Tag, Tiles, } from '../components/ui'; import { Bars } from '../components/ui'; import type { LoginDevicesResponse, LoginsResponse } from '../api/types'; /* The sign-in history. * * This is the page the whole login-analytics feature exists for, and its shape follows * from one observation: an operator arrives here with a *question*, not a browsing * intention — "did the bedroom TV connect this morning", "who is that address", "why does * this account keep failing" — so the filter bar is above the table and always visible, * not behind a disclosure, and every control in it maps to one server-side filter. * * Two tables of the same rows, deliberately, the stance the searches page takes: the * devices summary answers "which televisions are connecting", which is what you read when * you do not yet know where to look, and the log is uncollapsed and newest-first, which is * what you read when something has just happened. */ type View = 'log' | 'devices'; interface Filters { user: string; q: string; ip: string; outcome: '' | 'success' | 'failure'; from: string; to: string; } const EMPTY: Filters = { user: '', q: '', ip: '', outcome: '', from: '', to: '' }; const WINDOWS = [ { value: 1, label: 'Today' }, { value: 7, label: '7 days' }, { value: 30, label: '30 days' }, { value: 0, label: 'All' }, ] as const; export function LoginsPage() { const [view, setView] = useState('log'); const [days, setDays] = useState(7); const [filters, setFilters] = useState(EMPTY); const [page, setPage] = useState(0); const limit = 100; // The window and the explicit date range are the same filter expressed two ways, and an // explicit `from` wins: an operator who typed a date meant it, and silently narrowing it // to the last seven days would answer a question they did not ask. const params = useMemo( () => query({ ...filters, days: filters.from ? undefined : days || undefined, limit, offset: page * limit, }), [filters, days, page], ); const log = useQuery(`/admin/api/logins${params}`, { enabled: view === 'log' }); const devices = useQuery(`/admin/api/logins/devices${params}`, { enabled: view === 'devices', }); const users = log.data?.users ?? devices.data?.users ?? []; const totals = log.data?.totals ?? devices.data?.totals; const retention = log.data?.retentionDays ?? devices.data?.retentionDays ?? 90; const loading = view === 'log' ? log.loading : devices.loading; const error = view === 'log' ? log.error : devices.error; const update = (patch: Partial) => { setFilters((current) => ({ ...current, ...patch })); setPage(0); }; const active = Object.entries(filters).some(([, value]) => value !== '') || Boolean(filters.from); return ( <> } /> {totals ? ( 0 ? 'warn' : undefined, }, { label: 'Televisions', value: num(totals.devices), icon: 'tv', tone: 'info' }, { label: 'People', value: num(totals.users), icon: 'people', tone: 'note' }, { label: 'Addresses', value: num(totals.addresses), icon: 'globe', tone: 'data' }, { label: 'History kept', value: `${retention} days`, small: true, icon: 'clock', }, ]} /> ) : null} {/* Fast filtering is most of what makes this table useful, so the controls sit above it rather than behind a "filters" disclosure nobody opens. */}
({ value: entry.value as number, label: entry.label }))} onChange={(next) => { setDays(next); update({ from: '', to: '' }); }} /> update({ ip: event.target.value })} /> update({ from: event.target.value })} /> update({ to: event.target.value })} /> update({ q: event.target.value })} />
{active ? ( ) : null}
{loading ? ( ) : view === 'log' ? ( ) : ( )} ); } function LogView({ data, page, limit, onPage, }: { data: LoginsResponse | undefined; page: number; limit: number; onPage: (next: number) => void; }) { if (!data) return null; const shown = data.events.length; const from = data.total === 0 ? 0 : page * limit + 1; return ( <> row.day} valueOf={(row: (typeof data.days)[number]) => row.logins + row.failures} toneOf={(row: (typeof data.days)[number]) => (row.failures > row.logins ? 'bad' : undefined)} title={(row: (typeof data.days)[number]) => `${row.day}: ${row.logins} in, ${row.failures} refused, ${row.devices} televisions` } /> {data.addresses.length === 0 ? ( No addresses in this window. ) : (
{data.addresses.slice(0, 8).map((address) => (
{address.ipAddress}

{num(address.logins)} in {address.failures > 0 ? ` · ${num(address.failures)} refused` : ''}

{address.failures > 0 && address.logins === 0 ? only refused : null}
))}
)}
{data.total === 0 ? 'nothing matches' : `${num(from)}–${num(from + shown - 1)} of ${num(data.total)}`} } footer={ data.total > limit ? ( <> ) : undefined } > {data.events.length === 0 ? ( No sign-in attempts match these filters. ) : ( data.events.map((event) => ( )) )}
When Person Television Address Build Outcome
{when(event.occurredAt)} {event.username || unknown} {event.deviceId ? ( {event.deviceName || event.deviceId} ) : ( )} {event.ipAddress || '—'} {event.clientVersion || '—'} {event.success ? ( event.newDevice ? ( first sign-in ) : ( got in ) ) : ( {event.failureReason || 'refused'} )}
); } function DeviceView({ data }: { data: LoginDevicesResponse | undefined }) { if (!data) return null; return ( {data.devices.length === 0 ? ( No television has connected in this window. ) : ( data.devices.map((device) => ( {/* A device with failures and no successes has no last sign-in, which is a real answer rather than a zero one. */} )) )}
Television Person Today Sign-ins Refused Addresses Last address Last sign-in Build
{device.deviceName || device.deviceId} {device.username || '—'} {device.loginsToday > 0 ? num(device.loginsToday) : '—'} {num(device.logins)} {device.failures > 0 ? {num(device.failures)} : '—'} {num(device.distinctIps)} {device.lastIp || '—'}{device.lastLogin ? when(device.lastLogin) : '—'} {device.clientVersion || '—'}
); } /** Exported for the device page, which offers the same seven-day default. */ export const defaultWindowFrom = () => daysAgo(7);