feat(ui): tactical operation primitives and role surfaces
Reusable dossier primitives (classification strips, brackets, readouts, provenance chips, notice states) plus server-rendered /operations overview, ledger drill-down, and AAR with permission-gated sections and an authorized reconciliation panel. Realtime refresh is enhancement only. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
3c70160dfc
commit
c19d0d05ee
26 changed files with 2311 additions and 0 deletions
79
src/app/(frontend)/operations/[operationId]/page.tsx
Normal file
79
src/app/(frontend)/operations/[operationId]/page.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import { getOperationAar } from "@/lib/operations/surfaceQuery";
|
||||
import {
|
||||
AarView,
|
||||
NoticeState,
|
||||
OperationsRealtimeRefresher,
|
||||
OperationsStrip,
|
||||
PageShell,
|
||||
ReconcilePanel,
|
||||
} from "@/components/frontend/operations";
|
||||
import type { ReconcilableEvent } from "@/components/frontend/operations";
|
||||
|
||||
export const metadata = {
|
||||
title: "Operation AAR: Polaris Task Force",
|
||||
};
|
||||
|
||||
export default async function OperationAarPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ operationId: string }>;
|
||||
}) {
|
||||
const { operationId } = await params;
|
||||
const payload = await getPayload({ config });
|
||||
const { user: authUser } = await payload.auth({
|
||||
headers: await nextHeaders(),
|
||||
canSetHeaders: false,
|
||||
});
|
||||
const user = isPayloadUser(authUser) ? authUser : null;
|
||||
const surfaceUser = user ? { id: user.id } : null;
|
||||
|
||||
const aar = await getOperationAar(payload, surfaceUser, operationId);
|
||||
const canReconcile = user ? await hasPermission(payload, user, "operation-events:update") : false;
|
||||
const reconcilable: ReconcilableEvent[] =
|
||||
aar && canReconcile
|
||||
? aar.events
|
||||
.filter((ev) => ev.status !== "validated")
|
||||
.map((ev) => ({
|
||||
id: ev.id,
|
||||
eventId: ev.eventId,
|
||||
status: ev.status,
|
||||
errorCode: ev.errorCode,
|
||||
errorMessage: ev.errorMessage,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||
<OperationsRealtimeRefresher />
|
||||
<PageShell title={`Operation ${operationId}`} meta="AFTER-ACTION REPORT // PROVENANCE LINKED">
|
||||
{aar === null ? (
|
||||
<NoticeState
|
||||
variant="empty"
|
||||
message={`No operation events are recorded under "${operationId}".`}
|
||||
hint="The operation id may be wrong, or its events have not synced yet. Check the operations ledger."
|
||||
action={
|
||||
<Link
|
||||
href="/operations/ledger"
|
||||
className="border border-white/25 px-3 py-1.5 font-mono text-[10px] uppercase tracking-widest text-white/80 hover:border-white/40 hover:text-white"
|
||||
>
|
||||
Open operations ledger
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<AarView aar={aar} />
|
||||
<ReconcilePanel events={reconcilable} />
|
||||
</>
|
||||
)}
|
||||
</PageShell>
|
||||
<OperationsStrip left="OPERATION AAR // UNIT COMMAND" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/app/(frontend)/operations/actions.ts
Normal file
48
src/app/(frontend)/operations/actions.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { replayOperationEvent } from "@/lib/operations/reconcile";
|
||||
import { backfillOperationEvents } from "@/lib/operations/rollout";
|
||||
|
||||
export interface ActionResult<T> {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
const { headers } = await import("next/headers");
|
||||
const payload = await getPayload({ config });
|
||||
const { user: authUser } = await payload.auth({
|
||||
headers: await headers(),
|
||||
canSetHeaders: false,
|
||||
});
|
||||
return { payload, user: isPayloadUser(authUser) ? authUser : null };
|
||||
}
|
||||
|
||||
export async function reconcileOperationEvent(operationEventId: number): Promise<ActionResult<{ status?: string; effectsCreated?: number }>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!user) return { success: false, error: "Authentication required." };
|
||||
const result = await replayOperationEvent(payload, user, operationEventId);
|
||||
if (!result.ok) return { success: false, error: result.error ?? "Replay failed." };
|
||||
return { success: true, data: { status: result.status, effectsCreated: result.effectsCreated } };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function reconcileBackfill(): Promise<ActionResult<{ processed: number; skipped: number }>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!user) return { success: false, error: "Authentication required." };
|
||||
const result = await backfillOperationEvents(payload, user);
|
||||
if (result.forbidden) return { success: false, error: "forbidden" };
|
||||
return { success: true, data: { processed: result.processed, skipped: result.skipped } };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
65
src/app/(frontend)/operations/ledger/page.tsx
Normal file
65
src/app/(frontend)/operations/ledger/page.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { getOperationLedger } from "@/lib/operations/surfaceQuery";
|
||||
import {
|
||||
LedgerTable,
|
||||
OperationsRealtimeRefresher,
|
||||
OperationsStrip,
|
||||
PageShell,
|
||||
} from "@/components/frontend/operations";
|
||||
import type { LedgerTableFilters } from "@/components/frontend/operations";
|
||||
|
||||
export const metadata = {
|
||||
title: "Operations Ledger: Polaris Task Force",
|
||||
};
|
||||
|
||||
interface LedgerSearchParams {
|
||||
operationId?: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
page?: string;
|
||||
}
|
||||
|
||||
export default async function OperationsLedgerPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<LedgerSearchParams>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const payload = await getPayload({ config });
|
||||
const { user: authUser } = await payload.auth({
|
||||
headers: await nextHeaders(),
|
||||
canSetHeaders: false,
|
||||
});
|
||||
const user = isPayloadUser(authUser) ? authUser : null;
|
||||
const surfaceUser = user ? { id: user.id } : null;
|
||||
|
||||
const page = Number.parseInt(params.page ?? "1", 10);
|
||||
const filters: LedgerTableFilters = {
|
||||
operationId: params.operationId || undefined,
|
||||
status: params.status || undefined,
|
||||
type: params.type || undefined,
|
||||
};
|
||||
const result = await getOperationLedger(
|
||||
payload,
|
||||
surfaceUser,
|
||||
filters,
|
||||
Number.isNaN(page) ? 1 : page,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||
<OperationsRealtimeRefresher />
|
||||
<PageShell title="Operations ledger" meta="SOURCE EVENTS // EFFECT LEDGER">
|
||||
<p className="max-w-2xl text-sm text-white/70">
|
||||
Every validated, rejected, and dead-lettered operation event with its derived effects.
|
||||
Rejected and dead-lettered entries are retained as evidence and never applied.
|
||||
</p>
|
||||
</PageShell>
|
||||
<LedgerTable result={result} filters={filters} />
|
||||
<OperationsStrip left="OPERATIONS LEDGER // UNIT COMMAND" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/app/(frontend)/operations/page.tsx
Normal file
59
src/app/(frontend)/operations/page.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { getOperationOverview } from "@/lib/operations/surface";
|
||||
import {
|
||||
MyExtractionsCard,
|
||||
OperationsRealtimeRefresher,
|
||||
OperationsStrip,
|
||||
PageShell,
|
||||
PressureCard,
|
||||
ReadinessCard,
|
||||
RecentOperationsCard,
|
||||
ReservationsCard,
|
||||
} from "@/components/frontend/operations";
|
||||
|
||||
export const metadata = {
|
||||
title: "Operations: Polaris Task Force",
|
||||
};
|
||||
|
||||
export default async function OperationsPage() {
|
||||
const payload = await getPayload({ config });
|
||||
const { user: authUser } = await payload.auth({
|
||||
headers: await nextHeaders(),
|
||||
canSetHeaders: false,
|
||||
});
|
||||
const user = isPayloadUser(authUser) ? authUser : null;
|
||||
const surfaceUser = user ? { id: user.id } : null;
|
||||
|
||||
const overview = await getOperationOverview(payload, surfaceUser);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||
<OperationsRealtimeRefresher />
|
||||
<PageShell title="Operations" meta="OPERATION FEED // AFTER-ACTION RECORDS">
|
||||
<p className="max-w-2xl text-sm text-white/70">
|
||||
Live and historical operation activity: extraction returns, settlements, readiness
|
||||
aggregates, and zone pressure. Every effect here is provenance-linked to a validated
|
||||
source event from the Arma bridge.
|
||||
</p>
|
||||
</PageShell>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<RecentOperationsCard operations={overview.recentOperations} />
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<MyExtractionsCard extractions={overview.myExtractions} signedIn={user !== null} />
|
||||
<ReadinessCard readiness={overview.ownReadiness} canSee={overview.capabilities.canSeeReadiness} />
|
||||
<ReservationsCard
|
||||
reservations={overview.activeReservations}
|
||||
canSee={overview.capabilities.canSeeReservations}
|
||||
/>
|
||||
<PressureCard pressure={overview.pressure} canSee={overview.capabilities.canSeePressure} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OperationsStrip left="OPERATIONS // UNIT COMMAND" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -153,6 +153,7 @@ export function AppSidebar({
|
|||
{hasIntelligence && (
|
||||
<NavCore label="Intelligence" items={data.navIntelligence} />
|
||||
)}
|
||||
<NavCore label="Operations" items={data.navOperations} />
|
||||
{hasLogistics && (
|
||||
<NavCore label="Infrastructure" items={data.navLogistics} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Kanban,
|
||||
LifeBuoy,
|
||||
Package,
|
||||
Radar,
|
||||
ScrollText,
|
||||
Search,
|
||||
ShoppingBag,
|
||||
|
|
@ -66,6 +67,18 @@ export const sidebarData = {
|
|||
icon: BookOpen,
|
||||
},
|
||||
],
|
||||
navOperations: [
|
||||
{
|
||||
title: "Ops Overview",
|
||||
url: "/operations",
|
||||
icon: Radar,
|
||||
},
|
||||
{
|
||||
title: "Ops Ledger",
|
||||
url: "/operations/ledger",
|
||||
icon: ScrollText,
|
||||
},
|
||||
],
|
||||
navLogistics: [
|
||||
{
|
||||
title: "Assets",
|
||||
|
|
|
|||
158
src/components/frontend/operations/AarView.tsx
Normal file
158
src/components/frontend/operations/AarView.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import Link from "next/link";
|
||||
import { LedgerRow, type ReadoutCell } from "./LedgerRow";
|
||||
import { NoticeState } from "./NoticeState";
|
||||
import { ProvenanceChip } from "./ProvenanceChip";
|
||||
import { STATUS_TONE_CLASSES, statusToneFor } from "./tokens";
|
||||
import type { AarResult } from "@/lib/operations/surfaceQuery";
|
||||
|
||||
const headCell = "px-3 py-2 text-left font-mono text-[9px] uppercase tracking-widest text-white/50";
|
||||
const cell = "px-3 py-2 align-middle text-xs";
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section data-slot="aar-section" className="flex flex-col gap-2">
|
||||
<h2 className="font-mono text-[9px] uppercase tracking-widest text-white/60">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function countsRow(aar: AarResult): ReadoutCell[] {
|
||||
return [
|
||||
{ label: "Events", value: String(aar.counts.events), mono: true },
|
||||
{ label: "Validated", value: String(aar.counts.validated), mono: true },
|
||||
{ label: "Rejected", value: String(aar.counts.rejected), mono: true },
|
||||
{ label: "Applied", value: String(aar.counts.applied), mono: true },
|
||||
{ label: "Failed", value: String(aar.counts.failed), mono: true },
|
||||
{ label: "Pending", value: String(aar.counts.pending), mono: true },
|
||||
];
|
||||
}
|
||||
|
||||
export function AarView({ aar }: { aar: AarResult }) {
|
||||
const failedEffects = aar.effects.filter((e) => e.status === "failed" || e.status === "reversed");
|
||||
const appliedEffects = aar.effects.filter((e) => e.status === "applied");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6" data-slot="operations-aar-view">
|
||||
<LedgerRow cells={countsRow(aar)} />
|
||||
|
||||
<Section title="Timeline">
|
||||
{aar.events.length === 0 ? (
|
||||
<NoticeState variant="empty" message="No events recorded for this operation." />
|
||||
) : (
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className={headCell}>Event</th>
|
||||
<th className={headCell}>Type</th>
|
||||
<th className={headCell}>Status</th>
|
||||
<th className={headCell}>Occurred</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{aar.events.map((ev) => (
|
||||
<tr key={ev.id} className="border-b border-white/5 last:border-0">
|
||||
<td className={`${cell} font-mono text-white/85`}>{ev.eventId}</td>
|
||||
<td className={`${cell} font-mono`}>{ev.type}</td>
|
||||
<td className={cell}>
|
||||
<span
|
||||
className={`border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[statusToneFor(ev.status)]}`}
|
||||
>
|
||||
{ev.status}
|
||||
</span>
|
||||
{ev.errorCode && (
|
||||
<span className="ml-2 text-[10px] text-rose-300">
|
||||
{ev.errorCode}: {ev.errorMessage ?? "see notes"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={`${cell} font-mono tabular-nums text-white/70`}>{ev.occurredAt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Applied effects">
|
||||
{appliedEffects.length === 0 ? (
|
||||
<NoticeState variant="empty" message="No applied effects yet for this operation." />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{appliedEffects.map((eff) => (
|
||||
<li
|
||||
key={eff.id}
|
||||
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 truncate font-mono text-xs text-white/80">{eff.effectKey}</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest text-white/50">
|
||||
{eff.effectType}
|
||||
</span>
|
||||
<ProvenanceChip label={eff.status} href="/operations/ledger" />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Failed or reversed effects">
|
||||
{failedEffects.length === 0 ? (
|
||||
<NoticeState variant="empty" message="No failed or reversed effects. Clean run." />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{failedEffects.map((eff) => (
|
||||
<li key={eff.id} className="border border-rose-500/30 px-2 py-1.5">
|
||||
<p className="font-mono text-xs text-rose-300">{eff.effectKey}</p>
|
||||
<p className="text-xs text-white/70">
|
||||
{eff.notes ?? "Effect did not apply; see the operations ledger for reconciliation state."}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Zone pressure">
|
||||
{aar.pressure.length === 0 ? (
|
||||
<NoticeState variant="empty" message="No pressure effects recorded for this operation." />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{aar.pressure.map((p, i) => (
|
||||
<li key={`${p.zoneId}-${i}`} className="flex items-center justify-between border border-white/10 px-2 py-1.5">
|
||||
<span className="font-mono text-xs text-white/80">Zone {p.zoneId}</span>
|
||||
<span
|
||||
className={`font-mono text-[10px] uppercase tracking-widest ${p.delta >= 0 ? "text-emerald-400" : "text-rose-400"}`}
|
||||
>
|
||||
{p.delta >= 0 ? `+${p.delta}` : p.delta}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Reservations">
|
||||
{aar.reservations.length === 0 ? (
|
||||
<NoticeState variant="empty" message="No reservations linked to this operation." />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{aar.reservations.map((r) => (
|
||||
<li key={r.reservationKey} className="flex items-center justify-between border border-white/10 px-2 py-1.5">
|
||||
<span className="min-w-0 truncate font-mono text-xs text-white/80">{r.reservationKey}</span>
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest text-white/50">{r.status}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<p className="text-xs text-white/50">
|
||||
<Link href="/operations/ledger" className="underline-offset-4 hover:underline">
|
||||
Open the full operations ledger
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/components/frontend/operations/ClassificationStrip.tsx
Normal file
14
src/components/frontend/operations/ClassificationStrip.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { DOSSIER } from "./tokens";
|
||||
|
||||
/**
|
||||
* Full-width classification strip: a label at both ends in mono uppercase.
|
||||
* Carries real information (origin + reference), so it is not aria-hidden.
|
||||
*/
|
||||
export function ClassificationStrip({ left, right }: { left: string; right: string }) {
|
||||
return (
|
||||
<div className={`${DOSSIER.strip} flex items-center justify-between`} data-slot="classification-strip">
|
||||
<span className={DOSSIER.stripLabel}>{left}</span>
|
||||
<span className={DOSSIER.stripLabel}>{right}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/components/frontend/operations/CornerBrackets.tsx
Normal file
15
src/components/frontend/operations/CornerBrackets.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Four corner L-marks, inset from the edges. Decorative only.
|
||||
* Brightens on the PARENT's group-hover; all motion is transition-only and
|
||||
* disabled under prefers-reduced-motion.
|
||||
*/
|
||||
export function CornerBrackets() {
|
||||
return (
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-2 z-30" data-slot="corner-brackets">
|
||||
<span className="absolute left-0 top-0 size-3 border-l border-t border-white/25 transition-colors duration-300 group-hover:border-white/60 motion-reduce:transition-none" />
|
||||
<span className="absolute right-0 top-0 size-3 border-r border-t border-white/25 transition-colors duration-300 group-hover:border-white/60 motion-reduce:transition-none" />
|
||||
<span className="absolute bottom-0 left-0 size-3 border-b border-l border-white/25 transition-colors duration-300 group-hover:border-white/60 motion-reduce:transition-none" />
|
||||
<span className="absolute bottom-0 right-0 size-3 border-b border-r border-white/25 transition-colors duration-300 group-hover:border-white/60 motion-reduce:transition-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/components/frontend/operations/LedgerRow.tsx
Normal file
32
src/components/frontend/operations/LedgerRow.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { DOSSIER } from "./tokens";
|
||||
|
||||
export interface ReadoutCell {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanent single-line readout footer: cells separated by hairlines, each a
|
||||
* mono tabular value plus a small uppercase label. Presentational only.
|
||||
*/
|
||||
export function LedgerRow({ cells }: { cells: readonly ReadoutCell[] }) {
|
||||
return (
|
||||
<footer
|
||||
data-slot="ledger-row"
|
||||
className={`flex items-stretch ${DOSSIER.readout} ${cells.length ? "" : "hidden"}`}
|
||||
>
|
||||
{cells.map((cell, index) => (
|
||||
<div
|
||||
key={cell.label}
|
||||
className="flex min-w-0 flex-1 items-center justify-center gap-1.5 border-r border-white/15 px-1 last:border-r-0"
|
||||
>
|
||||
<span className={`truncate text-xs text-white/80 ${cell.mono ? "font-mono tabular-nums" : ""}`}>
|
||||
{cell.value}
|
||||
</span>
|
||||
<span className={`shrink-0 ${DOSSIER.readoutLabel}`}>{cell.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
137
src/components/frontend/operations/LedgerTable.tsx
Normal file
137
src/components/frontend/operations/LedgerTable.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import Link from "next/link";
|
||||
import { LedgerRow, type ReadoutCell } from "./LedgerRow";
|
||||
import { NoticeState } from "./NoticeState";
|
||||
import { STATUS_TONE_CLASSES, statusToneFor } from "./tokens";
|
||||
import type { LedgerResult } from "@/lib/operations/surfaceQuery";
|
||||
|
||||
const headCell = "px-3 py-2 text-left font-mono text-[9px] uppercase tracking-widest text-white/50";
|
||||
const cell = "px-3 py-2 align-middle text-xs";
|
||||
|
||||
export interface LedgerTableFilters {
|
||||
operationId?: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
function filterHref(filters: LedgerTableFilters, page: number): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.operationId) params.set("operationId", filters.operationId);
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.type) params.set("type", filters.type);
|
||||
if (page > 1) params.set("page", String(page));
|
||||
const qs = params.toString();
|
||||
return qs ? `/operations/ledger?${qs}` : "/operations/ledger";
|
||||
}
|
||||
|
||||
export function LedgerTable({
|
||||
result,
|
||||
filters,
|
||||
}: {
|
||||
result: LedgerResult;
|
||||
filters: LedgerTableFilters;
|
||||
}) {
|
||||
const countCells: ReadoutCell[] = Object.entries(result.effectCounts).map(([status, count]) => ({
|
||||
label: status,
|
||||
value: String(count),
|
||||
mono: true,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3" data-slot="operations-ledger-table">
|
||||
{countCells.length > 0 && <LedgerRow cells={countCells} />}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(["", "validated", "rejected", "dead-letter"] as const).map((status) => (
|
||||
<Link
|
||||
key={status || "all"}
|
||||
href={filterHref({ ...filters, status: status || undefined }, 1)}
|
||||
className={`border px-2 py-1 font-mono text-[10px] uppercase tracking-widest ${
|
||||
(filters.status ?? "") === status
|
||||
? "border-white/40 bg-white/10 text-white"
|
||||
: "border-white/15 text-white/60 hover:border-white/30 hover:text-white/90"
|
||||
}`}
|
||||
>
|
||||
{status || "all statuses"}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{result.events.length === 0 ? (
|
||||
<NoticeState
|
||||
variant="empty"
|
||||
message="No operation events match this view."
|
||||
hint="Adjust the filters, or wait for the next operation to report in."
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className={headCell}>Event</th>
|
||||
<th className={headCell}>Operation</th>
|
||||
<th className={headCell}>Type</th>
|
||||
<th className={headCell}>Status</th>
|
||||
<th className={headCell}>Occurred</th>
|
||||
<th className={headCell}>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.events.map((ev) => (
|
||||
<tr key={ev.id} className="border-b border-white/5 last:border-0">
|
||||
<td className={`${cell} font-mono text-white/85`}>{ev.eventId}</td>
|
||||
<td className={cell}>
|
||||
<Link
|
||||
href={`/operations/${encodeURIComponent(ev.operationId)}`}
|
||||
className="font-mono text-[10px] uppercase tracking-widest text-white/60 underline-offset-4 hover:underline"
|
||||
>
|
||||
{ev.operationId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className={`${cell} font-mono`}>{ev.type}</td>
|
||||
<td className={cell}>
|
||||
<span
|
||||
className={`border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[statusToneFor(ev.status)]}`}
|
||||
>
|
||||
{ev.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`${cell} font-mono tabular-nums text-white/70`}>{ev.occurredAt}</td>
|
||||
<td className={cell}>
|
||||
{ev.errorCode || ev.errorMessage ? (
|
||||
<span className="text-rose-300">
|
||||
{ev.errorCode ?? "error"}: {ev.errorMessage ?? "see reconciliation notes"}
|
||||
</span>
|
||||
) : ev.notes ? (
|
||||
<span className="text-white/60">{ev.notes}</span>
|
||||
) : (
|
||||
<span className="text-white/30">n/a</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest text-white/50">
|
||||
Page {result.page} of {Math.max(1, result.totalPages)} ({result.totalDocs} events)
|
||||
</span>
|
||||
<span className="flex gap-2">
|
||||
{result.page > 1 && (
|
||||
<Link
|
||||
href={filterHref(filters, result.page - 1)}
|
||||
className="border border-white/15 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/70 hover:border-white/30 hover:text-white"
|
||||
>
|
||||
Prev
|
||||
</Link>
|
||||
)}
|
||||
{result.page < result.totalPages && (
|
||||
<Link
|
||||
href={filterHref(filters, result.page + 1)}
|
||||
className="border border-white/15 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/70 hover:border-white/30 hover:text-white"
|
||||
>
|
||||
Next
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/frontend/operations/NoticeState.tsx
Normal file
37
src/components/frontend/operations/NoticeState.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Empty/error/loading notice states for operations surfaces.
|
||||
* `error` renders with role="alert" (assertive); empty/loading render with
|
||||
* role="status" (polite). Text is the message; optional action passes through.
|
||||
*/
|
||||
export function NoticeState({
|
||||
variant,
|
||||
message,
|
||||
hint,
|
||||
action,
|
||||
}: {
|
||||
variant: "empty" | "error" | "loading";
|
||||
message: string;
|
||||
hint?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
const role = variant === "error" ? "alert" : "status";
|
||||
const tone =
|
||||
variant === "error"
|
||||
? "border-rose-500/30 bg-rose-500/5 text-rose-300"
|
||||
: "border-border/60 bg-card/50 text-muted-foreground";
|
||||
return (
|
||||
<div
|
||||
role={role}
|
||||
data-slot="notice-state"
|
||||
data-variant={variant}
|
||||
aria-busy={variant === "loading" || undefined}
|
||||
className={`flex flex-col items-center justify-center gap-1.5 rounded-none border border-dashed px-6 py-10 text-center ${tone}`}
|
||||
>
|
||||
<p className="text-sm font-medium">{message}</p>
|
||||
{hint ? <p className="max-w-prose text-xs text-muted-foreground">{hint}</p> : null}
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
src/components/frontend/operations/OperationSections.tsx
Normal file
237
src/components/frontend/operations/OperationSections.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import Link from "next/link";
|
||||
import { CornerBrackets } from "./CornerBrackets";
|
||||
import { ClassificationStrip } from "./ClassificationStrip";
|
||||
import { LedgerRow, type ReadoutCell } from "./LedgerRow";
|
||||
import { NoticeState } from "./NoticeState";
|
||||
import { ProvenanceChip } from "./ProvenanceChip";
|
||||
import { DOSSIER } from "./tokens";
|
||||
import type { SurfaceOverview } from "@/lib/operations/surface";
|
||||
|
||||
const cell = "px-3 py-2 align-middle text-xs";
|
||||
const headCell = "px-3 py-2 text-left font-mono text-[9px] uppercase tracking-widest text-white/50";
|
||||
|
||||
function CardShell({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="group relative overflow-hidden rounded-none border border-border/70 bg-card" data-slot="operation-card">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-[5] opacity-[0.04] transition-opacity duration-500 group-hover:opacity-[0.07] motion-reduce:transition-none"
|
||||
style={DOSSIER.gridTexture}
|
||||
/>
|
||||
<CornerBrackets />
|
||||
<header className={`${DOSSIER.strip} relative z-10`}>
|
||||
<h2 className={DOSSIER.stripLabel}>{title}</h2>
|
||||
</header>
|
||||
<div className="relative z-10 p-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentOperationsCard({ operations }: { operations: SurfaceOverview["recentOperations"] }) {
|
||||
return (
|
||||
<CardShell title="Recent operations">
|
||||
{operations.length === 0 ? (
|
||||
<NoticeState variant="empty" message="No operation activity recorded yet." />
|
||||
) : (
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className={headCell}>Operation</th>
|
||||
<th className={headCell}>Events</th>
|
||||
<th className={headCell}>Applied</th>
|
||||
<th className={headCell}>Failed</th>
|
||||
<th className={headCell}>Rejected</th>
|
||||
<th className={headCell}>Last activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{operations.map((op) => (
|
||||
<tr key={op.operationId} className="border-b border-white/5 last:border-0">
|
||||
<td className={cell}>
|
||||
<Link
|
||||
href={`/operations/${encodeURIComponent(op.operationId)}`}
|
||||
className="font-mono text-xs text-white/90 underline-offset-4 hover:underline"
|
||||
>
|
||||
{op.operationId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className={`${cell} font-mono tabular-nums`}>{op.eventCount}</td>
|
||||
<td className={`${cell} font-mono tabular-nums text-emerald-400`}>{op.appliedCount}</td>
|
||||
<td className={`${cell} font-mono tabular-nums text-rose-400`}>{op.failedCount}</td>
|
||||
<td className={`${cell} font-mono tabular-nums text-amber-400`}>{op.rejectedCount}</td>
|
||||
<td className={`${cell} font-mono tabular-nums text-white/70`}>{op.lastOccurredAt ?? "n/a"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function MyExtractionsCard({
|
||||
extractions,
|
||||
signedIn,
|
||||
}: {
|
||||
extractions: SurfaceOverview["myExtractions"];
|
||||
signedIn: boolean;
|
||||
}) {
|
||||
if (!signedIn) {
|
||||
return (
|
||||
<CardShell title="My extractions">
|
||||
<NoticeState variant="empty" message="Sign in to see your extraction history." />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CardShell title="My extractions">
|
||||
{extractions.length === 0 ? (
|
||||
<NoticeState variant="empty" message="No extractions recorded for you yet." />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{extractions.map((ex) => (
|
||||
<li
|
||||
key={ex.eventId}
|
||||
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 truncate font-mono text-xs text-white/80">{ex.eventLabel}</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<Link
|
||||
href={`/operations/${encodeURIComponent(ex.operationId)}`}
|
||||
className="font-mono text-[10px] uppercase tracking-widest text-white/50 underline-offset-4 hover:underline"
|
||||
>
|
||||
{ex.operationId}
|
||||
</Link>
|
||||
<ProvenanceChip
|
||||
label={ex.effectStatus ?? "pending"}
|
||||
href={ex.effectStatus === "applied" ? "/operations/ledger?status=validated" : undefined}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReservationsCard({
|
||||
reservations,
|
||||
canSee,
|
||||
}: {
|
||||
reservations: SurfaceOverview["activeReservations"];
|
||||
canSee: boolean;
|
||||
}) {
|
||||
if (!canSee) {
|
||||
return (
|
||||
<CardShell title="Active reservations">
|
||||
<NoticeState variant="empty" message="Reservation summaries require the reservations permission." />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CardShell title="Active reservations">
|
||||
{reservations.length === 0 ? (
|
||||
<NoticeState variant="empty" message="No active reservations." />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{reservations.map((r) => {
|
||||
const cells: ReadoutCell[] = [
|
||||
{ label: "Key", value: r.reservationKey, mono: true },
|
||||
{ label: "Expires", value: r.expiresAt ?? "n/a" },
|
||||
];
|
||||
return <LedgerRow key={r.reservationKey} cells={cells} />;
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadinessCard({
|
||||
readiness,
|
||||
canSee,
|
||||
}: {
|
||||
readiness: SurfaceOverview["ownReadiness"];
|
||||
canSee: boolean;
|
||||
}) {
|
||||
if (!canSee) {
|
||||
return (
|
||||
<CardShell title="Readiness">
|
||||
<NoticeState variant="empty" message="Readiness aggregates require the readiness permission." />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CardShell title="Readiness">
|
||||
{readiness === null ? (
|
||||
<NoticeState variant="empty" message="No readiness aggregate computed for you yet." />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-mono text-2xl tabular-nums text-white">{readiness.score ?? "n/a"}</p>
|
||||
{readiness.explanation && <p className="text-xs text-white/70">{readiness.explanation}</p>}
|
||||
{readiness.windowLabel && (
|
||||
<p className="font-mono text-[9px] uppercase tracking-widest text-white/50">
|
||||
Window {readiness.windowLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function PressureCard({
|
||||
pressure,
|
||||
canSee,
|
||||
}: {
|
||||
pressure: SurfaceOverview["pressure"];
|
||||
canSee: boolean;
|
||||
}) {
|
||||
if (!canSee) {
|
||||
return (
|
||||
<CardShell title="Zone pressure">
|
||||
<NoticeState variant="empty" message="Pressure summaries require the zone pressure permission." />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CardShell title="Zone pressure">
|
||||
{pressure.length === 0 ? (
|
||||
<NoticeState variant="empty" message="No zone pressure recorded yet." />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{pressure.map((p) => (
|
||||
<li
|
||||
key={p.zoneId}
|
||||
className="flex items-center justify-between border border-white/10 px-2 py-1.5"
|
||||
>
|
||||
<span className="font-mono text-xs text-white/80">Zone {p.zoneId}</span>
|
||||
<span
|
||||
className={`font-mono text-[10px] uppercase tracking-widest ${
|
||||
p.state === "controlled"
|
||||
? "text-emerald-400"
|
||||
: p.state === "contested"
|
||||
? "text-amber-400"
|
||||
: "text-white/60"
|
||||
}`}
|
||||
>
|
||||
{p.state} ({p.pressure})
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function OperationsStrip({ left }: { left: string }) {
|
||||
return <ClassificationStrip left={left} right="Polaris // Ops" />;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useGameTick } from "@/hooks/useGameTick";
|
||||
|
||||
/**
|
||||
* Enhancement-only realtime refresh for /operations surfaces. The server
|
||||
* re-fetch on navigation is the correctness source; this only triggers an
|
||||
* early refresh when a game tick lands while the page is open.
|
||||
*/
|
||||
export function OperationsRealtimeRefresher() {
|
||||
const router = useRouter();
|
||||
useGameTick(() => {
|
||||
router.refresh();
|
||||
});
|
||||
return null;
|
||||
}
|
||||
27
src/components/frontend/operations/PageShell.tsx
Normal file
27
src/components/frontend/operations/PageShell.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { ClassificationStrip } from "./ClassificationStrip";
|
||||
|
||||
/**
|
||||
* Responsive page shell for operations surfaces: header with title + optional
|
||||
* classification-style subtitle line, then the content area. Server-renderable
|
||||
* (no hooks); pairs with the strategic primitives on task-9 role surfaces.
|
||||
*/
|
||||
export function PageShell({
|
||||
title,
|
||||
meta,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
meta?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div data-slot="page-shell" className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-xl font-bold tracking-tight text-foreground">{title}</h1>
|
||||
{meta ? <ClassificationStrip left={meta} right="Polaris // Ops" /> : null}
|
||||
</header>
|
||||
<div className="flex min-w-0 flex-col gap-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/components/frontend/operations/ProvenanceChip.tsx
Normal file
22
src/components/frontend/operations/ProvenanceChip.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { DOSSIER } from "./tokens";
|
||||
|
||||
/**
|
||||
* Small mono chip attributing a fact to its provenance (operation id, effect
|
||||
* key, source). With `href` it renders as a focusable link; otherwise a plain
|
||||
* marker.
|
||||
*/
|
||||
export function ProvenanceChip({ label, href }: { label: string; href?: string }) {
|
||||
const className = `inline-block rounded-sm ${DOSSIER.chip} ${href ? DOSSIER.focusRing : ""}`;
|
||||
if (href) {
|
||||
return (
|
||||
<a href={href} data-slot="provenance-chip" className={className}>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span data-slot="provenance-chip" className={className}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
75
src/components/frontend/operations/ReconcilePanel.tsx
Normal file
75
src/components/frontend/operations/ReconcilePanel.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { reconcileOperationEvent } from "@/app/(frontend)/operations/actions";
|
||||
|
||||
export interface ReconcilableEvent {
|
||||
id: number;
|
||||
eventId: string;
|
||||
status: string;
|
||||
errorCode: string | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorized dead-letter/rejected replay controls for the AAR page. Rendered
|
||||
* only when the viewer holds operation-events:update; the server action is the
|
||||
* authorization boundary, this panel is convenience only.
|
||||
*/
|
||||
export function ReconcilePanel({ events }: { events: ReconcilableEvent[] }) {
|
||||
const [results, setResults] = useState<Record<number, string>>({});
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const replay = (id: number) => {
|
||||
startTransition(async () => {
|
||||
const outcome = await reconcileOperationEvent(id);
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[id]: outcome.success
|
||||
? `Replayed: status ${outcome.data?.status ?? "unknown"}, ${outcome.data?.effectsCreated ?? 0} effect(s) created`
|
||||
: `Replay failed: ${outcome.error ?? "unknown error"}`,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section data-slot="reconcile-panel" className="flex flex-col gap-2">
|
||||
<h2 className="font-mono text-[9px] uppercase tracking-widest text-white/60">
|
||||
Reconciliation
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{events.map((ev) => (
|
||||
<li
|
||||
key={ev.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 border border-amber-500/30 px-2 py-1.5"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="font-mono text-xs text-white/85">{ev.eventId}</span>
|
||||
<span className="ml-2 font-mono text-[10px] uppercase tracking-widest text-amber-400">
|
||||
{ev.status}
|
||||
</span>
|
||||
{ev.errorCode && (
|
||||
<span className="ml-2 text-xs text-white/60">
|
||||
{ev.errorCode}: {ev.errorMessage ?? "no detail"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{results[ev.id] && <span className="text-xs text-white/60">{results[ev.id]}</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => replay(ev.id)}
|
||||
disabled={pending}
|
||||
className="border border-white/25 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/80 hover:border-white/40 hover:text-white disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Replaying..." : "Replay"}
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
25
src/components/frontend/operations/StatusReadout.tsx
Normal file
25
src/components/frontend/operations/StatusReadout.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { STATUS_TONE_CLASSES, type StatusTone } from "./tokens";
|
||||
|
||||
/**
|
||||
* Squared status pill with military-style uppercase text. The tone drives a
|
||||
* contrast-compliant -400 text color on a /20 background; the text itself is
|
||||
* the only status signal (no duplicate indicator).
|
||||
*/
|
||||
export function StatusReadout({
|
||||
label,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
label: string;
|
||||
tone?: StatusTone;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
data-slot="status-readout"
|
||||
data-tone={tone}
|
||||
className={`inline-block rounded-sm border px-2 py-1 text-[10px] font-semibold uppercase tracking-wider ${STATUS_TONE_CLASSES[tone]}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
53
src/components/frontend/operations/StrategicSummary.tsx
Normal file
53
src/components/frontend/operations/StrategicSummary.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ClassificationStrip } from "./ClassificationStrip";
|
||||
import { CornerBrackets } from "./CornerBrackets";
|
||||
import { LedgerRow, type ReadoutCell } from "./LedgerRow";
|
||||
import { DOSSIER } from "./tokens";
|
||||
|
||||
/**
|
||||
* Tactical strategic-summary surface: squared corners, faint 28px grid
|
||||
* texture, classification strip, corner brackets, and an optional permanent
|
||||
* readout footer. This is the reusable card primitive extracted from the
|
||||
* approved campaign-card dossier conventions.
|
||||
*
|
||||
* NOT for dense data UI, forms, dialogs, buttons, or shadcn-styled controls:
|
||||
* those keep the conventional shadcn baseline. Decorative layers are
|
||||
* aria-hidden; all motion is transition-only and disabled under
|
||||
* prefers-reduced-motion.
|
||||
*/
|
||||
export function StrategicSummary({
|
||||
classificationLeft,
|
||||
classificationRight,
|
||||
readoutCells,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
classificationLeft: string;
|
||||
classificationRight: string;
|
||||
readoutCells?: readonly ReadoutCell[];
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="strategic-summary"
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-none border border-border/70 bg-card transition-colors duration-300 hover:border-foreground/30 motion-reduce:transition-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-[5] opacity-[0.04] transition-opacity duration-500 group-hover:opacity-[0.07] motion-reduce:transition-none"
|
||||
style={DOSSIER.gridTexture}
|
||||
/>
|
||||
<CornerBrackets />
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<ClassificationStrip left={classificationLeft} right={classificationRight} />
|
||||
<div className="px-4 py-4">{children}</div>
|
||||
{readoutCells ? <LedgerRow cells={readoutCells} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/components/frontend/operations/index.ts
Normal file
21
src/components/frontend/operations/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export { ClassificationStrip } from "./ClassificationStrip";
|
||||
export { CornerBrackets } from "./CornerBrackets";
|
||||
export { StatusReadout } from "./StatusReadout";
|
||||
export { LedgerRow, type ReadoutCell } from "./LedgerRow";
|
||||
export { ProvenanceChip } from "./ProvenanceChip";
|
||||
export { NoticeState } from "./NoticeState";
|
||||
export { StrategicSummary } from "./StrategicSummary";
|
||||
export { PageShell } from "./PageShell";
|
||||
export { STATUS_TONE_CLASSES, DOSSIER, statusToneFor, type StatusTone } from "./tokens";
|
||||
export { OperationsRealtimeRefresher } from "./OperationsRealtimeRefresher";
|
||||
export {
|
||||
RecentOperationsCard,
|
||||
MyExtractionsCard,
|
||||
ReservationsCard,
|
||||
ReadinessCard,
|
||||
PressureCard,
|
||||
OperationsStrip,
|
||||
} from "./OperationSections";
|
||||
export { LedgerTable, type LedgerTableFilters } from "./LedgerTable";
|
||||
export { AarView } from "./AarView";
|
||||
export { ReconcilePanel, type ReconcilableEvent } from "./ReconcilePanel";
|
||||
55
src/components/frontend/operations/tokens.ts
Normal file
55
src/components/frontend/operations/tokens.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Shared tactical "dossier" tokens for operation primitives.
|
||||
*
|
||||
* Centralized so every operations surface renders status and readout text
|
||||
* with the same contrast-compliant, reduced-motion-safe classes. No CSS file
|
||||
* is added: tokens are expressed as Tailwind utility bundles (the frontend
|
||||
* styles.css is a frozen dirty-worktree baseline).
|
||||
*/
|
||||
|
||||
export type StatusTone = "neutral" | "info" | "success" | "warning" | "danger";
|
||||
|
||||
/** Contrast-compliant status text: -400 text on /20 bg (CampaignCard convention). */
|
||||
export const STATUS_TONE_CLASSES: Record<StatusTone, string> = {
|
||||
neutral: "border-muted/40 bg-muted/20 text-muted-foreground",
|
||||
info: "border-blue-500/40 bg-blue-500/20 text-blue-400",
|
||||
success: "border-emerald-500/40 bg-emerald-500/20 text-emerald-400",
|
||||
warning: "border-amber-500/40 bg-amber-500/20 text-amber-400",
|
||||
danger: "border-rose-500/40 bg-rose-500/20 text-rose-400",
|
||||
};
|
||||
|
||||
export function statusToneFor(status: string): StatusTone {
|
||||
switch (status) {
|
||||
case "validated":
|
||||
case "applied":
|
||||
return "success";
|
||||
case "rejected":
|
||||
case "dead-letter":
|
||||
return "warning";
|
||||
case "failed":
|
||||
case "reversed":
|
||||
return "danger";
|
||||
case "pending":
|
||||
return "info";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared dossier surface classes (classification strip, readouts, chips). */
|
||||
export const DOSSIER = {
|
||||
strip: "border-b border-white/15 bg-black/50 px-3 py-1.5 backdrop-blur-sm",
|
||||
stripLabel: "font-mono text-[9px] uppercase tracking-widest text-white/60",
|
||||
readout: "border-t border-white/15 bg-black/60 py-1.5 backdrop-blur-sm",
|
||||
readoutValue: "font-mono text-xs tabular-nums text-white/80",
|
||||
readoutLabel: "text-[9px] uppercase tracking-wider text-white/50",
|
||||
chip: "border border-white/25 bg-black/40 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-widest text-white/70",
|
||||
focusRing:
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
/** 1px white grid, 28px cells, faint; hover-safe, reduced-motion honored. */
|
||||
gridTexture: {
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px)",
|
||||
backgroundSize: "28px 28px",
|
||||
},
|
||||
} as const;
|
||||
315
src/lib/operations/surface.ts
Normal file
315
src/lib/operations/surface.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
/**
|
||||
* Operation overview surface service: role-scoped summaries for /operations.
|
||||
* Read-only. Queries run through collection access control (logged-in read);
|
||||
* gated sections are additionally permission-checked and return `null` when
|
||||
* the viewer lacks the permission. Raw effect payload JSON is never returned
|
||||
* to the UI, only derived human summaries and provenance links.
|
||||
*/
|
||||
import type { Payload, Where } from "payload";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import { effectivePressure } from "./territory";
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
export type SurfaceUser = { id: number } | null;
|
||||
|
||||
export interface SurfaceCapabilities {
|
||||
canSeeReservations: boolean;
|
||||
canSeeReadiness: boolean;
|
||||
canSeePressure: boolean;
|
||||
}
|
||||
|
||||
export interface OperationSummary {
|
||||
operationId: string;
|
||||
eventCount: number;
|
||||
appliedCount: number;
|
||||
failedCount: number;
|
||||
rejectedCount: number;
|
||||
lastOccurredAt: string | null;
|
||||
}
|
||||
|
||||
export interface MyExtractionSummary {
|
||||
eventId: number;
|
||||
eventLabel: string;
|
||||
operationId: string;
|
||||
occurredAt: string;
|
||||
effectStatus: string | null;
|
||||
}
|
||||
|
||||
export interface PressureSummary {
|
||||
zoneId: number;
|
||||
factionId: number | null;
|
||||
state: string;
|
||||
pressure: number;
|
||||
}
|
||||
|
||||
export interface SurfaceOverview {
|
||||
capabilities: SurfaceCapabilities;
|
||||
recentOperations: OperationSummary[];
|
||||
myExtractions: MyExtractionSummary[];
|
||||
activeReservations: {
|
||||
reservationKey: string;
|
||||
operationId: string;
|
||||
status: string;
|
||||
expiresAt: string | null;
|
||||
}[];
|
||||
ownReadiness: {
|
||||
score: number | null;
|
||||
explanation: string | null;
|
||||
windowLabel: string | null;
|
||||
} | null;
|
||||
pressure: PressureSummary[];
|
||||
}
|
||||
|
||||
export interface OperationEventRow {
|
||||
id: number;
|
||||
eventId: string;
|
||||
operationId: string;
|
||||
type: string;
|
||||
status: string;
|
||||
occurredAt: string;
|
||||
actorId: number | null;
|
||||
missionId: number | null;
|
||||
errorCode: string | null;
|
||||
errorMessage: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface OperationEffectRow {
|
||||
id: number;
|
||||
effectKey: string;
|
||||
operationId: string;
|
||||
effectType: string;
|
||||
status: string;
|
||||
appliedAt: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export async function getSurfaceCapabilities(
|
||||
payload: PayloadType,
|
||||
user: SurfaceUser,
|
||||
): Promise<SurfaceCapabilities> {
|
||||
const [reservations, readiness, pressure] = await Promise.all([
|
||||
hasPermission(payload, user, "operation-reservations:read"),
|
||||
hasPermission(payload, user, "operation-readiness:read"),
|
||||
hasPermission(payload, user, "zone-pressure:read"),
|
||||
]);
|
||||
return { canSeeReservations: reservations, canSeeReadiness: readiness, canSeePressure: pressure };
|
||||
}
|
||||
|
||||
function relationId(value: unknown): number | null {
|
||||
if (typeof value === "number") return value;
|
||||
if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) {
|
||||
const id = (value as { id: unknown }).id;
|
||||
return typeof id === "number" ? id : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Group recent events by operation id and attach effect status counts. */
|
||||
export async function getOperationOverview(
|
||||
payload: PayloadType,
|
||||
user: SurfaceUser,
|
||||
): Promise<SurfaceOverview> {
|
||||
const capabilities = await getSurfaceCapabilities(payload, user);
|
||||
|
||||
const events = await payload.find({
|
||||
collection: "operation-events",
|
||||
sort: "-occurredAt",
|
||||
limit: 50,
|
||||
depth: 0,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
|
||||
const operationIds = [...new Set(events.docs.map((d) => (d as { operationId: string }).operationId))];
|
||||
const effectsByOperation = new Map<string, { applied: number; failed: number; rejected: number }>();
|
||||
if (operationIds.length > 0) {
|
||||
const effectsWhere: Where = { operationId: { in: operationIds } };
|
||||
const effects = await payload.find({
|
||||
collection: "operation-effects",
|
||||
where: effectsWhere,
|
||||
limit: 1000,
|
||||
depth: 0,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
for (const doc of effects.docs) {
|
||||
const row = doc as unknown as {
|
||||
operationId: string;
|
||||
status: string;
|
||||
};
|
||||
const bucket = effectsByOperation.get(row.operationId) ?? { applied: 0, failed: 0, rejected: 0 };
|
||||
if (row.status === "applied") bucket.applied += 1;
|
||||
else if (row.status === "failed" || row.status === "reversed") bucket.failed += 1;
|
||||
else bucket.rejected += 1;
|
||||
effectsByOperation.set(row.operationId, bucket);
|
||||
}
|
||||
}
|
||||
|
||||
const eventCountByOperation = new Map<string, number>();
|
||||
const lastOccurred = new Map<string, string | null>();
|
||||
for (const doc of events.docs) {
|
||||
const row = doc as unknown as { operationId: string; occurredAt: string };
|
||||
eventCountByOperation.set(row.operationId, (eventCountByOperation.get(row.operationId) ?? 0) + 1);
|
||||
if (!lastOccurred.has(row.operationId)) lastOccurred.set(row.operationId, row.occurredAt);
|
||||
}
|
||||
const recentOperations: OperationSummary[] = operationIds.map((operationId) => {
|
||||
const counts = effectsByOperation.get(operationId) ?? { applied: 0, failed: 0, rejected: 0 };
|
||||
return {
|
||||
operationId,
|
||||
eventCount: eventCountByOperation.get(operationId) ?? 0,
|
||||
appliedCount: counts.applied,
|
||||
failedCount: counts.failed,
|
||||
rejectedCount: counts.rejected,
|
||||
lastOccurredAt: lastOccurred.get(operationId) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const myExtractions: MyExtractionSummary[] = user
|
||||
? await queryMyExtractions(payload, user.id)
|
||||
: [];
|
||||
|
||||
const activeReservations: SurfaceOverview["activeReservations"] = [];
|
||||
if (capabilities.canSeeReservations) {
|
||||
const reserved = await payload.find({
|
||||
collection: "operation-reservations",
|
||||
where: { status: { equals: "reserved" } },
|
||||
sort: "-createdAt",
|
||||
limit: 5,
|
||||
depth: 0,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
for (const doc of reserved.docs) {
|
||||
const row = doc as unknown as {
|
||||
reservationKey: string;
|
||||
operationId: string;
|
||||
status: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
activeReservations.push({
|
||||
reservationKey: row.reservationKey,
|
||||
operationId: row.operationId,
|
||||
status: row.status,
|
||||
expiresAt: row.expiresAt ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let ownReadiness: SurfaceOverview["ownReadiness"] = null;
|
||||
if (user && capabilities.canSeeReadiness) {
|
||||
const found = await payload.find({
|
||||
collection: "operation-readiness",
|
||||
where: {
|
||||
and: [
|
||||
{ subjectType: { equals: "user" } },
|
||||
{ subjectId: { equals: user.id } },
|
||||
],
|
||||
},
|
||||
sort: "-windowStart",
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
user,
|
||||
});
|
||||
if (found.docs.length > 0) {
|
||||
const row = found.docs[0] as unknown as {
|
||||
readinessScore: number | null;
|
||||
explanation: string | null;
|
||||
windowLabel: string | null;
|
||||
};
|
||||
ownReadiness = {
|
||||
score: row.readinessScore,
|
||||
explanation: row.explanation,
|
||||
windowLabel: row.windowLabel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const pressure: PressureSummary[] = [];
|
||||
if (capabilities.canSeePressure) {
|
||||
const rows = await payload.find({
|
||||
collection: "zone-pressure",
|
||||
sort: "-createdAt",
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
const now = new Date();
|
||||
const byZone = new Map<number, { factionId: number | null; deltas: { delta: number; createdAt: string }[] }>();
|
||||
for (const doc of rows.docs) {
|
||||
const row = doc as unknown as {
|
||||
zone: unknown;
|
||||
faction: unknown;
|
||||
delta: number;
|
||||
createdAt: string;
|
||||
};
|
||||
const zoneId = relationId(row.zone);
|
||||
if (zoneId === null) continue;
|
||||
const bucket = byZone.get(zoneId) ?? { factionId: relationId(row.faction), deltas: [] };
|
||||
bucket.deltas.push({ delta: row.delta, createdAt: row.createdAt });
|
||||
byZone.set(zoneId, bucket);
|
||||
}
|
||||
for (const [zoneId, bucket] of byZone) {
|
||||
const zone = effectivePressure(bucket.deltas, now);
|
||||
pressure.push({
|
||||
zoneId,
|
||||
factionId: bucket.factionId,
|
||||
state: zone.state,
|
||||
pressure: zone.pressure,
|
||||
});
|
||||
}
|
||||
pressure.sort((a, b) => b.pressure - a.pressure);
|
||||
}
|
||||
|
||||
return { capabilities, recentOperations, myExtractions, activeReservations, ownReadiness, pressure };
|
||||
}
|
||||
|
||||
async function queryMyExtractions(
|
||||
payload: PayloadType,
|
||||
userId: number,
|
||||
): Promise<MyExtractionSummary[]> {
|
||||
const extractionWhere: Where = {
|
||||
and: [
|
||||
{ type: { equals: "operation.extraction" } },
|
||||
{ actor: { equals: userId } },
|
||||
],
|
||||
};
|
||||
const extractions = await payload.find({
|
||||
collection: "operation-events",
|
||||
where: extractionWhere,
|
||||
sort: "-occurredAt",
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
user: { id: userId },
|
||||
});
|
||||
|
||||
const effectStatusByEvent = new Map<number, string>();
|
||||
const eventIds = extractions.docs.map((d) => d.id as number);
|
||||
if (eventIds.length > 0) {
|
||||
const effects = await payload.find({
|
||||
collection: "operation-effects",
|
||||
where: { operationEvent: { in: eventIds } },
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
user: { id: userId },
|
||||
});
|
||||
for (const doc of effects.docs) {
|
||||
const row = doc as unknown as { operationEvent: unknown; status: string };
|
||||
const eventId = relationId(row.operationEvent);
|
||||
if (eventId !== null) effectStatusByEvent.set(eventId, row.status);
|
||||
}
|
||||
}
|
||||
|
||||
return extractions.docs.map((doc) => {
|
||||
const row = doc as unknown as {
|
||||
id: number;
|
||||
eventId: string;
|
||||
operationId: string;
|
||||
occurredAt: string;
|
||||
};
|
||||
return {
|
||||
eventId: row.id,
|
||||
eventLabel: row.eventId,
|
||||
operationId: row.operationId,
|
||||
occurredAt: row.occurredAt,
|
||||
effectStatus: effectStatusByEvent.get(row.id) ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
259
src/lib/operations/surfaceQuery.ts
Normal file
259
src/lib/operations/surfaceQuery.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
/**
|
||||
* Operation ledger drill-down and AAR query service. Strips raw effect payload
|
||||
* JSON from every row: only event metadata, effect status, failure
|
||||
* explanations, and provenance links reach the UI. Gated sections are
|
||||
* permission-checked and return empty lists when the viewer lacks permission.
|
||||
*/
|
||||
import type { Payload, Where } from "payload";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import type { OperationEffectRow, OperationEventRow, SurfaceUser } from "./surface";
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
export interface LedgerFilters {
|
||||
operationId?: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface LedgerResult {
|
||||
events: OperationEventRow[];
|
||||
page: number;
|
||||
totalDocs: number;
|
||||
totalPages: number;
|
||||
effectCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface AarCounts {
|
||||
events: number;
|
||||
validated: number;
|
||||
rejected: number;
|
||||
deadLetter: number;
|
||||
applied: number;
|
||||
failed: number;
|
||||
pending: number;
|
||||
reversed: number;
|
||||
}
|
||||
|
||||
export interface AarResult {
|
||||
operationId: string;
|
||||
events: OperationEventRow[];
|
||||
effects: OperationEffectRow[];
|
||||
counts: AarCounts;
|
||||
pressure: { zoneId: number; factionId: number | null; delta: number; createdAt: string }[];
|
||||
reservations: { reservationKey: string; status: string; expiresAt: string | null }[];
|
||||
}
|
||||
|
||||
const LEDGER_PAGE_SIZE = 20;
|
||||
const AAR_EVENT_LIMIT = 500;
|
||||
|
||||
function toEventRow(doc: unknown): OperationEventRow {
|
||||
const row = doc as {
|
||||
id: number;
|
||||
eventId: string;
|
||||
operationId: string;
|
||||
type: string;
|
||||
status: string;
|
||||
occurredAt: string;
|
||||
actor?: number | { id: number } | null;
|
||||
mission?: number | { id: number } | null;
|
||||
error?: { code?: string; message?: string } | null;
|
||||
reconciliationNotes?: string | null;
|
||||
};
|
||||
return {
|
||||
id: row.id,
|
||||
eventId: row.eventId,
|
||||
operationId: row.operationId,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
occurredAt: row.occurredAt,
|
||||
actorId: typeof row.actor === "number" ? row.actor : row.actor?.id ?? null,
|
||||
missionId: typeof row.mission === "number" ? row.mission : row.mission?.id ?? null,
|
||||
errorCode: row.error?.code ?? null,
|
||||
errorMessage: row.error?.message ?? null,
|
||||
notes: row.reconciliationNotes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Effect rows never carry the payload JSON: only status, type, and notes. */
|
||||
function toEffectRow(doc: unknown): OperationEffectRow {
|
||||
const row = doc as {
|
||||
id: number;
|
||||
effectKey: string;
|
||||
operationId: string;
|
||||
effectType: string;
|
||||
status: string;
|
||||
appliedAt: string | null;
|
||||
reconciliationNotes?: string | null;
|
||||
};
|
||||
return {
|
||||
id: row.id,
|
||||
effectKey: row.effectKey,
|
||||
operationId: row.operationId,
|
||||
effectType: row.effectType,
|
||||
status: row.status,
|
||||
appliedAt: row.appliedAt ?? null,
|
||||
notes: row.reconciliationNotes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function bump(counts: Record<string, number>, key: string): void {
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
export async function getOperationLedger(
|
||||
payload: PayloadType,
|
||||
user: SurfaceUser,
|
||||
filters: LedgerFilters,
|
||||
requestedPage = 1,
|
||||
): Promise<LedgerResult> {
|
||||
const conditions: Where[] = [];
|
||||
if (filters.operationId) conditions.push({ operationId: { equals: filters.operationId } });
|
||||
if (filters.status) conditions.push({ status: { equals: filters.status } });
|
||||
if (filters.type) conditions.push({ type: { equals: filters.type } });
|
||||
const where: Where | undefined = conditions.length > 0 ? { and: conditions } : undefined;
|
||||
|
||||
const found = await payload.find({
|
||||
collection: "operation-events",
|
||||
where,
|
||||
sort: "-occurredAt",
|
||||
page: Math.max(1, requestedPage),
|
||||
limit: LEDGER_PAGE_SIZE,
|
||||
depth: 0,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
|
||||
const effectCounts: Record<string, number> = {};
|
||||
if (filters.operationId) {
|
||||
const effects = await payload.find({
|
||||
collection: "operation-effects",
|
||||
where: { operationId: { equals: filters.operationId } },
|
||||
limit: 1000,
|
||||
depth: 0,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
for (const doc of effects.docs) {
|
||||
const row = doc as { status: string };
|
||||
bump(effectCounts, row.status);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events: found.docs.map(toEventRow),
|
||||
page: found.page ?? Math.max(1, requestedPage),
|
||||
totalDocs: found.totalDocs ?? found.docs.length,
|
||||
totalPages: found.totalPages ?? 1,
|
||||
effectCounts,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOperationAar(
|
||||
payload: PayloadType,
|
||||
user: SurfaceUser,
|
||||
operationId: string,
|
||||
): Promise<AarResult | null> {
|
||||
const eventsFound = await payload.find({
|
||||
collection: "operation-events",
|
||||
where: { operationId: { equals: operationId } },
|
||||
sort: "-occurredAt",
|
||||
limit: AAR_EVENT_LIMIT,
|
||||
depth: 0,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
if (eventsFound.docs.length === 0) return null;
|
||||
|
||||
const effectsFound = await payload.find({
|
||||
collection: "operation-effects",
|
||||
where: { operationId: { equals: operationId } },
|
||||
sort: "-createdAt",
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
|
||||
const counts: AarCounts = {
|
||||
events: eventsFound.docs.length,
|
||||
validated: 0,
|
||||
rejected: 0,
|
||||
deadLetter: 0,
|
||||
applied: 0,
|
||||
failed: 0,
|
||||
pending: 0,
|
||||
reversed: 0,
|
||||
};
|
||||
for (const doc of eventsFound.docs) {
|
||||
const status = (doc as { status: string }).status;
|
||||
if (status === "validated") counts.validated += 1;
|
||||
else if (status === "rejected") counts.rejected += 1;
|
||||
else counts.deadLetter += 1;
|
||||
}
|
||||
for (const doc of effectsFound.docs) {
|
||||
const status = (doc as { status: string }).status;
|
||||
if (status === "applied") counts.applied += 1;
|
||||
else if (status === "failed") counts.failed += 1;
|
||||
else if (status === "pending") counts.pending += 1;
|
||||
else if (status === "reversed") counts.reversed += 1;
|
||||
}
|
||||
|
||||
const canSeePressure = await hasPermission(payload, user, "zone-pressure:read");
|
||||
const canSeeReservations = await hasPermission(payload, user, "operation-reservations:read");
|
||||
|
||||
const pressure: AarResult["pressure"] = [];
|
||||
if (canSeePressure) {
|
||||
const rows = await payload.find({
|
||||
collection: "zone-pressure",
|
||||
where: { operationId: { equals: operationId } },
|
||||
sort: "-createdAt",
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
for (const doc of rows.docs) {
|
||||
const row = doc as unknown as {
|
||||
zone: unknown;
|
||||
faction: unknown;
|
||||
delta: number;
|
||||
createdAt: string;
|
||||
};
|
||||
pressure.push({
|
||||
zoneId: typeof row.zone === "number" ? row.zone : (row.zone as { id: number } | null)?.id ?? 0,
|
||||
factionId: typeof row.faction === "number" ? row.faction : (row.faction as { id: number } | null)?.id ?? null,
|
||||
delta: row.delta,
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const reservations: AarResult["reservations"] = [];
|
||||
if (canSeeReservations) {
|
||||
const rows = await payload.find({
|
||||
collection: "operation-reservations",
|
||||
where: { operationId: { equals: operationId } },
|
||||
sort: "-createdAt",
|
||||
limit: 50,
|
||||
depth: 0,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
for (const doc of rows.docs) {
|
||||
const row = doc as unknown as {
|
||||
reservationKey: string;
|
||||
status: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
reservations.push({
|
||||
reservationKey: row.reservationKey,
|
||||
status: row.status,
|
||||
expiresAt: row.expiresAt ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
operationId,
|
||||
events: eventsFound.docs.map(toEventRow),
|
||||
effects: effectsFound.docs.map(toEffectRow),
|
||||
counts,
|
||||
pressure,
|
||||
reservations,
|
||||
};
|
||||
}
|
||||
58
tests/e2e/operations.e2e.spec.ts
Normal file
58
tests/e2e/operations.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { mkdirSync } from "node:fs";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const SHOT_DIR = ".omo/evidence/screenshots";
|
||||
async function shot(page: import("@playwright/test").Page, name: string): Promise<void> {
|
||||
mkdirSync(SHOT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: `${SHOT_DIR}/${name}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Operations surfaces: role-scoped visibility and failure-state behavior.
|
||||
* NOTE: these tests exercise the real dev server and are run in the final
|
||||
* verification wave (F3) under the dev-server protocol, not on every change.
|
||||
*/
|
||||
test.describe("Operations surfaces", () => {
|
||||
test("guests are gated away from /operations by the landing page", async ({ page }) => {
|
||||
await page.goto("/operations");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Polaris Task Force");
|
||||
});
|
||||
|
||||
test("an authed user sees the operations overview with role sections", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill("dev");
|
||||
await page.getByLabel("Password").fill("Test123");
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
|
||||
await page.goto("/operations");
|
||||
await expect(page.getByRole("heading", { name: "Operations" })).toBeVisible();
|
||||
await expect(page.getByText("Recent operations")).toBeVisible();
|
||||
await shot(page, "operations-overview-authed");
|
||||
await expect(page.getByText("My extractions")).toBeVisible();
|
||||
});
|
||||
|
||||
test("the ledger shows empty and filtered states without raw payloads", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill("dev");
|
||||
await page.getByLabel("Password").fill("Test123");
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
|
||||
await page.goto("/operations/ledger?status=dead-letter");
|
||||
await expect(page.getByRole("heading", { name: "Operations ledger" })).toBeVisible();
|
||||
await expect(page.getByText("No operation events match this view.")).toBeVisible();
|
||||
await shot(page, "operations-ledger-dead-letter-empty");
|
||||
});
|
||||
|
||||
test("an unknown operation id renders the empty AAR state", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill("dev");
|
||||
await page.getByLabel("Password").fill("Test123");
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
|
||||
await page.goto("/operations/definitely-not-a-real-operation");
|
||||
await expect(
|
||||
page.getByText("No operation events are recorded under"),
|
||||
).toBeVisible();
|
||||
await shot(page, "operations-aar-unknown-empty");
|
||||
});
|
||||
});
|
||||
184
tests/int/operation-ui-primitives.int.spec.ts
Normal file
184
tests/int/operation-ui-primitives.int.spec.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { createElement as h } from "react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import {
|
||||
ClassificationStrip,
|
||||
CornerBrackets,
|
||||
LedgerRow,
|
||||
NoticeState,
|
||||
PageShell,
|
||||
ProvenanceChip,
|
||||
StatusReadout,
|
||||
StrategicSummary,
|
||||
STATUS_TONE_CLASSES,
|
||||
type StatusTone,
|
||||
} from "@/components/frontend/operations";
|
||||
|
||||
// Vitest globals are off, so @testing-library auto-cleanup never registers;
|
||||
// without this, rendered DOM accumulates across tests and role queries match
|
||||
// stale elements.
|
||||
afterEach(cleanup);
|
||||
|
||||
/**
|
||||
* Component tests documenting every operations-primitive variant:
|
||||
* classification strip, corner brackets, status readout tones, ledger row,
|
||||
* provenance chip (plain + link), notice states (empty/loading/error),
|
||||
* the strategic summary composition, and the page shell.
|
||||
*
|
||||
* The file is .ts (the plan QA command names this exact path), so elements are
|
||||
* built with createElement instead of JSX syntax.
|
||||
*
|
||||
* Accessibility contract: semantic roles, aria-hidden decoration, keyboard
|
||||
* focus rings on interactive chips, contrast-compliant status text, and
|
||||
* reduced-motion variants on every transition.
|
||||
*/
|
||||
|
||||
describe("ClassificationStrip", () => {
|
||||
it("renders both classification labels", () => {
|
||||
render(h(ClassificationStrip, { left: "Operations // Unit Command", right: "REF 001" }));
|
||||
expect(screen.getByText("Operations // Unit Command")).toBeDefined();
|
||||
expect(screen.getByText("REF 001")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CornerBrackets", () => {
|
||||
it("renders four decorative corner marks with reduced-motion variants", () => {
|
||||
const { container } = render(h(CornerBrackets));
|
||||
const slot = container.querySelector('[data-slot="corner-brackets"]');
|
||||
expect(slot?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(slot?.querySelectorAll("span").length).toBe(4);
|
||||
expect(slot?.innerHTML).toContain("motion-reduce:transition-none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusReadout", () => {
|
||||
const tones: StatusTone[] = ["neutral", "info", "success", "warning", "danger"];
|
||||
|
||||
it.each(tones)("renders the %s tone with contrast-compliant status text", (tone) => {
|
||||
const { container } = render(h(StatusReadout, { label: `${tone} status`, tone }));
|
||||
const pill = screen.getByRole("status");
|
||||
expect(pill.textContent).toBe(`${tone} status`);
|
||||
expect(pill.getAttribute("data-tone")).toBe(tone);
|
||||
// Squared corners (rounded-sm), token-driven contrast classes present.
|
||||
expect(pill.className).toContain("rounded-sm");
|
||||
for (const cls of STATUS_TONE_CLASSES[tone].split(" ")) {
|
||||
expect(pill.className).toContain(cls);
|
||||
}
|
||||
expect(container.firstElementChild).toBe(pill);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LedgerRow", () => {
|
||||
it("renders permanent readout cells with mono values and hairline separators", () => {
|
||||
const cells = [
|
||||
{ label: "Msns", value: "3", mono: true },
|
||||
{ label: "Duration", value: "4h 0m", mono: true },
|
||||
{ label: "State", value: "Contested" },
|
||||
] as const;
|
||||
const { container } = render(h(LedgerRow, { cells }));
|
||||
const row = container.querySelector('[data-slot="ledger-row"]') as HTMLElement;
|
||||
expect(row.tagName).toBe("FOOTER");
|
||||
expect(row.className).toContain("border-t");
|
||||
expect(row.className).toContain("bg-black/60");
|
||||
|
||||
const rendered = Array.from(row.querySelectorAll("span")).map((v) => v.textContent);
|
||||
expect(rendered).toContain("3");
|
||||
expect(rendered).toContain("4h 0m");
|
||||
expect(rendered).toContain("Contested");
|
||||
|
||||
const hairlines = row.querySelectorAll(".border-r");
|
||||
expect(hairlines.length).toBe(cells.length);
|
||||
});
|
||||
|
||||
it("renders nothing when empty", () => {
|
||||
const { container } = render(h(LedgerRow, { cells: [] }));
|
||||
const row = container.querySelector('[data-slot="ledger-row"]') as HTMLElement;
|
||||
expect(row.className).toContain("hidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProvenanceChip", () => {
|
||||
it("renders a plain marker without href", () => {
|
||||
const { container } = render(h(ProvenanceChip, { label: "OP 42" }));
|
||||
const chip = container.querySelector('[data-slot="provenance-chip"]') as HTMLElement;
|
||||
expect(chip.tagName).toBe("SPAN");
|
||||
expect(chip.textContent).toBe("OP 42");
|
||||
expect(chip.className).toContain("font-mono");
|
||||
});
|
||||
|
||||
it("renders a keyboard-focusable link with href", () => {
|
||||
const { container } = render(h(ProvenanceChip, { label: "OP 42", href: "/logistics/structures/1" }));
|
||||
const chip = container.querySelector('[data-slot="provenance-chip"]') as HTMLElement;
|
||||
expect(chip.tagName).toBe("A");
|
||||
expect(chip.getAttribute("href")).toBe("/logistics/structures/1");
|
||||
expect(chip.className).toContain("focus-visible:ring-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("NoticeState", () => {
|
||||
it("renders empty and loading variants as polite status with aria-busy on loading", () => {
|
||||
const { container } = render(
|
||||
h("div", null,
|
||||
h(NoticeState, { variant: "empty", message: "No operations yet", hint: "Run an op to populate the ledger." }),
|
||||
h(NoticeState, { variant: "loading", message: "Loading operation ledger" }),
|
||||
),
|
||||
);
|
||||
const notices = container.querySelectorAll('[data-slot="notice-state"]');
|
||||
expect(notices.length).toBe(2);
|
||||
const empty = notices[0] as HTMLElement;
|
||||
const loading = notices[1] as HTMLElement;
|
||||
expect(empty.getAttribute("data-variant")).toBe("empty");
|
||||
expect(empty.getAttribute("role")).toBe("status");
|
||||
expect(empty.className).not.toContain("rose");
|
||||
expect(empty.textContent).toContain("No operations yet");
|
||||
expect(loading.getAttribute("data-variant")).toBe("loading");
|
||||
expect(loading.getAttribute("role")).toBe("status");
|
||||
expect(loading.getAttribute("aria-busy")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders the error variant as an assertive alert", () => {
|
||||
render(h(NoticeState, { variant: "error", message: "Failed effect", hint: "Replay from reconciliation." }));
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.getAttribute("data-variant")).toBe("error");
|
||||
expect(alert.className).toContain("border-rose-500/30");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StrategicSummary", () => {
|
||||
it("composes strip, brackets, grid texture, content, and readout footer", () => {
|
||||
const { container } = render(
|
||||
h(StrategicSummary,
|
||||
{
|
||||
classificationLeft: "Official // Unit Command",
|
||||
classificationRight: "REF 007",
|
||||
readoutCells: [{ label: "Msns", value: "2", mono: true }],
|
||||
children: h("p", null, "Summary body"),
|
||||
},
|
||||
),
|
||||
);
|
||||
const surface = container.querySelector('[data-slot="strategic-summary"]') as HTMLElement;
|
||||
expect(surface.className).toContain("rounded-none");
|
||||
|
||||
within(surface).getByText("Official // Unit Command");
|
||||
within(surface).getByText("REF 007");
|
||||
expect(surface.querySelector('[data-slot="corner-brackets"]')).not.toBeNull();
|
||||
expect(surface.querySelector('[data-slot="ledger-row"]')).not.toBeNull();
|
||||
expect(surface.textContent).toContain("Summary body");
|
||||
|
||||
const texture = surface.querySelector('div[style*="linear-gradient"]') as HTMLElement;
|
||||
expect(texture?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(texture?.style.backgroundSize).toBe("28px 28px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PageShell", () => {
|
||||
it("renders the title, meta strip, and content responsively", () => {
|
||||
render(
|
||||
h(PageShell, { title: "Operation Ledger", meta: "Command // Weekly", children: h("p", null, "Shell content") }),
|
||||
);
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
expect(heading.textContent).toContain("Operation Ledger");
|
||||
expect(screen.getByText("Command // Weekly")).toBeDefined();
|
||||
expect(screen.getByText("Shell content")).toBeDefined();
|
||||
});
|
||||
});
|
||||
305
tests/int/operations-surface.int.spec.ts
Normal file
305
tests/int/operations-surface.int.spec.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { getOperationOverview, getSurfaceCapabilities } from "@/lib/operations/surface";
|
||||
import { getOperationAar, getOperationLedger } from "@/lib/operations/surfaceQuery";
|
||||
|
||||
let payload: Payload;
|
||||
const RUN = `ops-surface-${Date.now().toString(36)}`;
|
||||
|
||||
const syncEventIds: number[] = [];
|
||||
const opEventIds: number[] = [];
|
||||
const opEffectIds: number[] = [];
|
||||
const userIds: number[] = [];
|
||||
const roleIds: number[] = [];
|
||||
const zoneIds: number[] = [];
|
||||
const mapIds: number[] = [];
|
||||
const factionIds: number[] = [];
|
||||
let serverId: number;
|
||||
let plainUserId: number;
|
||||
let permUserId: number;
|
||||
let adminUserId: number;
|
||||
let mapId: number;
|
||||
let zoneId: number;
|
||||
let factionId: number;
|
||||
|
||||
const OP_A = `${RUN}-op-alpha`;
|
||||
const OP_B = `${RUN}-op-beta`;
|
||||
|
||||
async function makeUser(label: string, roleDocIds?: number[]): Promise<number> {
|
||||
const user = await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username: `${RUN}-${label}`,
|
||||
discordUsername: `${RUN}-${label}-discord`,
|
||||
displayName: `${RUN} ${label}`,
|
||||
steamId: `${RUN}-steam-${label}`,
|
||||
password: "Test1234",
|
||||
...(roleDocIds && roleDocIds.length > 0 ? { roleDocs: roleDocIds } : {}),
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
userIds.push(user.id);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function makeRawEvent(label: string, type: string, payloadData: unknown): Promise<number> {
|
||||
const raw = await payload.create({
|
||||
collection: "arma-sync-events",
|
||||
data: {
|
||||
eventId: `${RUN}-${label}`,
|
||||
server: serverId,
|
||||
type,
|
||||
occurredAt: new Date().toISOString(),
|
||||
payload: payloadData as Record<string, unknown>,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
syncEventIds.push(raw.id);
|
||||
const found = await payload.find({
|
||||
collection: "operation-events",
|
||||
where: { sourceEvent: { equals: raw.id } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(found.docs).toHaveLength(1);
|
||||
opEventIds.push(found.docs[0].id);
|
||||
const effects = await payload.find({
|
||||
collection: "operation-effects",
|
||||
where: { operationEvent: { equals: found.docs[0].id } },
|
||||
limit: 10,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
for (const effect of effects.docs) opEffectIds.push(effect.id);
|
||||
return raw.id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
|
||||
const superRole = await payload.create({
|
||||
collection: "roles",
|
||||
data: { name: `${RUN} Surface Admin`, slug: `${RUN}-surface-admin`, isSuperuser: true },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
roleIds.push(superRole.id);
|
||||
|
||||
const permRole = await payload.create({
|
||||
collection: "roles",
|
||||
data: {
|
||||
name: `${RUN} Surface Viewer`,
|
||||
slug: `${RUN}-surface-viewer`,
|
||||
permissions: [
|
||||
"operation-reservations:read",
|
||||
"zone-pressure:read",
|
||||
"operation-readiness:read",
|
||||
],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
roleIds.push(permRole.id);
|
||||
|
||||
const faction = await payload.create({
|
||||
collection: "factions",
|
||||
data: { name: `${RUN} Faction`, type: "majorFaction" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
factionIds.push(faction.id);
|
||||
factionId = faction.id;
|
||||
|
||||
const server = await payload.create({
|
||||
collection: "game-servers",
|
||||
data: { serverId: `${RUN}-srv`, name: `${RUN} Server`, status: "online" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
serverId = server.id;
|
||||
|
||||
const map = await payload.create({
|
||||
collection: "maps",
|
||||
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
mapIds.push(map.id);
|
||||
mapId = map.id;
|
||||
|
||||
const zone = await payload.create({
|
||||
collection: "map-zones",
|
||||
data: {
|
||||
name: `${RUN} Zone Alpha`,
|
||||
map: mapId,
|
||||
type: "territory",
|
||||
points: [
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
[100, 100],
|
||||
[0, 100],
|
||||
],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
zoneIds.push(zone.id);
|
||||
zoneId = zone.id;
|
||||
|
||||
plainUserId = await makeUser("plain");
|
||||
permUserId = await makeUser("perm", [permRole.id]);
|
||||
adminUserId = await makeUser("admin", [superRole.id]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!payload) return;
|
||||
for (const id of opEffectIds) {
|
||||
await payload.delete({ collection: "operation-effects", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of opEventIds) {
|
||||
await payload.delete({ collection: "operation-events", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of syncEventIds) {
|
||||
await payload.delete({ collection: "arma-sync-events", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of zoneIds) {
|
||||
await payload.delete({ collection: "map-zones", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of mapIds) {
|
||||
await payload.delete({ collection: "maps", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of factionIds) {
|
||||
await payload.delete({ collection: "factions", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of userIds) {
|
||||
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of roleIds) {
|
||||
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
describe("operation surfaces: capabilities", () => {
|
||||
it("grants a superuser every gated capability", async () => {
|
||||
const adminUser = await payload.findByID({
|
||||
collection: "users",
|
||||
id: adminUserId,
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
const caps = await getSurfaceCapabilities(payload, { id: adminUserId });
|
||||
expect(adminUser.id).toBe(adminUserId);
|
||||
expect(caps.canSeeReservations).toBe(true);
|
||||
expect(caps.canSeeReadiness).toBe(true);
|
||||
expect(caps.canSeePressure).toBe(true);
|
||||
});
|
||||
|
||||
it("denies a plain user every gated capability", async () => {
|
||||
const caps = await getSurfaceCapabilities(payload, { id: plainUserId });
|
||||
expect(caps.canSeeReservations).toBe(false);
|
||||
expect(caps.canSeeReadiness).toBe(false);
|
||||
expect(caps.canSeePressure).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("operation surfaces: overview", () => {
|
||||
it("groups recent events by operation with effect status counts", async () => {
|
||||
await makeRawEvent("start-a1", "operation.start", { operationId: OP_A, version: 1 });
|
||||
await makeRawEvent("start-a2", "operation.start", { operationId: OP_A, version: 1 });
|
||||
await makeRawEvent("start-b1", "operation.start", { operationId: OP_B, version: 1 });
|
||||
|
||||
const overview = await getOperationOverview(payload, { id: adminUserId });
|
||||
const opA = overview.recentOperations.find((op) => op.operationId === OP_A);
|
||||
expect(opA).toBeDefined();
|
||||
expect(opA?.eventCount).toBe(2);
|
||||
expect(opA?.appliedCount).toBe(2);
|
||||
expect(opA?.failedCount).toBe(0);
|
||||
expect(opA?.lastOccurredAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("scopes my extractions to the requesting actor only", async () => {
|
||||
await makeRawEvent("extract-b1", "operation.extraction", {
|
||||
operationId: OP_B,
|
||||
version: 1,
|
||||
actorId: plainUserId,
|
||||
kind: "player",
|
||||
items: [{ assetId: 999_999_999, quantity: 1 }],
|
||||
});
|
||||
|
||||
const mine = await getOperationOverview(payload, { id: plainUserId });
|
||||
const myRows = mine.myExtractions.filter((ex) => ex.operationId === OP_B);
|
||||
expect(myRows).toHaveLength(1);
|
||||
expect(myRows[0].effectStatus).toBeTruthy();
|
||||
|
||||
const others = await getOperationOverview(payload, { id: permUserId });
|
||||
expect(others.myExtractions.some((ex) => ex.operationId === OP_B)).toBe(false);
|
||||
});
|
||||
|
||||
it("hides gated sections from plain users but keeps the ledger visible", async () => {
|
||||
const overview = await getOperationOverview(payload, { id: plainUserId });
|
||||
expect(overview.capabilities.canSeePressure).toBe(false);
|
||||
expect(overview.pressure).toHaveLength(0);
|
||||
expect(overview.recentOperations.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("operation surfaces: ledger and AAR", () => {
|
||||
it("filters the ledger by operation and reports effect counts without leaking payload", async () => {
|
||||
const result = await getOperationLedger(payload, { id: adminUserId }, { operationId: OP_A });
|
||||
expect(result.events).toHaveLength(2);
|
||||
expect(result.events.every((ev) => ev.operationId === OP_A)).toBe(true);
|
||||
expect(result.effectCounts.applied).toBe(2);
|
||||
for (const ev of result.events) {
|
||||
expect("payload" in ev ? ev.payload : undefined).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("paginates the ledger", async () => {
|
||||
const page1 = await getOperationLedger(payload, { id: adminUserId }, {}, 1);
|
||||
expect(page1.page).toBe(1);
|
||||
expect(page1.totalDocs).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("returns null for an unknown operation id", async () => {
|
||||
const aar = await getOperationAar(payload, { id: adminUserId }, `${RUN}-does-not-exist`);
|
||||
expect(aar).toBeNull();
|
||||
});
|
||||
|
||||
it("builds an AAR with counts and never includes raw effect payloads", async () => {
|
||||
const aar = await getOperationAar(payload, { id: adminUserId }, OP_B);
|
||||
expect(aar).not.toBeNull();
|
||||
expect(aar?.events.length).toBe(2);
|
||||
expect(aar?.counts.validated).toBe(2);
|
||||
expect(aar?.counts.applied).toBe(1);
|
||||
const failed = aar?.effects.filter((eff) => eff.status === "failed") ?? [];
|
||||
expect(failed.length).toBe(1);
|
||||
expect(failed[0]?.effectType).toBe("operation.extraction-recorded");
|
||||
for (const eff of aar?.effects ?? []) {
|
||||
expect("payload" in eff ? eff.payload : undefined).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("gates AAR pressure and reservation sections by permission", async () => {
|
||||
await makeRawEvent("end-a1", "operation.end", {
|
||||
operationId: OP_A,
|
||||
version: 1,
|
||||
outcome: "success",
|
||||
zoneId,
|
||||
factionId,
|
||||
});
|
||||
|
||||
const forAdmin = await getOperationAar(payload, { id: adminUserId }, OP_A);
|
||||
expect(forAdmin?.pressure).toHaveLength(1);
|
||||
expect(forAdmin?.pressure[0]?.zoneId).toBe(zoneId);
|
||||
expect(forAdmin?.pressure[0]?.delta).toBe(-25);
|
||||
|
||||
const forPlain = await getOperationAar(payload, { id: plainUserId }, OP_A);
|
||||
expect(forPlain?.pressure).toHaveLength(0);
|
||||
expect(forPlain?.reservations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue