Implement portal frontend for events
This commit is contained in:
@@ -367,3 +367,4 @@ app.listen(3000, async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type API = typeof app
|
export type API = typeof app
|
||||||
|
export { type EventStatus } from "./services/events";
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export class EventsService {
|
|||||||
} = {}, trx?: Transaction<DB>) {
|
} = {}, trx?: Transaction<DB>) {
|
||||||
return await (trx ?? db).selectFrom("events")
|
return await (trx ?? db).selectFrom("events")
|
||||||
.select([
|
.select([
|
||||||
"id", "type", "source", "started_at", "ended_at", "available_at",
|
"id", "group", "type", "source", "started_at", "ended_at", "available_at",
|
||||||
"errors", "rate_limits", "has_failed", "last_error", "created_at",
|
"errors", "rate_limits", "has_failed", "last_error", "created_at",
|
||||||
])
|
])
|
||||||
.$if(opt.filter?.group !== undefined, (qb) => qb.where("group", "=", opt.filter!.group!))
|
.$if(opt.filter?.group !== undefined, (qb) => qb.where("group", "=", opt.filter!.group!))
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
{
|
{
|
||||||
name: "Apparel",
|
name: "Apparel",
|
||||||
path: "/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 type { Printful } from "@blade-and-brawn/commerce";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { api } from "$lib/api.js";
|
import { api } from "$lib/api.js";
|
||||||
|
import type { EventStatus } from "@blade-and-brawn/api";
|
||||||
type SyncStatus =
|
|
||||||
| "waiting"
|
|
||||||
| "available"
|
|
||||||
| "processing"
|
|
||||||
| "failed"
|
|
||||||
| "fulfilled";
|
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
|
|
||||||
|
type SyncStatus = EventStatus | "none";
|
||||||
|
|
||||||
const synchronizer = $state({
|
const synchronizer = $state({
|
||||||
status: "none" as SyncStatus | "none",
|
status: "none" as SyncStatus,
|
||||||
syncingPProductIds: [] as number[],
|
syncingPProductIds: [] as number[],
|
||||||
get isInProgress() {
|
get isInProgress() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user