feat: dashboard OAuth provider management
Add OAuth provider management to the Hermes dashboard with full
lifecycle support for Anthropic (PKCE), Nous and OpenAI Codex
(device-code) flows.
## Backend (hermes_cli/web_server.py)
- 6 new API endpoints:
GET /api/providers/oauth — list providers with connection status
POST /api/providers/oauth/{id}/start — initiate PKCE or device-code
POST /api/providers/oauth/{id}/submit — exchange PKCE auth code
GET /api/providers/oauth/{id}/poll/{session} — poll device-code
DELETE /api/providers/oauth/{id} — disconnect provider
DELETE /api/providers/oauth/sessions/{id} — cancel pending session
- OAuth constants imported from anthropic_adapter (no duplication)
- Blocking I/O wrapped in run_in_executor for async safety
- In-memory session store with 15-minute TTL and automatic GC
- Auth token required on all mutating endpoints
## Frontend
- OAuthLoginModal — PKCE (paste auth code) and device-code (poll) flows
- OAuthProvidersCard — status, token preview, connect/disconnect actions
- Toast fix: createPortal to document.body for correct z-index
- App.tsx: skip animation key bump on initial mount (prevent double-mount)
- Integrated into the Env/Keys page
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react";
|
||||
import StatusPage from "@/pages/StatusPage";
|
||||
import ConfigPage from "@/pages/ConfigPage";
|
||||
@@ -36,8 +36,15 @@ const PAGE_COMPONENTS: Record<PageId, React.FC> = {
|
||||
export default function App() {
|
||||
const [page, setPage] = useState<PageId>("status");
|
||||
const [animKey, setAnimKey] = useState(0);
|
||||
const initialRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip the animation key bump on initial mount to avoid re-mounting
|
||||
// the default page component (which causes duplicate API requests).
|
||||
if (initialRef.current) {
|
||||
initialRef.current = false;
|
||||
return;
|
||||
}
|
||||
setAnimKey((k) => k + 1);
|
||||
}, [page]);
|
||||
|
||||
|
||||
365
web/src/components/OAuthLoginModal.tsx
Normal file
365
web/src/components/OAuthLoginModal.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react";
|
||||
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
/**
|
||||
* OAuthLoginModal — drives the in-browser OAuth flow for a single provider.
|
||||
*
|
||||
* Two variants share the same modal shell:
|
||||
*
|
||||
* - PKCE (Anthropic): user opens the auth URL in a new tab, authorizes,
|
||||
* pastes the resulting code back. We POST it to /submit which exchanges
|
||||
* the (code + verifier) pair for tokens server-side.
|
||||
*
|
||||
* - Device code (Nous, OpenAI Codex): we display the verification URL
|
||||
* and short user code; the backend polls the provider's token endpoint
|
||||
* in a background thread; we poll /poll/{session_id} every 2s for status.
|
||||
*
|
||||
* Edge cases handled:
|
||||
* - Popup blocker (we use plain anchor href + open in new tab; no popup
|
||||
* window.open which is more likely to be blocked).
|
||||
* - Modal dismissal mid-flight cancels the server-side session via DELETE.
|
||||
* - Code expiry surfaces as a clear error state with retry button.
|
||||
* - Polling continues to work if the user backgrounds the tab (setInterval
|
||||
* keeps firing in modern browsers; we guard against polls firing after
|
||||
* component unmount via an isMounted ref).
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
provider: OAuthProvider;
|
||||
onClose: () => void;
|
||||
onSuccess: (msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
type Phase = "idle" | "starting" | "awaiting_user" | "submitting" | "polling" | "approved" | "error";
|
||||
|
||||
export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props) {
|
||||
const [phase, setPhase] = useState<Phase>("starting");
|
||||
const [start, setStart] = useState<OAuthStartResponse | null>(null);
|
||||
const [pkceCode, setPkceCode] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
|
||||
const [codeCopied, setCodeCopied] = useState(false);
|
||||
const isMounted = useRef(true);
|
||||
const pollTimer = useRef<number | null>(null);
|
||||
|
||||
// Initiate flow on mount
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
api
|
||||
.startOAuthLogin(provider.id)
|
||||
.then((resp) => {
|
||||
if (!isMounted.current) return;
|
||||
setStart(resp);
|
||||
setSecondsLeft(resp.expires_in);
|
||||
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
|
||||
if (resp.flow === "pkce") {
|
||||
// Auto-open the auth URL in a new tab
|
||||
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
// Device-code: open the verification URL automatically
|
||||
window.open(resp.verification_url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!isMounted.current) return;
|
||||
setPhase("error");
|
||||
setErrorMsg(`Failed to start login: ${e}`);
|
||||
});
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
};
|
||||
// We only want to start the flow once on mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Tick the countdown
|
||||
useEffect(() => {
|
||||
if (secondsLeft === null) return;
|
||||
if (phase === "approved" || phase === "error") return;
|
||||
const tick = window.setInterval(() => {
|
||||
if (!isMounted.current) return;
|
||||
setSecondsLeft((s) => {
|
||||
if (s !== null && s <= 1) {
|
||||
// Session expired — transition to error state
|
||||
setPhase("error");
|
||||
setErrorMsg("Session expired. Click Retry to start a new login.");
|
||||
return 0;
|
||||
}
|
||||
return s !== null && s > 0 ? s - 1 : 0;
|
||||
});
|
||||
}, 1000);
|
||||
return () => window.clearInterval(tick);
|
||||
}, [secondsLeft, phase]);
|
||||
|
||||
// Device-code: poll backend every 2s
|
||||
useEffect(() => {
|
||||
if (!start || start.flow !== "device_code" || phase !== "polling") return;
|
||||
const sid = start.session_id;
|
||||
pollTimer.current = window.setInterval(async () => {
|
||||
try {
|
||||
const resp = await api.pollOAuthSession(provider.id, sid);
|
||||
if (!isMounted.current) return;
|
||||
if (resp.status === "approved") {
|
||||
setPhase("approved");
|
||||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
onSuccess(`${provider.name} connected`);
|
||||
window.setTimeout(() => isMounted.current && onClose(), 1500);
|
||||
} else if (resp.status !== "pending") {
|
||||
setPhase("error");
|
||||
setErrorMsg(resp.error_message || `Login ${resp.status}`);
|
||||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
}
|
||||
} catch (e) {
|
||||
// 404 = session expired/cleaned up; treat as error
|
||||
if (!isMounted.current) return;
|
||||
setPhase("error");
|
||||
setErrorMsg(`Polling failed: ${e}`);
|
||||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
}
|
||||
}, 2000);
|
||||
return () => {
|
||||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
};
|
||||
}, [start, phase, provider.id, provider.name, onSuccess, onClose]);
|
||||
|
||||
const handleSubmitPkceCode = async () => {
|
||||
if (!start || start.flow !== "pkce") return;
|
||||
if (!pkceCode.trim()) return;
|
||||
setPhase("submitting");
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
const resp = await api.submitOAuthCode(provider.id, start.session_id, pkceCode.trim());
|
||||
if (!isMounted.current) return;
|
||||
if (resp.ok && resp.status === "approved") {
|
||||
setPhase("approved");
|
||||
onSuccess(`${provider.name} connected`);
|
||||
window.setTimeout(() => isMounted.current && onClose(), 1500);
|
||||
} else {
|
||||
setPhase("error");
|
||||
setErrorMsg(resp.message || "Token exchange failed");
|
||||
}
|
||||
} catch (e) {
|
||||
if (!isMounted.current) return;
|
||||
setPhase("error");
|
||||
setErrorMsg(`Submit failed: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
// Cancel server session if still in flight
|
||||
if (start && phase !== "approved" && phase !== "error") {
|
||||
try {
|
||||
await api.cancelOAuthSession(start.session_id);
|
||||
} catch {
|
||||
// ignore — server-side TTL will clean it up anyway
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCopyUserCode = async (code: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCodeCopied(true);
|
||||
window.setTimeout(() => isMounted.current && setCodeCopied(false), 1500);
|
||||
} catch {
|
||||
onError("Clipboard write failed");
|
||||
}
|
||||
};
|
||||
|
||||
// Backdrop click closes
|
||||
const handleBackdrop = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) handleClose();
|
||||
};
|
||||
|
||||
const fmtTime = (s: number | null) => {
|
||||
if (s === null) return "";
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${String(r).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
onClick={handleBackdrop}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="oauth-modal-title"
|
||||
>
|
||||
<div className="relative w-full max-w-md border border-border bg-card shadow-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 id="oauth-modal-title" className="font-display text-base tracking-wider uppercase">
|
||||
Connect {provider.name}
|
||||
</h2>
|
||||
{secondsLeft !== null && phase !== "approved" && phase !== "error" && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Session expires in {fmtTime(secondsLeft)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── starting ───────────────────────────────────── */}
|
||||
{phase === "starting" && (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Initiating login flow…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── PKCE: paste code ───────────────────────────── */}
|
||||
{start?.flow === "pkce" && phase === "awaiting_user" && (
|
||||
<>
|
||||
<ol className="text-sm space-y-2 list-decimal list-inside text-muted-foreground">
|
||||
<li>
|
||||
A new tab opened to <code className="text-foreground">claude.ai</code>. Sign in
|
||||
and click <strong className="text-foreground">Authorize</strong>.
|
||||
</li>
|
||||
<li>Copy the <strong className="text-foreground">authorization code</strong> shown after authorizing.</li>
|
||||
<li>Paste it below and submit.</li>
|
||||
</ol>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
value={pkceCode}
|
||||
onChange={(e) => setPkceCode(e.target.value)}
|
||||
placeholder="Paste authorization code (with #state suffix is fine)"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<a
|
||||
href={(start as Extract<OAuthStartResponse, { flow: "pkce" }>).auth_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Re-open auth page
|
||||
</a>
|
||||
<Button onClick={handleSubmitPkceCode} disabled={!pkceCode.trim()} size="sm">
|
||||
Submit code
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── PKCE: submitting exchange ──────────────────── */}
|
||||
{phase === "submitting" && (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Exchanging code for tokens…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Device code: show code + URL, polling ──────── */}
|
||||
{start?.flow === "device_code" && phase === "polling" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A new tab opened. Enter this code if prompted:
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4">
|
||||
<code className="font-mono-ui text-2xl tracking-widest text-foreground">
|
||||
{(start as Extract<OAuthStartResponse, { flow: "device_code" }>).user_code}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleCopyUserCode(
|
||||
(start as Extract<OAuthStartResponse, { flow: "device_code" }>).user_code,
|
||||
)
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{codeCopied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
<a
|
||||
href={(start as Extract<OAuthStartResponse, { flow: "device_code" }>).verification_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Re-open verification page
|
||||
</a>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground border-t border-border pt-3">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Waiting for you to authorize in the browser…
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── approved ───────────────────────────────────── */}
|
||||
{phase === "approved" && (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-success">
|
||||
<Check className="h-5 w-5" />
|
||||
Connected! Closing…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── error ──────────────────────────────────────── */}
|
||||
{phase === "error" && (
|
||||
<>
|
||||
<div className="border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{errorMsg || "Login failed."}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Cancel the old session before starting a new one
|
||||
if (start?.session_id) {
|
||||
api.cancelOAuthSession(start.session_id).catch(() => {});
|
||||
}
|
||||
setErrorMsg(null);
|
||||
setStart(null);
|
||||
setPkceCode("");
|
||||
setPhase("starting");
|
||||
// Re-trigger the start effect by remounting (caller should re-key us)
|
||||
// Simpler: just kick off a new start manually
|
||||
api.startOAuthLogin(provider.id).then((resp) => {
|
||||
if (!isMounted.current) return;
|
||||
setStart(resp);
|
||||
setSecondsLeft(resp.expires_in);
|
||||
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
|
||||
if (resp.flow === "pkce") {
|
||||
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
window.open(resp.verification_url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}).catch((e) => {
|
||||
if (!isMounted.current) return;
|
||||
setPhase("error");
|
||||
setErrorMsg(`Retry failed: ${e}`);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
290
web/src/components/OAuthProvidersCard.tsx
Normal file
290
web/src/components/OAuthProvidersCard.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { ShieldCheck, ShieldOff, Copy, ExternalLink, RefreshCw, LogOut, Terminal, LogIn } from "lucide-react";
|
||||
import { api, type OAuthProvider } from "@/lib/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { OAuthLoginModal } from "@/components/OAuthLoginModal";
|
||||
|
||||
/**
|
||||
* OAuthProvidersCard — surfaces every OAuth-capable LLM provider with its
|
||||
* current connection status, a truncated token preview when connected, and
|
||||
* action buttons (Copy CLI command for setup, Disconnect for cleanup).
|
||||
*
|
||||
* Phase 1 scope: read-only status + disconnect + copy-to-clipboard CLI
|
||||
* command. Phase 2 will add in-browser PKCE / device-code flows so users
|
||||
* never need to drop to a terminal.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
onError?: (msg: string) => void;
|
||||
onSuccess?: (msg: string) => void;
|
||||
}
|
||||
|
||||
const FLOW_LABELS: Record<OAuthProvider["flow"], string> = {
|
||||
pkce: "Browser login (PKCE)",
|
||||
device_code: "Device code",
|
||||
external: "External CLI",
|
||||
};
|
||||
|
||||
function formatExpiresAt(expiresAt: string | null | undefined): string | null {
|
||||
if (!expiresAt) return null;
|
||||
try {
|
||||
const dt = new Date(expiresAt);
|
||||
if (Number.isNaN(dt.getTime())) return null;
|
||||
const now = Date.now();
|
||||
const diff = dt.getTime() - now;
|
||||
if (diff < 0) return "expired";
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 60) return `expires in ${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `expires in ${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `expires in ${days}d`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
||||
const [providers, setProviders] = useState<OAuthProvider[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
// Provider that the login modal is currently open for. null = modal closed.
|
||||
const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null);
|
||||
|
||||
// Use refs for callbacks to avoid re-creating refresh() when parent re-renders
|
||||
const onErrorRef = useRef(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.getOAuthProviders()
|
||||
.then((resp) => setProviders(resp.providers))
|
||||
.catch((e) => onErrorRef.current?.(`Failed to load providers: ${e}`))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleCopy = async (provider: OAuthProvider) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(provider.cli_command);
|
||||
setCopiedId(provider.id);
|
||||
onSuccess?.(`Copied: ${provider.cli_command}`);
|
||||
setTimeout(() => setCopiedId((v) => (v === provider.id ? null : v)), 1500);
|
||||
} catch {
|
||||
onError?.("Clipboard write failed — copy the command manually");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async (provider: OAuthProvider) => {
|
||||
if (!confirm(`Disconnect ${provider.name}? You'll need to log in again to use this provider.`)) {
|
||||
return;
|
||||
}
|
||||
setBusyId(provider.id);
|
||||
try {
|
||||
await api.disconnectOAuthProvider(provider.id);
|
||||
onSuccess?.(`${provider.name} disconnected`);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
onError?.(`Disconnect failed: ${e}`);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const connectedCount = providers?.filter((p) => p.status.logged_in).length ?? 0;
|
||||
const totalCount = providers?.length ?? 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Provider Logins (OAuth)</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="text-xs"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{connectedCount} of {totalCount} OAuth providers connected. Login flows currently
|
||||
run via the CLI; click <em>Copy command</em> and paste into a terminal to set up.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && providers === null && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
{providers && providers.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No OAuth-capable providers detected.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{providers?.map((p) => {
|
||||
const expiresLabel = formatExpiresAt(p.status.expires_at);
|
||||
const isBusy = busyId === p.id;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-center justify-between gap-4 py-3"
|
||||
>
|
||||
{/* Left: status icon + name + source */}
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
{p.status.logged_in ? (
|
||||
<ShieldCheck className="h-5 w-5 text-success shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<ShieldOff className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex flex-col min-w-0 gap-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm">{p.name}</span>
|
||||
<Badge variant="outline" className="text-[11px] uppercase tracking-wide">
|
||||
{FLOW_LABELS[p.flow]}
|
||||
</Badge>
|
||||
{p.status.logged_in && (
|
||||
<Badge variant="success" className="text-[11px]">
|
||||
Connected
|
||||
</Badge>
|
||||
)}
|
||||
{expiresLabel === "expired" && (
|
||||
<Badge variant="destructive" className="text-[11px]">
|
||||
Expired
|
||||
</Badge>
|
||||
)}
|
||||
{expiresLabel && expiresLabel !== "expired" && (
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{expiresLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{p.status.logged_in && p.status.token_preview && (
|
||||
<code className="text-xs text-muted-foreground font-mono-ui truncate">
|
||||
token{" "}
|
||||
<span className="text-foreground">{p.status.token_preview}</span>
|
||||
{p.status.source_label && (
|
||||
<span className="text-muted-foreground/70">
|
||||
{" "}· {p.status.source_label}
|
||||
</span>
|
||||
)}
|
||||
</code>
|
||||
)}
|
||||
{!p.status.logged_in && (
|
||||
<span className="text-xs text-muted-foreground/80">
|
||||
Not connected. Run{" "}
|
||||
<code className="text-foreground bg-secondary/40 px-1 rounded">
|
||||
{p.cli_command}
|
||||
</code>{" "}
|
||||
in a terminal.
|
||||
</span>
|
||||
)}
|
||||
{p.status.error && (
|
||||
<span className="text-xs text-destructive">
|
||||
{p.status.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Right: action buttons */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{p.docs_url && (
|
||||
<a
|
||||
href={p.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex"
|
||||
title={`Open ${p.name} docs`}
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{!p.status.logged_in && p.flow !== "external" && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setLoginFor(p)}
|
||||
className="text-xs h-7"
|
||||
title={`Start ${p.flow === "pkce" ? "browser" : "device code"} login`}
|
||||
>
|
||||
<LogIn className="h-3 w-3 mr-1" />
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
{!p.status.logged_in && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(p)}
|
||||
className="text-xs h-7"
|
||||
title="Copy CLI command (for external / fallback)"
|
||||
>
|
||||
{copiedId === p.id ? (
|
||||
<>Copied ✓</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
CLI
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{p.status.logged_in && p.flow !== "external" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnect(p)}
|
||||
disabled={isBusy}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
{isBusy ? (
|
||||
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
{p.status.logged_in && p.flow === "external" && (
|
||||
<span className="text-[11px] text-muted-foreground italic px-2">
|
||||
<Terminal className="h-3 w-3 inline mr-0.5" />
|
||||
Managed externally
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
{loginFor && (
|
||||
<OAuthLoginModal
|
||||
provider={loginFor}
|
||||
onClose={() => {
|
||||
setLoginFor(null);
|
||||
refresh(); // always refresh on close so token preview updates after login
|
||||
}}
|
||||
onSuccess={(msg) => onSuccess?.(msg)}
|
||||
onError={(msg) => onError?.(msg)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export function Toast({ toast }: { toast: { message: string; type: "success" | "error" } | null }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -17,11 +18,13 @@ export function Toast({ toast }: { toast: { message: string; type: "success" | "
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
// Portal to document.body so the toast escapes any ancestor stacking context
|
||||
// (e.g. <main> has `relative z-2`, which would trap z-50 below the header's z-40).
|
||||
return createPortal(
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`fixed top-4 right-4 z-50 border px-4 py-2.5 font-courier text-xs tracking-wider uppercase backdrop-blur-sm ${
|
||||
className={`fixed top-16 right-4 z-50 border px-4 py-2.5 font-courier text-xs tracking-wider uppercase backdrop-blur-sm ${
|
||||
current.type === "success"
|
||||
? "bg-success/15 text-success border-success/30"
|
||||
: "bg-destructive/15 text-destructive border-destructive/30"
|
||||
@@ -31,6 +34,7 @@ export function Toast({ toast }: { toast: { message: string; type: "success" | "
|
||||
}}
|
||||
>
|
||||
{current.message}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ async function getSessionToken(): Promise<string> {
|
||||
|
||||
export const api = {
|
||||
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
||||
getSessions: () => fetchJSON<SessionInfo[]>("/api/sessions"),
|
||||
getSessions: (limit = 20, offset = 0) =>
|
||||
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
|
||||
getSessionMessages: (id: string) =>
|
||||
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
|
||||
deleteSession: (id: string) =>
|
||||
@@ -110,6 +111,62 @@ export const api = {
|
||||
// Session search (FTS5)
|
||||
searchSessions: (q: string) =>
|
||||
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
|
||||
|
||||
// OAuth provider management
|
||||
getOAuthProviders: () =>
|
||||
fetchJSON<OAuthProvidersResponse>("/api/providers/oauth"),
|
||||
disconnectOAuthProvider: async (providerId: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<{ ok: boolean; provider: string }>(
|
||||
`/api/providers/oauth/${encodeURIComponent(providerId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
},
|
||||
startOAuthLogin: async (providerId: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<OAuthStartResponse>(
|
||||
`/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: "{}",
|
||||
},
|
||||
);
|
||||
},
|
||||
submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<OAuthSubmitResponse>(
|
||||
`/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ session_id: sessionId, code }),
|
||||
},
|
||||
);
|
||||
},
|
||||
pollOAuthSession: (providerId: string, sessionId: string) =>
|
||||
fetchJSON<OAuthPollResponse>(
|
||||
`/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`,
|
||||
),
|
||||
cancelOAuthSession: async (sessionId: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<{ ok: boolean }>(
|
||||
`/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export interface PlatformStatus {
|
||||
@@ -152,6 +209,13 @@ export interface SessionInfo {
|
||||
preview: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedSessions {
|
||||
sessions: SessionInfo[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface EnvVarInfo {
|
||||
is_set: boolean;
|
||||
redacted_value: string | null;
|
||||
@@ -260,3 +324,61 @@ export interface SessionSearchResult {
|
||||
export interface SessionSearchResponse {
|
||||
results: SessionSearchResult[];
|
||||
}
|
||||
|
||||
// ── OAuth provider types ────────────────────────────────────────────────
|
||||
|
||||
export interface OAuthProviderStatus {
|
||||
logged_in: boolean;
|
||||
source?: string | null;
|
||||
source_label?: string | null;
|
||||
token_preview?: string | null;
|
||||
expires_at?: string | null;
|
||||
has_refresh_token?: boolean;
|
||||
last_refresh?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface OAuthProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
/** "pkce" (browser redirect + paste code), "device_code" (show code + URL),
|
||||
* or "external" (delegated to a separate CLI like Claude Code or Qwen). */
|
||||
flow: "pkce" | "device_code" | "external";
|
||||
cli_command: string;
|
||||
docs_url: string;
|
||||
status: OAuthProviderStatus;
|
||||
}
|
||||
|
||||
export interface OAuthProvidersResponse {
|
||||
providers: OAuthProvider[];
|
||||
}
|
||||
|
||||
/** Discriminated union — the shape of /start depends on the flow. */
|
||||
export type OAuthStartResponse =
|
||||
| {
|
||||
session_id: string;
|
||||
flow: "pkce";
|
||||
auth_url: string;
|
||||
expires_in: number;
|
||||
}
|
||||
| {
|
||||
session_id: string;
|
||||
flow: "device_code";
|
||||
user_code: string;
|
||||
verification_url: string;
|
||||
expires_in: number;
|
||||
poll_interval: number;
|
||||
};
|
||||
|
||||
export interface OAuthSubmitResponse {
|
||||
ok: boolean;
|
||||
status: "approved" | "error";
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface OAuthPollResponse {
|
||||
session_id: string;
|
||||
status: "pending" | "approved" | "denied" | "expired" | "error";
|
||||
error_message?: string | null;
|
||||
expires_at?: number | null;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
<App />,
|
||||
);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Coins,
|
||||
Cpu,
|
||||
Database,
|
||||
Hash,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
@@ -26,17 +24,6 @@ function formatTokens(n: number): string {
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatCost(n: number): string {
|
||||
if (n < 0.01) return `$${n.toFixed(4)}`;
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** Pick the best cost value: actual > estimated > 0 */
|
||||
function bestCost(entry: { estimated_cost: number; actual_cost?: number }): number {
|
||||
if (entry.actual_cost && entry.actual_cost > 0) return entry.actual_cost;
|
||||
return entry.estimated_cost;
|
||||
}
|
||||
|
||||
function formatDate(day: string): string {
|
||||
try {
|
||||
const d = new Date(day + "T00:00:00");
|
||||
@@ -100,9 +87,6 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
const total = d.input_tokens + d.output_tokens;
|
||||
const inputH = Math.round((d.input_tokens / maxTokens) * CHART_HEIGHT_PX);
|
||||
const outputH = Math.round((d.output_tokens / maxTokens) * CHART_HEIGHT_PX);
|
||||
const cacheReadPct = d.cache_read_tokens > 0
|
||||
? Math.round((d.cache_read_tokens / (d.input_tokens + d.cache_read_tokens)) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
key={d.day}
|
||||
@@ -115,9 +99,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
<div className="font-medium">{formatDate(d.day)}</div>
|
||||
<div>Input: {formatTokens(d.input_tokens)}</div>
|
||||
<div>Output: {formatTokens(d.output_tokens)}</div>
|
||||
{cacheReadPct > 0 && <div>Cache hit: {cacheReadPct}%</div>}
|
||||
<div>Total: {formatTokens(total)}</div>
|
||||
{bestCost(d) > 0 && <div>Cost: {formatCost(bestCost(d))}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Input bar */}
|
||||
@@ -168,17 +150,11 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
<th className="text-left py-2 pr-4 font-medium">Date</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Sessions</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Input</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Output</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Cache Hit</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Cost</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Output</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((d) => {
|
||||
const cost = bestCost(d);
|
||||
const cacheHitPct = d.cache_read_tokens > 0 && d.input_tokens > 0
|
||||
? Math.round((d.cache_read_tokens / d.input_tokens) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<tr key={d.day} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
|
||||
<td className="py-2 pr-4 font-medium">{formatDate(d.day)}</td>
|
||||
@@ -186,15 +162,9 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
<td className="text-right py-2 px-4">
|
||||
<span className="text-[#ffe6cb]">{formatTokens(d.input_tokens)}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4">
|
||||
<td className="text-right py-2 pl-4">
|
||||
<span className="text-emerald-400">{formatTokens(d.output_tokens)}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">
|
||||
{cacheHitPct > 0 ? `${cacheHitPct}%` : "—"}
|
||||
</td>
|
||||
<td className="text-right py-2 pl-4 text-muted-foreground">
|
||||
{cost > 0 ? formatCost(cost) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -228,8 +198,7 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<th className="text-left py-2 pr-4 font-medium">Model</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Sessions</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Tokens</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Cost</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Tokens</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -239,14 +208,11 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
||||
<span className="font-mono-ui text-xs">{m.model}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">{m.sessions}</td>
|
||||
<td className="text-right py-2 px-4">
|
||||
<td className="text-right py-2 pl-4">
|
||||
<span className="text-[#ffe6cb]">{formatTokens(m.input_tokens)}</span>
|
||||
{" / "}
|
||||
<span className="text-emerald-400">{formatTokens(m.output_tokens)}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 pl-4 text-muted-foreground">
|
||||
{m.estimated_cost > 0 ? formatCost(m.estimated_cost) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -311,38 +277,26 @@ export default function AnalyticsPage() {
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* Summary cards — matches hermes's token model */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<SummaryCard
|
||||
icon={Hash}
|
||||
label="Total Tokens"
|
||||
value={formatTokens(data.totals.total_input + data.totals.total_output)}
|
||||
sub={`${formatTokens(data.totals.total_input)} in / ${formatTokens(data.totals.total_output)} out`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Database}
|
||||
label="Cache Hit"
|
||||
value={data.totals.total_cache_read > 0
|
||||
? `${Math.round((data.totals.total_cache_read / (data.totals.total_input + data.totals.total_cache_read)) * 100)}%`
|
||||
: "—"}
|
||||
sub={`${formatTokens(data.totals.total_cache_read)} tokens from cache`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Coins}
|
||||
label="Total Cost"
|
||||
value={formatCost(
|
||||
data.totals.total_actual_cost > 0
|
||||
? data.totals.total_actual_cost
|
||||
: data.totals.total_estimated_cost
|
||||
)}
|
||||
sub={data.totals.total_actual_cost > 0 ? "actual" : `estimated · last ${days}d`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={BarChart3}
|
||||
label="Total Sessions"
|
||||
value={String(data.totals.total_sessions)}
|
||||
sub={`~${(data.totals.total_sessions / days).toFixed(1)}/day avg`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={TrendingUp}
|
||||
label="API Calls"
|
||||
value={String(data.daily.reduce((sum, d) => sum + d.sessions, 0))}
|
||||
sub={`across ${data.by_model.length} models`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bar chart */}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { api } from "@/lib/api";
|
||||
import type { EnvVarInfo } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -495,6 +496,12 @@ export default function EnvPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════ OAuth Logins (sits above API keys — distinct auth mode) ══ */}
|
||||
<OAuthProvidersCard
|
||||
onError={(msg) => showToast(msg, "error")}
|
||||
onSuccess={(msg) => showToast(msg, "success")}
|
||||
/>
|
||||
|
||||
{/* ═══════════════ LLM Providers (grouped) ═══════════════ */}
|
||||
<Card>
|
||||
<CardHeader className="sticky top-14 z-10 bg-card border-b border-border">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
Search,
|
||||
@@ -287,6 +288,9 @@ function SessionRow({
|
||||
|
||||
export default function SessionsPage() {
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const PAGE_SIZE = 20;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
@@ -294,17 +298,21 @@ export default function SessionsPage() {
|
||||
const [searching, setSearching] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const loadSessions = useCallback(() => {
|
||||
const loadSessions = useCallback((p: number) => {
|
||||
setLoading(true);
|
||||
api
|
||||
.getSessions()
|
||||
.then(setSessions)
|
||||
.getSessions(PAGE_SIZE, p * PAGE_SIZE)
|
||||
.then((resp) => {
|
||||
setSessions(resp.sessions);
|
||||
setTotal(resp.total);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
loadSessions(page);
|
||||
}, [loadSessions, page]);
|
||||
|
||||
// Debounced FTS search
|
||||
useEffect(() => {
|
||||
@@ -334,6 +342,7 @@ export default function SessionsPage() {
|
||||
try {
|
||||
await api.deleteSession(id);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
setTotal((prev) => prev - 1);
|
||||
if (expandedId === id) setExpandedId(null);
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -370,7 +379,7 @@ export default function SessionsPage() {
|
||||
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-base font-semibold">Sessions</h1>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{sessions.length}
|
||||
{total}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative w-64">
|
||||
@@ -408,21 +417,57 @@ export default function SessionsPage() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{filtered.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
session={s}
|
||||
snippet={snippetMap.get(s.id)}
|
||||
searchQuery={search || undefined}
|
||||
isExpanded={expandedId === s.id}
|
||||
onToggle={() =>
|
||||
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||
}
|
||||
onDelete={() => handleDelete(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{filtered.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
session={s}
|
||||
snippet={snippetMap.get(s.id)}
|
||||
searchQuery={search || undefined}
|
||||
isExpanded={expandedId === s.id}
|
||||
onToggle={() =>
|
||||
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||
}
|
||||
onDelete={() => handleDelete(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination — hidden during search */}
|
||||
{!searchResults && total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground px-2">
|
||||
Page {page + 1} of {Math.ceil(total / PAGE_SIZE)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={(page + 1) * PAGE_SIZE >= total}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function StatusPage() {
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
api.getStatus().then(setStatus).catch(() => {});
|
||||
api.getSessions().then(setSessions).catch(() => {});
|
||||
api.getSessions(50).then((resp) => setSessions(resp.sessions)).catch(() => {});
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 5000);
|
||||
|
||||
Reference in New Issue
Block a user