Suspendedremoval display in Frontend
All checks were successful
Build and Push / build (push) Successful in 1m28s

This commit is contained in:
2026-05-01 21:39:16 +02:00
parent a79d0defa4
commit b12bca8818
3 changed files with 82 additions and 12 deletions

View File

@@ -2,7 +2,10 @@ import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations, getFormatter } from "next-intl/server"; import { getTranslations, getFormatter } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { listTenants } from "@/lib/k8s"; import { listTenants } from "@/lib/k8s";
import { listActiveTenantRequestsByOrgId } from "@/lib/db"; import {
listActiveTenantRequestsByOrgId,
syncProvisioningStatuses,
} from "@/lib/db";
import { import {
listVisibleTenants, listVisibleTenants,
canSeeInflightRequests, canSeeInflightRequests,
@@ -160,6 +163,23 @@ export default async function DashboardPage() {
// Pending/in-flight requests are only shown to roles that can act on // Pending/in-flight requests are only shown to roles that can act on
// them. `user`-role customers see no request cards. // them. `user`-role customers see no request cards.
//
// syncProvisioningStatuses runs on every dashboard load: it walks
// active and provisioning rows and reconciles them against the
// current cluster state. Without this, the operator-initiated
// 60-day TTL deletion (Bug 37b) leaves the portal showing "Your
// assistant is ready!" cards for tenants that no longer exist —
// the operator deletes the CR, but the DB row stays at active=true
// until something updates it. Running the sync at every dashboard
// load keeps the portal eventually consistent with the cluster
// without needing a separate cron/job.
//
// Cost: one K8s GET per row in (active, provisioning) status. At
// pilot scale this is small; if it grows we'd cache or move to a
// periodic background job.
if (canSeeInflightRequests(user)) {
await syncProvisioningStatuses();
}
const orgRequests = canSeeInflightRequests(user) const orgRequests = canSeeInflightRequests(user)
? await listActiveTenantRequestsByOrgId(user.orgId) ? await listActiveTenantRequestsByOrgId(user.orgId)
: []; : [];

View File

@@ -639,8 +639,26 @@ export async function deleteTenantRequest(id: string): Promise<void> {
} }
/** /**
* Sync provisioning statuses: for all requests with status "provisioning", * Reconcile the portal's tenant_requests table against actual cluster
* check if the PiecedTenant CR has reached "Ready" and update to "active". * state. Two passes, both walking only rows with `tenant_name` set
* (rows in pending/rejected/cancelled state don't have one and are
* irrelevant to this reconciliation):
*
* 1. provisioning → active: when a tenant CR's phase reaches Ready
* or Running, the portal flips the row to active so the
* "provisioning…" card transitions into the running tenant view.
*
* 2. active/provisioning → deleted: when the corresponding CR no
* longer exists in the cluster (404), or is mid-deletion (has
* metadata.deletionTimestamp set), the row gets flipped to
* `deleted`. The DB is otherwise blind to operator-initiated
* deletions — when the 60-day TTL fires (Bug 37b) and the
* operator deletes a suspended tenant, the portal would happily
* keep showing the "Your assistant is ready!" card forever.
* Without this reconciliation the dashboard drifts from reality.
*
* Errors are tolerated per-row: a transient API hiccup on one tenant
* shouldn't fail the whole sweep. Skipped rows get retried next call.
* *
* Slice 3 note: with multi-tenant per org, this iterates each row * Slice 3 note: with multi-tenant per org, this iterates each row
* individually (keyed by its own tenant_name), so multiple in-flight * individually (keyed by its own tenant_name), so multiple in-flight
@@ -648,24 +666,47 @@ export async function deleteTenantRequest(id: string): Promise<void> {
*/ */
export async function syncProvisioningStatuses(): Promise<void> { export async function syncProvisioningStatuses(): Promise<void> {
await ensureSchema(); await ensureSchema();
// Pull every row that *might* be reconcilable in one query — the
// status filter narrows to ones whose CR-vs-DB consistency is
// worth checking. Pending/rejected/cancelled rows have no
// tenant_name to compare against; deleted rows are terminal.
const result = await getPool().query<TenantRequest>( const result = await getPool().query<TenantRequest>(
"SELECT * FROM tenant_requests WHERE status = 'provisioning'" "SELECT * FROM tenant_requests WHERE status IN ('provisioning', 'active') AND tenant_name IS NOT NULL"
); );
for (const row of result.rows) { for (const row of result.rows) {
const mapped = mapRow(row); const mapped = mapRow(row);
if (!mapped.tenantName) continue; if (!mapped.tenantName) continue;
let tenant: Awaited<ReturnType<typeof getTenant>> = null;
try { try {
const tenant = await getTenant(mapped.tenantName); tenant = await getTenant(mapped.tenantName);
if (
tenant?.status?.phase === "Ready" ||
tenant?.status?.phase === "Running"
) {
await updateTenantRequestStatus(mapped.id, "active");
}
} catch { } catch {
// Tenant might not exist yet — skip // Transient API error — skip this row, retry on next sweep.
continue;
}
// CR gone, or mid-deletion. Flip the row to 'deleted'. The
// `markTenantRequestDeletedByTenantName` helper also nulls the
// tenant_name column so any future tenant created with the same
// name (unlikely given UUID-suffixed naming, but possible) won't
// collide with the unique index on (tenant_name) WHERE
// request_type = 'provision'.
if (!tenant || tenant.metadata.deletionTimestamp) {
await markTenantRequestDeletedByTenantName(mapped.tenantName);
continue;
}
// CR exists and is healthy. Promote provisioning → active when
// the operator reports the tenant has reached steady state.
// Keep `active` rows on `active` regardless of phase — a
// temporarily-Reconfiguring tenant is still active from the
// portal's billing/visibility perspective.
if (
mapped.status === "provisioning" &&
(tenant.status?.phase === "Ready" || tenant.status?.phase === "Running")
) {
await updateTenantRequestStatus(mapped.id, "active");
} }
} }
} }

View File

@@ -134,6 +134,15 @@ export interface PiecedTenant {
name: string; name: string;
namespace?: string; namespace?: string;
creationTimestamp?: string; creationTimestamp?: string;
/**
* Set by the API server when something issues a Delete on the CR.
* The CR continues to exist while finalizers run cleanup; once
* they all remove themselves, the API server permanently removes
* the CR. Used by the portal's status sync to detect tenants
* being torn down — the customer should see "Deleted" rather
* than "Ready" while the cleanup runs.
*/
deletionTimestamp?: string;
labels?: Record<string, string>; labels?: Record<string, string>;
annotations?: Record<string, string>; annotations?: Record<string, string>;
}; };