Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 82 additions & 6 deletions sigma-web/src/components/AiTriageDialog.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -189,17 +190,92 @@ export default function AiTriageDialog({ open, onClose, prefillAlert, prefillCon
{/* Result */}
{result && <TriageResultView result={result} />}

{isError && !result && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-700">
<strong>Request failed.</strong> {String(triage.error)}
</div>
)}
{isError && !result && <TriageErrorView error={triage.error} />}
</div>
</div>
</div>
);
}

/**
* 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 (
<div className="rounded-md bg-yellow-50 border border-yellow-200 p-4">
<div className="flex items-start gap-2">
<Clock className="text-yellow-600 shrink-0 mt-0.5" size={18} />
<div>
<p className="text-sm font-medium text-yellow-900">
You've hit your LLM rate limit.
</p>
<p className="text-sm text-yellow-900 mt-1">
{formatted
? <>Try again in <strong>{formatted}</strong>.</>
: <>Try again shortly.</>}
</p>
</div>
</div>
</div>
);
}

if (status === 403) {
return (
<div className="rounded-md bg-yellow-50 border border-yellow-200 p-4">
<div className="flex items-start gap-2">
<ShieldOff className="text-yellow-600 shrink-0 mt-0.5" size={18} />
<div>
<p className="text-sm font-medium text-yellow-900">
Your role doesn't permit AI triage.
</p>
<p className="text-sm text-yellow-900 mt-1">
Ask an admin or operator.
</p>
</div>
</div>
</div>
);
}
}

return (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-700">
<strong>Request failed.</strong> {String(error)}
</div>
);
}

function TriageResultView({ result }: { result: TriageResponse }) {
// Degraded path — the endpoint is up but the LLM isn't usable.
if (!result.available) {
Expand Down
2 changes: 1 addition & 1 deletion sigma-web/src/pages/VpsDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export default function VpsDetail() {
<BarChart3 size={14} /> Grafana
</a>
)}
{!isDeleted && (
{canMutate && !isDeleted && (
<button
onClick={() => setAiTriageOpen(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-blue-700 border border-blue-200 rounded-md hover:bg-blue-50"
Expand Down
Loading