96 lines
5.9 KiB
TypeScript
96 lines
5.9 KiB
TypeScript
import { events } from "@minecraft-account-manager/database";
|
|
import { desc, inArray } from "drizzle-orm";
|
|
import Link from "next/link";
|
|
import { db } from "@/lib/database";
|
|
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
|
|
import { eventIpSummary } from "@/lib/event-ip-summary";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
function values(value: string | string[] | undefined) {
|
|
return Array.isArray(value) ? value : value ? [value] : [];
|
|
}
|
|
|
|
export default async function EventsPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
}) {
|
|
const query = await searchParams;
|
|
const typeRows = await db.select({ type: events.type }).from(events).groupBy(events.type).orderBy(events.type);
|
|
const availableTypes = typeRows.map((row) => row.type);
|
|
const category = normalizeEventCategory(values(query.category)[0]);
|
|
const selectedTypes = normalizeSelectedEventTypes(query.type, availableTypes);
|
|
const categoryTypes = category === "all"
|
|
? availableTypes
|
|
: availableTypes.filter((type) => eventCategory(type) === category);
|
|
const filteredTypes = selectedTypes.length
|
|
? selectedTypes.filter((type) => categoryTypes.includes(type))
|
|
: categoryTypes;
|
|
const recentEvents = filteredTypes.length
|
|
? await db.select().from(events).where(inArray(events.type, filteredTypes)).orderBy(desc(events.time)).limit(100)
|
|
: [];
|
|
|
|
return (
|
|
<main className="mx-auto max-w-6xl px-6 py-14">
|
|
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">CloudEvents ledger</p>
|
|
<h1 className="mt-4 font-display text-5xl font-black uppercase">Event explorer</h1>
|
|
<p className="mt-4 max-w-2xl text-sm leading-6 text-muted">Filter the immutable audit ledger, then open an event to inspect its complete CloudEvents envelope and data.</p>
|
|
|
|
<form className="mt-8 border border-line bg-panel p-6" method="get">
|
|
<div className="grid gap-6 md:grid-cols-[0.45fr_1.55fr]">
|
|
<label className="font-mono text-xs font-bold uppercase tracking-wider" htmlFor="event-category">
|
|
View
|
|
<select className="mt-3 block w-full border border-line bg-canvas px-3 py-3 font-sans text-sm font-normal normal-case" defaultValue={category} id="event-category" name="category">
|
|
{eventCategoryValues.map((value) => <option key={value} value={value}>{value === "all" ? "All activity" : value}</option>)}
|
|
</select>
|
|
</label>
|
|
<fieldset>
|
|
<legend className="font-mono text-xs font-bold uppercase tracking-wider">Event types</legend>
|
|
<details className="mt-3 border border-line bg-canvas p-4" open={selectedTypes.length > 0}>
|
|
<summary className="cursor-pointer font-mono text-[10px] font-bold uppercase underline underline-offset-4">{selectedTypes.length ? `${selectedTypes.length} selected` : "All types in this view"}</summary>
|
|
<div className="mt-4 grid max-h-64 gap-3 overflow-y-auto sm:grid-cols-2">
|
|
{availableTypes.map((type) => (
|
|
<label className="flex items-start gap-2 font-mono text-[10px] leading-4" key={type}>
|
|
<input className="mt-0.5 size-4 accent-[var(--accent)]" defaultChecked={selectedTypes.includes(type)} name="type" type="checkbox" value={type} />
|
|
<span className="break-all">{type}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</details>
|
|
</fieldset>
|
|
</div>
|
|
<div className="mt-5 flex flex-wrap gap-4">
|
|
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Apply filters</button>
|
|
<Link className="self-center font-mono text-[10px] font-bold uppercase underline underline-offset-4" href="/admin/events">Clear filters</Link>
|
|
</div>
|
|
</form>
|
|
|
|
<p className="mt-8 font-mono text-[10px] uppercase tracking-widest text-muted" role="status">Showing {recentEvents.length} most recent matching events</p>
|
|
<div className="mt-3 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
|
<table className="w-full min-w-[760px] border-collapse text-left">
|
|
<caption className="sr-only">Filtered account manager events</caption>
|
|
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
|
|
<tr><th className="p-4" scope="col">Time</th><th className="p-4" scope="col">Type</th><th className="p-4" scope="col">Subject</th><th className="p-4" scope="col">IP</th><th className="p-4" scope="col">Network</th></tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-line text-xs">
|
|
{recentEvents.map((event) => {
|
|
const ip = eventIpSummary(event.data);
|
|
return (
|
|
<tr className="hover:bg-canvas/60" key={event.id}>
|
|
<td className="whitespace-nowrap p-4 font-mono text-muted"><time dateTime={event.time.toISOString()}>{event.time.toISOString()}</time></td>
|
|
<th className="p-4 text-left font-mono font-bold" scope="row"><Link className="underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/events/${event.id}`}>{event.type}</Link></th>
|
|
<td className="p-4 font-mono text-muted">{event.subject ?? "—"}</td>
|
|
<td className="p-4 font-mono text-muted">{event.ipAddress ?? "—"}</td>
|
|
<td className="p-4"><div className="font-mono text-[10px] font-bold uppercase">{ip.classification ?? "—"}</div><div className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"}</div></td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={5}>No events match these filters.</td></tr>}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|