From 019edf65507ae300d357b7f4cfb1bec4c82feb9c Mon Sep 17 00:00:00 2001 From: lai3d Date: Wed, 20 May 2026 17:35:23 +0800 Subject: [PATCH] Handle 403 and 429 from /api/ai/triage in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend PR #13 made /api/ai/triage admin/operator-only (403 for readonly and per-VPS agent keys), and PR #16 added a per-user LLM rate limit (429 with Retry-After). The web UI silently routed both into a generic red "Request failed" banner and still rendered the "Ask AI" button on the VPS detail page for every authenticated role. VpsDetail: gate the "Ask AI" button behind canMutate (admin|operator), matching the other mutation actions on the page. AiTriageDialog: branch on axios error status. 429 surfaces the Retry-After header as "Try again in X minutes Y seconds" in an amber panel (it's a recoverable hint, not a hard error). 403 renders a friendly "your role doesn't permit AI triage" amber panel — readonly users can still reach the standalone /ai-triage page from the sidebar, so the dialog still needs this branch. Other errors keep the existing red banner. Co-Authored-By: Claude Opus 4.7 (1M context) --- sigma-web/src/components/AiTriageDialog.tsx | 88 +++++++++++++++++++-- sigma-web/src/pages/VpsDetail.tsx | 2 +- 2 files changed, 83 insertions(+), 7 deletions(-) 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 && (