Implement portal frontend for events

This commit is contained in:
Dominic Ferrando
2026-07-17 10:06:53 -04:00
parent a2bc5f7362
commit ed17f4baf8
5 changed files with 261 additions and 10 deletions
+4 -1
View File
@@ -15,7 +15,10 @@
{
name: "Apparel",
path: "/apparel",
links: [{ name: "Products", path: "/products" }],
links: [
{ name: "Products", path: "/products" },
{ name: "Activity", path: "/activity" },
],
},
];
@@ -0,0 +1,251 @@
<script lang="ts">
import { api } from "$lib/api.js";
import type { EventStatus } from "@blade-and-brawn/api";
import { onMount } from "svelte";
type EventRow = NonNullable<
Awaited<ReturnType<typeof api.events.get>>["data"]
>[number];
const LIMIT = 25;
const STATUS_BADGE: Record<EventStatus, string> = {
waiting: "badge-ghost",
available: "badge-info",
processing: "badge-warning",
failed: "badge-error",
fulfilled: "badge-success",
};
let events = $state<EventRow[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
let offset = $state(0);
let group = $state("");
let status = $state<EventStatus | "">("");
let replayingIds = $state<string[]>([]);
function errorMessage(err: unknown): string {
const value = (err as { value?: { error?: string } })?.value;
return (
value?.error ??
(err instanceof Error ? err.message : "Unknown error")
);
}
function formatLabel(value: string): string {
return value
.replace(/^apparel_/, "")
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function formatDate(value: string | Date | null): string {
if (!value) return "—";
return new Date(value).toLocaleString();
}
function errorSummary(lastError: unknown): string {
if (
lastError &&
typeof lastError === "object" &&
"message" in lastError
) {
return String((lastError as { message: unknown }).message);
}
return "View error";
}
async function refresh() {
loading = true;
loadError = null;
try {
const res = await api.events.get({
query: {
group: group || undefined,
status: status || undefined,
limit: LIMIT,
offset,
},
});
if (res.error) throw res.error;
events = res.data as EventRow[];
} catch (err) {
loadError = errorMessage(err);
} finally {
loading = false;
}
}
function setGroup(value: string) {
group = value;
offset = 0;
refresh();
}
function setStatus(value: string) {
status = value as EventStatus | "";
offset = 0;
refresh();
}
function prevPage() {
offset = Math.max(0, offset - LIMIT);
refresh();
}
function nextPage() {
offset += LIMIT;
refresh();
}
async function replay(id: string) {
replayingIds = [...replayingIds, id];
try {
const res = await api.events({ id }).replay.post();
if (res.error) throw res.error;
await refresh();
} catch (err) {
loadError = errorMessage(err);
} finally {
replayingIds = replayingIds.filter((x) => x !== id);
}
}
onMount(refresh);
</script>
<div class="w-full flex items-center gap-2 flex-wrap mt-10 mb-5">
<select
class="select select-sm w-40"
value={group}
onchange={(e) => setGroup(e.currentTarget.value)}
>
<option value="">All groups</option>
<option value="apparel_sync">Sync</option>
<option value="apparel_order">Orders</option>
</select>
<select
class="select select-sm w-40"
value={status}
onchange={(e) => setStatus(e.currentTarget.value)}
>
<option value="">All statuses</option>
<option value="waiting">Waiting</option>
<option value="available">Available</option>
<option value="processing">Processing</option>
<option value="failed">Failed</option>
<option value="fulfilled">Fulfilled</option>
</select>
<button class="btn btn-sm" onclick={refresh} disabled={loading}>
{loading ? "Refreshing..." : "Refresh"}
</button>
<div class="flex-1"></div>
<button
class="btn btn-sm"
onclick={prevPage}
disabled={offset === 0 || loading}
>
Prev
</button>
<button
class="btn btn-sm"
onclick={nextPage}
disabled={events.length < LIMIT || loading}
>
Next
</button>
</div>
{#if loadError}
<div role="alert" class="alert alert-error w-full mb-4">
<span>{loadError}</span>
</div>
{/if}
<div class="w-full overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Group</th>
<th>Type</th>
<th>Status</th>
<th>Source</th>
<th>Created</th>
<th>Started</th>
<th>Ended</th>
<th>Errors</th>
<th>Rate limits</th>
<th>Last error</th>
<th></th>
</tr>
</thead>
<tbody>
{#each events as event (event.id)}
<tr class="hover:bg-base-300">
<td>{formatLabel(event.group)}</td>
<td>{formatLabel(event.type)}</td>
<td
><div class="badge {STATUS_BADGE[event.status]}">
{event.status}
</div></td
>
<td>{event.source}</td>
<td class="whitespace-nowrap"
>{formatDate(event.created_at)}</td
>
<td class="whitespace-nowrap"
>{formatDate(event.started_at)}</td
>
<td class="whitespace-nowrap"
>{formatDate(event.ended_at)}</td
>
<td>{event.errors}</td>
<td>{event.rate_limits}</td>
<td>
{#if event.last_error}
<details>
<summary
class="cursor-pointer text-error text-sm"
>
{errorSummary(event.last_error)}
</summary>
<pre
class="text-xs whitespace-pre-wrap max-w-md">{JSON.stringify(
event.last_error,
null,
2,
)}</pre>
</details>
{:else}
{/if}
</td>
<td>
{#if event.status === "failed"}
<button
class="btn btn-xs"
disabled={replayingIds.includes(event.id)}
onclick={() => replay(event.id)}
>
{replayingIds.includes(event.id)
? "Replaying..."
: "Replay"}
</button>
{/if}
</td>
</tr>
{:else}
<tr>
<td colspan="11" class="text-center opacity-60">
{loading ? "Loading..." : "No events found"}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
@@ -2,18 +2,14 @@
import type { Printful } from "@blade-and-brawn/commerce";
import { onMount } from "svelte";
import { api } from "$lib/api.js";
type SyncStatus =
| "waiting"
| "available"
| "processing"
| "failed"
| "fulfilled";
import type { EventStatus } from "@blade-and-brawn/api";
const { data } = $props();
type SyncStatus = EventStatus | "none";
const synchronizer = $state({
status: "none" as SyncStatus | "none",
status: "none" as SyncStatus,
syncingPProductIds: [] as number[],
get isInProgress() {
return (