feat(ui): top navbar ai info
This commit is contained in:
parent
f48cf45cce
commit
834fa4fdb0
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
|
|
@ -26,7 +26,11 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
||||
import type { ProviderCredentialRead, ProviderUsageLiveRead } from "@/api/generated/model";
|
||||
import type {
|
||||
ProviderCredentialRead,
|
||||
ProviderUsageLiveRead,
|
||||
SubscriptionWindowRead,
|
||||
} from "@/api/generated/model";
|
||||
import { customFetch } from "@/api/mutator";
|
||||
import {
|
||||
listProviderCredentialsApiV1ProviderCredentialsGet,
|
||||
|
|
@ -812,6 +816,194 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1096,11 +1288,30 @@ export default function AIProvidersSettingsPage() {
|
|||
const { isSignedIn } = useAuth();
|
||||
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
||||
const [credentials, setCredentials] = useState<ProviderCredentialRead[]>([]);
|
||||
const [navbarUsageByCredentialId, setNavbarUsageByCredentialId] = useState<
|
||||
Record<string, ProviderUsageLiveRead | null>
|
||||
>({});
|
||||
const [isNavbarUsageLoading, setIsNavbarUsageLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ProviderCredentialRead | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
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 () => {
|
||||
try {
|
||||
|
|
@ -1121,6 +1332,46 @@ export default function AIProvidersSettingsPage() {
|
|||
if (isSignedIn) void 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 (
|
||||
providerId: ProviderId,
|
||||
data: CredentialFormData,
|
||||
|
|
@ -1232,6 +1483,13 @@ export default function AIProvidersSettingsPage() {
|
|||
}}
|
||||
title="AI Providers"
|
||||
description="Configure API keys and subscription tokens for the AI providers your gateway uses."
|
||||
topNavContent={
|
||||
<ProviderNavbarStatus
|
||||
credentials={activeNavbarCredentials}
|
||||
usageByCredentialId={navbarUsageByCredentialId}
|
||||
isLoading={isNavbarUsageLoading}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{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">
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ type DashboardPageLayoutProps = {
|
|||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
topNavContent?: ReactNode;
|
||||
children: ReactNode;
|
||||
isAdmin?: boolean;
|
||||
adminOnlyMessage?: string;
|
||||
|
|
@ -38,6 +39,7 @@ export function DashboardPageLayout({
|
|||
title,
|
||||
description,
|
||||
headerActions,
|
||||
topNavContent,
|
||||
children,
|
||||
isAdmin,
|
||||
adminOnlyMessage,
|
||||
|
|
@ -51,7 +53,7 @@ export function DashboardPageLayout({
|
|||
typeof isAdmin === "boolean" && Boolean(adminOnlyMessage) && !isAdmin;
|
||||
|
||||
return (
|
||||
<DashboardShell>
|
||||
<DashboardShell topNavContent={topNavContent}>
|
||||
<SignedOut>
|
||||
<SignedOutPanel
|
||||
message={signedOut.message}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,13 @@ import { ThemeToggle } from "@/components/organisms/ThemeToggle";
|
|||
import { UserMenu } from "@/components/organisms/UserMenu";
|
||||
import { isOnboardingComplete } from "@/lib/onboarding";
|
||||
|
||||
export function DashboardShell({ children }: { children: ReactNode }) {
|
||||
export function DashboardShell({
|
||||
children,
|
||||
topNavContent,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
topNavContent?: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isSignedIn } = useAuth();
|
||||
|
|
@ -122,14 +128,21 @@ export function DashboardShell({ children }: { children: ReactNode }) {
|
|||
<BrandMark />
|
||||
</div>
|
||||
<SignedIn>
|
||||
<div className="hidden md:flex flex-1 items-center">
|
||||
<div className="max-w-[220px]">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="hidden max-w-[220px] shrink-0 md:block">
|
||||
<OrgSwitcher />
|
||||
</div>
|
||||
{topNavContent ? (
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
{topNavContent}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0 flex-1" />
|
||||
)}
|
||||
</div>
|
||||
</SignedIn>
|
||||
<SignedIn>
|
||||
<div className="ml-auto flex items-center gap-3 px-4 md:px-6">
|
||||
<div className="flex shrink-0 items-center gap-3 px-4 md:px-6">
|
||||
<div className="hidden text-right lg:block">
|
||||
<p className="text-sm font-semibold text-[color:var(--text)]">
|
||||
{displayName}
|
||||
|
|
|
|||
Loading…
Reference in New Issue