Files
memby/admin-ui/src/pages/Logins.tsx
T
2026-08-19 06:57:59 +12:00

420 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 server'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);