OCI Warning status

This commit is contained in:
2026-05-01 10:25:50 +02:00
parent f84516a65b
commit 17dfe4917d
8 changed files with 156 additions and 1 deletions

View File

@@ -11,6 +11,7 @@ import {
import { personalAccountAtCapacity } from "@/lib/personal-org"; import { personalAccountAtCapacity } from "@/lib/personal-org";
import { Card, CardHeader } from "@/components/ui/card"; import { Card, CardHeader } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { WarningBadge } from "@/components/ui/warning-badge";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow"; import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { ProvisioningStatus } from "@/components/onboarding/provisioning-status"; import { ProvisioningStatus } from "@/components/onboarding/provisioning-status";
import { formatDateTime } from "@/lib/format"; import { formatDateTime } from "@/lib/format";
@@ -348,7 +349,10 @@ export default async function DashboardPage() {
{tenant.metadata.name} {tenant.metadata.name}
</div> </div>
</div> </div>
<StatusBadge phase={tenant.status?.phase ?? "Pending"} /> <div className="flex items-center gap-2 shrink-0">
<StatusBadge phase={tenant.status?.phase ?? "Pending"} />
<WarningBadge warnings={tenant.status?.warnings ?? []} />
</div>
</div> </div>
{tenant.spec.agentName && ( {tenant.spec.agentName && (

View File

@@ -4,6 +4,7 @@ import { redirect, notFound } from "next/navigation";
import { getTenant } from "@/lib/k8s"; import { getTenant } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility"; import { canUserSeeTenant } from "@/lib/visibility";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { WarningBadge } from "@/components/ui/warning-badge";
import { UsageDisplay } from "@/components/dashboard/usage-display"; import { UsageDisplay } from "@/components/dashboard/usage-display";
import { PackageList } from "@/components/packages/package-list"; import { PackageList } from "@/components/packages/package-list";
import { WorkspaceEditor } from "@/components/packages/workspace-editor"; import { WorkspaceEditor } from "@/components/packages/workspace-editor";
@@ -88,6 +89,7 @@ export default async function TenantDetailPage({
{tenant.spec.displayName || name} {tenant.spec.displayName || name}
</h1> </h1>
<StatusBadge phase={tenant.status?.phase ?? "Pending"} /> <StatusBadge phase={tenant.status?.phase ?? "Pending"} />
<WarningBadge warnings={tenant.status?.warnings ?? []} />
</div> </div>
{tenant.spec.agentName && ( {tenant.spec.agentName && (
<p className="text-sm text-text-secondary mt-3"> <p className="text-sm text-text-secondary mt-3">

View File

@@ -0,0 +1,118 @@
"use client";
import { useTranslations } from "next-intl";
/**
* Tenant warning shape received from the operator's status.warnings.
* Mirror of the operator's `TenantWarning` type. See
* pieced-operator/api/v1alpha1/piecedtenant_types.go.
*/
export interface TenantWarning {
source: string;
reason?: string;
message?: string;
since?: string;
}
interface Props {
warnings: TenantWarning[];
}
/**
* Renders a small amber warning badge if there are any non-fatal
* warnings on the tenant. The badge sits visually next to the phase
* StatusBadge — they're separate concepts (phase = lifecycle, warnings
* = observed sub-issues) and may both be present at once (e.g. tenant
* is `Ready` but has a SkillPacksReady=False warning).
*
* Hover/focus reveals the warning detail. We don't truncate the message
* inside the tooltip; OCI/CRD condition messages tend to be short and
* include the actionable detail (which skill, which secret, which
* resolver). If a future warning source has a 5-line stacktrace as a
* message we'll need a different treatment; cross that bridge then.
*
* Returns null when there are no warnings — keep render-call sites
* simple, they don't have to gate on length themselves.
*/
export function WarningBadge({ warnings }: Props) {
const t = useTranslations("warnings");
if (!warnings || warnings.length === 0) return null;
const tooltipLabel = (() => {
try {
return warnings.length === 1
? t("oneTooltip")
: t("manyTooltip", { count: warnings.length });
} catch {
return warnings.length === 1
? "1 warning"
: `${warnings.length} warnings`;
}
})();
return (
<span className="relative group inline-flex">
<button
type="button"
// Button is non-actionable in itself — it exists purely to get
// keyboard focus for screen readers and keyboard users, so the
// tooltip isn't pointer-only. `aria-label` carries the summary;
// the full content is in the tooltip below for sighted users.
aria-label={tooltipLabel}
className="inline-flex items-center gap-1 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-400 hover:bg-amber-500/20 focus:outline-none focus:ring-1 focus:ring-amber-400 cursor-help"
// No onClick — this is informational, not actionable. Pure
// hover/focus widget. tabIndex defaults to 0 for buttons.
>
<svg
viewBox="0 0 24 24"
width={12}
height={12}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 9v4" />
<path d="M12 17h.01" />
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
</svg>
<span>{warnings.length}</span>
</button>
{/*
Tooltip. Hidden by default; shown on hover OR focus of the
sibling button. Positioned below-right so it doesn't collide with
the StatusBadge that typically sits left of this. Constrained
width so long messages wrap.
z-50 keeps it above table rows / cards.
*/}
<div
role="tooltip"
className="invisible group-hover:visible group-focus-within:visible absolute left-0 top-full mt-1 z-50 w-72 rounded-lg border border-border bg-surface-1 p-3 shadow-lg text-left"
>
<div className="text-[10px] uppercase tracking-wider text-text-muted mb-2">
{tooltipLabel}
</div>
<ul className="space-y-2">
{warnings.map((w, i) => (
<li key={i} className="text-xs">
<div className="font-mono text-amber-400 break-all">
{w.source}
</div>
{w.reason && (
<div className="text-text-secondary">{w.reason}</div>
)}
{w.message && (
<div className="text-text-secondary mt-0.5 break-words">
{w.message}
</div>
)}
</li>
))}
</ul>
</div>
</span>
);
}

View File

@@ -361,5 +361,9 @@
"Error": "Fehler", "Error": "Fehler",
"Deleting": "Wird gelöscht", "Deleting": "Wird gelöscht",
"Reconfiguring": "Wird neu konfiguriert" "Reconfiguring": "Wird neu konfiguriert"
},
"warnings": {
"oneTooltip": "1 Warnung",
"manyTooltip": "{count} Warnungen"
} }
} }

View File

@@ -361,5 +361,9 @@
"Error": "Error", "Error": "Error",
"Deleting": "Deleting", "Deleting": "Deleting",
"Reconfiguring": "Reconfiguring" "Reconfiguring": "Reconfiguring"
},
"warnings": {
"oneTooltip": "1 warning",
"manyTooltip": "{count} warnings"
} }
} }

View File

@@ -361,5 +361,9 @@
"Error": "Erreur", "Error": "Erreur",
"Deleting": "Suppression", "Deleting": "Suppression",
"Reconfiguring": "Reconfiguration" "Reconfiguring": "Reconfiguration"
},
"warnings": {
"oneTooltip": "1 avertissement",
"manyTooltip": "{count} avertissements"
} }
} }

View File

@@ -361,5 +361,9 @@
"Error": "Errore", "Error": "Errore",
"Deleting": "Eliminazione", "Deleting": "Eliminazione",
"Reconfiguring": "Riconfigurazione" "Reconfiguring": "Riconfigurazione"
},
"warnings": {
"oneTooltip": "1 avviso",
"manyTooltip": "{count} avvisi"
} }
} }

View File

@@ -103,6 +103,21 @@ export interface PiecedTenantStatus {
litellmKeyAlias?: string; litellmKeyAlias?: string;
tenantNamespace?: string; tenantNamespace?: string;
enabledPackages?: string[]; enabledPackages?: string[];
/**
* Non-fatal issues from downstream resources surfaced by the operator
* (e.g. an OpenClawInstance sub-condition reporting failure). The
* tenant is still usable — these are informational, rendered as a
* warning badge alongside the phase.
*
* `source` is "<Kind>/<ConditionType>" e.g. "OpenClawInstance/SkillPacksReady".
* `message` is shown in the tooltip when the user hovers the badge.
*/
warnings?: Array<{
source: string;
reason?: string;
message?: string;
since?: string;
}>;
conditions?: Array<{ conditions?: Array<{
type: string; type: string;
status: string; status: string;