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 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);
|