diff --git a/sigma-web/src/components/AiTriageDialog.tsx b/sigma-web/src/components/AiTriageDialog.tsx
index 9a177b5..b02bad0 100644
--- a/sigma-web/src/components/AiTriageDialog.tsx
+++ b/sigma-web/src/components/AiTriageDialog.tsx
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
-import { X, Sparkles, AlertTriangle, ListChecks, Lightbulb } from 'lucide-react';
+import { X, Sparkles, AlertTriangle, ListChecks, Lightbulb, Clock, ShieldOff } from 'lucide-react';
+import axios from 'axios';
import { useAiTriage } from '@/hooks/useAiTriage';
import type { AlertInfo, TriageContext, TriageResponse } from '@/types/api';
@@ -189,17 +190,92 @@ export default function AiTriageDialog({ open, onClose, prefillAlert, prefillCon
{/* Result */}
{result && }
- {isError && !result && (
-
- Request failed. {String(triage.error)}
-
- )}
+ {isError && !result && }
);
}
+/**
+ * Format a Retry-After value (in seconds) as a human-friendly duration.
+ * Uses minutes+seconds for >= 60s, otherwise plain seconds.
+ */
+function formatRetryAfter(secondsRaw: string | number | undefined): string | null {
+ if (secondsRaw === undefined || secondsRaw === null || secondsRaw === '') return null;
+ const total = typeof secondsRaw === 'number' ? secondsRaw : parseInt(secondsRaw, 10);
+ if (!Number.isFinite(total) || total <= 0) return null;
+ if (total < 60) {
+ return `${total} second${total === 1 ? '' : 's'}`;
+ }
+ const minutes = Math.floor(total / 60);
+ const seconds = total % 60;
+ const mPart = `${minutes} minute${minutes === 1 ? '' : 's'}`;
+ if (seconds === 0) return mPart;
+ const sPart = `${seconds} second${seconds === 1 ? '' : 's'}`;
+ return `${mPart} ${sPart}`;
+}
+
+/**
+ * Renders the triage error state. Distinguishes:
+ * - 429: per-user LLM rate limit hit. Surface Retry-After in an amber
+ * "try again later" panel — it's a soft, recoverable hint, not a failure.
+ * - 403: caller's role (readonly / per-VPS agent key) is not allowed to
+ * invoke triage. Amber panel suggesting they ask an admin/operator.
+ * - Anything else: keep the original red "Request failed" banner.
+ */
+function TriageErrorView({ error }: { error: unknown }) {
+ if (axios.isAxiosError(error)) {
+ const status = error.response?.status;
+
+ if (status === 429) {
+ const retryAfterHeader = error.response?.headers?.['retry-after'];
+ const formatted = formatRetryAfter(retryAfterHeader as string | undefined);
+ return (
+
+
+
+
+
+ You've hit your LLM rate limit.
+
+
+ {formatted
+ ? <>Try again in {formatted}.>
+ : <>Try again shortly.>}
+
+
+
+
+ );
+ }
+
+ if (status === 403) {
+ return (
+
+
+
+
+
+ Your role doesn't permit AI triage.
+
+
+ Ask an admin or operator.
+
+
+
+
+ );
+ }
+ }
+
+ return (
+
+ Request failed. {String(error)}
+
+ );
+}
+
function TriageResultView({ result }: { result: TriageResponse }) {
// Degraded path — the endpoint is up but the LLM isn't usable.
if (!result.available) {
diff --git a/sigma-web/src/pages/VpsDetail.tsx b/sigma-web/src/pages/VpsDetail.tsx
index 43c87f9..dd55d6b 100644
--- a/sigma-web/src/pages/VpsDetail.tsx
+++ b/sigma-web/src/pages/VpsDetail.tsx
@@ -155,7 +155,7 @@ export default function VpsDetail() {
Grafana
)}
- {!isDeleted && (
+ {canMutate && !isDeleted && (