Push the rest

This commit is contained in:
2026-05-11 10:58:46 +02:00
parent adb5c1a439
commit 0031caf16c
94 changed files with 11777 additions and 3474 deletions

View File

@@ -0,0 +1,108 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { getAuditLog } from "../../api/gateway";
const DOMAIN_OPTIONS = ["", "aw", "wwi", "platform"];
export default function AuditPage() {
const [domain, setDomain] = useState("");
const query = useQuery({
queryKey: ["audit", domain],
queryFn: () => getAuditLog(200, domain || undefined),
staleTime: 30_000,
});
return (
<div className="flex flex-col gap-6 max-w-[1100px] mx-auto">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-semibold text-[rgba(233,244,255,0.92)]">Audit Log</h1>
<p className="text-sm text-[rgba(233,244,255,0.5)] mt-1">
All system actions recorded across services
</p>
</div>
<div className="flex items-center gap-3">
<select
className="form-input text-sm"
value={domain}
onChange={(e) => setDomain(e.target.value)}
>
<option value="">All domains</option>
{DOMAIN_OPTIONS.filter(Boolean).map((d) => (
<option key={d} value={d}>
{d.toUpperCase()}
</option>
))}
</select>
<button
className="btn-secondary"
onClick={() => void query.refetch()}
disabled={query.isFetching}
>
{query.isFetching ? "Loading…" : "Refresh"}
</button>
</div>
</div>
<div className="card">
{query.isLoading ? (
<div className="text-[rgba(233,244,255,0.4)] text-sm py-8 text-center">Loading</div>
) : query.isError ? (
<div className="text-red-400 text-sm py-8 text-center">
Failed to load audit log.
</div>
) : (query.data?.length ?? 0) === 0 ? (
<div className="text-[rgba(233,244,255,0.4)] text-sm py-8 text-center">
No entries found.
</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>Domain</th>
<th>Service</th>
<th>Entity</th>
<th>Actor</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{query.data!.map((r) => (
<tr key={r.id}>
<td className="text-xs text-[rgba(233,244,255,0.5)] whitespace-nowrap">
{new Date(r.occurred_at).toLocaleString()}
</td>
<td className="font-mono text-xs">{r.action}</td>
<td className="text-xs">{r.domain}</td>
<td className="text-xs text-[rgba(233,244,255,0.5)]">{r.service}</td>
<td className="text-xs">{r.entity_type}</td>
<td className="text-xs text-[rgba(233,244,255,0.5)] max-w-[120px] truncate">
{r.actor_id}
</td>
<td>
<span
className={
r.status === "success"
? "badge badge-low"
: r.status === "failure"
? "badge badge-high"
: "badge"
}
>
{r.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { getExportHistory } from "../../api/gateway";
const DOMAIN_OPTIONS = ["", "aw", "wwi"];
function formatBytes(n: number) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
export default function ExportsPage() {
const [domain, setDomain] = useState("");
const query = useQuery({
queryKey: ["exports", domain],
queryFn: () => getExportHistory(200, domain || undefined),
staleTime: 30_000,
});
return (
<div className="flex flex-col gap-6 max-w-[1100px] mx-auto">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-semibold text-[rgba(233,244,255,0.92)]">
Export History
</h1>
<p className="text-sm text-[rgba(233,244,255,0.5)] mt-1">
All data exports generated across domains
</p>
</div>
<div className="flex items-center gap-3">
<select
className="form-input text-sm"
value={domain}
onChange={(e) => setDomain(e.target.value)}
>
<option value="">All domains</option>
{DOMAIN_OPTIONS.filter(Boolean).map((d) => (
<option key={d} value={d}>
{d.toUpperCase()}
</option>
))}
</select>
<button
className="btn-secondary"
onClick={() => void query.refetch()}
disabled={query.isFetching}
>
{query.isFetching ? "Loading…" : "Refresh"}
</button>
</div>
</div>
<div className="card">
{query.isLoading ? (
<div className="text-[rgba(233,244,255,0.4)] text-sm py-8 text-center">Loading</div>
) : query.isError ? (
<div className="text-red-400 text-sm py-8 text-center">
Failed to load export history.
</div>
) : (query.data?.length ?? 0) === 0 ? (
<div className="text-[rgba(233,244,255,0.4)] text-sm py-8 text-center">
No exports recorded yet.
</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>Time</th>
<th>Domain</th>
<th>View</th>
<th>Format</th>
<th>Rows</th>
<th>Size</th>
<th>Actor</th>
</tr>
</thead>
<tbody>
{query.data!.map((r) => (
<tr key={r.id}>
<td className="text-xs text-[rgba(233,244,255,0.5)] whitespace-nowrap">
{new Date(r.exported_at).toLocaleString()}
</td>
<td className="text-xs">{r.domain.toUpperCase()}</td>
<td className="text-xs font-mono">{r.source_view}</td>
<td>
<span className="badge">{r.format.toUpperCase()}</span>
</td>
<td className="text-xs">{r.row_count.toLocaleString()}</td>
<td className="text-xs text-[rgba(233,244,255,0.5)]">
{formatBytes(r.file_size_bytes)}
</td>
<td className="text-xs text-[rgba(233,244,255,0.5)] max-w-[120px] truncate">
{r.actor_id}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,201 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { getAWJobs, getWWIJobs, triggerAWJob, triggerWWIJob } from "../../api/gateway";
import type { JobExecution } from "../../api/types";
const AW_JOBS = [
{ id: "forecast", label: "Daily Forecast" },
{ id: "scores", label: "Rep & Product Scores" },
{ id: "data_quality", label: "Data Quality" },
{ id: "anomaly_detection", label: "Anomaly Detection" },
];
const WWI_JOBS = [
{ id: "reorder", label: "Reorder Recommendations" },
{ id: "supplier_scores", label: "Supplier Scores" },
{ id: "events", label: "Business Events" },
{ id: "data_quality", label: "Data Quality" },
];
function statusClass(status: string) {
if (status === "success") return "badge badge-low";
if (status === "failure") return "badge badge-high";
return "badge";
}
function JobTable({ rows }: { rows: JobExecution[] }) {
if (rows.length === 0) {
return (
<div className="text-[rgba(233,244,255,0.4)] text-sm text-center py-8">
No job history yet.
</div>
);
}
return (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>Job</th>
<th>Status</th>
<th>Started</th>
<th>Duration</th>
<th>Records</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td className="font-mono text-xs">{r.job_name}</td>
<td><span className={statusClass(r.status)}>{r.status}</span></td>
<td className="text-xs text-[rgba(233,244,255,0.5)]">
{new Date(r.started_at).toLocaleString()}
</td>
<td className="text-xs">
{r.duration_ms != null ? `${r.duration_ms.toLocaleString()} ms` : "—"}
</td>
<td className="text-xs">{r.records_processed ?? "—"}</td>
<td className="text-xs text-red-400 max-w-[200px] truncate">
{r.error_message ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function TriggerButton({
jobId,
label,
domain,
onTrigger,
}: {
jobId: string;
label: string;
domain: "aw" | "wwi";
onTrigger: (id: string) => Promise<unknown>;
}) {
const [triggered, setTriggered] = useState(false);
const mutation = useMutation({
mutationFn: () => onTrigger(jobId),
onSuccess: () => setTriggered(true),
});
return (
<button
className="btn-secondary text-xs"
onClick={() => mutation.mutate()}
disabled={mutation.isPending || triggered}
title={`Trigger ${domain.toUpperCase()} ${label}`}
>
{mutation.isPending ? "Triggering…" : triggered ? "Triggered" : `${label}`}
</button>
);
}
export default function OperationsPage() {
const qc = useQueryClient();
const awJobs = useQuery({
queryKey: ["jobs", "aw"],
queryFn: () => getAWJobs(50),
staleTime: 30_000,
});
const wwiJobs = useQuery({
queryKey: ["jobs", "wwi"],
queryFn: () => getWWIJobs(50),
staleTime: 30_000,
});
const refresh = () => {
void qc.invalidateQueries({ queryKey: ["jobs"] });
};
return (
<div className="flex flex-col gap-6 max-w-[1100px] mx-auto">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-semibold text-[rgba(233,244,255,0.92)]">Operations</h1>
<p className="text-sm text-[rgba(233,244,255,0.5)] mt-1">
Scheduled job history and manual triggers
</p>
</div>
<button className="btn-secondary" onClick={refresh}>
Refresh
</button>
</div>
{/* Manual triggers */}
<div className="card">
<h2 className="text-base font-semibold text-[rgba(233,244,255,0.8)] mb-3">
Manual Job Triggers
</h2>
<div className="flex flex-col gap-4">
<div>
<div className="text-xs text-[rgba(233,244,255,0.4)] uppercase tracking-wider mb-2">
AdventureWorks
</div>
<div className="flex flex-wrap gap-2">
{AW_JOBS.map((j) => (
<TriggerButton
key={j.id}
jobId={j.id}
label={j.label}
domain="aw"
onTrigger={triggerAWJob}
/>
))}
</div>
</div>
<div>
<div className="text-xs text-[rgba(233,244,255,0.4)] uppercase tracking-wider mb-2">
WideWorldImporters
</div>
<div className="flex flex-wrap gap-2">
{WWI_JOBS.map((j) => (
<TriggerButton
key={j.id}
jobId={j.id}
label={j.label}
domain="wwi"
onTrigger={triggerWWIJob}
/>
))}
</div>
</div>
</div>
</div>
{/* AW job history */}
<div className="card">
<h2 className="text-base font-semibold text-[rgba(233,244,255,0.8)] mb-3">
AdventureWorks Job History
</h2>
{awJobs.isLoading ? (
<div className="text-[rgba(233,244,255,0.4)] text-sm py-4">Loading</div>
) : awJobs.isError ? (
<div className="text-red-400 text-sm py-4">Failed to load job history.</div>
) : (
<JobTable rows={awJobs.data ?? []} />
)}
</div>
{/* WWI job history */}
<div className="card">
<h2 className="text-base font-semibold text-[rgba(233,244,255,0.8)] mb-3">
WideWorldImporters Job History
</h2>
{wwiJobs.isLoading ? (
<div className="text-[rgba(233,244,255,0.4)] text-sm py-4">Loading</div>
) : wwiJobs.isError ? (
<div className="text-red-400 text-sm py-4">Failed to load job history.</div>
) : (
<JobTable rows={wwiJobs.data ?? []} />
)}
</div>
</div>
);
}