119 lines
4.2 KiB
TypeScript
119 lines
4.2 KiB
TypeScript
"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>
|
|
);
|
|
}
|