feat: More updates to hq
This commit is contained in:
@@ -7,6 +7,8 @@ import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { ManageBillingButton } from "@/components/ManageBillingButton";
|
||||
import { TermSpark } from "@/components/TermBar";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
import { formatDate, licenceState } from "@/lib/format";
|
||||
|
||||
export default function BillingPage() {
|
||||
@@ -72,42 +74,49 @@ export default function BillingPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">You have no subscriptions. Cloud instances and self-hosted licences are both bought from the pricing page.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
<th className="px-4 py-2.5 font-normal">Instance</th>
|
||||
<th className="px-4 py-2.5 font-normal">Plan</th>
|
||||
<th className="px-4 py-2.5 font-normal">Term</th>
|
||||
<th className="px-4 py-2.5 font-normal">Status</th>
|
||||
<th className="px-4 py-2.5 font-normal">Renews</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Panel title="Subscriptions" meta={rows.length ? `${rows.length}` : undefined} bodyless>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No subscriptions yet."
|
||||
body="Cloud instances and self-hosted licences are both bought from the plan page, and each one bills separately."
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Instance</TH>
|
||||
<TH>Plan</TH>
|
||||
<TH>Billing</TH>
|
||||
<TH>Status</TH>
|
||||
<TH>Renews</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows.map((s) => {
|
||||
const start = periodStart(s.current_period_end, s.term);
|
||||
const name = nameFor(s.instance_id);
|
||||
return (
|
||||
<tr key={s.subscription_id} className="border-b border-rule-soft last:border-0">
|
||||
<td className="px-4 py-3">{nameFor(s.instance_id) ?? <span className="text-ink-3">Not linked yet</span>}</td>
|
||||
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
|
||||
<td className="px-4 py-3">{s.term}</td>
|
||||
<td className="px-4 py-3">{s.status}</td>
|
||||
<td className="px-4 py-3">
|
||||
<TR key={s.subscription_id}>
|
||||
<TD>
|
||||
{name ?? <span className="text-ink-3">Not linked yet</span>}
|
||||
{name && <Sub>{s.instance_id?.slice(0, 8)}</Sub>}
|
||||
</TD>
|
||||
<TD>{s.tier.replace("_", " ")}</TD>
|
||||
<TD className="text-ink-2">{s.term}</TD>
|
||||
<TD className="text-ink-2">{s.status}</TD>
|
||||
<TD>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{start && <TermSpark issuedAt={start} expiresAt={s.current_period_end} state={licenceState(s.current_period_end, true)} />}
|
||||
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">{formatDate(s.current_period_end)}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</TD>
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MembersPanel } from "@/components/MembersPanel";
|
||||
import { RelinkPanel } from "@/components/RelinkPanel";
|
||||
import { StatePill } from "@/components/StatePill";
|
||||
import { TermBar } from "@/components/TermBar";
|
||||
import { EmptyState, Note, Panel } from "@/components/Panel";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { formatDate, licenceState, limitLabel } from "@/lib/format";
|
||||
@@ -143,36 +144,24 @@ export default function InstancePage() {
|
||||
* text — which is the arithmetic this bar does for the reader.
|
||||
*/}
|
||||
{lic && (
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h2 className="text-[0.95rem] font-bold">Licence</h2>
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">
|
||||
{lic.tier.replace("_", " ")} · {cloud ? "Cloud" : "Self-hosted"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Panel title="Licence" meta={`${lic.tier.replace("_", " ")} · ${cloud ? "Cloud" : "Self-hosted"}`}>
|
||||
<TermBar issuedAt={lic.issued_at} expiresAt={lic.expires_at} state={state} />
|
||||
|
||||
{state === "warn" && (
|
||||
<p className="rounded border border-rule border-l-[3px] border-l-warn bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2">
|
||||
Renewing extends the term from the current expiry, not from today, so nothing is lost by renewing early.
|
||||
</p>
|
||||
)}
|
||||
{state === "warn" && <Note tone="warn">Renewing extends the term from the current expiry, not from today, so nothing is lost by renewing early.</Note>}
|
||||
{state === "expired" && (
|
||||
<p className="rounded border border-rule border-l-[3px] border-l-expired bg-panel-2 px-3.5 py-2.5 text-[0.84rem] text-ink-2">
|
||||
Servers and monitors keep running and your agents keep their keys. Changes are disabled until this is renewed.
|
||||
</p>
|
||||
<Note tone="expired">Servers and monitors keep running and your agents keep their keys. Changes are disabled until this is renewed.</Note>
|
||||
)}
|
||||
</section>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{cloud ? (
|
||||
<MembersPanel instanceId={instance.instance_id} />
|
||||
) : (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
|
||||
Users for this install are managed inside it, in Settings → Instance. We do
|
||||
not have access to your own deployment.
|
||||
</p>
|
||||
<Panel title="People">
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
Users for this install are managed inside it, in Settings → Instance. We have no access to your own deployment.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{lic && !cloud && (
|
||||
@@ -193,9 +182,12 @@ export default function InstancePage() {
|
||||
)}
|
||||
|
||||
{!lic && (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
|
||||
No licence has been issued for this instance yet.
|
||||
</p>
|
||||
<Panel bodyless>
|
||||
<EmptyState
|
||||
title="No licence issued yet."
|
||||
body="A licence binds to one install, so it is issued once this instance is linked to the ID its install reports."
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
</PageFrame>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { API_BASE, NotConnected, api, type License } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { InstanceRecord } from "@/components/InstanceRecord";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { Panel } from "@/components/Panel";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { LinkButton } from "@/components/Button";
|
||||
import { StatePill } from "@/components/StatePill";
|
||||
@@ -200,13 +201,7 @@ export default function OverviewPage() {
|
||||
* nobody needs to read twice a week.
|
||||
*/}
|
||||
{attention.length > 0 && (
|
||||
<section className="grid overflow-hidden rounded border border-rule bg-panel">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-rule-soft px-4 py-3">
|
||||
<h2 className="text-[0.95rem] font-bold">Needs you</h2>
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">
|
||||
{attention.length} {attention.length === 1 ? "item" : "items"}
|
||||
</span>
|
||||
</div>
|
||||
<Panel title="Needs you" meta={`${attention.length} ${attention.length === 1 ? "item" : "items"}`} bodyless>
|
||||
<ul className="grid">
|
||||
{attention.map((a) => (
|
||||
<li key={a.id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft px-4 py-3 last:border-b-0">
|
||||
@@ -221,7 +216,7 @@ export default function OverviewPage() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{live.map((i, n) => {
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Field } from "@/components/Field";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
|
||||
export function AccountSearch() {
|
||||
const [q, setQ] = useState("");
|
||||
@@ -14,56 +15,65 @@ export function AccountSearch() {
|
||||
queryFn: () => api.staff.accounts(q || undefined),
|
||||
});
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Field
|
||||
label="Search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
hint="Name, email, Paddle customer ID, or an instance UUID."
|
||||
/>
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
|
||||
<th className="px-4 py-2.5">Account</th>
|
||||
<th className="px-4 py-2.5">Billing email</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data ?? []).map((a) => (
|
||||
<tr
|
||||
key={a.account_id}
|
||||
className="border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/staff/accounts/${a.account_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
<Panel>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Search</span>
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Name, email, ctm_… or an instance UUID"
|
||||
className="w-full rounded border border-rule bg-panel-2 px-3 py-2 text-[0.9rem] text-ink focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</Panel>
|
||||
|
||||
<Panel bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Account</TH>
|
||||
<TH>Billing email</TH>
|
||||
<TH>Status</TH>
|
||||
<TH>Created</TH>
|
||||
<TH />
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows.map((a) => (
|
||||
<TR key={a.account_id}>
|
||||
<TD>
|
||||
<Link href={`/staff/accounts/${a.account_id}`} className="font-semibold text-accent no-underline hover:underline">
|
||||
{a.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[0.82rem]">
|
||||
{a.billing_email}
|
||||
</td>
|
||||
<td className="px-4 py-3">{a.status}</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(a.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
<Sub>
|
||||
<span className="font-mono">{a.account_id}</span>
|
||||
</Sub>
|
||||
</TD>
|
||||
<TD className="font-mono text-[0.82rem] text-ink-2">{a.billing_email}</TD>
|
||||
<TD className="text-ink-2">{a.status}</TD>
|
||||
<TD className="font-mono tabular-nums text-ink-2">{formatDate(a.created_at)}</TD>
|
||||
<TD numeric>
|
||||
<Link href={`/staff/accounts/${a.account_id}`} className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 no-underline hover:text-accent">
|
||||
Open
|
||||
</Link>
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!isFetching && (data ?? []).length === 0 && (
|
||||
<p className="px-4 py-6 text-ink-3">
|
||||
No account matches that. Try the instance UUID from the customer’s
|
||||
email.
|
||||
</p>
|
||||
</TBody>
|
||||
</Table>
|
||||
|
||||
{!isFetching && rows.length === 0 && (
|
||||
<EmptyState
|
||||
title={q ? "No account matches that." : "No accounts yet."}
|
||||
body={q ? "Try the instance UUID from the customer's email — it resolves to the account that owns it." : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
|
||||
export default function AccountDetailPage() {
|
||||
const id = String(useParams().id);
|
||||
@@ -17,7 +19,7 @@ export default function AccountDetailPage() {
|
||||
if (isLoading || !data) return <p className="text-ink-3">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<div className="grid gap-5">
|
||||
<PageHeader
|
||||
back={{ href: "/staff/accounts", label: "Accounts" }}
|
||||
title={data.account.name}
|
||||
@@ -29,72 +31,126 @@ export default function AccountDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Panel title="Instances">
|
||||
<ul className="grid gap-2">
|
||||
{data.instances.map((i) => (
|
||||
<li key={i.instance_id} className="flex flex-wrap justify-between gap-2">
|
||||
<Link href={`/staff/instances/${i.instance_id}`} className="text-accent underline">
|
||||
{i.name || i.instance_id}
|
||||
</Link>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
{i.deployment} · {i.tier ?? "no tier"} · {i.status}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.instances.length === 0 && <li className="text-ink-3">None.</li>}
|
||||
</ul>
|
||||
{/*
|
||||
* Four lists of "thing · thing · thing" became four tables. Each row
|
||||
* held three or four separate facts run into one string with
|
||||
* middots, which cannot be scanned down a column — and a staff
|
||||
* screen is read by scanning down a column.
|
||||
*/}
|
||||
<Panel title="Instances" meta={String(data.instances.length)} bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Instance</TH>
|
||||
<TH>Deployment</TH>
|
||||
<TH>Tier</TH>
|
||||
<TH>Status</TH>
|
||||
<TH />
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{data.instances.map((i) => (
|
||||
<TR key={i.instance_id}>
|
||||
<TD>
|
||||
<Link href={`/staff/instances/${i.instance_id}`} className="font-semibold text-accent no-underline hover:underline">
|
||||
{i.name || "Unnamed instance"}
|
||||
</Link>
|
||||
<Sub>
|
||||
<span className="font-mono">{i.instance_id.slice(0, 8)}</span>
|
||||
</Sub>
|
||||
</TD>
|
||||
<TD className="text-ink-2">{i.deployment === "cloud" ? "Cloud" : "Self-hosted"}</TD>
|
||||
<TD className="text-ink-2">{i.tier?.replace("_", " ") ?? "—"}</TD>
|
||||
<TD className="text-ink-2">{i.status}</TD>
|
||||
<TD numeric>
|
||||
<Link href={`/staff/instances/${i.instance_id}`} className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 no-underline hover:text-accent">
|
||||
Open
|
||||
</Link>
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{data.instances.length === 0 && <EmptyState title="No instances on this account." body="They have signed up but not created or linked anything yet." />}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Subscriptions">
|
||||
<ul className="grid gap-2">
|
||||
{data.subscriptions.map((s) => (
|
||||
<li key={s.subscription_id} className="flex flex-wrap justify-between gap-2">
|
||||
<span>
|
||||
{s.tier.replace("_", " ")} · {s.term}
|
||||
</span>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">
|
||||
{s.status} · renews {formatDate(s.current_period_end)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{data.subscriptions.length === 0 && <li className="text-ink-3">None.</li>}
|
||||
</ul>
|
||||
<Panel title="Subscriptions" meta={String(data.subscriptions.length)} bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Tier</TH>
|
||||
<TH>Billing</TH>
|
||||
<TH>Status</TH>
|
||||
<TH>Renews</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{data.subscriptions.map((s) => (
|
||||
<TR key={s.subscription_id}>
|
||||
<TD>{s.tier.replace("_", " ")}</TD>
|
||||
<TD className="text-ink-2">{s.term}</TD>
|
||||
<TD className="text-ink-2">{s.status}</TD>
|
||||
<TD className="whitespace-nowrap font-mono tabular-nums text-ink-2">{formatDate(s.current_period_end)}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{data.subscriptions.length === 0 && <EmptyState title="No subscriptions." body="Everything on this account is Free, or nothing has been bought yet." />}
|
||||
</Panel>
|
||||
|
||||
<Panel title="People">
|
||||
<ul className="grid gap-2">
|
||||
{data.users.map((u) => (
|
||||
<li key={u.user_id} className="flex flex-wrap justify-between gap-2">
|
||||
<span className="font-mono text-[0.82rem]">{u.email}</span>
|
||||
<span className="font-mono text-[0.82rem] text-ink-3">{u.verified_at ? `verified ${formatDate(u.verified_at)}` : "not verified"}</span>
|
||||
</li>
|
||||
))}
|
||||
{data.users.length === 0 && <li className="text-ink-3">None this is a cloud account, so its people sign in with their control-plane details.</li>}
|
||||
</ul>
|
||||
<Panel title="People" meta={String(data.users.length)} bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Email</TH>
|
||||
<TH>Role</TH>
|
||||
<TH>Verified</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{data.users.map((u) => (
|
||||
<TR key={u.user_id}>
|
||||
<TD className="font-mono text-[0.82rem]">{u.email}</TD>
|
||||
<TD className="text-ink-2">{u.account_role}</TD>
|
||||
<TD className="text-ink-2">
|
||||
{u.verified_at ? (
|
||||
<span className="font-mono tabular-nums">{formatDate(u.verified_at)}</span>
|
||||
) : (
|
||||
<span className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-warn">Not verified</span>
|
||||
)}
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{data.users.length === 0 && (
|
||||
<EmptyState title="No HQ people on this account." body="This is a cloud account, so its people sign in with their control-plane details instead." />
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Audit">
|
||||
<ul className="grid gap-1 font-mono text-[0.82rem]">
|
||||
{data.audit.map((e, n) => (
|
||||
<li key={n} className="flex flex-wrap justify-between gap-2 text-ink-2">
|
||||
<span>
|
||||
{e.action} · {e.actor}
|
||||
</span>
|
||||
<span className="tabular-nums text-ink-3">{formatDate(e.created_at)}</span>
|
||||
</li>
|
||||
))}
|
||||
{data.audit.length === 0 && <li className="text-ink-3">Nothing yet.</li>}
|
||||
</ul>
|
||||
<Panel title="Audit" meta="Newest first" bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Date</TH>
|
||||
<TH>Actor</TH>
|
||||
<TH>Action</TH>
|
||||
<TH>Target</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{data.audit.map((e, n) => (
|
||||
<TR key={n}>
|
||||
<TD className="whitespace-nowrap font-mono tabular-nums text-ink-2">{formatDate(e.created_at)}</TD>
|
||||
<TD className="text-ink-2">{e.actor}</TD>
|
||||
<TD className="font-mono text-[0.8rem]">{e.action}</TD>
|
||||
<TD className="text-ink-2">{e.target ?? "—"}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{data.audit.length === 0 && <EmptyState title="Nothing recorded against this account yet." />}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,18 +4,15 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatDate, formatStamp } from "@/lib/format";
|
||||
import { Field } from "@/components/Field";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
|
||||
export default function AuditPage() {
|
||||
const [filter, setFilter] = useState("");
|
||||
const { data } = useQuery({ queryKey: ["staff-audit"], queryFn: () => api.staff.audit() });
|
||||
|
||||
const rows = (data ?? []).filter((e) =>
|
||||
filter
|
||||
? `${e.action} ${e.actor} ${e.target ?? ""}`.toLowerCase().includes(filter.toLowerCase())
|
||||
: true,
|
||||
);
|
||||
const rows = (data ?? []).filter((e) => (filter ? `${e.action} ${e.actor} ${e.target ?? ""}`.toLowerCase().includes(filter.toLowerCase()) : true));
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
@@ -24,30 +21,59 @@ export default function AuditPage() {
|
||||
subtitle="Every mutating action across every account, newest first."
|
||||
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
|
||||
/>
|
||||
<Field
|
||||
label="Filter"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
hint="Action, actor or target."
|
||||
/>
|
||||
<ul className="grid gap-2 rounded border border-rule bg-panel p-5 font-mono text-[0.82rem]">
|
||||
{rows.map((e, n) => (
|
||||
<li
|
||||
key={n}
|
||||
className="grid gap-1 border-b border-rule-soft pb-2 last:border-0 sm:grid-cols-[11rem_1fr]"
|
||||
>
|
||||
<span className="tabular-nums text-ink-3">
|
||||
{formatDate(e.created_at)} {formatStamp(e.created_at)}
|
||||
</span>
|
||||
<span className="text-ink-2">
|
||||
<b className="text-ink">{e.action}</b> · {e.actor}
|
||||
{e.target && ` · ${e.target}`}
|
||||
{e.detail && ` · ${e.detail}`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{rows.length === 0 && <li className="text-ink-3">Nothing matches that.</li>}
|
||||
</ul>
|
||||
|
||||
<Panel>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Filter</span>
|
||||
<input
|
||||
type="search"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Action, actor or target"
|
||||
className="w-full rounded border border-rule bg-panel-2 px-3 py-2 text-[0.9rem] text-ink focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</Panel>
|
||||
|
||||
{/*
|
||||
* A table, not a list of mono sentences joined by middots. Every row
|
||||
* held five separate facts run together into one string, so nothing
|
||||
* could be scanned down a column — which is the only way anyone
|
||||
* reads an audit log looking for "who did this".
|
||||
*/}
|
||||
<Panel bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Time</TH>
|
||||
<TH>Actor</TH>
|
||||
<TH>Action</TH>
|
||||
<TH>Target</TH>
|
||||
<TH>Detail</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows.map((e, n) => (
|
||||
<TR key={n}>
|
||||
<TD className="whitespace-nowrap font-mono text-[0.78rem] tabular-nums text-ink-2">
|
||||
{formatStamp(e.created_at)}
|
||||
<Sub>{formatDate(e.created_at)}</Sub>
|
||||
</TD>
|
||||
<TD className="text-ink-2">{e.actor}</TD>
|
||||
<TD className="font-mono text-[0.8rem]">{e.action}</TD>
|
||||
<TD className="text-ink-2">{e.target ?? "—"}</TD>
|
||||
<TD className="text-[0.82rem] text-ink-3">{e.detail ?? "—"}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{rows.length === 0 && (
|
||||
<EmptyState
|
||||
title={filter ? "Nothing matches that." : "No actions recorded yet."}
|
||||
body={filter ? "Clear the filter to see the whole log." : "Every licence issued, relinked or reaped is written here as it happens."}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { PageFrame } from "@/components/PageFrame";
|
||||
import { Panel } from "@/components/Panel";
|
||||
import { TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
import { api, type CatalogueRow, type Term } from "@/lib/api";
|
||||
|
||||
const ENVS = ["sandbox", "production"] as const;
|
||||
@@ -69,35 +71,27 @@ export default function CataloguePage() {
|
||||
{isLoading ? (
|
||||
<p className="text-[0.85rem] text-ink-3">Loading…</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4">
|
||||
{groups.map((g) => {
|
||||
const [deployment, tier] = g.split("/");
|
||||
const terms = termsFor(deployment);
|
||||
return (
|
||||
<section key={g} className="space-y-2">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">
|
||||
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}{" "}
|
||||
{tier}
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[42rem] border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr className="border-b border-rule text-left text-ink-3">
|
||||
<th className="py-2 pr-3 font-normal">Component</th>
|
||||
<Panel key={g} title={`${deployment === "cloud" ? "Cloud" : "Self-Hosted"} ${tier}`} meta={terms.join(" · ")} bodyless>
|
||||
<Table className="min-w-[42rem]">
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Component</TH>
|
||||
{ENVS.map((env) =>
|
||||
terms.map((t) => (
|
||||
<th
|
||||
key={`${env}-${t}`}
|
||||
className="py-2 pr-3 font-normal"
|
||||
>
|
||||
<TH key={`${env}-${t}`}>
|
||||
{env} / {t}
|
||||
</th>
|
||||
</TH>
|
||||
)),
|
||||
)}
|
||||
<th className="py-2 font-normal" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<TH />
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows
|
||||
.filter(
|
||||
(r) =>
|
||||
@@ -111,19 +105,11 @@ export default function CataloguePage() {
|
||||
JSON.stringify(ids) !==
|
||||
JSON.stringify(r.price_ids ?? {});
|
||||
return (
|
||||
<tr
|
||||
key={k}
|
||||
className="border-b border-rule/60"
|
||||
>
|
||||
<td className="py-2 pr-3 text-ink">
|
||||
{componentLabel(r)}
|
||||
</td>
|
||||
<TR key={k}>
|
||||
<TD className="text-ink">{componentLabel(r)}</TD>
|
||||
{ENVS.map((env) =>
|
||||
terms.map((t) => (
|
||||
<td
|
||||
key={`${env}-${t}`}
|
||||
className="py-2 pr-3"
|
||||
>
|
||||
<TD key={`${env}-${t}`}>
|
||||
<input
|
||||
value={
|
||||
ids[env]?.[t] ?? ""
|
||||
@@ -145,12 +131,12 @@ export default function CataloguePage() {
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-40 rounded border border-rule bg-panel px-2 py-1 font-mono text-[0.78rem] text-ink"
|
||||
className="w-40 rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.78rem] text-ink focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</td>
|
||||
</TD>
|
||||
)),
|
||||
)}
|
||||
<td className="py-2">
|
||||
<TD numeric>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
@@ -162,18 +148,17 @@ export default function CataloguePage() {
|
||||
price_ids: ids,
|
||||
})
|
||||
}
|
||||
className="rounded border border-accent/50 px-2.5 py-1 text-[0.78rem] text-accent disabled:opacity-40"
|
||||
className="rounded border border-accent px-2.5 py-1 font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</TD>
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</TBody>
|
||||
</Table>
|
||||
</Panel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { api, type Deployment, type InjectionState } from "@/lib/api";
|
||||
import { Ledger } from "@/components/Ledger";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { TermBar } from "@/components/TermBar";
|
||||
import { Panel } from "@/components/Panel";
|
||||
import { licenceState } from "@/lib/format";
|
||||
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
|
||||
import { IssuePanel } from "./IssuePanel";
|
||||
@@ -68,20 +69,15 @@ export default function StaffInstancePage() {
|
||||
* array, which is the server's ordering and not a guarantee.
|
||||
*/}
|
||||
{current && (
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h2 className="text-xl">Current licence</h2>
|
||||
<span className="font-mono text-[0.72rem] tabular-nums text-ink-3">{current.license_id}</span>
|
||||
</div>
|
||||
<Panel title="Current licence" meta={current.license_id}>
|
||||
<TermBar issuedAt={current.issued_at} expiresAt={current.expires_at} state={licenceState(current.expires_at, true)} className="max-w-xl" />
|
||||
</section>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">Licence history</h2>
|
||||
<Panel title="Licence history" meta="Append-only">
|
||||
<Ledger licenses={data.licenses} />
|
||||
<IssuePanel instanceId={data.instance.instance_id} />
|
||||
</section>
|
||||
</Panel>
|
||||
|
||||
<EntitlementSection instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,12 @@ import { useState } from "react";
|
||||
import { api, type Tier } from "@/lib/api";
|
||||
import { formatDate, licenceState } from "@/lib/format";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { Sub, TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
import { TermSpark } from "@/components/TermBar";
|
||||
|
||||
const SELECT = "rounded border border-rule bg-panel-2 px-2.5 py-2 text-[0.9rem] text-ink focus:border-accent focus:outline-none";
|
||||
|
||||
export default function LicensesPage() {
|
||||
const [tier, setTier] = useState<"" | Tier>("");
|
||||
const [reason, setReason] = useState("");
|
||||
@@ -15,9 +19,8 @@ export default function LicensesPage() {
|
||||
|
||||
// Filtered here rather than server-side: the endpoint caps at 500 rows and
|
||||
// staff are narrowing a list they can already see.
|
||||
const rows = (data ?? []).filter(
|
||||
(l) => (!tier || l.tier === tier) && (!reason || l.reason === reason),
|
||||
);
|
||||
const rows = (data ?? []).filter((l) => (!tier || l.tier === tier) && (!reason || l.reason === reason));
|
||||
const filtered = Boolean(tier || reason);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
@@ -26,90 +29,93 @@ export default function LicensesPage() {
|
||||
subtitle="Append-only. A renewal writes a new row and supersedes the old one."
|
||||
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as Tier | "")}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
aria-label="Filter by tier"
|
||||
>
|
||||
<option value="">All tiers</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
<option value="self_hosted">Self-Hosted (legacy)</option>
|
||||
</select>
|
||||
<select
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
aria-label="Filter by reason"
|
||||
>
|
||||
<option value="">All reasons</option>
|
||||
<option value="new">New</option>
|
||||
<option value="renewal">Renewal</option>
|
||||
<option value="tier_change">Tier change</option>
|
||||
<option value="relink">Relink</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
|
||||
<th className="px-4 py-2.5">Issued</th>
|
||||
<th className="px-4 py-2.5">Instance</th>
|
||||
<th className="px-4 py-2.5">Tier</th>
|
||||
<th className="px-4 py-2.5">Reason</th>
|
||||
<th className="px-4 py-2.5">Term</th>
|
||||
<th className="px-4 py-2.5">Expires</th>
|
||||
<th className="px-4 py-2.5">State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((l) => (
|
||||
<tr
|
||||
key={l.license_id}
|
||||
className="border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(l.issued_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/staff/instances/${l.instance_id}`}
|
||||
className="font-mono text-[0.82rem] text-accent underline"
|
||||
>
|
||||
{l.instance_id.slice(0, 8)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">{l.tier.replace("_", " ")}</td>
|
||||
<td className="px-4 py-3">{l.reason.replace("_", " ")}</td>
|
||||
{/* A superseded row's term is not a countdown to
|
||||
anything — it ended when its successor was
|
||||
issued, so drawing a bar for it would invite
|
||||
a comparison that means nothing. */}
|
||||
<td className="px-4 py-3">
|
||||
{l.superseded_by ? (
|
||||
<span className="font-mono text-[0.72rem] text-ink-3">superseded</span>
|
||||
) : (
|
||||
<TermSpark issuedAt={l.issued_at} expiresAt={l.expires_at} state={licenceState(l.expires_at, true)} />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(l.expires_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-ink-3">
|
||||
{l.superseded_by ? "superseded" : "current"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<Panel>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Tier</span>
|
||||
<select value={tier} onChange={(e) => setTier(e.target.value as Tier | "")} className={SELECT} aria-label="Filter by tier">
|
||||
<option value="">All tiers</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
<option value="self_hosted">Self-Hosted (legacy)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">Reason</span>
|
||||
<select value={reason} onChange={(e) => setReason(e.target.value)} className={SELECT} aria-label="Filter by reason">
|
||||
<option value="">All reasons</option>
|
||||
<option value="new">New</option>
|
||||
<option value="renewal">Renewal</option>
|
||||
<option value="tier_change">Tier change</option>
|
||||
<option value="relink">Relink</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel bodyless>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Issued</TH>
|
||||
<TH>Instance</TH>
|
||||
<TH>Tier</TH>
|
||||
<TH>Reason</TH>
|
||||
<TH>Term</TH>
|
||||
<TH>Expires</TH>
|
||||
<TH>State</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{rows.map((l) => {
|
||||
const dead = Boolean(l.superseded_by);
|
||||
return (
|
||||
// A superseded row is overprinted rather than hidden:
|
||||
// it is the only record of why an instance stopped
|
||||
// working on a given date.
|
||||
<TR key={l.license_id} className={dead ? "text-ink-3" : undefined}>
|
||||
<TD className="whitespace-nowrap font-mono tabular-nums">{formatDate(l.issued_at)}</TD>
|
||||
<TD>
|
||||
<Link href={`/staff/instances/${l.instance_id}`} className="font-mono text-[0.82rem] text-accent no-underline hover:underline">
|
||||
{l.instance_id.slice(0, 8)}
|
||||
</Link>
|
||||
<Sub>
|
||||
<span className="font-mono">{l.license_id.slice(0, 8)}</span>
|
||||
</Sub>
|
||||
</TD>
|
||||
<TD>{l.tier.replace("_", " ")}</TD>
|
||||
<TD className="text-ink-2">{l.reason.replace("_", " ")}</TD>
|
||||
{/* A superseded row's term is not a countdown to
|
||||
anything — it ended when its successor was
|
||||
issued, so drawing a bar would invite a
|
||||
comparison that means nothing. */}
|
||||
<TD>
|
||||
{dead ? (
|
||||
<span className="font-mono text-[0.72rem] text-ink-3">—</span>
|
||||
) : (
|
||||
<TermSpark issuedAt={l.issued_at} expiresAt={l.expires_at} state={licenceState(l.expires_at, true)} />
|
||||
)}
|
||||
</TD>
|
||||
<TD className="whitespace-nowrap font-mono tabular-nums">{formatDate(l.expires_at)}</TD>
|
||||
<TD>
|
||||
<span className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3">{dead ? "superseded" : "current"}</span>
|
||||
</TD>
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
|
||||
{rows.length === 0 && (
|
||||
<p className="px-4 py-6 text-ink-3">No licences match those filters.</p>
|
||||
<EmptyState
|
||||
title={filtered ? "No licences match those filters." : "No licences issued yet."}
|
||||
body={filtered ? "Clear a filter to widen the search." : "Every issue, renewal and relink writes a row here."}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { Queue } from "@/components/Queue";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { EmptyState, Panel } from "@/components/Panel";
|
||||
import { TBody, TD, TH, THead, TR, Table } from "@/components/Table";
|
||||
import { StatePill } from "@/components/StatePill";
|
||||
import { LinkButton } from "@/components/Button";
|
||||
import { daysRemaining, formatStamp } from "@/lib/format";
|
||||
@@ -123,46 +125,37 @@ export default function StaffDashboard() {
|
||||
</RailCard>
|
||||
}
|
||||
>
|
||||
<section className="overflow-hidden rounded border border-rule bg-panel">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft bg-panel-2 px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-[0.95rem]">Recent activity</h2>
|
||||
<p className="text-[0.8rem] text-ink-2">Every licence issued, relinked or reaped, newest first.</p>
|
||||
</div>
|
||||
<Link href="/staff/audit" className="text-[0.82rem] font-semibold text-accent underline">
|
||||
Full audit
|
||||
<Panel
|
||||
title="Recent activity"
|
||||
actions={
|
||||
<Link href="/staff/audit" className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent no-underline hover:underline">
|
||||
Full audit →
|
||||
</Link>
|
||||
</header>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left text-[0.86rem]">
|
||||
<thead>
|
||||
<tr className="border-b border-rule-soft font-mono text-[0.64rem] uppercase tracking-[0.11em] text-ink-3">
|
||||
<th className="px-4 py-2 font-normal">Time</th>
|
||||
<th className="px-4 py-2 font-normal">Action</th>
|
||||
<th className="px-4 py-2 font-normal">Target</th>
|
||||
<th className="px-4 py-2 font-normal">Actor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(audit.data ?? []).slice(0, 12).map((e, n) => (
|
||||
<tr key={n} className="border-b border-rule-soft last:border-0">
|
||||
<td className="px-4 py-2.5 font-mono tabular-nums text-ink-2">{new Date(e.created_at).toISOString().slice(11, 16)}</td>
|
||||
<td className="px-4 py-2.5">{e.action}</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">{e.target ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-ink-3">{e.actor}</td>
|
||||
</tr>
|
||||
))}
|
||||
{audit.data?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-6 text-ink-3">
|
||||
Nothing yet today.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
bodyless
|
||||
>
|
||||
<Table>
|
||||
<THead>
|
||||
<TR className="hover:bg-transparent">
|
||||
<TH>Time</TH>
|
||||
<TH>Actor</TH>
|
||||
<TH>Action</TH>
|
||||
<TH>Target</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{(audit.data ?? []).slice(0, 12).map((e, n) => (
|
||||
<TR key={n}>
|
||||
<TD className="whitespace-nowrap font-mono tabular-nums text-ink-2">{new Date(e.created_at).toISOString().slice(11, 16)}</TD>
|
||||
<TD className="text-ink-2">{e.actor}</TD>
|
||||
<TD className="font-mono text-[0.8rem]">{e.action}</TD>
|
||||
<TD className="text-ink-2">{e.target ?? "—"}</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{audit.data?.length === 0 && <EmptyState title="Nothing yet today." body="Every licence issued, relinked or reaped appears here as it happens." />}
|
||||
</Panel>
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
||||
import { api, type Deployment, type Plan, type Tier } from "@/lib/api";
|
||||
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Panel } from "@/components/Panel";
|
||||
|
||||
const SUPPORT_LEVELS = [
|
||||
{ value: "community", label: "Community" },
|
||||
@@ -31,7 +32,7 @@ function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Pl
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{LIMIT_FIELDS.map((f) => (
|
||||
<label key={f.key} className="block">
|
||||
@@ -48,7 +49,7 @@ function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Pl
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
className="w-full rounded border border-rule bg-panel-2 px-2 py-1.5 text-[0.85rem] text-ink focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<span className="mt-0.5 block text-[0.72rem] text-ink-3">−1 is unlimited</span>
|
||||
</label>
|
||||
@@ -58,7 +59,7 @@ function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Pl
|
||||
<select
|
||||
value={draft.support_level}
|
||||
onChange={(e) => setDraft({ ...draft, support_level: e.target.value })}
|
||||
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
className="w-full rounded border border-rule bg-panel-2 px-2 py-1.5 text-[0.85rem] text-ink focus:border-accent focus:outline-none"
|
||||
>
|
||||
{SUPPORT_LEVELS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
@@ -76,7 +77,12 @@ function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Pl
|
||||
|
||||
<p className="text-[0.78rem] text-ink-3">Changes apply to licences issued from now on. Existing licences snapshotted their plan and are unaffected.</p>
|
||||
|
||||
<button type="button" disabled={!dirty || saving} onClick={() => onSave(draft)} className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => onSave(draft)}
|
||||
className="justify-self-start rounded border border-accent bg-accent px-3.5 py-2 text-[0.86rem] font-semibold text-accent-ink disabled:opacity-40"
|
||||
>
|
||||
{saving ? "Saving…" : "Save allowances"}
|
||||
</button>
|
||||
</div>
|
||||
@@ -126,20 +132,19 @@ export default function PlansPage() {
|
||||
)}
|
||||
|
||||
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
|
||||
<section key={deployment} className="space-y-3">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">{deployment === "cloud" ? "Cloud" : "Self-Hosted"}</h2>
|
||||
<section key={deployment} className="grid gap-3">
|
||||
<h2 className="font-mono text-[0.68rem] uppercase tracking-[0.14em] text-ink-3">{deployment === "cloud" ? "Cloud" : "Self-Hosted"}</h2>
|
||||
{(plans.data ?? [])
|
||||
.filter((p) => p.deployment === deployment)
|
||||
.map((p) => (
|
||||
<article key={`${p.deployment}/${p.tier}`} className="rounded-lg border border-rule bg-panel p-4">
|
||||
<header className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-[0.9rem] font-medium text-ink">{p.name}</h3>
|
||||
<span className="font-mono text-[0.75rem] text-ink-3">
|
||||
{p.deployment}/{p.tier}
|
||||
</span>
|
||||
</header>
|
||||
<Panel
|
||||
key={`${p.deployment}/${p.tier}`}
|
||||
title={p.name}
|
||||
meta={`${p.deployment}/${p.tier}`}
|
||||
actions={!p.active ? <span className="font-mono text-[0.64rem] uppercase tracking-[0.12em] text-warn">Not offered</span> : undefined}
|
||||
>
|
||||
<AllowanceForm plan={p} saving={saving === `${p.deployment}/${p.tier}`} onSave={(next: Plan) => setDraft(next)} />
|
||||
</article>
|
||||
</Panel>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user