Push the rest
This commit is contained in:
108
frontend/src/pages/ops/AuditPage.tsx
Normal file
108
frontend/src/pages/ops/AuditPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
frontend/src/pages/ops/ExportsPage.tsx
Normal file
108
frontend/src/pages/ops/ExportsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
frontend/src/pages/ops/OperationsPage.tsx
Normal file
201
frontend/src/pages/ops/OperationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user