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
+255
View File
@@ -0,0 +1,255 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { query } from '../api/client';
import { useQuery } from '../lib/hooks';
import { ago, num, when } from '../lib/format';
import {
Banner,
Button,
Card,
EmptyRow,
Field,
Loading,
PageHead,
Segments,
TableWrap,
Tag,
Tiles,
} from '../components/ui';
import { Glyph } from '../components/Icon';
import {
eventIcon,
eventTone,
eventTypeLabel,
useNotifications,
type AdminEvent,
type EventTypeCount,
} from '../lib/notifications';
/* The activity feed in full: the bell's dropdown with filters and no twenty-row cap.
*
* It reads its own window from the server rather than the provider's cached list, because
* the provider holds only the most recent fifty — enough for a badge and a dropdown, not
* enough to answer "what happened on Tuesday". The badge and the "mark all read" button
* still come from the provider, so this page and the bell can never disagree about how
* much is unread. */
interface FeedResponse {
events: AdminEvent[];
total: number;
unread: number;
types: EventTypeCount[];
subscribers: number;
}
const WINDOWS = [
{ value: 1, label: 'Today' },
{ value: 7, label: '7 days' },
{ value: 30, label: '30 days' },
];
export function ActivityPage() {
const { unread, connected, markAllRead, reload: reloadBell } = useNotifications();
const [days, setDays] = useState(7);
const [type, setType] = useState('');
const [severity, setSeverity] = useState('');
const [unreadOnly, setUnreadOnly] = useState(false);
const [page, setPage] = useState(0);
const limit = 100;
const path = useMemo(
() =>
`/admin/api/notifications${query({
days,
type,
severity,
unread: unreadOnly,
limit,
offset: page * limit,
})}`,
[days, type, severity, unreadOnly, page],
);
const { data, error, loading, reload } = useQuery<FeedResponse>(path);
const markAll = async () => {
await markAllRead();
await reload();
};
return (
<>
<PageHead
title="Activity"
intro="Every administrative event the gateway has published: sign-ins, devices, scheduled tasks, integrations and the server itself. The same feed the bell and every integration read from."
actions={
unread > 0 ? (
<Button onClick={() => void markAll()} icon="check">
Mark all read
</Button>
) : undefined
}
/>
<Banner message={error} />
<Tiles
tiles={[
{ label: 'Events in window', value: num(data?.total ?? 0), icon: 'bell', tone: 'info' },
{ label: 'Unread', value: num(unread), icon: 'alert', tone: unread > 0 ? 'warn' : undefined },
{ label: 'Kinds seen', value: num(data?.types.length ?? 0), icon: 'list', tone: 'note' },
{
label: 'Live feed',
value: connected ? 'connected' : 'reconnecting',
small: true,
icon: 'pulse',
tone: connected ? 'ok' : 'warn',
},
]}
/>
<div className="filters">
<Field label="Window">
<Segments
value={days}
options={WINDOWS.map((w) => ({ value: w.value, label: w.label }))}
onChange={(next) => {
setDays(next);
setPage(0);
}}
/>
</Field>
<Field label="Kind">
{/* Built from what has actually been published, so the filter can neither offer a
kind that matches nothing nor miss one a service added after this shipped. */}
<select
value={type}
onChange={(event) => {
setType(event.target.value);
setPage(0);
}}
>
<option value="">Everything</option>
{(data?.types ?? []).map((entry) => (
<option key={entry.type} value={entry.type}>
{eventTypeLabel(entry.type)} ({entry.count})
</option>
))}
</select>
</Field>
<Field label="Severity">
<select
value={severity}
onChange={(event) => {
setSeverity(event.target.value);
setPage(0);
}}
>
<option value="">Any</option>
<option value="info">Information</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
</Field>
<Field label="Read state">
<select
value={unreadOnly ? 'unread' : ''}
onChange={(event) => {
setUnreadOnly(event.target.value === 'unread');
setPage(0);
}}
>
<option value="">All</option>
<option value="unread">Unread only</option>
</select>
</Field>
<div className="filter-actions">
<Button
variant="quiet"
size="sm"
icon="refresh"
onClick={() => {
void reload();
void reloadBell();
}}
>
Refresh
</Button>
</div>
</div>
{loading ? (
<Loading />
) : (
<Card
title="Events"
icon="bell"
tone="info"
footer={
(data?.total ?? 0) > limit ? (
<>
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
Newer
</Button>
<Button
size="sm"
disabled={(page + 1) * limit >= (data?.total ?? 0)}
onClick={() => setPage(page + 1)}
>
Older
</Button>
</>
) : undefined
}
>
<TableWrap>
<table>
<thead>
<tr>
<th className="nowrap">When</th>
<th>Kind</th>
<th>What happened</th>
<th>Who</th>
<th>What</th>
<th />
</tr>
</thead>
<tbody>
{(data?.events.length ?? 0) === 0 ? (
<EmptyRow columns={6}>Nothing has happened in this window.</EmptyRow>
) : (
data?.events.map((event) => (
<tr key={event.id}>
<td className="nowrap muted" title={when(event.occurredAt)}>
{ago(event.occurredAt)}
</td>
<td className="nowrap">
<span className="row tight">
<Glyph name={eventIcon(event.type)} tone={eventTone(event)} />
{eventTypeLabel(event.type)}
</span>
</td>
<td>
<b>{event.title}</b>
{event.summary ? <div className="muted">{event.summary}</div> : null}
</td>
<td className="muted nowrap">{event.actor || '—'}</td>
<td className="muted nowrap">{event.target || '—'}</td>
<td className="nowrap">
{!event.readAt ? <Tag tone="ok">new</Tag> : null}
{event.link ? (
<Link className="table-row-link" to={event.link}>
Open
</Link>
) : null}
</td>
</tr>
))
)}
</tbody>
</table>
</TableWrap>
</Card>
)}
</>
);
}