+
- No notifications
+ {nothingYet ? "No notifications yet" : "You're all caught up"}
- You're all caught up.
+ {nothingYet
+ ? "Billing, quota, and platform messages land here."
+ : "Nothing unread."}
);
}
-function NotificationsPanel() {
- const items = useAtomValue(appNotificationsAtom);
- const [readIds, setReadIds] = useAtom(notificationReadIdsAtom);
+export function NotificationsPanel({ feed }: { feed: NotificationFeed }) {
+ const { items, markAllRead, markRead, readIds, unreadCount } = feed;
const [tab, setTab] = useState
("all");
+ const [expanded, setExpanded] = useState>(
+ () => new Set()
+ );
- const unreadCount = countUnreadNotifications(items, readIds);
const visible = visibleNotifications(items, tab, readIds);
- const markRead = useCallback(
- (id: string) => {
- setReadIds((previous) => new Set(previous).add(id));
- },
- [setReadIds]
- );
- const markAllRead = useCallback(() => {
- setReadIds((previous) => {
- const next = new Set(previous);
- for (const item of items) {
+ const toggle = (item: AppNotification) => {
+ setExpanded((prev) => {
+ const next = new Set(prev);
+ if (next.has(item.id)) {
+ next.delete(item.id);
+ } else {
next.add(item.id);
}
return next;
});
- }, [items, setReadIds]);
+ markRead(item);
+ };
return (
<>
-
+
Notifications
-
+ Mark all as read
+
-
- {(
- [
- { key: "all", label: "All" },
+
+ 0 ? `Unread ${unreadCount}` : "Unread",
+ label: (
+
+ Unread
+ {unreadCount > 0 ? (
+
+ {unreadCount}
+
+ ) : null}
+
+ ),
+ value: "unread",
},
- ] as const
- ).map((entry) => (
-
- ))}
+ ]}
+ size="sm"
+ value={tab}
+ width="full"
+ />
{visible.length === 0 ? (
-
+
) : (
-
-
+
+
{visible.map((item) => (
-
markRead(item.id)}
+ onRead={() => markRead(item)}
+ onToggle={() => toggle(item)}
unread={isNotificationUnread(item, readIds)}
/>
))}
@@ -218,9 +288,8 @@ function NotificationsPanel() {
export function AppSidebarNotifications() {
const { state } = useSidebar();
const expanded = state === "expanded";
- const items = useAtomValue(appNotificationsAtom);
- const readIds = useAtomValue(notificationReadIdsAtom);
- const unreadCount = countUnreadNotifications(items, readIds);
+ const feed = useNotificationFeed();
+ const { unreadCount } = feed;
const badgeLabel = notificationBadgeLabel(unreadCount);
const [open, setOpen] = useState(false);
// Collapsed anchor: the row keeps its full expanded width under the rail's
@@ -244,7 +313,10 @@ export function AppSidebarNotifications() {
return (
<>
-
+ {/* The billing Dev Mock's scenarios drive the inbox fixtures too, so
+ its panel entry is reachable from anywhere the inbox is. */}
+
+
{/* Collapsed rail: anchor the icon slot, sideOffset 6 (the rail-wide
convention for popovers) — the default trigger anchor sits at the
- clipped full-width row's edge, far past the visible rail. */}
+ clipped full-width row's edge, far past the visible rail. The
+ panel keeps a floor height so an empty inbox and a one-item inbox
+ read as the same surface. */}
-
+
>
diff --git a/apps/ui/src/features/shell/app-sidebar.tsx b/apps/ui/src/features/shell/app-sidebar.tsx
index ed1e4434..d374b2b0 100644
--- a/apps/ui/src/features/shell/app-sidebar.tsx
+++ b/apps/ui/src/features/shell/app-sidebar.tsx
@@ -33,6 +33,7 @@ import {
useState,
} from "react";
import { loadWorkspaceQuotaSnapshot } from "@/features/billing/workspace-quota-client";
+import { observeWorkspaceQuotaForInbox } from "@/features/notifications/quota-observation";
import { projectIdFromPathname } from "@/features/panes/use-project-id";
import { useProjectsExplorerReadModel } from "@/features/projects/explorer/use-projects-explorer";
import type {
@@ -42,7 +43,7 @@ import type {
import { createAppSidebarProjectGroups } from "@/features/shell/app-sidebar.groups";
import { AppSidebarAccount } from "@/features/shell/app-sidebar-account";
import { AppSidebarNotifications } from "@/features/shell/app-sidebar-notifications";
-import { kubeconfigAtom, namespaceAtom } from "@/lib/auth-store";
+import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store";
const APP_SIDEBAR_NAV_ID = "app-sidebar-nav";
const APP_SIDEBAR_WIDTH = "13.75rem";
@@ -540,6 +541,7 @@ function AppSidebarChrome({
currentProjectId: string | undefined;
projectsActive: boolean;
}) {
+ const appToken = useAtomValue(appTokenAtom).trim();
const kubeconfig = useAtomValue(kubeconfigAtom).trim();
const namespace = useAtomValue(namespaceAtom);
const { devMockActive, states } = useProjectsExplorerReadModel({
@@ -556,10 +558,18 @@ function AppSidebarChrome({
);
// Warm the workspace-quota cache so chat turns can inject the snapshot
- // without waiting on the desktop SDK (see project-workspace-layout).
+ // without waiting on the desktop SDK (see project-workspace-layout), and
+ // let the quota-exhausted producer observe the first snapshot; the Status
+ // Hint keeps observing on its polling cadence.
useEffect(() => {
- loadWorkspaceQuotaSnapshot(namespace).catch(() => undefined);
- }, [namespace]);
+ if (appToken === "" || kubeconfig === "") {
+ loadWorkspaceQuotaSnapshot(namespace).catch(() => undefined);
+ return;
+ }
+ observeWorkspaceQuotaForInbox({ appToken, kubeconfig, namespace }).catch(
+ () => undefined
+ );
+ }, [appToken, kubeconfig, namespace]);
return (
loadWorkspaceSubscriptionSummary({ appToken, kubeconfig, workspace }),
+ {
+ refreshInterval: options.refreshInterval,
+ revalidateOnFocus: false,
+ shouldRetryOnError: false,
+ }
+ );
+}
diff --git a/apps/ui/src/features/status-hint/status-hint-banner.interaction.test.tsx b/apps/ui/src/features/status-hint/status-hint-banner.interaction.test.tsx
new file mode 100644
index 00000000..bcd1c45a
--- /dev/null
+++ b/apps/ui/src/features/status-hint/status-hint-banner.interaction.test.tsx
@@ -0,0 +1,105 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { render } from "@testing-library/react/pure";
+
+import { withTestDom } from "@/features/project-canvas/react-test-harness";
+
+import type { StatusHint } from "./status-hint-model";
+
+const PAYMENT_DUE: StatusHint = {
+ cta: { href: "/billing", label: "Renew plan" },
+ description:
+ "This workspace is suspended. Resources will be deleted on Sep 6 unless the subscription is renewed.",
+ dismissible: false,
+ id: "payment-due",
+ title: "Workspace suspended — payment due",
+ tone: "destructive",
+};
+
+const QUOTA_FULL: StatusHint = {
+ cta: { href: "/billing/usage", label: "View usage" },
+ description:
+ "New deployments can't start until storage is freed or the plan is upgraded.",
+ dismissible: true,
+ id: "quota-full",
+ title: "Storage quota is full",
+ tone: "warning",
+};
+
+async function renderHint(
+ hint: StatusHint,
+ run: (
+ rendered: ReturnType,
+ dismissals: () => number,
+ act: (run: () => void) => Promise
+ ) => void | Promise
+) {
+ await withTestDom(async (act) => {
+ const { StatusHintBannerView } = await import("./status-hint-banner");
+ let rendered: ReturnType | undefined;
+ let dismissed = 0;
+ try {
+ await act(() => {
+ rendered = render(
+ {
+ dismissed += 1;
+ }}
+ />
+ );
+ });
+ if (rendered != null) {
+ await run(rendered, () => dismissed, act);
+ }
+ } finally {
+ await act(() => rendered?.unmount());
+ }
+ });
+}
+
+test("a non-dismissible hint renders title, description, and CTA with no close button", async () => {
+ await renderHint(PAYMENT_DUE, (rendered) => {
+ // A destructive, non-dismissible state announces itself as an alert.
+ const banner = rendered.getByRole("alert");
+ assert.equal(banner.dataset.state, "payment-due");
+ assert.equal(banner.dataset.tone, "destructive");
+ const text = banner.textContent ?? "";
+ assert.ok(text.includes("Workspace suspended — payment due"));
+ assert.ok(text.includes("Resources will be deleted on Sep 6"));
+ const cta = rendered.getByRole("button", { name: "Renew plan" });
+ assert.equal(cta.getAttribute("href"), "/billing");
+ assert.equal(rendered.queryByRole("button", { name: "Dismiss" }), null);
+ });
+});
+
+test("a dismissible hint pins a close button that reports the dismissal", async () => {
+ await renderHint(QUOTA_FULL, async (rendered, dismissals, act) => {
+ assert.equal(rendered.getByRole("status").dataset.state, "quota-full");
+ const close = rendered.getByRole("button", { name: "Dismiss" });
+ await act(() => close.click());
+ assert.equal(dismissals(), 1);
+ assert.equal(
+ rendered.getByRole("button", { name: "View usage" }).getAttribute("href"),
+ "/billing/usage"
+ );
+ });
+});
+
+test("the CTA records the billing return route before navigating", async () => {
+ await renderHint(QUOTA_FULL, async (rendered, _dismissals, act) => {
+ window.history.replaceState({}, "", "/project/demo?tab=canvas");
+ window.sessionStorage.removeItem("billing-return-route");
+ const cta = rendered.getByRole("button", { name: "View usage" });
+ await act(() => {
+ cta.dispatchEvent(
+ new MouseEvent("click", { bubbles: true, cancelable: true })
+ );
+ });
+ assert.equal(
+ window.sessionStorage.getItem("billing-return-route"),
+ "/project/demo?tab=canvas"
+ );
+ });
+});
diff --git a/apps/ui/src/features/status-hint/status-hint-banner.tsx b/apps/ui/src/features/status-hint/status-hint-banner.tsx
new file mode 100644
index 00000000..17094cec
--- /dev/null
+++ b/apps/ui/src/features/status-hint/status-hint-banner.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { AppButton } from "@workspace/ui/components/app-button";
+import { AppIconButton } from "@workspace/ui/components/app-icon-button";
+import { cn } from "@workspace/ui/lib/utils";
+import { Info, TriangleAlert, X } from "lucide-react";
+import Link from "next/link";
+
+import { recordBillingReturnRoute } from "@/features/billing/billing-return-route";
+import { BILLING_SURFACE_TONES } from "@/features/billing/billing-surface-tones";
+
+import type { StatusHint } from "./status-hint-model";
+import { useStatusHint } from "./use-status-hint";
+
+/**
+ * The status hint surface (design spec §7): a full-width tinted strip at the
+ * very top of the content area, in document flow, that explains a holding
+ * billing state and offers the fix. One tonal recipe across tones — a
+ * payment-due stage only ever changes its words, never its visuals.
+ */
+
+export function StatusHintBannerView({
+ hint,
+ onDismiss,
+}: {
+ hint: StatusHint;
+ onDismiss: () => void;
+}) {
+ const Icon = hint.tone === "info" ? Info : TriangleAlert;
+ return (
+
+
+
+
+ {hint.title}
+
+
+
+ {hint.description}
+
+
+ {hint.cta.label}
+
+ }
+ size="sm"
+ variant="secondary"
+ />
+ {hint.dismissible ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+/**
+ * The connected banner. Mounted at the top of every app shell's content
+ * column (project and Billing Area alike); renders nothing while no state
+ * holds. The Plan view's own subscription warning stays — it owns "what
+ * next on this page", the banner owns the global state.
+ */
+export function StatusHintBanner() {
+ const { dismiss, hint } = useStatusHint();
+ if (hint == null) {
+ return null;
+ }
+ return (
+ dismiss(hint.id)} />
+ );
+}
diff --git a/apps/ui/src/features/status-hint/status-hint-model.test.ts b/apps/ui/src/features/status-hint/status-hint-model.test.ts
new file mode 100644
index 00000000..972785ba
--- /dev/null
+++ b/apps/ui/src/features/status-hint/status-hint-model.test.ts
@@ -0,0 +1,360 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import type { WorkspaceSubscriptionSummary } from "@/features/billing/billing-plan-data";
+
+import {
+ evaluateStatusHints,
+ reconcileDismissed,
+ type StatusHintInputs,
+ selectStatusHint,
+} from "./status-hint-model";
+
+const NOW = new Date("2026-08-25T12:00:00Z");
+
+function subscription(
+ overrides: Partial
+): WorkspaceSubscriptionSummary {
+ return {
+ currentPeriodEndAt: "2026-09-12T12:00:00Z",
+ isActiveFreeTrial: false,
+ isPayg: false,
+ lifecycle: "active",
+ planName: "Hobby",
+ recoveryVoice: "renew",
+ role: "OWNER",
+ warningDeadlineAt: null,
+ warningStage: null,
+ ...overrides,
+ };
+}
+
+const QUIET: StatusHintInputs = {
+ availableBalanceMicroUnits: 50_000_000,
+ now: NOW,
+ quota: [
+ { label: "CPU", percentUsed: 37.5, type: "cpu" },
+ { label: "Memory", percentUsed: 75, type: "memory" },
+ { label: "Storage", percentUsed: 60, type: "storage" },
+ { label: "Ports", percentUsed: 25, type: "nodeport" },
+ { label: "Traffic", percentUsed: 100, type: "traffic" },
+ ],
+ subscription: subscription({}),
+};
+
+const PAYMENT_DUE: Partial = {
+ currentPeriodEndAt: "2026-08-23T12:00:00Z",
+ lifecycle: "payment-due",
+ warningDeadlineAt: "2026-09-06T12:00:00Z",
+ warningStage: "expired",
+};
+
+function ids(inputs: StatusHintInputs) {
+ return evaluateStatusHints(inputs).hints.map((hint) => hint.id);
+}
+
+test("a quiet workspace holds no state and settles every input", () => {
+ const evaluation = evaluateStatusHints(QUIET);
+ assert.deepEqual(evaluation.hints, []);
+ assert.deepEqual(evaluation.settled, [
+ "payment-due",
+ "account-debt",
+ "quota-full",
+ "trial-expiry",
+ ]);
+});
+
+test("payment-due while suspended ships the settled strings with the derived deletion date", () => {
+ const [hint] = evaluateStatusHints({
+ ...QUIET,
+ subscription: subscription(PAYMENT_DUE),
+ }).hints;
+ assert.deepEqual(hint, {
+ cta: { href: "/billing", label: "Renew plan" },
+ description:
+ "This workspace is suspended. Resources will be deleted on Sep 6 unless the subscription is renewed.",
+ dismissible: false,
+ id: "payment-due",
+ title: "Workspace suspended — payment due",
+ tone: "destructive",
+ });
+});
+
+test("payment-due stage progression changes copy and dates only — never the visuals", () => {
+ const suspended = evaluateStatusHints({
+ ...QUIET,
+ subscription: subscription(PAYMENT_DUE),
+ }).hints[0];
+ const imminent = evaluateStatusHints({
+ ...QUIET,
+ subscription: subscription({
+ ...PAYMENT_DUE,
+ currentPeriodEndAt: "2026-08-15T12:00:00Z",
+ warningDeadlineAt: "2026-08-29T12:00:00Z",
+ warningStage: "deletion-imminent",
+ }),
+ }).hints[0];
+ assert.ok(suspended && imminent);
+ assert.equal(imminent.title, "Workspace suspended — deletion imminent");
+ assert.equal(
+ imminent.description,
+ "Resources will be permanently deleted on Aug 29. This cannot be undone."
+ );
+ assert.equal(imminent.tone, suspended.tone);
+ assert.equal(imminent.dismissible, suspended.dismissible);
+ assert.deepEqual(imminent.cta, suspended.cta);
+});
+
+test("an expired Free trial never asks for a renewal", () => {
+ const [hint] = evaluateStatusHints({
+ ...QUIET,
+ subscription: subscription({
+ ...PAYMENT_DUE,
+ planName: "Free",
+ recoveryVoice: "resubscribe",
+ }),
+ }).hints;
+ assert.ok(hint);
+ assert.equal(hint.id, "payment-due");
+ assert.deepEqual(hint.cta, {
+ href: "/billing?mode=upgrade",
+ label: "Upgrade plan",
+ });
+ assert.equal(
+ hint.description,
+ "This workspace is suspended. Resources will be deleted on Sep 6 unless you upgrade to a paid plan."
+ );
+});
+
+test("a missing deadline speaks of deletion without inventing a date", () => {
+ const [hint] = evaluateStatusHints({
+ ...QUIET,
+ subscription: subscription({ ...PAYMENT_DUE, warningDeadlineAt: null }),
+ }).hints;
+ assert.ok(hint);
+ assert.equal(
+ hint.description,
+ "This workspace is suspended. Resources will be deleted soon unless the subscription is renewed."
+ );
+});
+
+test("Account Debt lights up from the available balance and never mentions a subscription", () => {
+ const [hint] = evaluateStatusHints({
+ ...QUIET,
+ availableBalanceMicroUnits: 0,
+ }).hints;
+ assert.deepEqual(hint, {
+ cta: { href: "/billing", label: "Top up balance" },
+ description:
+ "Pay-as-you-go workspaces are suspended. Top up your balance to restore them.",
+ dismissible: false,
+ id: "account-debt",
+ title: "Account balance in debt",
+ tone: "destructive",
+ });
+});
+
+test("a PAYG workspace reported in debt is Account Debt, not payment-due", () => {
+ // The platform reports it as a DEBT status with no subscription and no
+ // timestamps — CONTEXT.md: never voice it as a subscription expiring.
+ assert.deepEqual(
+ ids({
+ ...QUIET,
+ availableBalanceMicroUnits: null,
+ subscription: subscription({
+ isPayg: true,
+ lifecycle: "payment-due",
+ planName: "PAYG",
+ warningStage: "expired",
+ }),
+ }),
+ ["account-debt"]
+ );
+});
+
+test("quota-full names the first full resource and is a warning, not red", () => {
+ const [hint] = evaluateStatusHints({
+ ...QUIET,
+ quota: [
+ { label: "CPU", percentUsed: 80, type: "cpu" },
+ { label: "Memory", percentUsed: 100, type: "memory" },
+ { label: "Storage", percentUsed: 100, type: "storage" },
+ ],
+ }).hints;
+ assert.deepEqual(hint, {
+ cta: { href: "/billing/usage", label: "View usage" },
+ description:
+ "New deployments can't start until memory is freed or the plan is upgraded.",
+ dismissible: true,
+ id: "quota-full",
+ title: "Memory quota is full",
+ tone: "warning",
+ });
+});
+
+test("quota-full covers pods", () => {
+ const [pods] = evaluateStatusHints({
+ ...QUIET,
+ quota: [{ label: "Pods", percentUsed: 100, type: "pod" }],
+ }).hints;
+ assert.equal(pods?.title, "Pods quota is full");
+ assert.equal(
+ pods?.description,
+ "New deployments can't start until pods is freed or the plan is upgraded."
+ );
+});
+
+test("quota-full keeps CPU capitalised and ignores traffic", () => {
+ const [cpu] = evaluateStatusHints({
+ ...QUIET,
+ quota: [{ label: "CPU", percentUsed: 100, type: "cpu" }],
+ }).hints;
+ assert.equal(cpu?.title, "CPU quota is full");
+ assert.equal(
+ cpu?.description,
+ "New deployments can't start until CPU is freed or the plan is upgraded."
+ );
+ assert.deepEqual(
+ ids({
+ ...QUIET,
+ quota: [{ label: "Traffic", percentUsed: 100, type: "traffic" }],
+ }),
+ []
+ );
+});
+
+test("trial-expiry opens three days before the Free trial ends and counts down", () => {
+ const trial = (endsAt: string) =>
+ evaluateStatusHints({
+ ...QUIET,
+ subscription: subscription({
+ currentPeriodEndAt: endsAt,
+ isActiveFreeTrial: true,
+ planName: "Free",
+ }),
+ }).hints;
+ assert.deepEqual(trial("2026-09-04T12:00:00Z"), []);
+ assert.deepEqual(trial("2026-08-28T12:00:00Z"), [
+ {
+ cta: { href: "/billing?mode=upgrade", label: "View plans" },
+ description:
+ "Your workspace will be suspended when the trial ends on Aug 28. Upgrade to keep it running.",
+ dismissible: true,
+ id: "trial-expiry",
+ title: "Free trial ends in 3 days",
+ tone: "info",
+ },
+ ]);
+ assert.equal(
+ trial("2026-08-26T12:00:00Z")[0]?.title,
+ "Free trial ends tomorrow"
+ );
+ assert.equal(
+ trial("2026-08-25T12:30:00Z")[0]?.title,
+ "Free trial ends today"
+ );
+ // Once expired, section B owns the flow.
+ assert.deepEqual(trial("2026-08-24T12:00:00Z"), []);
+});
+
+test("severity orders payment-due > account-debt > quota-full > trial-expiry", () => {
+ assert.deepEqual(
+ ids({
+ availableBalanceMicroUnits: -1,
+ now: NOW,
+ quota: [{ label: "Storage", percentUsed: 100, type: "storage" }],
+ subscription: subscription(PAYMENT_DUE),
+ }),
+ ["payment-due", "account-debt", "quota-full"]
+ );
+ assert.deepEqual(
+ ids({
+ availableBalanceMicroUnits: -1,
+ now: NOW,
+ quota: [{ label: "Storage", percentUsed: 100, type: "storage" }],
+ subscription: subscription({
+ currentPeriodEndAt: "2026-08-27T12:00:00Z",
+ isActiveFreeTrial: true,
+ planName: "Free",
+ }),
+ }),
+ ["account-debt", "quota-full", "trial-expiry"]
+ );
+});
+
+test("unknown inputs neither light a state nor settle it", () => {
+ const evaluation = evaluateStatusHints({
+ availableBalanceMicroUnits: null,
+ now: NOW,
+ quota: null,
+ subscription: null,
+ });
+ assert.deepEqual(evaluation.hints, []);
+ assert.deepEqual(evaluation.settled, []);
+ // The subscription alone settles the subscription-driven states.
+ assert.deepEqual(
+ evaluateStatusHints({
+ availableBalanceMicroUnits: null,
+ now: NOW,
+ quota: null,
+ subscription: subscription({}),
+ }).settled,
+ ["payment-due", "trial-expiry"]
+ );
+});
+
+test("the single slot shows the most severe undismissed hint", () => {
+ const { hints } = evaluateStatusHints({
+ ...QUIET,
+ quota: [{ label: "Storage", percentUsed: 100, type: "storage" }],
+ subscription: subscription({
+ currentPeriodEndAt: "2026-08-27T12:00:00Z",
+ isActiveFreeTrial: true,
+ planName: "Free",
+ }),
+ });
+ assert.equal(selectStatusHint(hints, [])?.id, "quota-full");
+ assert.equal(selectStatusHint(hints, ["quota-full"])?.id, "trial-expiry");
+ assert.equal(selectStatusHint(hints, ["quota-full", "trial-expiry"]), null);
+});
+
+test("a suppressed state takes over when the higher one clears", () => {
+ const withDebt = evaluateStatusHints({
+ ...QUIET,
+ availableBalanceMicroUnits: 0,
+ quota: [{ label: "Storage", percentUsed: 100, type: "storage" }],
+ });
+ assert.equal(selectStatusHint(withDebt.hints, [])?.id, "account-debt");
+ const recovered = evaluateStatusHints({
+ ...QUIET,
+ quota: [{ label: "Storage", percentUsed: 100, type: "storage" }],
+ });
+ assert.equal(selectStatusHint(recovered.hints, [])?.id, "quota-full");
+});
+
+test("dismissals survive while the state holds and revive on re-entry", () => {
+ const full = evaluateStatusHints({
+ ...QUIET,
+ quota: [{ label: "Storage", percentUsed: 100, type: "storage" }],
+ });
+ const dismissed = ["quota-full", "trial-expiry"] as const;
+ // Still full: the quota dismissal stands; the trial state is settled
+ // absent, so its stale dismissal is forgotten.
+ assert.deepEqual(reconcileDismissed(dismissed, full), ["quota-full"]);
+ // Freed: the dismissal is forgotten, so the next fill shows the banner.
+ assert.deepEqual(
+ reconcileDismissed(["quota-full"], evaluateStatusHints(QUIET)),
+ []
+ );
+ // Unknown quota: nothing is decided yet, so the dismissal is kept.
+ assert.deepEqual(
+ reconcileDismissed(
+ ["quota-full"],
+ evaluateStatusHints({ ...QUIET, quota: null })
+ ),
+ ["quota-full"]
+ );
+ // Reconciling is identity-stable when nothing changes.
+ const stable = ["quota-full"] as const;
+ assert.equal(reconcileDismissed(stable, full), stable);
+});
diff --git a/apps/ui/src/features/status-hint/status-hint-model.ts b/apps/ui/src/features/status-hint/status-hint-model.ts
new file mode 100644
index 00000000..50d0584e
--- /dev/null
+++ b/apps/ui/src/features/status-hint/status-hint-model.ts
@@ -0,0 +1,293 @@
+import type { WorkspaceSubscriptionSummary } from "@/features/billing/billing-plan-data";
+import type { BillingSurfaceTone } from "@/features/billing/billing-surface-tones";
+import type {
+ BillingQuotaType,
+ BillingUsageRow,
+} from "@/features/billing/billing-usage-data";
+import type { NotificationCTA } from "@/features/shell/app-sidebar-notifications-model";
+import { DAY_MS } from "@/lib/time";
+
+/**
+ * The status hint surface's domain model (design spec §7): which billing
+ * states currently hold, in severity order, and the single-slot and
+ * dismiss/revive rules the banner applies to them. Pure — the hook feeds it
+ * already-proxied account, subscription, and quota reads.
+ */
+
+export type StatusHintId =
+ | "payment-due"
+ | "account-debt"
+ | "quota-full"
+ | "trial-expiry";
+
+export type StatusHintTone = BillingSurfaceTone;
+
+export interface StatusHint {
+ /** The fix, deep-linking to the page that solves the problem. */
+ cta: NotificationCTA;
+ description: string;
+ /** Critical, blocking, system-condition hints get no close button. */
+ dismissible: boolean;
+ id: StatusHintId;
+ title: string;
+ tone: StatusHintTone;
+}
+
+export type StatusHintQuotaRow = Pick<
+ BillingUsageRow,
+ "label" | "percentUsed" | "type"
+>;
+
+/** `null` marks an input that has not answered yet — unknown, not absent. */
+export interface StatusHintInputs {
+ /** Balance − DeductionBalance + usable credits, the platform's debt formula. */
+ availableBalanceMicroUnits: number | null;
+ now: Date;
+ quota: readonly StatusHintQuotaRow[] | null;
+ subscription: WorkspaceSubscriptionSummary | null;
+}
+
+export interface StatusHintEvaluation {
+ /** Every holding state, most severe first. */
+ hints: StatusHint[];
+ /** States whose inputs answered — their absence from `hints` is a fact. */
+ settled: StatusHintId[];
+}
+
+/** Severity order: dunning > global suspension > hard stop > heads-up. */
+const SEVERITY: readonly StatusHintId[] = [
+ "payment-due",
+ "account-debt",
+ "quota-full",
+ "trial-expiry",
+];
+
+const DATE_FORMATTER = new Intl.DateTimeFormat("en-US", {
+ day: "numeric",
+ month: "short",
+});
+
+/** The trial-expiry state opens this many days before the Free trial ends. */
+export const TRIAL_EXPIRY_NOTICE_DAYS = 3;
+
+/** The quota resources the catalog names (A2); traffic and GPU stay out. */
+const QUOTA_FULL_TYPES: ReadonlySet = new Set([
+ "cpu",
+ "memory",
+ "storage",
+ "pod",
+ "nodeport",
+]);
+
+function parsedDate(iso: string | null): Date | null {
+ if (iso == null || iso.trim() === "") {
+ return null;
+ }
+ const date = new Date(iso);
+ return Number.isNaN(date.getTime()) ? null : date;
+}
+
+function formatDate(date: Date): string {
+ return DATE_FORMATTER.format(date);
+}
+
+function paymentDueHint(
+ subscription: WorkspaceSubscriptionSummary
+): StatusHint | null {
+ // A PAYG workspace in DEBT is Account Debt (no subscription, no dates).
+ if (subscription.isPayg || subscription.lifecycle !== "payment-due") {
+ return null;
+ }
+ // An unpriced Free plan is not a renewal target: its recovery is choosing
+ // a paid plan, so the CTA opens the Plan Picker and never says "renew".
+ const resubscribe = subscription.recoveryVoice === "resubscribe";
+ const cta = resubscribe
+ ? { href: "/billing?mode=upgrade", label: "Upgrade plan" }
+ : { href: "/billing", label: "Renew plan" };
+ const deadline = parsedDate(subscription.warningDeadlineAt);
+ if (subscription.warningStage === "deletion-imminent") {
+ // Copy advances with the stage; the visuals deliberately do not.
+ return {
+ cta,
+ description:
+ deadline == null
+ ? "Resources will be permanently deleted soon. This cannot be undone."
+ : `Resources will be permanently deleted on ${formatDate(deadline)}. This cannot be undone.`,
+ dismissible: false,
+ id: "payment-due",
+ title: "Workspace suspended — deletion imminent",
+ tone: "destructive",
+ };
+ }
+ const when = deadline == null ? "soon" : `on ${formatDate(deadline)}`;
+ const wayOut = resubscribe
+ ? "unless you upgrade to a paid plan"
+ : "unless the subscription is renewed";
+ return {
+ cta,
+ description: `This workspace is suspended. Resources will be deleted ${when} ${wayOut}.`,
+ dismissible: false,
+ id: "payment-due",
+ title: "Workspace suspended — payment due",
+ tone: "destructive",
+ };
+}
+
+const ACCOUNT_DEBT_HINT: StatusHint = {
+ cta: { href: "/billing", label: "Top up balance" },
+ description:
+ "Pay-as-you-go workspaces are suspended. Top up your balance to restore them.",
+ dismissible: false,
+ id: "account-debt",
+ title: "Account balance in debt",
+ tone: "destructive",
+};
+
+function accountDebtHolds(inputs: StatusHintInputs): boolean | null {
+ // The platform treats only a strictly positive available amount as good
+ // standing; a PAYG workspace it already reports in DEBT is the same fact.
+ if (
+ inputs.subscription?.isPayg &&
+ inputs.subscription.lifecycle === "payment-due"
+ ) {
+ return true;
+ }
+ if (inputs.availableBalanceMicroUnits == null) {
+ return null;
+ }
+ return inputs.availableBalanceMicroUnits <= 0;
+}
+
+/** "CPU" keeps its initialism mid-sentence; the rest read as common nouns. */
+function resourceNoun(label: string): string {
+ return label === label.toUpperCase() ? label : label.toLowerCase();
+}
+
+function quotaFullHint(
+ quota: readonly StatusHintQuotaRow[]
+): StatusHint | null {
+ const full = quota.find(
+ (row) => QUOTA_FULL_TYPES.has(row.type) && row.percentUsed >= 100
+ );
+ if (full == null) {
+ return null;
+ }
+ return {
+ cta: { href: "/billing/usage", label: "View usage" },
+ description: `New deployments can't start until ${resourceNoun(full.label)} is freed or the plan is upgraded.`,
+ dismissible: true,
+ id: "quota-full",
+ title: `${full.label} quota is full`,
+ tone: "warning",
+ };
+}
+
+/** Whole calendar days from `now` to `target` in the viewer's time zone. */
+function calendarDaysUntil(target: Date, now: Date): number {
+ const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const end = new Date(
+ target.getFullYear(),
+ target.getMonth(),
+ target.getDate()
+ );
+ return Math.round((end.getTime() - start.getTime()) / DAY_MS);
+}
+
+function trialEndsIn(days: number): string {
+ if (days <= 0) {
+ return "today";
+ }
+ if (days === 1) {
+ return "tomorrow";
+ }
+ return `in ${days} days`;
+}
+
+function trialExpiryHint(
+ subscription: WorkspaceSubscriptionSummary,
+ now: Date
+): StatusHint | null {
+ if (!subscription.isActiveFreeTrial) {
+ return null;
+ }
+ const endsAt = parsedDate(subscription.currentPeriodEndAt);
+ if (endsAt == null) {
+ return null;
+ }
+ // Once expired, the payment-due pipeline owns the flow.
+ if (endsAt.getTime() < now.getTime()) {
+ return null;
+ }
+ const days = calendarDaysUntil(endsAt, now);
+ if (days > TRIAL_EXPIRY_NOTICE_DAYS) {
+ return null;
+ }
+ return {
+ cta: { href: "/billing?mode=upgrade", label: "View plans" },
+ description: `Your workspace will be suspended when the trial ends on ${formatDate(endsAt)}. Upgrade to keep it running.`,
+ dismissible: true,
+ id: "trial-expiry",
+ title: `Free trial ends ${trialEndsIn(days)}`,
+ tone: "info",
+ };
+}
+
+export function evaluateStatusHints(
+ inputs: StatusHintInputs
+): StatusHintEvaluation {
+ const { subscription } = inputs;
+ const debt = accountDebtHolds(inputs);
+ let accountDebt: StatusHint | null | undefined;
+ if (debt != null) {
+ accountDebt = debt ? ACCOUNT_DEBT_HINT : null;
+ }
+ const outcomes: Record = {
+ "account-debt": accountDebt,
+ "payment-due":
+ subscription == null ? undefined : paymentDueHint(subscription),
+ "quota-full":
+ inputs.quota == null ? undefined : quotaFullHint(inputs.quota),
+ "trial-expiry":
+ subscription == null
+ ? undefined
+ : trialExpiryHint(subscription, inputs.now),
+ };
+ const hints: StatusHint[] = [];
+ const settled: StatusHintId[] = [];
+ for (const id of SEVERITY) {
+ const outcome = outcomes[id];
+ if (outcome === undefined) {
+ continue;
+ }
+ settled.push(id);
+ if (outcome != null) {
+ hints.push(outcome);
+ }
+ }
+ return { hints, settled };
+}
+
+/** The one hint the single slot shows, skipping user-dismissed ones. */
+export function selectStatusHint(
+ hints: readonly StatusHint[],
+ dismissed: readonly StatusHintId[]
+): StatusHint | null {
+ return hints.find((hint) => !dismissed.includes(hint.id)) ?? null;
+}
+
+/**
+ * Edge semantics for dismissals: a dismissal stands while its state holds
+ * (or is still unknown) and is forgotten once the state is settled absent,
+ * so the banner revives when the state re-enters. Returns the same array
+ * when nothing changes, so stores can skip a write.
+ */
+export function reconcileDismissed(
+ dismissed: readonly StatusHintId[],
+ evaluation: StatusHintEvaluation
+): readonly StatusHintId[] {
+ const holding = new Set(evaluation.hints.map((hint) => hint.id));
+ const next = dismissed.filter(
+ (id) => holding.has(id) || !evaluation.settled.includes(id)
+ );
+ return next.length === dismissed.length ? dismissed : next;
+}
diff --git a/apps/ui/src/features/status-hint/status-hint-store.ts b/apps/ui/src/features/status-hint/status-hint-store.ts
new file mode 100644
index 00000000..3ce789db
--- /dev/null
+++ b/apps/ui/src/features/status-hint/status-hint-store.ts
@@ -0,0 +1,14 @@
+import { atomWithStorage, createJSONStorage } from "jotai/utils";
+
+import type { StatusHintId } from "./status-hint-model";
+
+/**
+ * Ids of dismissible hints the user closed. Session-scoped so a reload keeps
+ * a dismissal, while `reconcileDismissed` forgets it once its state is
+ * settled absent — re-entry revives the banner (edge semantics).
+ */
+export const statusHintDismissedAtom = atomWithStorage(
+ "status-hint-dismissed",
+ [],
+ createJSONStorage(() => sessionStorage)
+);
diff --git a/apps/ui/src/features/status-hint/status-hint-through-fixtures.test.ts b/apps/ui/src/features/status-hint/status-hint-through-fixtures.test.ts
new file mode 100644
index 00000000..9de72432
--- /dev/null
+++ b/apps/ui/src/features/status-hint/status-hint-through-fixtures.test.ts
@@ -0,0 +1,129 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { loadAccountBalanceMicroUnits } from "@/features/billing/account-balance";
+import { loadAccountCredits } from "@/features/billing/account-credits";
+import { loadWorkspaceSubscriptionSummary } from "@/features/billing/billing-plan-data";
+import { loadWorkspaceQuotaUsage } from "@/features/billing/billing-usage-data";
+import {
+ BILLING_DEV_SCENARIOS,
+ type BillingDevScenario,
+} from "@/features/billing/dev-mock-cookie";
+import { scenarioTestFetch } from "@/features/billing/server/dev-fixtures/scenario-test-fetch";
+
+import {
+ evaluateStatusHints,
+ type StatusHintId,
+ selectStatusHint,
+} from "./status-hint-model";
+
+/**
+ * Pins the banner states to the dev fixtures through the same loaders the
+ * hook uses, so every state the design catalogs can be simulated locally
+ * and the fixtures keep meaning what their names promise.
+ */
+
+const DATED_DELETION_PATTERN = /deleted on [A-Z][a-z]{2} \d/;
+const RENEWAL_PATTERN = /renew/i;
+
+const CREDENTIALS = {
+ appToken: "test-token",
+ kubeconfig: "test-kubeconfig",
+ workspace: "ns-test",
+};
+
+async function hintFor(scenario: BillingDevScenario) {
+ const fetch = scenarioTestFetch(scenario);
+ const [subscription, balance, credits, quota] = await Promise.all([
+ loadWorkspaceSubscriptionSummary(CREDENTIALS, { fetch }),
+ loadAccountBalanceMicroUnits(CREDENTIALS, fetch),
+ loadAccountCredits(CREDENTIALS, fetch),
+ loadWorkspaceQuotaUsage(CREDENTIALS, fetch),
+ ]);
+ const evaluation = evaluateStatusHints({
+ availableBalanceMicroUnits: balance + credits.usableMicroUnits,
+ now: new Date(),
+ quota,
+ subscription,
+ });
+ assert.deepEqual(
+ evaluation.settled,
+ ["payment-due", "account-debt", "quota-full", "trial-expiry"],
+ `${scenario}: every input answers`
+ );
+ return { evaluation, hint: selectStatusHint(evaluation.hints, []) };
+}
+
+const EXPECTED: Record = {
+ active: null,
+ "active-balance": null,
+ cancelling: null,
+ deleted: null,
+ free: null,
+ "free-expired": "payment-due",
+ "free-expiring": "trial-expiry",
+ "mixed-workspaces": null,
+ paused: null,
+ payg: null,
+ "payg-debt": "account-debt",
+ "payg-debt-deletion": "account-debt",
+ "payg-debt-final": "account-debt",
+ "payment-due": "payment-due",
+ "payment-due-deletion": "payment-due",
+ "payment-due-final": "payment-due",
+ "pending-upgrade": null,
+ "quota-full": "quota-full",
+ "status-unknown": null,
+};
+
+test("every scenario lands on the banner state its name promises", async () => {
+ for (const scenario of BILLING_DEV_SCENARIOS) {
+ const { hint } = await hintFor(scenario);
+ assert.equal(hint?.id ?? null, EXPECTED[scenario], `${scenario}: slot`);
+ }
+});
+
+test("the payment-due scenarios walk the stages with identical visuals", async () => {
+ const suspended = (await hintFor("payment-due")).hint;
+ const deletion = (await hintFor("payment-due-deletion")).hint;
+ const final = (await hintFor("payment-due-final")).hint;
+ assert.equal(suspended?.title, "Workspace suspended — payment due");
+ assert.match(suspended?.description ?? "", DATED_DELETION_PATTERN);
+ assert.equal(deletion?.title, "Workspace suspended — deletion imminent");
+ assert.equal(final?.title, "Workspace suspended — deletion imminent");
+ for (const stage of [deletion, final]) {
+ assert.equal(stage?.tone, suspended?.tone);
+ assert.equal(stage?.dismissible, false);
+ assert.deepEqual(stage?.cta, suspended?.cta);
+ }
+ // Account Debt is suppressed behind payment-due here and takes over once
+ // the subscription recovers; the slot is one banner regardless.
+ const { evaluation } = await hintFor("payment-due");
+ assert.deepEqual(
+ evaluation.hints.map((hint) => hint.id),
+ ["payment-due", "account-debt"]
+ );
+});
+
+test("an expired Free trial asks for an upgrade, never a renewal", async () => {
+ const { hint } = await hintFor("free-expired");
+ assert.deepEqual(hint?.cta, {
+ href: "/billing?mode=upgrade",
+ label: "Upgrade plan",
+ });
+ assert.doesNotMatch(hint?.description ?? "", RENEWAL_PATTERN);
+});
+
+test("quota-full names storage and stays a warning", async () => {
+ const { hint } = await hintFor("quota-full");
+ assert.equal(hint?.title, "Storage quota is full");
+ assert.equal(hint?.tone, "warning");
+ assert.equal(hint?.dismissible, true);
+});
+
+test("free-expiring sits inside the three-day trial notice", async () => {
+ const { hint } = await hintFor("free-expiring");
+ assert.equal(hint?.title, "Free trial ends in 3 days");
+ assert.equal(hint?.tone, "info");
+ assert.equal(hint?.cta.href, "/billing?mode=upgrade");
+});
diff --git a/apps/ui/src/features/status-hint/use-status-hint.ts b/apps/ui/src/features/status-hint/use-status-hint.ts
new file mode 100644
index 00000000..ee303012
--- /dev/null
+++ b/apps/ui/src/features/status-hint/use-status-hint.ts
@@ -0,0 +1,142 @@
+"use client";
+
+import { kubeconfigCredentialKey } from "@workspace/api/credential-key";
+import { useAtom, useAtomValue } from "jotai";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import useSWR from "swr";
+
+import { loadAccountBalanceMicroUnits } from "@/features/billing/account-balance";
+import { loadAccountCredits } from "@/features/billing/account-credits";
+import { accountCreditsSwrKey } from "@/features/billing/billing-subscription-settlement";
+import { loadWorkspaceQuotaUsage } from "@/features/billing/billing-usage-data";
+import { observeWorkspaceQuotaForInbox } from "@/features/notifications/quota-observation";
+import { useWorkspaceSubscriptionSummary } from "@/features/shell/use-workspace-subscription-summary";
+import { appTokenAtom, kubeconfigAtom, namespaceAtom } from "@/lib/auth-store";
+
+import {
+ evaluateStatusHints,
+ reconcileDismissed,
+ type StatusHint,
+ type StatusHintId,
+ selectStatusHint,
+} from "./status-hint-model";
+import { statusHintDismissedAtom } from "./status-hint-store";
+
+/** States clear on their own; the inputs follow the inbox's polling cadence. */
+export const STATUS_HINT_REFRESH_INTERVAL_MS = 5 * 60_000;
+
+export interface StatusHintSlot {
+ dismiss: (id: StatusHintId) => void;
+ hint: StatusHint | null;
+}
+
+/**
+ * The single slot's contents, evaluated from already-proxied account,
+ * subscription, and quota reads: the subscription summary the App Sidebar
+ * already holds, the account's cash and usable credits (the platform's debt
+ * formula), and the workspace's resource quota. A read that has not
+ * answered leaves its state unknown — never lit, never cleared.
+ */
+export function useStatusHint(): StatusHintSlot {
+ const appToken = useAtomValue(appTokenAtom).trim();
+ const kubeconfig = useAtomValue(kubeconfigAtom).trim();
+ const workspace = useAtomValue(namespaceAtom).trim();
+ const credentialsReady =
+ appToken !== "" && kubeconfig !== "" && workspace !== "";
+ const credentialKey = kubeconfigCredentialKey(kubeconfig);
+
+ const subscription = useWorkspaceSubscriptionSummary({
+ refreshInterval: STATUS_HINT_REFRESH_INTERVAL_MS,
+ });
+ const swrOptions = {
+ refreshInterval: STATUS_HINT_REFRESH_INTERVAL_MS,
+ revalidateOnFocus: false,
+ shouldRetryOnError: false,
+ };
+ const balance = useSWR(
+ credentialsReady
+ ? (["status-hint-balance", credentialKey, appToken] as const)
+ : null,
+ () => loadAccountBalanceMicroUnits({ appToken, kubeconfig }),
+ swrOptions
+ );
+ // The settlement flow refreshes this key after a payment, so a top-up
+ // that lands through Brain clears Account Debt without waiting a cycle.
+ const credits = useSWR(
+ credentialsReady ? accountCreditsSwrKey({ appToken, kubeconfig }) : null,
+ () => loadAccountCredits({ appToken, kubeconfig }),
+ swrOptions
+ );
+ // Every quota poll is also the quota-exhausted producer's observation
+ // point (A1): the inbox observes on the banner's cadence, so the two
+ // cannot disagree for minutes, and a recovery between chat turns still
+ // releases the live key.
+ const quota = useSWR(
+ credentialsReady
+ ? (["status-hint-quota", workspace, credentialKey, appToken] as const)
+ : null,
+ () => loadWorkspaceQuotaUsage({ appToken, kubeconfig, workspace }),
+ {
+ ...swrOptions,
+ onSuccess: () => {
+ observeWorkspaceQuotaForInbox({
+ appToken,
+ kubeconfig,
+ namespace: workspace,
+ }).catch(() => undefined);
+ },
+ }
+ );
+
+ // Trial-expiry is a clock state as much as a data state: the window opens
+ // and the title counts down while the subscription payload stays the same,
+ // so the clock advances on the polling cadence too.
+ const [now, setNow] = useState(() => new Date());
+ useEffect(() => {
+ const timer = window.setInterval(() => {
+ setNow(new Date());
+ }, STATUS_HINT_REFRESH_INTERVAL_MS);
+ return () => {
+ window.clearInterval(timer);
+ };
+ }, []);
+
+ const balanceMicroUnits = balance.data;
+ const usableCreditMicroUnits = credits.data?.usableMicroUnits;
+ const quotaRows = quota.data;
+ const subscriptionSummary = subscription.data;
+ const evaluation = useMemo(
+ () =>
+ evaluateStatusHints({
+ availableBalanceMicroUnits:
+ balanceMicroUnits == null || usableCreditMicroUnits == null
+ ? null
+ : balanceMicroUnits + usableCreditMicroUnits,
+ now,
+ quota: quotaRows ?? null,
+ subscription: subscriptionSummary ?? null,
+ }),
+ [
+ balanceMicroUnits,
+ now,
+ quotaRows,
+ subscriptionSummary,
+ usableCreditMicroUnits,
+ ]
+ );
+
+ const [dismissed, setDismissed] = useAtom(statusHintDismissedAtom);
+ useEffect(() => {
+ setDismissed((previous) => reconcileDismissed(previous, evaluation));
+ }, [evaluation, setDismissed]);
+ const dismiss = useCallback(
+ (id: StatusHintId) => {
+ setDismissed((previous) =>
+ previous.includes(id) ? previous : [...previous, id]
+ );
+ },
+ [setDismissed]
+ );
+
+ return { dismiss, hint: selectStatusHint(evaluation.hints, dismissed) };
+}
diff --git a/apps/ui/src/lib/identity-fingerprint-core.test.ts b/apps/ui/src/lib/identity-fingerprint-core.test.ts
index 49c61540..c802f282 100644
--- a/apps/ui/src/lib/identity-fingerprint-core.test.ts
+++ b/apps/ui/src/lib/identity-fingerprint-core.test.ts
@@ -24,6 +24,10 @@ import {
marketingAttributionSubjects,
marketingLifecycleEvents,
} from "@/features/marketing/schema";
+import {
+ notificationMessages,
+ notificationReadReceipts,
+} from "@/features/notifications/schema";
import { onboardingProfiles } from "@/features/onboarding/schema";
import {
@@ -682,6 +686,185 @@ test("where both merged accounts hold a profile, the survivor's row wins and the
assert.equal(telemetry?.profilesReleased, 1);
});
+function selectReceipts(userUids: string[]) {
+ return db
+ .select({
+ messageId: notificationReadReceipts.messageId,
+ messageKey: notificationReadReceipts.messageKey,
+ userUid: notificationReadReceipts.userUid,
+ })
+ .from(notificationReadReceipts)
+ .where(inArray(notificationReadReceipts.userUid, userUids))
+ .orderBy(
+ notificationReadReceipts.userUid,
+ notificationReadReceipts.messageKey
+ );
+}
+
+function selectMessages(userUids: string[]) {
+ return db
+ .select({
+ dedupeKey: notificationMessages.dedupeKey,
+ id: notificationMessages.id,
+ namespace: notificationMessages.namespace,
+ releasedAt: notificationMessages.releasedAt,
+ userUid: notificationMessages.userUid,
+ })
+ .from(notificationMessages)
+ .where(inArray(notificationMessages.userUid, userUids))
+ .orderBy(notificationMessages.userUid, notificationMessages.id);
+}
+
+test("a merge re-keys the tombstone's read receipts and drops those the survivor already holds", async () => {
+ await observe({
+ crName: "receipt-cr",
+ mintedAt: 10_000,
+ userUid: "receipt-tombstone-uid",
+ });
+ await db.insert(notificationMessages).values({
+ dedupeKey: "quota-exhausted:receipt-ns:cpu",
+ id: "receipt-msg",
+ kind: "quota-exhausted",
+ namespace: "receipt-ns",
+ payload: { kind: "quota-exhausted", limit: 1, resource: "cpu", used: 1 },
+ });
+ await db.insert(notificationReadReceipts).values([
+ // Only the tombstone read these: they follow the survivor, the db one
+ // still attached to its row.
+ {
+ messageKey: "cr:debt-choice-debtperiod:1",
+ userUid: "receipt-tombstone-uid",
+ },
+ {
+ messageId: "receipt-msg",
+ messageKey: "db:receipt-msg",
+ userUid: "receipt-tombstone-uid",
+ },
+ // Both read this one: the survivor's stands, the tombstone's is dropped.
+ {
+ messageKey: "cr:workspace-debt-debt:1",
+ userUid: "receipt-tombstone-uid",
+ },
+ { messageKey: "cr:workspace-debt-debt:1", userUid: "receipt-survivor-uid" },
+ ]);
+
+ const telemetry = await observeCapturingTelemetry({
+ crName: "receipt-cr",
+ mintedAt: 11_000,
+ userUid: "receipt-survivor-uid",
+ });
+
+ assert.deepEqual(
+ await selectReceipts(["receipt-tombstone-uid", "receipt-survivor-uid"]),
+ [
+ {
+ messageId: null,
+ messageKey: "cr:debt-choice-debtperiod:1",
+ userUid: "receipt-survivor-uid",
+ },
+ {
+ messageId: null,
+ messageKey: "cr:workspace-debt-debt:1",
+ userUid: "receipt-survivor-uid",
+ },
+ {
+ messageId: "receipt-msg",
+ messageKey: "db:receipt-msg",
+ userUid: "receipt-survivor-uid",
+ },
+ ]
+ );
+ assert.equal(telemetry?.receiptsRekeyed, 2);
+ assert.equal(telemetry?.receiptsReleased, 1);
+});
+
+test("a merge re-keys the tombstone's account-scoped messages, key included, and collapses onto the survivor's live row", async () => {
+ await observe({
+ crName: "message-cr",
+ mintedAt: 12_000,
+ userUid: "message-tombstone-uid",
+ });
+ const giftPayload = {
+ giftMicroUnits: 1_000_000,
+ kind: "credit-hint",
+ } as const;
+ await db.insert(notificationMessages).values([
+ // The tombstone's welcome, observed in its own workspace.
+ {
+ dedupeKey: "credit-hint:message-tombstone-uid",
+ id: "message-tombstone-gift",
+ kind: "credit-hint",
+ namespace: "tombstone-ns",
+ payload: giftPayload,
+ userUid: "message-tombstone-uid",
+ },
+ ]);
+
+ const first = await observeCapturingTelemetry({
+ crName: "message-cr",
+ mintedAt: 13_000,
+ userUid: "message-survivor-uid",
+ });
+
+ // The row and its dedupe key follow the survivor: a later gift observation
+ // for the survivor finds this row and writes no second welcome.
+ assert.deepEqual(
+ await selectMessages(["message-tombstone-uid", "message-survivor-uid"]),
+ [
+ {
+ dedupeKey: "credit-hint:message-survivor-uid",
+ id: "message-tombstone-gift",
+ namespace: "tombstone-ns",
+ releasedAt: null,
+ userUid: "message-survivor-uid",
+ },
+ ]
+ );
+ assert.equal(first?.messagesRekeyed, 1);
+ assert.equal(first?.messagesReleased, 0);
+
+ // A second merge into the same survivor, from an account that also holds
+ // a live welcome: one row per person, so the newcomer's is deleted and
+ // its receipt cascades.
+ await observe({
+ crName: "message-cr-2",
+ mintedAt: 14_000,
+ userUid: "message-tombstone-2-uid",
+ });
+ await db.insert(notificationMessages).values({
+ dedupeKey: "credit-hint:message-tombstone-2-uid",
+ id: "message-tombstone-2-gift",
+ kind: "credit-hint",
+ namespace: "tombstone-2-ns",
+ payload: giftPayload,
+ userUid: "message-tombstone-2-uid",
+ });
+ await db.insert(notificationReadReceipts).values({
+ messageId: "message-tombstone-2-gift",
+ messageKey: "db:message-tombstone-2-gift",
+ userUid: "message-tombstone-2-uid",
+ });
+
+ const second = await observeCapturingTelemetry({
+ crName: "message-cr-2",
+ mintedAt: 15_000,
+ userUid: "message-survivor-uid",
+ });
+
+ assert.deepEqual(
+ (
+ await selectMessages(["message-tombstone-2-uid", "message-survivor-uid"])
+ ).map((row) => row.id),
+ ["message-tombstone-gift"]
+ );
+ assert.deepEqual(
+ await selectReceipts(["message-tombstone-2-uid", "message-survivor-uid"]),
+ []
+ );
+ assert.equal(second?.messagesRekeyed, 0);
+ assert.equal(second?.messagesReleased, 1);
+});
+
test("a write-transaction re-check passes only while the binding is current", async () => {
await observe({ crName: "guard-cr", mintedAt: 5100, userUid: "guard-uid" });
diff --git a/apps/ui/src/lib/identity-fingerprint-core.ts b/apps/ui/src/lib/identity-fingerprint-core.ts
index b2725f66..0adfceec 100644
--- a/apps/ui/src/lib/identity-fingerprint-core.ts
+++ b/apps/ui/src/lib/identity-fingerprint-core.ts
@@ -1,4 +1,4 @@
-import { and, eq, notExists, sql } from "drizzle-orm";
+import { and, eq, isNull, notExists, sql } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import type {
@@ -17,6 +17,10 @@ import {
marketingAttributionSubjects,
marketingLifecycleEvents,
} from "@/features/marketing/schema";
+import {
+ notificationMessages,
+ notificationReadReceipts,
+} from "@/features/notifications/schema";
import { onboardingProfiles } from "@/features/onboarding/schema";
import { rekeyCanonicalIdentityUids } from "@/lib/identity-uid-canonicalization";
@@ -228,8 +232,12 @@ async function rekeyPersonalResources(
identityUidCanonicalizationsRekeyed: number;
installSessionsRekeyed: number;
lifecycleEventsRekeyed: number;
+ messagesReleased: number;
+ messagesRekeyed: number;
profilesReleased: number;
profilesRekeyed: number;
+ receiptsReleased: number;
+ receiptsRekeyed: number;
}> {
const identityUidCanonicalizationsRekeyed = await rekeyCanonicalIdentityUids(
tx,
@@ -413,6 +421,73 @@ async function rekeyPersonalResources(
.where(eq(onboardingProfiles.userUid, input.tombstoneUserUid))
.returning({ userUid: onboardingProfiles.userUid });
+ // Notification read receipts are keyed by (uid, message key): the
+ // tombstone's receipts follow the survivor except where the survivor
+ // already read the same message, and the rest are deleted — a receipt is
+ // a fact about one person, so two rows for one message collapse to one.
+ const survivorReceipts = alias(notificationReadReceipts, "survivor_receipts");
+ const rekeyedReceipts = await tx
+ .update(notificationReadReceipts)
+ .set({ userUid: input.survivorUserUid })
+ .where(
+ and(
+ eq(notificationReadReceipts.userUid, input.tombstoneUserUid),
+ notExists(
+ tx
+ .select({ userUid: survivorReceipts.userUid })
+ .from(survivorReceipts)
+ .where(
+ and(
+ eq(survivorReceipts.userUid, input.survivorUserUid),
+ eq(
+ survivorReceipts.messageKey,
+ notificationReadReceipts.messageKey
+ )
+ )
+ )
+ )
+ )
+ )
+ .returning({ messageKey: notificationReadReceipts.messageKey });
+ const releasedReceipts = await tx
+ .delete(notificationReadReceipts)
+ .where(eq(notificationReadReceipts.userUid, input.tombstoneUserUid))
+ .returning({ messageKey: notificationReadReceipts.messageKey });
+
+ // Account-scoped notification messages (ADR-0067: the gift hint) are
+ // uid-keyed rows whose dedupe key names the uid too, so the key moves with
+ // the row — a later observation then finds the survivor's row instead of
+ // writing a second welcome. Where the survivor already holds a live row
+ // under the re-keyed name, the tombstone's is a duplicate of the same
+ // one-per-person fact and is deleted (its receipts cascade).
+ const survivorDedupeKey = sql`replace(${notificationMessages.dedupeKey}, ${input.tombstoneUserUid}, ${input.survivorUserUid})`;
+ const survivorMessages = alias(notificationMessages, "survivor_messages");
+ const rekeyedMessages = await tx
+ .update(notificationMessages)
+ .set({ dedupeKey: survivorDedupeKey, userUid: input.survivorUserUid })
+ .where(
+ and(
+ eq(notificationMessages.userUid, input.tombstoneUserUid),
+ notExists(
+ tx
+ .select({ id: survivorMessages.id })
+ .from(survivorMessages)
+ .where(
+ and(
+ eq(survivorMessages.userUid, input.survivorUserUid),
+ isNull(survivorMessages.releasedAt),
+ eq(survivorMessages.dedupeKey, survivorDedupeKey)
+ )
+ )
+ )
+ )
+ )
+ .returning({ id: notificationMessages.id });
+ const releasedMessages = await tx
+ .delete(notificationMessages)
+ .where(eq(notificationMessages.userUid, input.tombstoneUserUid))
+ .returning({ id: notificationMessages.id });
+
return {
attributionSubjectsRekeyed: rekeyedAttributionSubjects.length,
attributionSubjectsReleased: releasedAttributionSubjects.length,
@@ -424,7 +499,11 @@ async function rekeyPersonalResources(
identityUidCanonicalizationsRekeyed,
installSessionsRekeyed: rekeyedInstallSessions.length,
lifecycleEventsRekeyed: rekeyedLifecycleEvents.length,
+ messagesReleased: releasedMessages.length,
+ messagesRekeyed: rekeyedMessages.length,
profilesReleased: releasedProfiles.length,
profilesRekeyed: rekeyedProfiles.length,
+ receiptsReleased: releasedReceipts.length,
+ receiptsRekeyed: rekeyedReceipts.length,
};
}
diff --git a/apps/ui/src/lib/time.ts b/apps/ui/src/lib/time.ts
new file mode 100644
index 00000000..3961dd40
--- /dev/null
+++ b/apps/ui/src/lib/time.ts
@@ -0,0 +1,4 @@
+/** Millisecond durations shared by countdowns, relative times, and sweeps. */
+export const MINUTE_MS = 60_000;
+export const HOUR_MS = 60 * MINUTE_MS;
+export const DAY_MS = 24 * HOUR_MS;
diff --git a/docs/adr/0067-store-notifications-hybrid-cr-read-proxy-and-brain-postgres.md b/docs/adr/0067-store-notifications-hybrid-cr-read-proxy-and-brain-postgres.md
new file mode 100644
index 00000000..260f5ce9
--- /dev/null
+++ b/docs/adr/0067-store-notifications-hybrid-cr-read-proxy-and-brain-postgres.md
@@ -0,0 +1,105 @@
+# Store Notifications Hybrid: Read Platform CRs Live, Keep Brain's Own in App Postgres
+
+The Notification Center is the user's single inbox for platform messages
+(the account and workspace-subscription controllers' debt ladder) and
+Brain-produced messages (quota exhausted, and later the gift hint and
+subscription-change receipts). The platform already carries its messages as
+Notification CRs (`notifications.notification.sealos.io/v1`) in every user
+namespace, with special semantics Brain cannot reproduce: fixed names per
+scenario overwritten in place, a revive to unread on escalation, and an
+automatic read-back on recovery. Brain, in turn, holds no standing cluster
+credentials — every request arrives with the user's own kubeconfig
+(ADR-0052) — and no controller of its own.
+
+## Decision
+
+Each message has exactly one source of truth, chosen by who produces it.
+
+- **Platform messages are read live from the cluster and never copied.** The
+ Go API serves a read proxy (`/api/notification/v1alpha1`) that lists the
+ current namespace's Notification CRs with the caller's kubeconfig bearer
+ token and merge-patches the same `isRead` label the Sealos desktop writes.
+ The client polls it at the desktop's cadence (≤5 minutes). The display
+ layer may substitute Brain-voiced copy and a CTA for the known fixed-name
+ debt-ladder CRs and hide the low-balance tiers from gift-only newcomers —
+ display-only: the CR is never touched, and unknown names show upstream's
+ text as written.
+- **Brain-produced messages live in Brain's own Postgres** under a new schema,
+ `sealai_notification`: `notification_messages` (namespace, kind, project,
+ structured payload, `dedupe_key`, optional `user_uid`) rendered
+ client-side. A message is workspace-scoped (its namespace is the inbox,
+ like the rest of the app's schemas) or account-scoped (`user_uid` names
+ the person, the row follows them into every workspace's inbox, and the
+ namespace only records where it was first observed — the gift hint, later
+ the expiry reminder). An inbox lists its workspace's rows plus its
+ person's account rows. Producers write at
+ natural observation points during user requests — no scheduler, no
+ controller. The dedupe key is the idempotency mechanism (naming is dedupe;
+ a partial unique index spans live keys, and recovery releases the key so a
+ re-entry writes a fresh entry while history stays). Retention is 365 days,
+ swept opportunistically on every write.
+- **Read state is a per-user, additive receipt** (`notification_read_receipts`:
+ user × message key × read_at). The key is the source-prefixed notification
+ id — `db:` for Brain entries, `cr::` for platform CRs,
+ versioned by the CR's own timestamp so an upstream revive reads as unread
+ again. No workspace in the key: upstream writes account-level messages
+ into every user namespace, and a person reads a message once. Any role can mark anything read; Owners and Managers
+ additionally patch the CR label best-effort for desktop parity, Developers
+ skip. A platform message is unread iff the label says unread and no receipt
+ exists; upstream's auto-read stacks on top with no reconciliation.
+- **The frontend merges the two streams** into one list sorted by real time,
+ with `cr:`/`db:` id prefixes and per-source mark-read dispatch.
+
+## Considered Options
+
+- **All-CRD: Brain writes its own messages as Notification CRs too.** The
+ survey (AIM-316) showed this would make Brain's events visible in the
+ desktop's own inbox for free. Rejected: writing CRs needs a
+ ServiceAccount with cluster-wide `notifications` create/update (the
+ "architecture D" controller verified in AIM-321), which means standing
+ credentials, a deployment surface Brain does not have today, and a
+ per-user-namespace fan-out for every producer. Fixed-name overwrite
+ semantics also cannot represent history ("one entry per threshold
+ crossing").
+- **Full mirror: ingest every platform CR into Brain's store and serve from
+ there.** Rejected: it duplicates the source of truth for messages whose
+ lifecycle (overwrite, revive, auto-read) the platform owns, so the mirror
+ is wrong whenever the watch lags and would need reconciliation logic for
+ every upstream rule. Brain would also need credentials to watch namespaces
+ it is not currently serving.
+- **Ingest-on-read: copy CRs into the store when a user opens the inbox,
+ then serve the copy.** Rejected: it inherits the mirror's staleness for
+ exactly the revive and auto-read cases that matter most, adds a write to
+ every read, and buys nothing the live read does not already give.
+
+The pivot that decided it: **Brain holds no standing credentials.** Every
+option that writes or watches cluster state outside a user's request needs
+credentials Brain does not have; reading with the caller's kubeconfig and
+keeping Brain's own messages in Brain's own database needs none.
+
+## Consequences
+
+- The inbox's aggregation boundary is the current workspace, because the
+ caller's kubeconfig reaches one namespace. Account-level platform messages
+ still appear everywhere since upstream writes them to every user namespace;
+ account-scoped Brain messages appear everywhere because the inbox query
+ adds the person's `user_uid` rows.
+- Brain-produced messages are invisible to the desktop's own inbox; the
+ desktop is a later channel if ever wanted.
+- Read state can diverge between Brain and the desktop for Developers (no
+ patch permission) and whenever the best-effort patch fails; the receipt is
+ authoritative inside Brain.
+- Producers only fire where a request carries the observed data (the chat
+ turn and the sidebar's quota warm-up for quota; the inbox's own credits
+ read for the gift hint; the Plan view's settlement for subscription-change
+ receipts). A state that changes while no
+ one is using Brain is noticed on the next request, not at the moment it
+ changes. The observed snapshot is the client's (the desktop SDK's quota
+ read, already trusted for chat context), so a workspace member could post
+ a fabricated one — the write lands only in that member's own verified
+ workspace, as a nuisance entry, never across workspaces.
+- Freshness is poll-bound (≤5 minutes); a WATCH upgrade is a later change to
+ the proxy only.
+- `sealai_notification` joins the app-owned schemas: migrations apply at
+ boot, PGlite tests replay them, and receipts re-key on account merge with
+ the other uid-keyed personal resources (ADR-0059).
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 064f2ec9..e01b26dd 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -38,6 +38,7 @@ One line per decision; the linked record is authoritative. When adding an ADR, t
- [0064 — Pin the Current Billing Region by Deployment-Declared Domain](0064-pin-the-current-billing-region-by-deployment-declared-domain.md)
- [0065 — Gate Free Chat Turns on the Active Free Trial and block on exhaustion](0065-gate-free-chat-turns-on-the-active-free-trial-and-block-on-exhaustion.md) *(replaces the deleted ADR-0033)*
- [0066 — Give Canvas Resources Editable Display Names Stored on the Resource](0066-store-resource-display-names-in-annotations.md)
+- [0067 — Store Notifications Hybrid: Read Platform CRs Live, Keep Brain's Own in App Postgres](0067-store-notifications-hybrid-cr-read-proxy-and-brain-postgres.md)
## Conventions
diff --git a/packages/api/src/constants.ts b/packages/api/src/constants.ts
index ba24f608..667e680c 100644
--- a/packages/api/src/constants.ts
+++ b/packages/api/src/constants.ts
@@ -61,6 +61,12 @@ export const API_ROUTES = {
stop: "/api/db/v1alpha1/stop",
},
+ notification: {
+ base: "/api/notification/v1alpha1",
+ /** GET list of the namespace's upstream Notification CRs — group root. */
+ root: "/api/notification/v1alpha1",
+ },
+
telemetry: {
base: "/api/telemetry/v1alpha1",
logsHealth: "/api/telemetry/v1alpha1/logs/health",
diff --git a/packages/api/src/hooks/index.ts b/packages/api/src/hooks/index.ts
index 534181ed..eb37f718 100644
--- a/packages/api/src/hooks/index.ts
+++ b/packages/api/src/hooks/index.ts
@@ -49,6 +49,17 @@ export {
type K8sNamespacedListRefreshInterval,
useK8sNamespacedList,
} from "./use-k8s-namespaced-list";
+export {
+ buildNotificationCRListRequest,
+ buildNotificationCRReadRequest,
+ markNotificationCRRead,
+ NOTIFICATION_CR_REFRESH_INTERVAL_MS,
+ type NotificationCRItem,
+ type NotificationCRListResponse,
+ type NotificationCRReadResponse,
+ notificationCRReadPath,
+ useNotificationCRList,
+} from "./use-notification-crs";
export {
type BrainProductResourceKind,
type UseBrainProductResourceOptions,
diff --git a/packages/api/src/hooks/use-notification-crs.test.ts b/packages/api/src/hooks/use-notification-crs.test.ts
new file mode 100644
index 00000000..cb74b66c
--- /dev/null
+++ b/packages/api/src/hooks/use-notification-crs.test.ts
@@ -0,0 +1,47 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { API_ROUTES } from "../constants";
+import {
+ buildNotificationCRListRequest,
+ buildNotificationCRReadRequest,
+ notificationCRReadPath,
+} from "./use-notification-crs";
+
+const KUBECONFIG = "apiVersion: v1\nclusters: []";
+const AUTHORIZATION = "Bearer apiVersion%3A%20v1%0Aclusters%3A%20%5B%5D";
+
+test("the list request reads the namespace's Notification CRs with the kubeconfig bearer", () => {
+ const got = buildNotificationCRListRequest({
+ kubeconfig: KUBECONFIG,
+ namespace: "ns-a",
+ });
+
+ assert.equal(got.method, "GET");
+ assert.equal(got.path, API_ROUTES.notification.root);
+ assert.deepEqual(got.query, { namespace: "ns-a" });
+ assert.deepEqual(got.header, { Authorization: AUTHORIZATION });
+});
+
+test("the mark-read request patches one CR by name", () => {
+ const got = buildNotificationCRReadRequest({
+ kubeconfig: KUBECONFIG,
+ name: "debt-choice-debtperiod",
+ namespace: "ns-a",
+ });
+
+ assert.equal(got.method, "PATCH");
+ assert.equal(
+ got.path,
+ "/api/notification/v1alpha1/debt-choice-debtperiod/read"
+ );
+ assert.deepEqual(got.query, { namespace: "ns-a" });
+ assert.deepEqual(got.header, { Authorization: AUTHORIZATION });
+});
+
+test("CR names are path-encoded", () => {
+ assert.equal(
+ notificationCRReadPath("weird/name"),
+ "/api/notification/v1alpha1/weird%2Fname/read"
+ );
+});
diff --git a/packages/api/src/hooks/use-notification-crs.ts b/packages/api/src/hooks/use-notification-crs.ts
new file mode 100644
index 00000000..ae73c606
--- /dev/null
+++ b/packages/api/src/hooks/use-notification-crs.ts
@@ -0,0 +1,129 @@
+"use client";
+
+import useSWR from "swr";
+import { API_ROUTES } from "../constants";
+import {
+ kubeconfigBearerHeader,
+ kubeconfigCredentialKey,
+} from "../credential-key";
+import { type FetcherOptions, fetcher } from "../fetch";
+import { ApiUrl } from "../utils";
+
+/**
+ * One upstream Notification CR as the Go read proxy flattens it. The
+ * platform owns these messages (fixed names, overwritten in place, revived
+ * as unread on escalation, auto-read on recovery); Brain reads them live with
+ * the caller's own kubeconfig and never copies them.
+ */
+export interface NotificationCRItem {
+ creationTimestamp?: string;
+ desktopPopup: boolean;
+ from?: string;
+ importance?: string;
+ /** `metadata.labels.isRead === "true"` — the desktop's read state. */
+ isRead: boolean;
+ message: string;
+ name: string;
+ namespace: string;
+ /** `spec.timestamp` in Unix seconds (creation time when upstream omitted it). */
+ timestamp: number;
+ title: string;
+ uid?: string;
+ /**
+ * The id's version component: `spec.timestamp`, or the CR's generation
+ * when upstream omitted it. Changes on an in-place overwrite, never on a
+ * label patch — so a revived fixed-name CR is a new id no receipt covers.
+ */
+ version: number;
+}
+
+export interface NotificationCRListResponse {
+ items: NotificationCRItem[];
+ namespace: string;
+}
+
+export interface NotificationCRReadResponse {
+ isRead: boolean;
+ name: string;
+ namespace: string;
+}
+
+export type NotificationCRFetchRequest = Omit;
+
+/** The desktop polls its own inbox every 5 minutes; match it. */
+export const NOTIFICATION_CR_REFRESH_INTERVAL_MS = 5 * 60_000;
+
+export function notificationCRReadPath(name: string): string {
+ return `${API_ROUTES.notification.root}/${encodeURIComponent(name)}/read`;
+}
+
+export function buildNotificationCRListRequest(options: {
+ kubeconfig: string;
+ namespace: string;
+}): NotificationCRFetchRequest {
+ return {
+ header: {
+ Authorization: kubeconfigBearerHeader(options.kubeconfig),
+ },
+ method: "GET",
+ path: API_ROUTES.notification.root,
+ query: { namespace: options.namespace },
+ };
+}
+
+export function buildNotificationCRReadRequest(options: {
+ kubeconfig: string;
+ name: string;
+ namespace: string;
+}): NotificationCRFetchRequest {
+ return {
+ header: {
+ Authorization: kubeconfigBearerHeader(options.kubeconfig),
+ },
+ method: "PATCH",
+ path: notificationCRReadPath(options.name),
+ query: { namespace: options.namespace },
+ };
+}
+
+/** Merge-patches the CR's `isRead` label — the desktop-compatible read write. */
+export function markNotificationCRRead(options: {
+ kubeconfig: string;
+ name: string;
+ namespace: string;
+}): Promise {
+ return fetcher({
+ base: ApiUrl(),
+ ...buildNotificationCRReadRequest(options),
+ });
+}
+
+export function useNotificationCRList(options: {
+ enabled?: boolean;
+ kubeconfig?: string;
+ namespace: string;
+ /** @default NOTIFICATION_CR_REFRESH_INTERVAL_MS */
+ refreshInterval?: number;
+}) {
+ const {
+ enabled = true,
+ refreshInterval = NOTIFICATION_CR_REFRESH_INTERVAL_MS,
+ } = options;
+ const kubeconfig = options.kubeconfig ?? "";
+ const namespace = options.namespace.trim();
+ const credentialKey = kubeconfigCredentialKey(kubeconfig);
+ const shouldFetch = enabled && kubeconfig.trim() !== "" && namespace !== "";
+ const swrKey = shouldFetch
+ ? ([API_ROUTES.notification.root, namespace, credentialKey] as const)
+ : null;
+
+ return useSWR(
+ swrKey,
+ () =>
+ fetcher({
+ base: ApiUrl(),
+ ...buildNotificationCRListRequest({ kubeconfig, namespace }),
+ }),
+ { refreshInterval, revalidateOnFocus: true }
+ );
+}