0.1.38 gateway

This commit is contained in:
ponzischeme89
2026-08-14 09:40:03 +12:00
parent abc392d30b
commit 5e2ed3d12e
2847 changed files with 1072928 additions and 3783 deletions
+419
View File
@@ -0,0 +1,419 @@
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<View>('log');
const [days, setDays] = useState<number>(7);
const [filters, setFilters] = useState<Filters>(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<LoginsResponse>(`/admin/api/logins${params}`, { enabled: view === 'log' });
const devices = useQuery<LoginDevicesResponse>(`/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<Filters>) => {
setFilters((current) => ({ ...current, ...patch }));
setPage(0);
};
const active =
Object.entries(filters).some(([, value]) => value !== '') || Boolean(filters.from);
return (
<>
<PageHead
title="Sign-in history"
intro="Every connection attempt, kept as history rather than as the latest state of a device. A television that has since been removed still appears here, because it still connected."
actions={
<Segments
value={view}
options={[
{ value: 'log', label: 'Log' },
{ value: 'devices', label: 'By device' },
]}
onChange={setView}
/>
}
/>
<Banner message={error} />
{totals ? (
<Tiles
tiles={[
{ label: 'Successful sign-ins', value: num(totals.logins), icon: 'key', tone: 'ok' },
{
label: 'Refused',
value: num(totals.failures),
icon: 'shield',
tone: totals.failures > 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. */}
<div className="filters">
<Field label="Window">
<Segments
value={filters.from ? -1 : days}
options={WINDOWS.map((entry) => ({ value: entry.value as number, label: entry.label }))}
onChange={(next) => {
setDays(next);
update({ from: '', to: '' });
}}
/>
</Field>
<Field label="Person">
<select value={filters.user} onChange={(event) => update({ user: event.target.value })}>
<option value="">Anyone</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.username || user.id}
</option>
))}
</select>
</Field>
<Field label="Outcome">
<select
value={filters.outcome}
onChange={(event) => update({ outcome: event.target.value as Filters['outcome'] })}
>
<option value="">Both</option>
<option value="success">Got in</option>
<option value="failure">Refused</option>
</select>
</Field>
<Field label="Address">
<input
type="text"
value={filters.ip}
placeholder="10.0.0.4"
onChange={(event) => update({ ip: event.target.value })}
/>
</Field>
<Field label="From">
<input type="date" value={filters.from} onChange={(event) => update({ from: event.target.value })} />
</Field>
<Field label="To">
<input type="date" value={filters.to} onChange={(event) => update({ to: event.target.value })} />
</Field>
<Field label="Search" grow>
<input
type="search"
value={filters.q}
placeholder="Name, device or address"
onChange={(event) => update({ q: event.target.value })}
/>
</Field>
<div className="filter-actions">
{active ? (
<Button
variant="quiet"
size="sm"
onClick={() => {
setFilters(EMPTY);
setPage(0);
}}
>
Clear
</Button>
) : null}
</div>
</div>
{loading ? (
<Loading />
) : view === 'log' ? (
<LogView data={log.data} page={page} limit={limit} onPage={setPage} />
) : (
<DeviceView data={devices.data} />
)}
</>
);
}
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 (
<>
<Grid cols="wide">
<Card
title="Attempts per day"
intro="Grouped in the household's own timezone, so an evening sign-in stays on the day it happened."
icon="chart"
tone="info"
>
<Bars
data={data.days}
labelOf={(row: (typeof data.days)[number]) => 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`
}
/>
</Card>
<Card title="Where from" icon="globe" tone="data">
{data.addresses.length === 0 ? (
<Empty>No addresses in this window.</Empty>
) : (
<div className="list">
{data.addresses.slice(0, 8).map((address) => (
<div className="list-item" key={address.ipAddress}>
<div className="list-body">
<b className="mono">{address.ipAddress}</b>
<p>
{num(address.logins)} in
{address.failures > 0 ? ` · ${num(address.failures)} refused` : ''}
</p>
</div>
{address.failures > 0 && address.logins === 0 ? <Tag tone="bad">only refused</Tag> : null}
</div>
))}
</div>
)}
</Card>
</Grid>
<Card
title="Attempts"
intro="Uncollapsed and newest first: this is what to read when somebody says a television will not sign in."
icon="key"
tone="ok"
actions={
<span className="filter-summary">
{data.total === 0
? 'nothing matches'
: `${num(from)}${num(from + shown - 1)} of ${num(data.total)}`}
</span>
}
footer={
data.total > limit ? (
<>
<Button size="sm" disabled={page === 0} onClick={() => onPage(page - 1)}>
Newer
</Button>
<Button
size="sm"
disabled={(page + 1) * limit >= data.total}
onClick={() => onPage(page + 1)}
>
Older
</Button>
</>
) : undefined
}
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">When</th>
<th>Person</th>
<th>Television</th>
<th className="nowrap">Address</th>
<th>Build</th>
<th>Outcome</th>
</tr>
</thead>
<tbody>
{data.events.length === 0 ? (
<EmptyRow columns={6}>No sign-in attempts match these filters.</EmptyRow>
) : (
data.events.map((event) => (
<tr key={event.id}>
<td className="nowrap muted">{when(event.occurredAt)}</td>
<td>{event.username || <span className="quiet">unknown</span>}</td>
<td>
{event.deviceId ? (
<Link className="table-row-link" to={`/admin/devices/${encodeURIComponent(event.deviceId)}`}>
{event.deviceName || event.deviceId}
</Link>
) : (
<span className="quiet"></span>
)}
</td>
<td className="mono nowrap">{event.ipAddress || '—'}</td>
<td className="mono">{event.clientVersion || '—'}</td>
<td className="nowrap">
{event.success ? (
event.newDevice ? (
<Tag tone="info">first sign-in</Tag>
) : (
<Tag tone="ok">got in</Tag>
)
) : (
<Tag tone="bad">{event.failureReason || 'refused'}</Tag>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
</>
);
}
function DeviceView({ data }: { data: LoginDevicesResponse | undefined }) {
if (!data) return null;
return (
<Card
title="Televisions"
intro="Grouped from the history, not from the session list — a set whose session has expired still connected, and this is the record of it."
icon="tv"
tone="info"
>
<TableWrap>
<table>
<thead>
<tr>
<th>Television</th>
<th>Person</th>
<th className="num">Today</th>
<th className="num">Sign-ins</th>
<th className="num">Refused</th>
<th className="num">Addresses</th>
<th className="nowrap">Last address</th>
<th className="nowrap">Last sign-in</th>
<th>Build</th>
</tr>
</thead>
<tbody>
{data.devices.length === 0 ? (
<EmptyRow columns={9}>No television has connected in this window.</EmptyRow>
) : (
data.devices.map((device) => (
<tr key={device.deviceId}>
<td>
<Link className="table-row-link" to={`/admin/devices/${encodeURIComponent(device.deviceId)}`}>
{device.deviceName || device.deviceId}
</Link>
</td>
<td className="muted">{device.username || '—'}</td>
<td className="num">{device.loginsToday > 0 ? num(device.loginsToday) : '—'}</td>
<td className="num">{num(device.logins)}</td>
<td className="num">
{device.failures > 0 ? <span className="mono">{num(device.failures)}</span> : '—'}
</td>
<td className="num">{num(device.distinctIps)}</td>
<td className="mono nowrap">{device.lastIp || '—'}</td>
{/* A device with failures and no successes has no last sign-in, which is
a real answer rather than a zero one. */}
<td className="nowrap muted">{device.lastLogin ? when(device.lastLogin) : '—'}</td>
<td className="mono">{device.clientVersion || '—'}</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
);
}
/** Exported for the device page, which offers the same seven-day default. */
export const defaultWindowFrom = () => daysAgo(7);