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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 44 additions & 5 deletions src/admin-ui/admin-legacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,14 +185,53 @@ export function initAdminPage(options = {}) {
if (!Number.isFinite(seconds)) return null;
return Math.max(((seconds * 1000) - Date.now()) / 86400000, 1 / (24 * 60));
}
function msUntilReset(resetsAt) {
const seconds = Number(resetsAt);
if (!Number.isFinite(seconds)) return null;
return Math.max((seconds * 1000) - Date.now(), 60000);
}
function weightedWeeklyQuotaScore(remaining, refreshDays) {
if (remaining == null) return null;
return (remaining / 100) / ((refreshDays || 7) / 7);
}
function weightedQuotaWindowScore(remaining, limit, fallbackWindowMins) {
if (remaining == null) return null;
const windowMins = normalizeWindowDurationMins(limit?.windowDurationMins, fallbackWindowMins);
const resetMs = msUntilReset(limit?.resetsAt) || windowMins * 60000;
return (remaining / 100) / (resetMs / (windowMins * 60000));
}
function formatWeightedWeeklyQuotaScore(score) {
if (!Number.isFinite(Number(score))) return "0";
return Number(score).toFixed(2).replace(/\.00$/, "").replace(/(\.\d)0$/, "$1");
}
function normalizeWindowDurationMins(value, fallback) {
const mins = Number(value);
return Number.isFinite(mins) && mins > 0 ? mins : fallback;
}
function formatWindowDuration(windowMins) {
if (windowMins % 1440 === 0) return (windowMins / 1440) + "d";
if (windowMins % 60 === 0) return (windowMins / 60) + "h";
return windowMins + "m";
}
function quotaWindowDisplay(limit, fallbackWindowMins) {
const remaining = remainingPercent(limit?.usedPercent);
if (remaining == null) return null;
const windowMins = normalizeWindowDurationMins(limit?.windowDurationMins, fallbackWindowMins);
const score = weightedQuotaWindowScore(remaining, limit, windowMins);
return formatWindowDuration(windowMins) + " " + Math.round(remaining) + "% / " + formatWeightedWeeklyQuotaScore(score);
}
function authQuotaDisplay(rateLimits) {
const primary = rateLimits?.primary;
const primaryRemaining = remainingPercent(primary?.usedPercent);
const primaryScore = weightedQuotaWindowScore(primaryRemaining, primary, 300);
const shortLabel = Number.isFinite(Number(primaryScore)) && Number(primaryScore) < 0.5
? quotaWindowDisplay(primary, 300)
: null;
return [
quotaWindowDisplay(rateLimits?.secondary, 10080),
shortLabel
].filter(Boolean).join(" | ") || null;
}
function weeklyQuotaDisplay(limit) {
const remaining = remainingPercent(limit?.usedPercent);
if (remaining == null) return null;
Expand All @@ -219,11 +258,11 @@ export function initAdminPage(options = {}) {
if (plan === "chatgpt") return "ChatGPT";
return plan;
}
function profileTooltip(profile, weeklyLabel, secondary) {
function profileTooltip(profile, quotaLabel, secondary) {
return [
profileAccountLabel(profile),
profilePlanLabel(profile),
weeklyLabel ? "周额度 " + weeklyLabel : "",
quotaLabel ? "额度 " + quotaLabel : "",
secondary ? "周重置 " + formatResetTime(secondary.resetsAt) : "",
profile.name ? "内部标识 " + profile.name : ""
].filter(Boolean).join(" · ");
Expand All @@ -234,7 +273,7 @@ export function initAdminPage(options = {}) {
const rateLimits = profile.rateLimits || {};
if (rateLimits.ok === false) return null;
const secondary = rateLimits.rateLimits?.secondary;
const label = weeklyQuotaDisplay(secondary);
const label = authQuotaDisplay(rateLimits.rateLimits);
if (!label) return null;
const remaining = remainingPercent(secondary?.usedPercent);
const score = weeklyQuotaScore(secondary);
Expand Down Expand Up @@ -504,9 +543,9 @@ export function initAdminPage(options = {}) {
if (!rateLimits || !rateLimits.ok) return '<div class="summary-detail">' + esc(rateLimits?.error || "额度不可用") + '</div>';
const snapshot = rateLimits.rateLimits || {};
const secondary = snapshot.secondary;
const weeklyLabel = weeklyQuotaDisplay(secondary);
const quotaLabel = authQuotaDisplay(snapshot);
return '<div class="quota-grid">' +
'<div class="quota-line"><span>周额度</span><strong>' + esc(weeklyLabel || "--") + '</strong><span>' + esc(secondary ? formatResetTime(secondary.resetsAt) : "不可用") + '</span></div>' +
'<div class="quota-line"><span>额度</span><strong>' + esc(quotaLabel || "--") + '</strong><span>' + esc(secondary ? "周 " + formatResetTime(secondary.resetsAt) : "不可用") + '</span></div>' +
'</div>';
}
function renderAuthProfiles(data) {
Expand Down
2 changes: 1 addition & 1 deletion src/admin-ui/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@
.auth-profile-label { color: var(--muted); font-size: 10px; white-space: nowrap; }

.session-timeline-panel .mini-body { flex: 1; min-height: 0; overflow: hidden; padding: 0; }
.timeline { height: 100%; display: grid; gap: 0; overflow: auto; border: 0; }
.timeline { height: 100%; display: grid; grid-auto-rows: max-content; align-content: start; gap: 0; overflow: auto; border: 0; }
.timeline-event { display: grid; grid-template-columns: 72px 90px minmax(0, 1fr); gap: 6px; align-items: start; padding: 4px 6px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 10px; }
.timeline-event:last-child { border-bottom: 0; }
.timeline-main { min-width: 0; }
Expand Down
18 changes: 9 additions & 9 deletions src/admin-ui/auth-profile-display.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { formatWeeklyQuotaDisplay } from "../auth-profile-quota.js";
import { formatAuthQuotaDisplay } from "../auth-profile-quota.js";

type AuthProfileRecord = Record<string, any>;
interface QuotaLabelOptions {
Expand Down Expand Up @@ -66,12 +66,12 @@ export function profileQuotaLabel(profile: AuthProfileRecord, options: QuotaLabe
}

const limits = rateLimits.rateLimits || {};
const label = formatWeeklyQuotaDisplay({
usedPercent: limits.secondary?.usedPercent,
resetsAt: limits.secondary?.resetsAt,
const label = formatAuthQuotaDisplay({
primary: limits.primary,
secondary: limits.secondary,
now: options.now
});
return label ?? "周额度未知";
return label ?? "额度未知";
}

export function profileWeeklyQuotaLabel(profile: AuthProfileRecord, options: QuotaLabelOptions = {}): string {
Expand All @@ -81,11 +81,11 @@ export function profileWeeklyQuotaLabel(profile: AuthProfileRecord, options: Quo
}

const limits = rateLimits.rateLimits || {};
return formatWeeklyQuotaDisplay({
usedPercent: limits.secondary?.usedPercent,
resetsAt: limits.secondary?.resetsAt,
return formatAuthQuotaDisplay({
primary: limits.primary,
secondary: limits.secondary,
now: options.now
}) ?? "周额度未知";
}) ?? "额度未知";
}

function readString(value: unknown): string | null {
Expand Down
23 changes: 15 additions & 8 deletions src/admin-ui/session-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type TimelinePayload = {
} | TimelineEvent[];

const sessionFilters = ["ongoing", "all", "active", "inbound", "jobs", "issues", "usage"];
const AUTO_AUTH_PROFILE_VALUE = "__auto_auth_profile__";

export function AdminSessionsView(): React.JSX.Element {
const permalinkSessionKey = readPermalinkSessionKey();
Expand Down Expand Up @@ -577,7 +578,7 @@ function AuthProfilePanel({ session, profiles, currentProfile: providedCurrentPr
readonly currentProfile?: SessionRecord | undefined;
}): React.JSX.Element {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const [selected, setSelected] = useState(String(session.authProfileName || ""));
const [selected, setSelected] = useState(() => initialAuthProfileSelection(session));
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const currentProfile = providedCurrentProfile ?? profiles.find((profile) => profile.name === session.authProfileName);
Expand All @@ -588,7 +589,7 @@ function AuthProfilePanel({ session, profiles, currentProfile: providedCurrentPr
const compactLabel = currentProfile ? profileQuotaLabel(currentProfile) : (blocked ? "账号不可用" : "账号");

useEffect(() => {
setSelected(String(session.authProfileName || ""));
setSelected(initialAuthProfileSelection(session));
setMessage(null);
}, [session.key, session.authProfileName, session.authBlockedAt]);

Expand All @@ -613,7 +614,8 @@ function AuthProfilePanel({ session, profiles, currentProfile: providedCurrentPr
}

async function switchProfile(): Promise<void> {
if (!selected || selected === session.authProfileName) {
const autoSelected = selected === AUTO_AUTH_PROFILE_VALUE;
if (!autoSelected && (!selected || selected === session.authProfileName)) {
return;
}

Expand All @@ -623,11 +625,11 @@ function AuthProfilePanel({ session, profiles, currentProfile: providedCurrentPr
await requestJson("/admin/api/sessions/" + encodeURIComponent(String(session.key || "")) + "/auth-profile", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: selected })
body: JSON.stringify(autoSelected ? { mode: "auto" } : { name: selected })
});
const timelinePayload = await requestJson(sessionTimelineApiPath(String(session.key || "")));
publishTimelinePayload(String(session.key || ""), timelinePayload as TimelinePayload);
setMessage("已切换,正在恢复待处理消息");
setMessage(autoSelected ? "已自动分配,正在恢复待处理消息" : "已切换,正在恢复待处理消息");
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
Expand All @@ -653,7 +655,7 @@ function AuthProfilePanel({ session, profiles, currentProfile: providedCurrentPr
</div>
{currentProfile ? (
<div className="auth-profile-dialog-current">
<span>周额度</span>
<span>额度</span>
<strong>{profileQuotaLabel(currentProfile)}</strong>
</div>
) : null}
Expand All @@ -671,6 +673,7 @@ function AuthProfilePanel({ session, profiles, currentProfile: providedCurrentPr
onChange={(event) => setSelected(event.target.value)}
>
<option value="">选择账号</option>
<option value={AUTO_AUTH_PROFILE_VALUE}>自动分配(按额度规则)</option>
{profiles.map((profile) => (
<option key={profile.name} value={profile.name} disabled={!profileIsSelectable(profile)}>
{profileOptionLabel(profile)}
Expand All @@ -680,10 +683,10 @@ function AuthProfilePanel({ session, profiles, currentProfile: providedCurrentPr
<button
type="button"
className="link-button"
disabled={busy || !selected || selected === session.authProfileName}
disabled={busy || (selected !== AUTO_AUTH_PROFILE_VALUE && (!selected || selected === session.authProfileName))}
onClick={() => { void switchProfile(); }}
>
切换并继续处理
{selected === AUTO_AUTH_PROFILE_VALUE ? "自动分配并继续处理" : "切换并继续处理"}
</button>
</div>
{message ? <div className="summary-detail">{message}</div> : null}
Expand All @@ -696,6 +699,10 @@ function AuthProfilePanel({ session, profiles, currentProfile: providedCurrentPr
);
}

function initialAuthProfileSelection(session: SessionRecord): string {
return session.authBlockedAt ? AUTO_AUTH_PROFILE_VALUE : String(session.authProfileName || "");
}

function TimelinePayloadView({ payload }: { readonly payload: TimelinePayload }): React.JSX.Element {
const events = (Array.isArray(payload) ? payload : (payload.events || [])).filter(isTimelineEventVisible);
if (!events.length) return <div className="summary-detail">暂无时间线事件</div>;
Expand Down
128 changes: 128 additions & 0 deletions src/auth-profile-quota.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export const MS_PER_DAY = 24 * 60 * 60 * 1000;
export const MS_PER_MINUTE = 60 * 1000;
export const MIN_REFRESH_DAYS = 1 / (24 * 60);
export const DEFAULT_SHORT_WINDOW_MINS = 300;
export const DEFAULT_WEEKLY_WINDOW_MINS = 10_080;
export const SHORT_WINDOW_DISPLAY_SCORE_THRESHOLD = 0.5;

export function remainingPercent(usedPercent: unknown): number | undefined {
const used = Number(usedPercent);
Expand All @@ -23,6 +27,18 @@ export function daysUntilReset(
return Math.max(deltaDays, MIN_REFRESH_DAYS);
}

export function msUntilReset(
resetsAt: unknown,
now: Date | number | string | undefined = undefined
): number | undefined {
const resetSeconds = Number(resetsAt);
if (!Number.isFinite(resetSeconds)) {
return undefined;
}

return Math.max(resetSeconds * 1000 - timestampMs(now), MS_PER_MINUTE);
}

export function weightedWeeklyQuotaScore(
remaining: number | undefined,
refreshDays: number | undefined
Expand All @@ -35,6 +51,25 @@ export function weightedWeeklyQuotaScore(
return (remaining / 100) / (days / 7);
}

export function weightedQuotaWindowScore(options: {
readonly remaining: number | undefined;
readonly resetsAt?: unknown;
readonly windowDurationMins?: unknown;
readonly fallbackWindowDurationMins: number;
readonly now?: Date | number | string | undefined;
}): number | undefined {
if (options.remaining === undefined) {
return undefined;
}

const windowMins = normalizeWindowDurationMins(
options.windowDurationMins,
options.fallbackWindowDurationMins
);
const resetMs = msUntilReset(options.resetsAt, options.now) ?? windowMins * MS_PER_MINUTE;
return (options.remaining / 100) / (resetMs / (windowMins * MS_PER_MINUTE));
}

export function formatWeeklyQuotaDisplay(options: {
readonly usedPercent: unknown;
readonly resetsAt?: unknown;
Expand All @@ -49,6 +84,84 @@ export function formatWeeklyQuotaDisplay(options: {
return `${Math.round(remaining)}% | ${formatWeightedWeeklyQuotaScore(score)}`;
}

export function formatQuotaWindowDisplay(options: {
readonly usedPercent: unknown;
readonly resetsAt?: unknown;
readonly windowDurationMins?: unknown;
readonly fallbackWindowDurationMins: number;
readonly now?: Date | number | string | undefined;
}): string | null {
const remaining = remainingPercent(options.usedPercent);
if (remaining === undefined) {
return null;
}

const windowMins = normalizeWindowDurationMins(
options.windowDurationMins,
options.fallbackWindowDurationMins
);
const score = weightedQuotaWindowScore({
remaining,
resetsAt: options.resetsAt,
windowDurationMins: windowMins,
fallbackWindowDurationMins: windowMins,
now: options.now
});
return `${formatWindowDuration(windowMins)} ${Math.round(remaining)}% / ${formatWeightedWeeklyQuotaScore(score)}`;
}

export function formatAuthQuotaDisplay(options: {
readonly primary?: {
readonly usedPercent?: unknown;
readonly resetsAt?: unknown;
readonly windowDurationMins?: unknown;
} | null | undefined;
readonly secondary?: {
readonly usedPercent?: unknown;
readonly resetsAt?: unknown;
readonly windowDurationMins?: unknown;
} | null | undefined;
readonly now?: Date | number | string | undefined;
}): string | null {
const weekly = formatQuotaWindowDisplay({
usedPercent: options.secondary?.usedPercent,
resetsAt: options.secondary?.resetsAt,
windowDurationMins: options.secondary?.windowDurationMins,
fallbackWindowDurationMins: DEFAULT_WEEKLY_WINDOW_MINS,
now: options.now
});
const short = shouldShowShortWindowQuota(options.primary, options.now)
? formatQuotaWindowDisplay({
usedPercent: options.primary?.usedPercent,
resetsAt: options.primary?.resetsAt,
windowDurationMins: options.primary?.windowDurationMins,
fallbackWindowDurationMins: DEFAULT_SHORT_WINDOW_MINS,
now: options.now
})
: null;
const parts = [weekly, short].filter(Boolean);
return parts.length ? parts.join(" | ") : null;
}

export function shouldShowShortWindowQuota(
limit: {
readonly usedPercent?: unknown;
readonly resetsAt?: unknown;
readonly windowDurationMins?: unknown;
} | null | undefined,
now?: Date | number | string | undefined
): boolean {
const remaining = remainingPercent(limit?.usedPercent);
const score = weightedQuotaWindowScore({
remaining,
resetsAt: limit?.resetsAt,
windowDurationMins: limit?.windowDurationMins,
fallbackWindowDurationMins: DEFAULT_SHORT_WINDOW_MINS,
now
});
return Number.isFinite(score) && Number(score) < SHORT_WINDOW_DISPLAY_SCORE_THRESHOLD;
}

export function formatWeightedWeeklyQuotaScore(score: number | undefined): string {
if (!Number.isFinite(score)) {
return "0";
Expand All @@ -73,3 +186,18 @@ export function timestampMs(value: Date | number | string | undefined): number {
}
return Date.now();
}

function normalizeWindowDurationMins(value: unknown, fallback: number): number {
const mins = Number(value);
return Number.isFinite(mins) && mins > 0 ? mins : fallback;
}

function formatWindowDuration(windowMins: number): string {
if (windowMins % (24 * 60) === 0) {
return `${windowMins / (24 * 60)}d`;
}
if (windowMins % 60 === 0) {
return `${windowMins / 60}h`;
}
return `${windowMins}m`;
}
Loading
Loading