0.1.38 gateway
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user