mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
fix: improve the UI for check quota of the user
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
- **User limits**: add per-user total-token budgets for Orbit Provider and Codex with rolling 5-hour and weekly windows
|
- **User limits**: add per-user total-token budgets for Orbit Provider and Codex with rolling 5-hour and weekly windows
|
||||||
|
- **User quota**: show remaining Orbit and Codex headroom in the users table with session and weekly usage details
|
||||||
- **Orbit Provider**: add Anthropic-compatible API-key routing for Claude Opus 4.6–4.8 models
|
- **Orbit Provider**: add Anthropic-compatible API-key routing for Claude Opus 4.6–4.8 models
|
||||||
- **xAI**: Grok Imagine video generation (`/v1/videos`) + CLI
|
- **xAI**: Grok Imagine video generation (`/v1/videos`) + CLI
|
||||||
- **CLI tools**: Grok Build setup — writes `[model.9router]` to `~/.grok/config.toml`
|
- **CLI tools**: Grok Build setup — writes `[model.9router]` to `~/.grok/config.toml`
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import {
|
||||||
|
TOKEN_LIMIT_PROVIDER_OPTIONS,
|
||||||
|
TOKEN_LIMIT_WINDOW_OPTIONS,
|
||||||
|
formatTokenCount,
|
||||||
|
getProviderRemainingPercentage,
|
||||||
|
getQuotaTone,
|
||||||
|
} from "./tokenLimitDisplay.js";
|
||||||
|
|
||||||
|
function buildQuotaSummary(providerUsage) {
|
||||||
|
return TOKEN_LIMIT_WINDOW_OPTIONS
|
||||||
|
.map(({ id, name }) => {
|
||||||
|
const usage = providerUsage?.[id];
|
||||||
|
if (!usage?.limit) return null;
|
||||||
|
return `${name}: ${formatTokenCount(usage.remaining)} of ${formatTokenCount(usage.limit)} tokens left`;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuotaCell({ userId, refreshKey = 0 }) {
|
||||||
|
const [state, setState] = useState({ status: "loading", data: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function loadUsage() {
|
||||||
|
setState((current) => ({ ...current, status: "loading" }));
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/users/${userId}/token-usage`, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(payload.error || "Failed to load token usage");
|
||||||
|
setState({ status: "ready", data: payload });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name !== "AbortError") setState({ status: "error", data: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadUsage();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [refreshKey, userId]);
|
||||||
|
|
||||||
|
if (state.status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="flex w-24 flex-col gap-1.5" aria-label="Loading quota">
|
||||||
|
<span className="h-2.5 w-20 animate-pulse rounded-full bg-surface-2" />
|
||||||
|
<span className="h-2.5 w-14 animate-pulse rounded-full bg-surface-2" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "error") {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2 text-xs text-text-muted" title="Quota data is unavailable">
|
||||||
|
<span className="size-1.5 rounded-full bg-text-muted/50" aria-hidden="true" />
|
||||||
|
Unavailable
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredProviders = TOKEN_LIMIT_PROVIDER_OPTIONS.flatMap((provider) => {
|
||||||
|
const usage = state.data?.providers?.[provider.id];
|
||||||
|
const remainingPercentage = getProviderRemainingPercentage(usage);
|
||||||
|
return remainingPercentage === null ? [] : [{ ...provider, usage, remainingPercentage }];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (configuredProviders.length === 0) {
|
||||||
|
return <span className="text-xs text-text-muted">Unlimited</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start gap-1.5">
|
||||||
|
{configuredProviders.map((provider) => {
|
||||||
|
const tone = getQuotaTone(provider.remainingPercentage);
|
||||||
|
const title = `${provider.name} · ${buildQuotaSummary(provider.usage)}`;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={provider.id}
|
||||||
|
className="inline-flex items-center gap-2 whitespace-nowrap text-xs"
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<span className={`size-1.5 shrink-0 rounded-full ${tone.dot}`} aria-hidden="true" />
|
||||||
|
<span className="w-10 text-text-muted">{provider.shortName}</span>
|
||||||
|
<strong className={`min-w-8 text-right font-semibold tabular-nums ${tone.text}`}>
|
||||||
|
{provider.remainingPercentage}%
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
QuotaCell.propTypes = {
|
||||||
|
userId: PropTypes.string.isRequired,
|
||||||
|
refreshKey: PropTypes.number,
|
||||||
|
};
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import {
|
||||||
|
TOKEN_LIMIT_PROVIDER_OPTIONS,
|
||||||
|
TOKEN_LIMIT_WINDOW_OPTIONS,
|
||||||
|
formatTokenCount,
|
||||||
|
getProviderRemainingPercentage,
|
||||||
|
getQuotaTone,
|
||||||
|
} from "./tokenLimitDisplay.js";
|
||||||
|
|
||||||
|
function UsageWindow({ option, usage }) {
|
||||||
|
const hasLimit = usage?.limit > 0;
|
||||||
|
|
||||||
|
if (!hasLimit) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2 py-3 first:pt-0 last:pb-0 sm:grid-cols-[9rem_minmax(0,1fr)] sm:items-center sm:gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-text-muted">{option.name}</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-text-muted">{option.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm font-medium tabular-nums text-text-main">
|
||||||
|
{formatTokenCount(usage?.used)} used
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-border-subtle bg-surface px-2 py-1 text-[11px] font-medium text-text-muted">
|
||||||
|
Unlimited
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tone = getQuotaTone(usage.remainingPercentage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2 py-3 first:pt-0 last:pb-0 sm:grid-cols-[9rem_minmax(0,1fr)] sm:items-center sm:gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-text-muted">{option.name}</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-text-muted">{option.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-3 text-xs">
|
||||||
|
<span className={`font-semibold tabular-nums ${tone.text}`}>
|
||||||
|
{usage.remainingPercentage}% remaining
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-right tabular-nums text-text-muted">
|
||||||
|
{formatTokenCount(usage.used)} / {formatTokenCount(usage.limit)} used
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="h-2 overflow-hidden rounded-full bg-surface"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label={`${option.name} quota remaining`}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={usage.remainingPercentage}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-[width] duration-300 ${tone.bar}`}
|
||||||
|
style={{ width: `${usage.remainingPercentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3" aria-label="Loading token usage">
|
||||||
|
{[1, 2].map((key) => (
|
||||||
|
<div key={key} className="animate-pulse rounded-xl border border-border-subtle p-4">
|
||||||
|
<div className="h-4 w-24 rounded bg-surface-2" />
|
||||||
|
<div className="mt-4 h-2 w-full rounded-full bg-surface-2" />
|
||||||
|
<div className="mt-4 h-2 w-4/5 rounded-full bg-surface-2" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TokenLimitsUsage({ userId, refreshKey = 0 }) {
|
||||||
|
const [state, setState] = useState({ status: "loading", data: null, error: "" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function loadUsage() {
|
||||||
|
setState((current) => ({ ...current, status: "loading", error: "" }));
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/users/${userId}/token-usage`, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(payload.error || "Failed to load token usage");
|
||||||
|
setState({ status: "ready", data: payload, error: "" });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name !== "AbortError") {
|
||||||
|
setState({ status: "error", data: null, error: error?.message || "Failed to load token usage" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadUsage();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [refreshKey, userId]);
|
||||||
|
|
||||||
|
if (state.status === "loading") return <UsageSkeleton />;
|
||||||
|
|
||||||
|
if (state.status === "error") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 rounded-xl border border-red-500/25 bg-red-500/5 px-4 py-3">
|
||||||
|
<span className="material-symbols-outlined mt-0.5 text-[19px] text-red-500">error</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-text-main">Usage data is unavailable</p>
|
||||||
|
<p className="mt-0.5 text-xs text-text-muted">{state.error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{TOKEN_LIMIT_PROVIDER_OPTIONS.map((provider) => {
|
||||||
|
const providerUsage = state.data?.providers?.[provider.id];
|
||||||
|
const remainingPercentage = getProviderRemainingPercentage(providerUsage);
|
||||||
|
const tone = remainingPercentage === null ? null : getQuotaTone(remainingPercentage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section key={provider.id} className="overflow-hidden rounded-xl border border-border-subtle bg-surface-2/25">
|
||||||
|
<header className="flex items-center justify-between gap-3 border-b border-border-subtle px-4 py-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<span className="material-symbols-outlined text-[20px] text-brand-500">{provider.icon}</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-sm font-semibold text-text-main">{provider.name}</h3>
|
||||||
|
<p className="truncate text-[11px] text-text-muted">{provider.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{remainingPercentage === null ? (
|
||||||
|
<span className="shrink-0 text-xs font-medium text-text-muted">No limits</span>
|
||||||
|
) : (
|
||||||
|
<span className={`inline-flex shrink-0 items-center gap-1.5 text-xs font-semibold tabular-nums ${tone.text}`}>
|
||||||
|
<span className={`size-1.5 rounded-full ${tone.dot}`} aria-hidden="true" />
|
||||||
|
{remainingPercentage}% min.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<div className="divide-y divide-border-subtle px-4 py-3">
|
||||||
|
{TOKEN_LIMIT_WINDOW_OPTIONS.map((option) => (
|
||||||
|
<UsageWindow key={option.id} option={option} usage={providerUsage?.[option.id]} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
UsageWindow.propTypes = {
|
||||||
|
option: PropTypes.shape({
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
description: PropTypes.string.isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
usage: PropTypes.shape({
|
||||||
|
limit: PropTypes.number,
|
||||||
|
used: PropTypes.number,
|
||||||
|
remainingPercentage: PropTypes.number,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
TokenLimitsUsage.propTypes = {
|
||||||
|
userId: PropTypes.string.isRequired,
|
||||||
|
refreshKey: PropTypes.number,
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
USER_TOKEN_LIMIT_PROVIDERS,
|
||||||
|
USER_TOKEN_LIMIT_WINDOWS,
|
||||||
|
} from "open-sse/config/userTokenLimits.js";
|
||||||
|
|
||||||
|
const TOKEN_FORMATTER = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
|
||||||
|
const COMPACT_TOKEN_FORMATTER = new Intl.NumberFormat("en-US", {
|
||||||
|
notation: "compact",
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TOKEN_LIMIT_PROVIDER_OPTIONS = Object.freeze([
|
||||||
|
{
|
||||||
|
id: USER_TOKEN_LIMIT_PROVIDERS.ORBIT,
|
||||||
|
name: "Orbit Provider",
|
||||||
|
shortName: "Orbit",
|
||||||
|
description: "Anthropic-compatible traffic routed through Orbit.",
|
||||||
|
icon: "orbit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: USER_TOKEN_LIMIT_PROVIDERS.CODEX,
|
||||||
|
name: "Codex",
|
||||||
|
shortName: "Codex",
|
||||||
|
description: "OpenAI Codex responses and coding sessions.",
|
||||||
|
icon: "terminal",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const TOKEN_LIMIT_WINDOW_OPTIONS = Object.freeze([
|
||||||
|
{
|
||||||
|
id: USER_TOKEN_LIMIT_WINDOWS.SESSION,
|
||||||
|
name: "Session",
|
||||||
|
description: "Rolling 5 hours",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: USER_TOKEN_LIMIT_WINDOWS.WEEKLY,
|
||||||
|
name: "Weekly",
|
||||||
|
description: "Resets Monday, Vietnam time",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function getQuotaTone(remainingPercentage) {
|
||||||
|
if (remainingPercentage > 70) {
|
||||||
|
return {
|
||||||
|
bar: "bg-emerald-500",
|
||||||
|
dot: "bg-emerald-500",
|
||||||
|
text: "text-emerald-600 dark:text-emerald-400",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (remainingPercentage >= 30) {
|
||||||
|
return {
|
||||||
|
bar: "bg-amber-500",
|
||||||
|
dot: "bg-amber-500",
|
||||||
|
text: "text-amber-600 dark:text-amber-400",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bar: "bg-red-500",
|
||||||
|
dot: "bg-red-500",
|
||||||
|
text: "text-red-600 dark:text-red-400",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderRemainingPercentage(providerUsage) {
|
||||||
|
const activeWindows = TOKEN_LIMIT_WINDOW_OPTIONS
|
||||||
|
.map(({ id }) => providerUsage?.[id])
|
||||||
|
.filter((windowUsage) => windowUsage?.limit > 0);
|
||||||
|
|
||||||
|
if (activeWindows.length === 0) return null;
|
||||||
|
return Math.min(...activeWindows.map((windowUsage) => windowUsage.remainingPercentage));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTokenCount(value, compact = false) {
|
||||||
|
const amount = Math.max(0, Number(value) || 0);
|
||||||
|
return (compact ? COMPACT_TOKEN_FORMATTER : TOKEN_FORMATTER).format(amount);
|
||||||
|
}
|
||||||
@@ -6,26 +6,12 @@ import { Button, Card, Input } from "@/shared/components";
|
|||||||
import Modal, { ConfirmModal } from "@/shared/components/Modal";
|
import Modal, { ConfirmModal } from "@/shared/components/Modal";
|
||||||
import useUserStore from "@/store/userStore";
|
import useUserStore from "@/store/userStore";
|
||||||
import { formatVietnamDateTime } from "@/shared/utils/dateTime";
|
import { formatVietnamDateTime } from "@/shared/utils/dateTime";
|
||||||
import {
|
import { USER_TOKEN_LIMIT_WINDOWS } from "open-sse/config/userTokenLimits.js";
|
||||||
USER_TOKEN_LIMIT_PROVIDERS,
|
import QuotaCell from "./components/QuotaCell";
|
||||||
USER_TOKEN_LIMIT_WINDOWS,
|
import TokenLimitsUsage from "./components/TokenLimitsUsage";
|
||||||
} from "open-sse/config/userTokenLimits.js";
|
import { TOKEN_LIMIT_PROVIDER_OPTIONS } from "./components/tokenLimitDisplay.js";
|
||||||
|
|
||||||
const EMPTY_FORM = { username: "", password: "", role: "user", isActive: true };
|
const EMPTY_FORM = { username: "", password: "", role: "user", isActive: true };
|
||||||
const TOKEN_LIMIT_PROVIDER_OPTIONS = [
|
|
||||||
{
|
|
||||||
id: USER_TOKEN_LIMIT_PROVIDERS.ORBIT,
|
|
||||||
name: "Orbit Provider",
|
|
||||||
description: "Anthropic-compatible traffic routed through Orbit.",
|
|
||||||
icon: "orbit",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: USER_TOKEN_LIMIT_PROVIDERS.CODEX,
|
|
||||||
name: "Codex",
|
|
||||||
description: "OpenAI Codex responses and coding sessions.",
|
|
||||||
icon: "terminal",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function createEmptyTokenLimits() {
|
function createEmptyTokenLimits() {
|
||||||
return Object.fromEntries(TOKEN_LIMIT_PROVIDER_OPTIONS.map(({ id }) => [
|
return Object.fromEntries(TOKEN_LIMIT_PROVIDER_OPTIONS.map(({ id }) => [
|
||||||
@@ -58,6 +44,7 @@ export default function UsersPage() {
|
|||||||
const [limitsLoading, setLimitsLoading] = useState(false);
|
const [limitsLoading, setLimitsLoading] = useState(false);
|
||||||
const [limitsSaving, setLimitsSaving] = useState(false);
|
const [limitsSaving, setLimitsSaving] = useState(false);
|
||||||
const [limitsError, setLimitsError] = useState("");
|
const [limitsError, setLimitsError] = useState("");
|
||||||
|
const [quotaRefreshKey, setQuotaRefreshKey] = useState(0);
|
||||||
|
|
||||||
const loadUsers = useCallback(async () => {
|
const loadUsers = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -192,6 +179,7 @@ export default function UsersPage() {
|
|||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok) throw new Error(data.error || "Failed to save token limits");
|
if (!response.ok) throw new Error(data.error || "Failed to save token limits");
|
||||||
|
setQuotaRefreshKey((current) => current + 1);
|
||||||
setLimitEditor(null);
|
setLimitEditor(null);
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
setLimitsError(requestError.message || "Failed to save token limits");
|
setLimitsError(requestError.message || "Failed to save token limits");
|
||||||
@@ -228,20 +216,24 @@ export default function UsersPage() {
|
|||||||
<th className="px-5 py-3 font-medium">Username</th>
|
<th className="px-5 py-3 font-medium">Username</th>
|
||||||
<th className="px-5 py-3 font-medium">Role</th>
|
<th className="px-5 py-3 font-medium">Role</th>
|
||||||
<th className="px-5 py-3 font-medium">Status</th>
|
<th className="px-5 py-3 font-medium">Status</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Quota remaining</th>
|
||||||
<th className="px-5 py-3 font-medium">Created</th>
|
<th className="px-5 py-3 font-medium">Created</th>
|
||||||
<th className="px-5 py-3 text-right font-medium">Actions</th>
|
<th className="px-5 py-3 text-right font-medium">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border-subtle">
|
<tbody className="divide-y divide-border-subtle">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr><td colSpan="5" className="px-5 py-12 text-center text-text-muted">Loading users…</td></tr>
|
<tr><td colSpan="6" className="px-5 py-12 text-center text-text-muted">Loading users…</td></tr>
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<tr><td colSpan="5" className="px-5 py-12 text-center text-text-muted">No users found.</td></tr>
|
<tr><td colSpan="6" className="px-5 py-12 text-center text-text-muted">No users found.</td></tr>
|
||||||
) : users.map((entry) => (
|
) : users.map((entry) => (
|
||||||
<tr key={entry.id} className="transition-colors hover:bg-surface-2/40">
|
<tr key={entry.id} className="transition-colors hover:bg-surface-2/40">
|
||||||
<td className="px-5 py-4 font-medium text-text-main">{entry.username}{entry.id === user.id ? <span className="ml-2 text-xs font-normal text-text-muted">(you)</span> : null}</td>
|
<td className="px-5 py-4 font-medium text-text-main">{entry.username}{entry.id === user.id ? <span className="ml-2 text-xs font-normal text-text-muted">(you)</span> : null}</td>
|
||||||
<td className="px-5 py-4"><span className={`rounded-full px-2 py-1 text-xs font-medium ${entry.role === "admin" ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"}`}>{entry.role}</span></td>
|
<td className="px-5 py-4"><span className={`rounded-full px-2 py-1 text-xs font-medium ${entry.role === "admin" ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"}`}>{entry.role}</span></td>
|
||||||
<td className="px-5 py-4"><span className={entry.isActive ? "text-emerald-600 dark:text-emerald-400" : "text-text-muted"}>{entry.isActive ? "Active" : "Disabled"}</span></td>
|
<td className="px-5 py-4"><span className={entry.isActive ? "text-emerald-600 dark:text-emerald-400" : "text-text-muted"}>{entry.isActive ? "Active" : "Disabled"}</span></td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
{entry.role === "user" ? <QuotaCell userId={entry.id} refreshKey={quotaRefreshKey} /> : <span className="text-xs text-text-muted">—</span>}
|
||||||
|
</td>
|
||||||
<td className="px-5 py-4 text-text-muted">{formatDate(entry.createdAt)}</td>
|
<td className="px-5 py-4 text-text-muted">{formatDate(entry.createdAt)}</td>
|
||||||
<td className="px-5 py-4 text-right">
|
<td className="px-5 py-4 text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
@@ -274,10 +266,11 @@ export default function UsersPage() {
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={!!limitEditor}
|
isOpen={!!limitEditor}
|
||||||
onClose={() => !limitsSaving && setLimitEditor(null)}
|
onClose={() => !limitsSaving && setLimitEditor(null)}
|
||||||
title={`Token limits · ${limitEditor?.username || "user"}`}
|
title={`Usage & limits · ${limitEditor?.username || "user"}`}
|
||||||
|
size="xl"
|
||||||
footer={<><Button variant="ghost" onClick={() => setLimitEditor(null)} disabled={limitsSaving}>Cancel</Button><Button variant="primary" onClick={saveTokenLimits} loading={limitsSaving} disabled={limitsLoading}>Save limits</Button></>}
|
footer={<><Button variant="ghost" onClick={() => setLimitEditor(null)} disabled={limitsSaving}>Cancel</Button><Button variant="primary" onClick={saveTokenLimits} loading={limitsSaving} disabled={limitsLoading}>Save limits</Button></>}
|
||||||
>
|
>
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
<div className="rounded-xl border border-brand-500/20 bg-brand-500/5 px-4 py-3">
|
<div className="rounded-xl border border-brand-500/20 bg-brand-500/5 px-4 py-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<span className="material-symbols-outlined mt-0.5 text-[20px] text-brand-500">hourglass_top</span>
|
<span className="material-symbols-outlined mt-0.5 text-[20px] text-brand-500">hourglass_top</span>
|
||||||
@@ -290,10 +283,24 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
{limitsError ? <p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">{limitsError}</p> : null}
|
{limitsError ? <p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">{limitsError}</p> : null}
|
||||||
|
|
||||||
|
{limitEditor ? (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-500">Current headroom</p>
|
||||||
|
<p className="mt-1 text-sm text-text-muted">The lowest active window determines the quota shown in the users table.</p>
|
||||||
|
</div>
|
||||||
|
<TokenLimitsUsage userId={limitEditor.id} refreshKey={quotaRefreshKey} />
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{limitsLoading ? (
|
{limitsLoading ? (
|
||||||
<div className="py-10 text-center text-sm text-text-muted">Loading token limits…</div>
|
<div className="py-10 text-center text-sm text-text-muted">Loading token limits…</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<section className="space-y-3 border-t border-border-subtle pt-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-500">Budget settings</p>
|
||||||
|
<p className="mt-1 text-sm text-text-muted">Set 0 to leave a provider window unlimited.</p>
|
||||||
|
</div>
|
||||||
{TOKEN_LIMIT_PROVIDER_OPTIONS.map((provider) => (
|
{TOKEN_LIMIT_PROVIDER_OPTIONS.map((provider) => (
|
||||||
<section key={provider.id} className="rounded-xl border border-border-subtle bg-surface-2/35 p-4">
|
<section key={provider.id} className="rounded-xl border border-border-subtle bg-surface-2/35 p-4">
|
||||||
<div className="mb-4 flex items-center gap-3">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
@@ -333,7 +340,7 @@ export default function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
getUserById,
|
||||||
|
getUserProviderTokenUsageSince,
|
||||||
|
getUserTokenLimits,
|
||||||
|
} from "@/lib/db/index.js";
|
||||||
|
import { requireAdminUser } from "@/lib/auth/currentUser.js";
|
||||||
|
import { getUserTokenLimitWindowStart } from "@/lib/tokenLimitEnforcer.js";
|
||||||
|
import {
|
||||||
|
USER_TOKEN_LIMIT_PROVIDER_IDS,
|
||||||
|
USER_TOKEN_LIMIT_WINDOW_IDS,
|
||||||
|
} from "open-sse/config/userTokenLimits.js";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const NO_STORE_HEADERS = { "Cache-Control": "no-store" };
|
||||||
|
|
||||||
|
function errorResponse(error) {
|
||||||
|
const message = error?.message || "Request failed";
|
||||||
|
const status = message === "Unauthorized"
|
||||||
|
? 401
|
||||||
|
: message === "Forbidden"
|
||||||
|
? 403
|
||||||
|
: message === "User not found"
|
||||||
|
? 404
|
||||||
|
: 400;
|
||||||
|
return NextResponse.json({ error: message }, { status, headers: NO_STORE_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWindowUsage(limit, used, windowStart) {
|
||||||
|
const normalizedLimit = Math.max(0, Number(limit) || 0);
|
||||||
|
const normalizedUsed = Math.max(0, Number(used) || 0);
|
||||||
|
const remaining = normalizedLimit > 0
|
||||||
|
? Math.max(0, normalizedLimit - normalizedUsed)
|
||||||
|
: null;
|
||||||
|
const remainingPercentage = normalizedLimit > 0
|
||||||
|
? Math.round((remaining / normalizedLimit) * 100)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
limit: normalizedLimit,
|
||||||
|
used: normalizedUsed,
|
||||||
|
remaining,
|
||||||
|
remainingPercentage,
|
||||||
|
windowStart: windowStart.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request, { params }) {
|
||||||
|
try {
|
||||||
|
await requireAdminUser();
|
||||||
|
|
||||||
|
const { userId } = await params;
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
if (!user) throw new Error("User not found");
|
||||||
|
if (user.role !== "user") throw new Error("Token usage only applies to user accounts");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const limits = await getUserTokenLimits(user.id);
|
||||||
|
const windows = Object.fromEntries(USER_TOKEN_LIMIT_WINDOW_IDS.map((windowType) => [
|
||||||
|
windowType,
|
||||||
|
getUserTokenLimitWindowStart(windowType, now),
|
||||||
|
]));
|
||||||
|
|
||||||
|
const usageEntries = await Promise.all(
|
||||||
|
USER_TOKEN_LIMIT_PROVIDER_IDS.flatMap((provider) => (
|
||||||
|
USER_TOKEN_LIMIT_WINDOW_IDS.map(async (windowType) => {
|
||||||
|
const used = await getUserProviderTokenUsageSince(
|
||||||
|
user.id,
|
||||||
|
provider,
|
||||||
|
windows[windowType],
|
||||||
|
);
|
||||||
|
return [provider, windowType, used];
|
||||||
|
})
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const usageByProvider = Object.fromEntries(
|
||||||
|
USER_TOKEN_LIMIT_PROVIDER_IDS.map((provider) => [provider, {}]),
|
||||||
|
);
|
||||||
|
for (const [provider, windowType, used] of usageEntries) {
|
||||||
|
usageByProvider[provider][windowType] = buildWindowUsage(
|
||||||
|
limits[provider]?.[windowType],
|
||||||
|
used,
|
||||||
|
windows[windowType],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
userId: user.id,
|
||||||
|
providers: usageByProvider,
|
||||||
|
updatedAt: now.toISOString(),
|
||||||
|
}, { headers: NO_STORE_HEADERS });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const requireAdminUser = vi.fn();
|
||||||
|
const getUserById = vi.fn();
|
||||||
|
const getUserTokenLimits = vi.fn();
|
||||||
|
const getUserProviderTokenUsageSince = vi.fn();
|
||||||
|
const getUserTokenLimitWindowStart = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("next/server", () => ({
|
||||||
|
NextResponse: {
|
||||||
|
json(body, init = {}) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: init.status || 200,
|
||||||
|
headers: { "Content-Type": "application/json", ...(init.headers || {}) },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth/currentUser.js", () => ({ requireAdminUser }));
|
||||||
|
vi.mock("@/lib/db/index.js", () => ({
|
||||||
|
getUserById,
|
||||||
|
getUserTokenLimits,
|
||||||
|
getUserProviderTokenUsageSince,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/tokenLimitEnforcer.js", () => ({ getUserTokenLimitWindowStart }));
|
||||||
|
|
||||||
|
const { GET } = await import("@/app/api/users/[userId]/token-usage/route.js");
|
||||||
|
const context = (userId = "user-1") => ({ params: Promise.resolve({ userId }) });
|
||||||
|
const request = new Request("https://9router.local/api/users/user-1/token-usage");
|
||||||
|
|
||||||
|
const sessionStart = new Date("2026-07-17T05:00:00.000Z");
|
||||||
|
const weeklyStart = new Date("2026-07-13T17:00:00.000Z");
|
||||||
|
const limits = {
|
||||||
|
"orbit-provider": { session: 100, weekly: 1000 },
|
||||||
|
codex: { session: 0, weekly: 500 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function usageKey(provider, since) {
|
||||||
|
return `${provider}|${since.toISOString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("/api/users/[userId]/token-usage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
requireAdminUser.mockReset();
|
||||||
|
getUserById.mockReset();
|
||||||
|
getUserTokenLimits.mockReset();
|
||||||
|
getUserProviderTokenUsageSince.mockReset();
|
||||||
|
getUserTokenLimitWindowStart.mockReset();
|
||||||
|
|
||||||
|
requireAdminUser.mockResolvedValue({ id: "admin-1", role: "admin" });
|
||||||
|
getUserById.mockResolvedValue({ id: "user-1", role: "user", isActive: true });
|
||||||
|
getUserTokenLimits.mockResolvedValue(limits);
|
||||||
|
getUserTokenLimitWindowStart.mockImplementation((windowType) => (
|
||||||
|
windowType === "session" ? sessionStart : weeklyStart
|
||||||
|
));
|
||||||
|
|
||||||
|
const usage = new Map([
|
||||||
|
[usageKey("orbit-provider", sessionStart), 25],
|
||||||
|
[usageKey("orbit-provider", weeklyStart), 1200],
|
||||||
|
[usageKey("codex", sessionStart), 12],
|
||||||
|
[usageKey("codex", weeklyStart), 400],
|
||||||
|
]);
|
||||||
|
getUserProviderTokenUsageSince.mockImplementation(async (_userId, provider, since) => (
|
||||||
|
usage.get(usageKey(provider, since)) || 0
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns usage and remaining headroom for every provider window", async () => {
|
||||||
|
const response = await GET(request, context());
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
userId: "user-1",
|
||||||
|
providers: {
|
||||||
|
"orbit-provider": {
|
||||||
|
session: {
|
||||||
|
limit: 100,
|
||||||
|
used: 25,
|
||||||
|
remaining: 75,
|
||||||
|
remainingPercentage: 75,
|
||||||
|
windowStart: sessionStart.toISOString(),
|
||||||
|
},
|
||||||
|
weekly: {
|
||||||
|
limit: 1000,
|
||||||
|
used: 1200,
|
||||||
|
remaining: 0,
|
||||||
|
remainingPercentage: 0,
|
||||||
|
windowStart: weeklyStart.toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
session: {
|
||||||
|
limit: 0,
|
||||||
|
used: 12,
|
||||||
|
remaining: null,
|
||||||
|
remainingPercentage: null,
|
||||||
|
windowStart: sessionStart.toISOString(),
|
||||||
|
},
|
||||||
|
weekly: {
|
||||||
|
limit: 500,
|
||||||
|
used: 400,
|
||||||
|
remaining: 100,
|
||||||
|
remainingPercentage: 20,
|
||||||
|
windowStart: weeklyStart.toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(payload.updatedAt).toEqual(expect.any(String));
|
||||||
|
expect(getUserProviderTokenUsageSince).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires an administrator and an existing regular user", async () => {
|
||||||
|
requireAdminUser.mockRejectedValueOnce(new Error("Forbidden"));
|
||||||
|
expect((await GET(request, context())).status).toBe(403);
|
||||||
|
|
||||||
|
getUserById.mockResolvedValueOnce(null);
|
||||||
|
expect((await GET(request, context("missing-user"))).status).toBe(404);
|
||||||
|
|
||||||
|
getUserById.mockResolvedValueOnce({ id: "admin-2", role: "admin", isActive: true });
|
||||||
|
expect((await GET(request, context("admin-2"))).status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user