420 lines
14 KiB
TypeScript
420 lines
14 KiB
TypeScript
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);
|