diff --git a/adminsite/app/(staff)/staff/page.tsx b/adminsite/app/(staff)/staff/page.tsx
new file mode 100644
index 0000000..3ef0370
--- /dev/null
+++ b/adminsite/app/(staff)/staff/page.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { API_BASE, NotConnected, api } from "@/lib/api";
+import { NotConnectedPanel } from "@/components/NotConnected";
+import { Queue } from "@/components/Queue";
+import { daysRemaining } from "@/lib/format";
+
+const HOURS_48 = 48 * 3600_000;
+
+export default function StaffDashboard() {
+ const injection = useQuery({ queryKey: ["injection"], queryFn: api.staff.injectionHealth });
+ const expiring = useQuery({
+ queryKey: ["instances", "expiring"],
+ queryFn: () => api.staff.instances({ expiring: "true" }),
+ });
+ const pastDue = useQuery({
+ queryKey: ["subs", "past_due"],
+ queryFn: () => api.staff.subscriptions("past_due"),
+ });
+ const unlinked = useQuery({
+ queryKey: ["instances", "awaiting_link"],
+ queryFn: () => api.staff.instances({ status: "awaiting_link" }),
+ });
+
+ if (injection.error instanceof NotConnected) return ;
+
+ const stale = (unlinked.data ?? []).filter(
+ (i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48,
+ );
+
+ return (
+
+
Operations
+
+ ({
+ label: i.name || i.instance_id,
+ href: `/staff/instances/${i.instance_id}`,
+ meta: i.inject_failed_at
+ ? new Date(i.inject_failed_at).toISOString().slice(11, 16)
+ : "",
+ }))}
+ />
+ ({
+ label: i.name || i.instance_id,
+ href: `/staff/instances/${i.instance_id}`,
+ meta: i.tier ?? "",
+ }))}
+ />
+ ({
+ label: s.instance_id || s.account_id,
+ href: `/staff/accounts/${s.account_id}`,
+ meta: `${daysRemaining(s.current_period_end)}d`,
+ }))}
+ />
+ ({
+ label: i.name || i.instance_id,
+ href: `/staff/accounts/${i.account_id}`,
+ meta: `${Math.floor((Date.now() - new Date(i.created_at).getTime()) / 86_400_000)}d`,
+ }))}
+ />
+
+
+ );
+}
diff --git a/adminsite/components/Queue.test.tsx b/adminsite/components/Queue.test.tsx
new file mode 100644
index 0000000..ed0bb7a
--- /dev/null
+++ b/adminsite/components/Queue.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Queue } from "./Queue";
+
+describe("Queue", () => {
+ it("shows the count and links every row to the work", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Failed injections")).toBeInTheDocument();
+ expect(screen.getByText("2")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Acme Production" })).toHaveAttribute(
+ "href",
+ "/staff/instances/i1",
+ );
+ });
+
+ it("says so plainly when there is nothing to do", () => {
+ render();
+ expect(screen.getByText(/nothing to do/i)).toBeInTheDocument();
+ });
+});
diff --git a/adminsite/components/Queue.tsx b/adminsite/components/Queue.tsx
new file mode 100644
index 0000000..cb58231
--- /dev/null
+++ b/adminsite/components/Queue.tsx
@@ -0,0 +1,53 @@
+import Link from "next/link";
+import clsx from "clsx";
+
+const TONE = {
+ expired: "border-l-expired text-expired",
+ warn: "border-l-warn text-warn",
+ accent: "border-l-accent text-accent",
+} as const;
+
+export function Queue({
+ title,
+ count,
+ tone,
+ items,
+}: {
+ title: string;
+ count: number;
+ tone: keyof typeof TONE;
+ items: { label: string; href: string; meta: string }[];
+}) {
+ return (
+
+
+ {title}
+
+
+ {count}
+
+ {items.length === 0 ? (
+ Nothing to do here.
+ ) : (
+
+ {items.map((i) => (
+ -
+
+ {i.label}
+
+ {i.meta}
+
+ ))}
+
+ )}
+
+ );
+}