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} +
  • + ))} +
+ )} +
+ ); +}