fix(ui): top navbar
This commit is contained in:
parent
834fa4fdb0
commit
f0eb706d82
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
|
@ -29,7 +29,6 @@ import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
||||||
import type {
|
import type {
|
||||||
ProviderCredentialRead,
|
ProviderCredentialRead,
|
||||||
ProviderUsageLiveRead,
|
ProviderUsageLiveRead,
|
||||||
SubscriptionWindowRead,
|
|
||||||
} from "@/api/generated/model";
|
} from "@/api/generated/model";
|
||||||
import { customFetch } from "@/api/mutator";
|
import { customFetch } from "@/api/mutator";
|
||||||
import {
|
import {
|
||||||
|
|
@ -152,6 +151,10 @@ const PROVIDERS: ProviderConfig[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function announceProviderCredentialsUpdated() {
|
||||||
|
window.dispatchEvent(new Event("provider-credentials-updated"));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Help toggle — collapsible inline instructions
|
// Help toggle — collapsible inline instructions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -816,194 +819,6 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Navbar provider status — compact mirror of the active provider card
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function providerShortLabel(provider: string): string {
|
|
||||||
const config = PROVIDERS.find((item) => item.id === provider);
|
|
||||||
if (!config) return provider;
|
|
||||||
return config.label.split(" ")[0].replace("/", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampPct(pct: number): number {
|
|
||||||
return Math.max(0, Math.min(100, pct));
|
|
||||||
}
|
|
||||||
|
|
||||||
function remainingPct(window: SubscriptionWindowRead | null): number | null {
|
|
||||||
if (!window) return null;
|
|
||||||
return clampPct(100 - window.pct_used);
|
|
||||||
}
|
|
||||||
|
|
||||||
function remainingColor(pct: number | null): string {
|
|
||||||
if (pct == null) return "bg-[color:var(--text-quiet)]";
|
|
||||||
if (pct <= 10) return "bg-[color:var(--danger)]";
|
|
||||||
if (pct <= 25) return "bg-[color:var(--warning)]";
|
|
||||||
return "bg-[color:var(--success)]";
|
|
||||||
}
|
|
||||||
|
|
||||||
function findSessionWindow(usage: ProviderUsageLiveRead | null | undefined) {
|
|
||||||
const windows = usage?.subscription_windows ?? [];
|
|
||||||
return (
|
|
||||||
windows.find((window) => {
|
|
||||||
const key = `${window.key} ${window.label}`.toLowerCase();
|
|
||||||
return (
|
|
||||||
key.includes("current") ||
|
|
||||||
key.includes("session") ||
|
|
||||||
key.includes("five") ||
|
|
||||||
key.includes("hour")
|
|
||||||
);
|
|
||||||
}) ??
|
|
||||||
windows[0] ??
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findModelsWindow(
|
|
||||||
usage: ProviderUsageLiveRead | null | undefined,
|
|
||||||
sessionWindow: SubscriptionWindowRead | null,
|
|
||||||
) {
|
|
||||||
const windows = usage?.subscription_windows ?? [];
|
|
||||||
return (
|
|
||||||
windows.find((window) => {
|
|
||||||
if (window.key === sessionWindow?.key) return false;
|
|
||||||
const key = `${window.key} ${window.label}`.toLowerCase();
|
|
||||||
return key.includes("all") || key.includes("model") || key.includes("week");
|
|
||||||
}) ??
|
|
||||||
windows.find((window) => window.key !== sessionWindow?.key) ??
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetLabel(
|
|
||||||
sessionWindow: SubscriptionWindowRead | null,
|
|
||||||
modelsWindow: SubscriptionWindowRead | null,
|
|
||||||
): string {
|
|
||||||
const resetInMs = sessionWindow?.reset_in_ms ?? modelsWindow?.reset_in_ms;
|
|
||||||
return resetInMs == null ? "—" : fmtResetMs(resetInMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function keyLabel(cred: ProviderCredentialRead): string {
|
|
||||||
if (cred.has_api_key && cred.api_key_last_four) return `API ••••${cred.api_key_last_four}`;
|
|
||||||
if (cred.has_api_key) return "API set";
|
|
||||||
if (cred.base_url) return "API local";
|
|
||||||
return "API missing";
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenLabel(
|
|
||||||
cred: ProviderCredentialRead,
|
|
||||||
usage: ProviderUsageLiveRead | null | undefined,
|
|
||||||
): string {
|
|
||||||
if ((usage?.subscription_windows?.length ?? 0) > 0) return "Token connected";
|
|
||||||
if (cred.has_session_key) return "Token set";
|
|
||||||
return "Token auto";
|
|
||||||
}
|
|
||||||
|
|
||||||
function MiniRemainingBar({
|
|
||||||
pct,
|
|
||||||
className = "w-10",
|
|
||||||
}: {
|
|
||||||
pct: number | null;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const width = pct == null ? 0 : clampPct(pct);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`h-1.5 overflow-hidden rounded-full bg-[color:var(--surface-strong)] ${className}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`block h-full rounded-full ${remainingColor(pct)}`}
|
|
||||||
style={{ width: `${width}%` }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderNavbarStatus({
|
|
||||||
credentials,
|
|
||||||
usageByCredentialId,
|
|
||||||
isLoading,
|
|
||||||
}: {
|
|
||||||
credentials: ProviderCredentialRead[];
|
|
||||||
usageByCredentialId: Record<string, ProviderUsageLiveRead | null>;
|
|
||||||
isLoading: boolean;
|
|
||||||
}) {
|
|
||||||
const primary = credentials[0];
|
|
||||||
if (!primary) return null;
|
|
||||||
|
|
||||||
const usage = usageByCredentialId[primary.id];
|
|
||||||
const sessionWindow = findSessionWindow(usage);
|
|
||||||
const modelsWindow = findModelsWindow(usage, sessionWindow);
|
|
||||||
const sessionRemaining = remainingPct(sessionWindow);
|
|
||||||
const modelsRemaining = remainingPct(modelsWindow);
|
|
||||||
const primaryRemaining = sessionRemaining ?? modelsRemaining;
|
|
||||||
const providerLabel = providerShortLabel(primary.provider);
|
|
||||||
const extraCount = Math.max(0, credentials.length - 1);
|
|
||||||
const loadingLabel = isLoading && usage == null ? "Syncing" : "Active";
|
|
||||||
|
|
||||||
const miniStat = (
|
|
||||||
label: string,
|
|
||||||
pct: number | null,
|
|
||||||
title: string,
|
|
||||||
) => (
|
|
||||||
<span
|
|
||||||
className="inline-flex h-7 min-w-0 items-center gap-1.5 rounded-full border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-2 text-[11px] font-medium text-[color:var(--text-muted)]"
|
|
||||||
title={title}
|
|
||||||
>
|
|
||||||
<span className="text-[color:var(--text-quiet)]">{label}</span>
|
|
||||||
<MiniRemainingBar pct={pct} />
|
|
||||||
<span className="tabular-nums text-[color:var(--text)]">
|
|
||||||
{pct == null ? "—" : `${Math.round(pct)}% left`}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-9 min-w-0 items-center">
|
|
||||||
<div className="hidden h-9 min-w-0 items-center gap-1.5 xl:flex">
|
|
||||||
<span className="inline-flex h-7 items-center gap-1.5 rounded-full border border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] px-2 text-[11px] font-semibold text-[color:var(--text)]">
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-[color:var(--success)]" />
|
|
||||||
{providerLabel}: {loadingLabel}
|
|
||||||
{extraCount > 0 ? (
|
|
||||||
<span className="text-[color:var(--text-muted)]">+{extraCount}</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex h-7 items-center rounded-full border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-2 text-[11px] font-medium text-[color:var(--text-muted)]">
|
|
||||||
{keyLabel(primary)}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex h-7 items-center rounded-full border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-2 text-[11px] font-medium text-[color:var(--text-muted)]">
|
|
||||||
{tokenLabel(primary, usage)}
|
|
||||||
</span>
|
|
||||||
{miniStat(
|
|
||||||
"Session",
|
|
||||||
sessionRemaining,
|
|
||||||
sessionWindow ? `${sessionWindow.label}: ${Math.round(sessionWindow.pct_used)}% used` : "No session usage returned",
|
|
||||||
)}
|
|
||||||
{miniStat(
|
|
||||||
"Models",
|
|
||||||
modelsRemaining,
|
|
||||||
modelsWindow ? `${modelsWindow.label}: ${Math.round(modelsWindow.pct_used)}% used` : "No model usage returned",
|
|
||||||
)}
|
|
||||||
<span className="inline-flex h-7 items-center rounded-full border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-2 text-[11px] font-medium text-[color:var(--text-muted)]">
|
|
||||||
Reset {resetLabel(sessionWindow, modelsWindow)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="hidden h-8 min-w-0 max-w-[46vw] items-center gap-2 rounded-full border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-2.5 text-[11px] font-semibold text-[color:var(--text)] sm:flex xl:hidden"
|
|
||||||
title={`${providerLabel}: ${loadingLabel}`}
|
|
||||||
>
|
|
||||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[color:var(--success)]" />
|
|
||||||
<span className="truncate">{providerLabel}</span>
|
|
||||||
<MiniRemainingBar pct={primaryRemaining} className="w-12 shrink-0" />
|
|
||||||
<span className="shrink-0 tabular-nums text-[color:var(--text-muted)]">
|
|
||||||
{primaryRemaining == null ? "Usage —" : `${Math.round(primaryRemaining)}% left`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Credential row
|
// Credential row
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -1288,30 +1103,11 @@ export default function AIProvidersSettingsPage() {
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
||||||
const [credentials, setCredentials] = useState<ProviderCredentialRead[]>([]);
|
const [credentials, setCredentials] = useState<ProviderCredentialRead[]>([]);
|
||||||
const [navbarUsageByCredentialId, setNavbarUsageByCredentialId] = useState<
|
|
||||||
Record<string, ProviderUsageLiveRead | null>
|
|
||||||
>({});
|
|
||||||
const [isNavbarUsageLoading, setIsNavbarUsageLoading] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ProviderCredentialRead | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<ProviderCredentialRead | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const activeNavbarCredentials = useMemo(() => {
|
|
||||||
const order = new Map(PROVIDERS.map((provider, index) => [provider.id, index]));
|
|
||||||
return credentials
|
|
||||||
.filter(
|
|
||||||
(cred) =>
|
|
||||||
cred.active && (cred.has_api_key || cred.has_session_key || Boolean(cred.base_url)),
|
|
||||||
)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const providerDelta =
|
|
||||||
(order.get(a.provider as ProviderId) ?? 99) -
|
|
||||||
(order.get(b.provider as ProviderId) ?? 99);
|
|
||||||
if (providerDelta !== 0) return providerDelta;
|
|
||||||
return a.account_key.localeCompare(b.account_key);
|
|
||||||
});
|
|
||||||
}, [credentials]);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1332,46 +1128,6 @@ export default function AIProvidersSettingsPage() {
|
||||||
if (isSignedIn) void load();
|
if (isSignedIn) void load();
|
||||||
}, [isSignedIn, load]);
|
}, [isSignedIn, load]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isSignedIn || activeNavbarCredentials.length === 0) {
|
|
||||||
setNavbarUsageByCredentialId({});
|
|
||||||
setIsNavbarUsageLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const fetchNavbarUsage = async (refresh = false) => {
|
|
||||||
setIsNavbarUsageLoading(true);
|
|
||||||
const pairs = await Promise.all(
|
|
||||||
activeNavbarCredentials.slice(0, 3).map(async (cred) => {
|
|
||||||
try {
|
|
||||||
const res = await getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet(
|
|
||||||
cred.id,
|
|
||||||
refresh ? { refresh: true } : undefined,
|
|
||||||
);
|
|
||||||
return [cred.id, res.status === 200 ? res.data : null] as const;
|
|
||||||
} catch {
|
|
||||||
return [cred.id, null] as const;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (!cancelled) {
|
|
||||||
setNavbarUsageByCredentialId(Object.fromEntries(pairs));
|
|
||||||
setIsNavbarUsageLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void fetchNavbarUsage();
|
|
||||||
const interval = window.setInterval(() => {
|
|
||||||
void fetchNavbarUsage(true);
|
|
||||||
}, 60_000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
window.clearInterval(interval);
|
|
||||||
};
|
|
||||||
}, [activeNavbarCredentials, isSignedIn]);
|
|
||||||
|
|
||||||
const handleAdd = async (
|
const handleAdd = async (
|
||||||
providerId: ProviderId,
|
providerId: ProviderId,
|
||||||
data: CredentialFormData,
|
data: CredentialFormData,
|
||||||
|
|
@ -1386,6 +1142,7 @@ export default function AIProvidersSettingsPage() {
|
||||||
});
|
});
|
||||||
if (res.status === 201) {
|
if (res.status === 201) {
|
||||||
setCredentials((prev) => [...prev, res.data]);
|
setCredentials((prev) => [...prev, res.data]);
|
||||||
|
announceProviderCredentialsUpdated();
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Failed to save credential.");
|
throw new Error("Failed to save credential.");
|
||||||
}
|
}
|
||||||
|
|
@ -1443,6 +1200,7 @@ export default function AIProvidersSettingsPage() {
|
||||||
);
|
);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
setCredentials((prev) => prev.map((c) => (c.id === cred.id ? res.data : c)));
|
setCredentials((prev) => prev.map((c) => (c.id === cred.id ? res.data : c)));
|
||||||
|
announceProviderCredentialsUpdated();
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Failed to update credential.");
|
throw new Error("Failed to update credential.");
|
||||||
}
|
}
|
||||||
|
|
@ -1455,6 +1213,7 @@ export default function AIProvidersSettingsPage() {
|
||||||
);
|
);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
setCredentials((prev) => prev.map((c) => (c.id === cred.id ? res.data : c)));
|
setCredentials((prev) => prev.map((c) => (c.id === cred.id ? res.data : c)));
|
||||||
|
announceProviderCredentialsUpdated();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1465,6 +1224,7 @@ export default function AIProvidersSettingsPage() {
|
||||||
try {
|
try {
|
||||||
await deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete(deleteTarget.id);
|
await deleteProviderCredentialApiV1ProviderCredentialsCredentialIdDelete(deleteTarget.id);
|
||||||
setCredentials((prev) => prev.filter((c) => c.id !== deleteTarget.id));
|
setCredentials((prev) => prev.filter((c) => c.id !== deleteTarget.id));
|
||||||
|
announceProviderCredentialsUpdated();
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDeleteError(err instanceof Error ? err.message : "Delete failed.");
|
setDeleteError(err instanceof Error ? err.message : "Delete failed.");
|
||||||
|
|
@ -1483,13 +1243,6 @@ export default function AIProvidersSettingsPage() {
|
||||||
}}
|
}}
|
||||||
title="AI Providers"
|
title="AI Providers"
|
||||||
description="Configure API keys and subscription tokens for the AI providers your gateway uses."
|
description="Configure API keys and subscription tokens for the AI providers your gateway uses."
|
||||||
topNavContent={
|
|
||||||
<ProviderNavbarStatus
|
|
||||||
credentials={activeNavbarCredentials}
|
|
||||||
usageByCredentialId={navbarUsageByCredentialId}
|
|
||||||
isLoading={isNavbarUsageLoading}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center gap-2 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6 text-sm text-muted">
|
<div className="flex items-center gap-2 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-6 text-sm text-muted">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,388 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/clerk";
|
||||||
|
import type {
|
||||||
|
ProviderCredentialRead,
|
||||||
|
ProviderUsageLiveRead,
|
||||||
|
SubscriptionWindowRead,
|
||||||
|
} from "@/api/generated/model";
|
||||||
|
import {
|
||||||
|
getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet,
|
||||||
|
listProviderCredentialsApiV1ProviderCredentialsGet,
|
||||||
|
} from "@/api/generated/provider-credentials/provider-credentials";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
|
||||||
|
type NavbarProviderId = "anthropic" | "openai";
|
||||||
|
|
||||||
|
type ProviderNavbarItem = {
|
||||||
|
providerId: NavbarProviderId;
|
||||||
|
label: string;
|
||||||
|
status: "Active" | "Inactive" | "Not configured" | "Syncing";
|
||||||
|
sessionRemaining: number | null;
|
||||||
|
modelsRemaining: number | null;
|
||||||
|
reset: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAVBAR_PROVIDER_IDS: NavbarProviderId[] = ["anthropic", "openai"];
|
||||||
|
|
||||||
|
function providerShortLabel(provider: string): string {
|
||||||
|
if (provider === "anthropic") return "Claude";
|
||||||
|
if (provider === "openai") return "GPT";
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPct(pct: number): number {
|
||||||
|
return Math.max(0, Math.min(100, pct));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtResetMs(ms: number | null | undefined): string {
|
||||||
|
if (ms == null || ms <= 0) return "< 1m";
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
if (s < 60) return `${s}s`;
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}m ${s % 60}s`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
return `${h}h ${m % 60}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainingPct(window: SubscriptionWindowRead | null): number | null {
|
||||||
|
if (!window) return null;
|
||||||
|
return clampPct(100 - window.pct_used);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainingColor(pct: number | null): string {
|
||||||
|
if (pct == null) return "bg-[color:var(--text-quiet)]";
|
||||||
|
if (pct <= 10) return "bg-[color:var(--danger)]";
|
||||||
|
if (pct <= 25) return "bg-[color:var(--warning)]";
|
||||||
|
return "bg-[color:var(--success)]";
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSessionWindow(usage: ProviderUsageLiveRead | null | undefined) {
|
||||||
|
const windows = usage?.subscription_windows ?? [];
|
||||||
|
return (
|
||||||
|
windows.find((window) => {
|
||||||
|
const key = `${window.key} ${window.label}`.toLowerCase();
|
||||||
|
return (
|
||||||
|
key.includes("current") ||
|
||||||
|
key.includes("session") ||
|
||||||
|
key.includes("five") ||
|
||||||
|
key.includes("hour")
|
||||||
|
);
|
||||||
|
}) ??
|
||||||
|
windows[0] ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findModelsWindow(
|
||||||
|
usage: ProviderUsageLiveRead | null | undefined,
|
||||||
|
sessionWindow: SubscriptionWindowRead | null,
|
||||||
|
) {
|
||||||
|
const windows = usage?.subscription_windows ?? [];
|
||||||
|
return (
|
||||||
|
windows.find((window) => {
|
||||||
|
if (window.key === sessionWindow?.key) return false;
|
||||||
|
const key = `${window.key} ${window.label}`.toLowerCase();
|
||||||
|
return key.includes("all") || key.includes("model") || key.includes("week");
|
||||||
|
}) ??
|
||||||
|
windows.find((window) => window.key !== sessionWindow?.key) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLabel(
|
||||||
|
sessionWindow: SubscriptionWindowRead | null,
|
||||||
|
modelsWindow: SubscriptionWindowRead | null,
|
||||||
|
): string {
|
||||||
|
const resetInMs = sessionWindow?.reset_in_ms ?? modelsWindow?.reset_in_ms;
|
||||||
|
return resetInMs == null ? "-" : fmtResetMs(resetInMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRemaining(pct: number | null): string {
|
||||||
|
return pct == null ? "-" : `${Math.round(pct)}% left`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerStatusColor(status: ProviderNavbarItem["status"]): string {
|
||||||
|
if (status === "Active") return "bg-[color:var(--success)]";
|
||||||
|
if (status === "Syncing") return "bg-[color:var(--accent)]";
|
||||||
|
if (status === "Inactive") return "bg-[color:var(--warning)]";
|
||||||
|
return "bg-[color:var(--text-quiet)]";
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniRemainingBar({
|
||||||
|
pct,
|
||||||
|
className = "w-10",
|
||||||
|
}: {
|
||||||
|
pct: number | null;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (pct == null) return null;
|
||||||
|
const width = clampPct(pct);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`h-1 overflow-hidden rounded-full bg-[color:var(--surface-strong)] ${className}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`block h-full rounded-full ${remainingColor(pct)}`}
|
||||||
|
style={{ width: `${width}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProviderNavbarItems({
|
||||||
|
credentials,
|
||||||
|
usageByCredentialId,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
credentials: ProviderCredentialRead[];
|
||||||
|
usageByCredentialId: Record<string, ProviderUsageLiveRead | null>;
|
||||||
|
isLoading: boolean;
|
||||||
|
}): ProviderNavbarItem[] {
|
||||||
|
return NAVBAR_PROVIDER_IDS.map((providerId) => {
|
||||||
|
const providerCredentials = credentials
|
||||||
|
.filter((cred) => cred.provider === providerId)
|
||||||
|
.sort((a, b) => a.account_key.localeCompare(b.account_key));
|
||||||
|
const activeCredential =
|
||||||
|
providerCredentials.find(
|
||||||
|
(cred) =>
|
||||||
|
cred.active &&
|
||||||
|
(cred.has_api_key || cred.has_session_key || Boolean(cred.base_url)),
|
||||||
|
) ?? null;
|
||||||
|
const credential = activeCredential ?? providerCredentials[0] ?? null;
|
||||||
|
const usage = activeCredential
|
||||||
|
? usageByCredentialId[activeCredential.id]
|
||||||
|
: null;
|
||||||
|
const status: ProviderNavbarItem["status"] = activeCredential
|
||||||
|
? isLoading && usage == null
|
||||||
|
? "Syncing"
|
||||||
|
: "Active"
|
||||||
|
: credential
|
||||||
|
? "Inactive"
|
||||||
|
: "Not configured";
|
||||||
|
const sessionWindow = findSessionWindow(usage);
|
||||||
|
const modelsWindow = findModelsWindow(usage, sessionWindow);
|
||||||
|
const sessionRemaining = remainingPct(sessionWindow);
|
||||||
|
const modelsRemaining = remainingPct(modelsWindow);
|
||||||
|
|
||||||
|
return {
|
||||||
|
providerId,
|
||||||
|
label: providerShortLabel(providerId),
|
||||||
|
status,
|
||||||
|
sessionRemaining,
|
||||||
|
modelsRemaining,
|
||||||
|
reset: resetLabel(sessionWindow, modelsWindow),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderInlineStatus({
|
||||||
|
item,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
item: ProviderNavbarItem;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex min-w-0 items-center ${
|
||||||
|
compact ? "flex-wrap gap-x-2 gap-y-1" : "gap-1.5 whitespace-nowrap"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 shrink-0 rounded-full ${providerStatusColor(item.status)}`}
|
||||||
|
/>
|
||||||
|
<span className="font-semibold text-[color:var(--text)]">{item.label}</span>
|
||||||
|
<span className="text-[color:var(--text-muted)]">{item.status}</span>
|
||||||
|
<span className="text-[color:var(--text-quiet)]">|</span>
|
||||||
|
<span className="text-[color:var(--text-muted)]">
|
||||||
|
Session{" "}
|
||||||
|
<span className="tabular-nums text-[color:var(--text)]">
|
||||||
|
{formatRemaining(item.sessionRemaining)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<MiniRemainingBar pct={item.sessionRemaining} className="w-8 opacity-80" />
|
||||||
|
<span className="text-[color:var(--text-quiet)]">|</span>
|
||||||
|
<span className="text-[color:var(--text-muted)]">
|
||||||
|
Models{" "}
|
||||||
|
<span className="tabular-nums text-[color:var(--text)]">
|
||||||
|
{formatRemaining(item.modelsRemaining)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<MiniRemainingBar pct={item.modelsRemaining} className="w-8 opacity-80" />
|
||||||
|
<span className="text-[color:var(--text-quiet)]">|</span>
|
||||||
|
<span className="text-[color:var(--text-muted)]">
|
||||||
|
Reset <span className="tabular-nums text-[color:var(--text)]">{item.reset}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderNavbarStatus() {
|
||||||
|
const { isSignedIn } = useAuth();
|
||||||
|
const [credentials, setCredentials] = useState<ProviderCredentialRead[]>([]);
|
||||||
|
const [usageByCredentialId, setUsageByCredentialId] = useState<
|
||||||
|
Record<string, ProviderUsageLiveRead | null>
|
||||||
|
>({});
|
||||||
|
const [isUsageLoading, setIsUsageLoading] = useState(false);
|
||||||
|
|
||||||
|
const usageCredentials = useMemo(() => {
|
||||||
|
return credentials
|
||||||
|
.filter(
|
||||||
|
(cred) =>
|
||||||
|
NAVBAR_PROVIDER_IDS.includes(cred.provider as NavbarProviderId) &&
|
||||||
|
cred.active &&
|
||||||
|
(cred.has_api_key || cred.has_session_key || Boolean(cred.base_url)),
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const providerDelta =
|
||||||
|
NAVBAR_PROVIDER_IDS.indexOf(a.provider as NavbarProviderId) -
|
||||||
|
NAVBAR_PROVIDER_IDS.indexOf(b.provider as NavbarProviderId);
|
||||||
|
if (providerDelta !== 0) return providerDelta;
|
||||||
|
return a.account_key.localeCompare(b.account_key);
|
||||||
|
});
|
||||||
|
}, [credentials]);
|
||||||
|
|
||||||
|
const loadCredentials = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await listProviderCredentialsApiV1ProviderCredentialsGet();
|
||||||
|
if (res.status === 200) setCredentials(res.data);
|
||||||
|
} catch {
|
||||||
|
setCredentials([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSignedIn) return;
|
||||||
|
|
||||||
|
const initialLoad = window.setTimeout(() => {
|
||||||
|
void loadCredentials();
|
||||||
|
}, 0);
|
||||||
|
window.addEventListener("provider-credentials-updated", loadCredentials);
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void loadCredentials();
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(initialLoad);
|
||||||
|
window.removeEventListener("provider-credentials-updated", loadCredentials);
|
||||||
|
window.clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [isSignedIn, loadCredentials]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSignedIn || usageCredentials.length === 0) {
|
||||||
|
const resetUsage = window.setTimeout(() => {
|
||||||
|
setUsageByCredentialId({});
|
||||||
|
setIsUsageLoading(false);
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(resetUsage);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const fetchUsage = async (refresh = false) => {
|
||||||
|
setIsUsageLoading(true);
|
||||||
|
const pairs = await Promise.all(
|
||||||
|
usageCredentials.map(async (cred) => {
|
||||||
|
try {
|
||||||
|
const res = await getProviderUsageLiveApiV1ProviderCredentialsCredentialIdUsageGet(
|
||||||
|
cred.id,
|
||||||
|
refresh ? { refresh: true } : undefined,
|
||||||
|
);
|
||||||
|
return [cred.id, res.status === 200 ? res.data : null] as const;
|
||||||
|
} catch {
|
||||||
|
return [cred.id, null] as const;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!cancelled) {
|
||||||
|
setUsageByCredentialId(Object.fromEntries(pairs));
|
||||||
|
setIsUsageLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialFetch = window.setTimeout(() => {
|
||||||
|
void fetchUsage();
|
||||||
|
}, 0);
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void fetchUsage(true);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(initialFetch);
|
||||||
|
window.clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [isSignedIn, usageCredentials]);
|
||||||
|
|
||||||
|
const items = buildProviderNavbarItems({
|
||||||
|
credentials,
|
||||||
|
usageByCredentialId,
|
||||||
|
isLoading: isUsageLoading,
|
||||||
|
});
|
||||||
|
const activeCount = items.filter(
|
||||||
|
(item) => item.status === "Active" || item.status === "Syncing",
|
||||||
|
).length;
|
||||||
|
const primaryRemaining =
|
||||||
|
items
|
||||||
|
.flatMap((item) => [item.sessionRemaining, item.modelsRemaining])
|
||||||
|
.filter((pct): pct is number => pct != null)
|
||||||
|
.sort((a, b) => a - b)[0] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-9 min-w-0 items-center text-[11px]">
|
||||||
|
<div className="hidden h-9 min-w-0 items-center gap-2 overflow-hidden xl:flex">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={item.providerId} className="flex min-w-0 items-center gap-2">
|
||||||
|
{index > 0 ? (
|
||||||
|
<span className="h-3.5 w-px shrink-0 bg-[color:var(--border)]" />
|
||||||
|
) : null}
|
||||||
|
<ProviderInlineStatus item={item} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-8 min-w-0 max-w-[42vw] items-center gap-2 text-[11px] font-medium text-[color:var(--text-muted)] transition hover:text-[color:var(--text)] xl:hidden"
|
||||||
|
aria-label="Open provider status summary"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||||
|
activeCount > 0
|
||||||
|
? "bg-[color:var(--success)]"
|
||||||
|
: "bg-[color:var(--text-quiet)]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="truncate">Providers {activeCount} active</span>
|
||||||
|
<MiniRemainingBar pct={primaryRemaining} className="w-10 shrink-0 opacity-80" />
|
||||||
|
<span className="shrink-0 tabular-nums">
|
||||||
|
{formatRemaining(primaryRemaining)}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="center"
|
||||||
|
sideOffset={10}
|
||||||
|
className="w-[min(420px,calc(100vw-2rem))] rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-3 shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-2 text-[11px]">
|
||||||
|
{items.map((item) => (
|
||||||
|
<ProviderInlineStatus key={item.providerId} item={item} compact />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
} from "@/api/generated/users/users";
|
} from "@/api/generated/users/users";
|
||||||
import { BrandMark } from "@/components/atoms/BrandMark";
|
import { BrandMark } from "@/components/atoms/BrandMark";
|
||||||
import { OrgSwitcher } from "@/components/organisms/OrgSwitcher";
|
import { OrgSwitcher } from "@/components/organisms/OrgSwitcher";
|
||||||
|
import { ProviderNavbarStatus } from "@/components/organisms/ProviderNavbarStatus";
|
||||||
import { ThemeToggle } from "@/components/organisms/ThemeToggle";
|
import { ThemeToggle } from "@/components/organisms/ThemeToggle";
|
||||||
import { UserMenu } from "@/components/organisms/UserMenu";
|
import { UserMenu } from "@/components/organisms/UserMenu";
|
||||||
import { isOnboardingComplete } from "@/lib/onboarding";
|
import { isOnboardingComplete } from "@/lib/onboarding";
|
||||||
|
|
@ -137,7 +138,9 @@ export function DashboardShell({
|
||||||
{topNavContent}
|
{topNavContent}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="min-w-0 flex-1" />
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
|
<ProviderNavbarStatus />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue