mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-08-28 16:31:13 +08:00
feat(quotaTimeline): add manual reset credit handling and related UI updates
This commit is contained in:
@@ -309,6 +309,25 @@ $lane-width: 210px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// A reset credit is use-it-or-lose-it, so its expiry sits above the window bar
|
||||
// as a narrow amber tick without competing with the bar's own label.
|
||||
.resetCreditTick {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 999px;
|
||||
background: var(--amber-color);
|
||||
box-shadow: 0 0 0 1px var(--bg-primary);
|
||||
cursor: help;
|
||||
|
||||
&:hover {
|
||||
width: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.laneIdle {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
@@ -351,6 +370,13 @@ $lane-width: 210px;
|
||||
background: color-mix(in srgb, var(--text-tertiary, #888) 12%, transparent);
|
||||
}
|
||||
|
||||
.swatchCredit {
|
||||
width: 2px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--amber-color);
|
||||
}
|
||||
|
||||
.legendNote {
|
||||
flex: 1 1 320px;
|
||||
min-width: 0;
|
||||
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
buildTimelineLane,
|
||||
laneHasWindow,
|
||||
projectLane,
|
||||
projectResetCredits,
|
||||
timelineSpan,
|
||||
DAY_MS,
|
||||
HOUR_MS,
|
||||
} from '../quotaTimelineModel';
|
||||
import type { TimelineLane, TimelineMode } from '../quotaTimelineModel';
|
||||
import type { QuotaFileEntry } from '../logic';
|
||||
@@ -41,6 +43,19 @@ const formatTime = (ms: number) => {
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
const formatRelativeTime = (expiresAtMs: number, now: number, locale?: string) => {
|
||||
const remainingMs = Math.max(0, expiresAtMs - now);
|
||||
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'always' });
|
||||
|
||||
if (remainingMs >= DAY_MS) {
|
||||
return formatter.format(Math.ceil(remainingMs / DAY_MS), 'day');
|
||||
}
|
||||
if (remainingMs >= HOUR_MS) {
|
||||
return formatter.format(Math.ceil(remainingMs / HOUR_MS), 'hour');
|
||||
}
|
||||
return formatter.format(Math.max(1, Math.ceil(remainingMs / 60_000)), 'minute');
|
||||
};
|
||||
|
||||
export interface QuotaTimelineProps {
|
||||
entries: QuotaFileEntry[];
|
||||
/**
|
||||
@@ -271,6 +286,12 @@ export function QuotaTimeline({
|
||||
<span className={`${styles.swatch} ${styles.swatchPast}`} />
|
||||
{t('quota_management.windows_legend_elapsed', { defaultValue: 'elapsed' })}
|
||||
</span>
|
||||
<span className={styles.legendItem}>
|
||||
<span className={styles.swatchCredit} />
|
||||
{t('quota_management.windows_legend_reset_credit', {
|
||||
defaultValue: 'manual reset expiry',
|
||||
})}
|
||||
</span>
|
||||
<span className={styles.legendNote}>
|
||||
{mode === 'weekly'
|
||||
? t('quota_management.windows_note_weekly', {
|
||||
@@ -299,12 +320,16 @@ interface LaneProps {
|
||||
}
|
||||
|
||||
function Lane({ lane, span, now, mode, cells, nowPercent, resolvedTheme }: LaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const windows = useMemo(
|
||||
() => projectLane(lane, span.startMs, span.endMs, now, mode),
|
||||
[lane, span, now, mode]
|
||||
);
|
||||
const resetCredits = useMemo(
|
||||
() => projectResetCredits(lane, span.startMs, span.endMs, now),
|
||||
[lane, span, now]
|
||||
);
|
||||
|
||||
const colorSet = TYPE_COLORS[lane.provider] || TYPE_COLORS.unknown;
|
||||
const color: ThemeColors =
|
||||
@@ -399,6 +424,36 @@ function Lane({ lane, span, now, mode, cells, nowPercent, resolvedTheme }: LaneP
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{resetCredits.map((credit, index) => {
|
||||
const grantedLabel = t('quota_management.windows_credit_granted', {
|
||||
defaultValue: 'Granted',
|
||||
});
|
||||
const expiresLabel = t('quota_management.windows_credit_expires', {
|
||||
defaultValue: 'Expires',
|
||||
});
|
||||
const title = [
|
||||
t('quota_management.windows_reset_credit', { defaultValue: 'Manual reset' }),
|
||||
credit.grantedAtMs !== null
|
||||
? `${grantedLabel}: ${formatDay(credit.grantedAtMs)} ${formatTime(credit.grantedAtMs)}`
|
||||
: null,
|
||||
`${expiresLabel}: ${formatDay(credit.expiresAtMs)} ${formatTime(credit.expiresAtMs)}`,
|
||||
formatRelativeTime(credit.expiresAtMs, now, i18n.resolvedLanguage),
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
<span
|
||||
key={credit.id || `${credit.expiresAtMs}-${index}`}
|
||||
className={styles.resetCreditTick}
|
||||
style={{ left: `${credit.leftPercent}%` }}
|
||||
title={title}
|
||||
role="img"
|
||||
aria-label={title.split('\n').join(', ')}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -33,6 +33,18 @@ export interface TimelineLimit {
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
/** A manual quota-reset credit attached to a Codex credential. */
|
||||
export interface TimelineResetCredit {
|
||||
id: string;
|
||||
grantedAtMs: number | null;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
/** A reset-credit expiry projected onto the visible span. */
|
||||
export interface TimelineResetCreditMark extends TimelineResetCredit {
|
||||
leftPercent: number;
|
||||
}
|
||||
|
||||
/** One credential's row in the chart. */
|
||||
export interface TimelineLane {
|
||||
name: string;
|
||||
@@ -45,6 +57,7 @@ export interface TimelineLane {
|
||||
/** Remaining percent reported for the window ending at `anchorMs`. */
|
||||
remaining: number | null;
|
||||
limits: TimelineLimit[];
|
||||
resetCredits: TimelineResetCredit[];
|
||||
}
|
||||
|
||||
/** One drawn bar: a single window occurrence within the visible span. */
|
||||
@@ -174,6 +187,29 @@ export function projectLane(
|
||||
.filter((window): window is TimelineWindow => window !== null);
|
||||
}
|
||||
|
||||
/** Project unexpired reset-credit expiry instants onto the visible span. */
|
||||
export function projectResetCredits(
|
||||
lane: TimelineLane,
|
||||
spanStartMs: number,
|
||||
spanEndMs: number,
|
||||
now: number
|
||||
): TimelineResetCreditMark[] {
|
||||
const span = spanEndMs - spanStartMs;
|
||||
if (span <= 0) return [];
|
||||
|
||||
return lane.resetCredits
|
||||
.filter(
|
||||
(credit) =>
|
||||
credit.expiresAtMs > now &&
|
||||
credit.expiresAtMs >= spanStartMs &&
|
||||
credit.expiresAtMs < spanEndMs
|
||||
)
|
||||
.map((credit) => ({
|
||||
...credit,
|
||||
leftPercent: ((credit.expiresAtMs - spanStartMs) / span) * 100,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the window a lane is drawn from: the one whose period best fits the
|
||||
* visible span, tie-broken by the soonest reset.
|
||||
@@ -188,10 +224,9 @@ export function projectLane(
|
||||
* With nothing under the bound, the shortest available window is used rather
|
||||
* than drawing nothing.
|
||||
*/
|
||||
export function pickLaneWindow<T extends { resetAtMs?: number | null; periodHours?: number | null }>(
|
||||
windows: readonly T[],
|
||||
maxPeriodHours?: number
|
||||
): T | null {
|
||||
export function pickLaneWindow<
|
||||
T extends { resetAtMs?: number | null; periodHours?: number | null },
|
||||
>(windows: readonly T[], maxPeriodHours?: number): T | null {
|
||||
const usable = windows.filter(
|
||||
(window) => typeof window.resetAtMs === 'number' && Number.isFinite(window.resetAtMs)
|
||||
);
|
||||
@@ -239,6 +274,13 @@ interface WindowLike {
|
||||
periodHours?: number | null;
|
||||
}
|
||||
|
||||
interface ResetCreditLike {
|
||||
id?: string;
|
||||
status?: string;
|
||||
grantedAt?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
interface KimiRowLike {
|
||||
label?: string;
|
||||
labelKey?: string;
|
||||
@@ -301,6 +343,7 @@ export function buildTimelineLane(input: TimelineLaneInput): TimelineLane {
|
||||
periodHours: null,
|
||||
remaining: null,
|
||||
limits: [],
|
||||
resetCredits: [],
|
||||
};
|
||||
|
||||
if (!quota || quota.status !== 'success') return empty;
|
||||
@@ -312,6 +355,24 @@ export function buildTimelineLane(input: TimelineLaneInput): TimelineLane {
|
||||
const chosen = pickLaneWindow(windows, maxPeriodHours);
|
||||
if (!chosen) return empty;
|
||||
|
||||
const resetCredits =
|
||||
provider === 'codex'
|
||||
? ((quota as { rateLimitResetCredits?: ResetCreditLike[] }).rateLimitResetCredits ?? [])
|
||||
.filter((credit) => credit.status === 'available')
|
||||
.map((credit): TimelineResetCredit | null => {
|
||||
const expiresAtMs = new Date(credit.expiresAt ?? '').getTime();
|
||||
if (!Number.isFinite(expiresAtMs)) return null;
|
||||
|
||||
const grantedAtMs = new Date(credit.grantedAt ?? '').getTime();
|
||||
return {
|
||||
id: credit.id ?? '',
|
||||
grantedAtMs: Number.isFinite(grantedAtMs) ? grantedAtMs : null,
|
||||
expiresAtMs,
|
||||
};
|
||||
})
|
||||
.filter((credit): credit is TimelineResetCredit => credit !== null)
|
||||
: [];
|
||||
|
||||
return {
|
||||
...empty,
|
||||
anchorMs: chosen.resetAtMs ?? null,
|
||||
@@ -325,6 +386,7 @@ export function buildTimelineLane(input: TimelineLaneInput): TimelineLane {
|
||||
label: window.label ?? '',
|
||||
remaining: clampPercent(100 - (window.usedPercent as number)),
|
||||
})),
|
||||
resetCredits,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -409,4 +471,3 @@ export function buildTimelineLane(input: TimelineLaneInput): TimelineLane {
|
||||
|
||||
return empty;
|
||||
}
|
||||
|
||||
|
||||
@@ -1172,6 +1172,10 @@
|
||||
"windows_legend_current": "current window",
|
||||
"windows_legend_upcoming": "upcoming",
|
||||
"windows_legend_elapsed": "elapsed",
|
||||
"windows_legend_reset_credit": "manual reset expiry",
|
||||
"windows_reset_credit": "Manual reset",
|
||||
"windows_credit_granted": "Granted",
|
||||
"windows_credit_expires": "Expires",
|
||||
"windows_note_weekly": "Each bar is one full quota window, drawn from when it opened to when it resets. Lanes ending together compete for the same days.",
|
||||
"windows_note_session": "Each bar is one 5-hour window. Only credentials with a window counting down can be projected; the rest stay empty rather than invented.",
|
||||
"weekday_sun": "Sun",
|
||||
|
||||
@@ -1159,6 +1159,10 @@
|
||||
"windows_legend_current": "текущее окно",
|
||||
"windows_legend_upcoming": "предстоящее",
|
||||
"windows_legend_elapsed": "прошедшее",
|
||||
"windows_legend_reset_credit": "срок ручного сброса",
|
||||
"windows_reset_credit": "Ручной сброс",
|
||||
"windows_credit_granted": "Предоставлен",
|
||||
"windows_credit_expires": "Истекает",
|
||||
"windows_note_weekly": "Каждая полоса — одно окно квоты, от открытия до сброса. Окна, заканчивающиеся вместе, конкурируют за одни и те же дни.",
|
||||
"windows_note_session": "Каждая полоса — одно 5-часовое окно. Показаны только учётные данные с активным окном; остальные остаются пустыми.",
|
||||
"weekday_sun": "Вс",
|
||||
|
||||
@@ -1172,6 +1172,10 @@
|
||||
"windows_legend_current": "当前窗口",
|
||||
"windows_legend_upcoming": "即将开始",
|
||||
"windows_legend_elapsed": "已结束",
|
||||
"windows_legend_reset_credit": "主动重置过期",
|
||||
"windows_reset_credit": "主动重置",
|
||||
"windows_credit_granted": "获得时间",
|
||||
"windows_credit_expires": "过期时间",
|
||||
"windows_note_weekly": "每条表示一个完整的配额窗口,从开启到重置。同时结束的窗口会争用相同的日期。",
|
||||
"windows_note_session": "每条表示一个 5 小时窗口。仅显示有正在计时窗口的凭证,其余保持为空。",
|
||||
"weekday_sun": "日",
|
||||
|
||||
@@ -1198,6 +1198,10 @@
|
||||
"windows_legend_current": "目前視窗",
|
||||
"windows_legend_upcoming": "即將開始",
|
||||
"windows_legend_elapsed": "已結束",
|
||||
"windows_legend_reset_credit": "主動重設過期",
|
||||
"windows_reset_credit": "主動重設",
|
||||
"windows_credit_granted": "取得時間",
|
||||
"windows_credit_expires": "過期時間",
|
||||
"windows_note_weekly": "每條表示一個完整的配額視窗,從開啟到重設。同時結束的視窗會競用相同的日期。",
|
||||
"windows_note_session": "每條表示一個 5 小時視窗。僅顯示有正在計時視窗的憑證,其餘保持為空。",
|
||||
"weekday_sun": "日",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
laneHasWindow,
|
||||
pickLaneWindow,
|
||||
projectLane,
|
||||
projectResetCredits,
|
||||
startOfDay,
|
||||
startOfWeek,
|
||||
timelineSpan,
|
||||
@@ -13,8 +14,7 @@ import {
|
||||
} from '../src/features/quota/quotaTimelineModel';
|
||||
import type { TimelineLane } from '../src/features/quota/quotaTimelineModel';
|
||||
|
||||
const at = (y: number, m: number, d: number, h = 0, min = 0) =>
|
||||
new Date(y, m, d, h, min).getTime();
|
||||
const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m, d, h, min).getTime();
|
||||
|
||||
describe('windowsIn', () => {
|
||||
test('projects backwards and forwards from the anchor', () => {
|
||||
@@ -107,6 +107,7 @@ describe('projectLane', () => {
|
||||
periodHours: 24 * 7,
|
||||
remaining: 40,
|
||||
limits: [],
|
||||
resetCredits: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
@@ -170,6 +171,29 @@ describe('projectLane', () => {
|
||||
);
|
||||
expect(windows.some((w) => w.endMs - w.startMs === 5 * HOUR_MS)).toBe(true);
|
||||
});
|
||||
|
||||
test('projects only unexpired reset credits inside the visible span', () => {
|
||||
const now = at(2026, 6, 29, 12);
|
||||
const visibleExpiry = at(2026, 7, 2, 12);
|
||||
const marks = projectResetCredits(
|
||||
lane({
|
||||
resetCredits: [
|
||||
{ id: 'expired', grantedAtMs: null, expiresAtMs: now - HOUR_MS },
|
||||
{ id: 'visible', grantedAtMs: now - DAY_MS, expiresAtMs: visibleExpiry },
|
||||
{ id: 'outside', grantedAtMs: now, expiresAtMs: span.endMs + HOUR_MS },
|
||||
],
|
||||
}),
|
||||
span.startMs,
|
||||
span.endMs,
|
||||
now
|
||||
);
|
||||
|
||||
expect(marks).toHaveLength(1);
|
||||
expect(marks[0].id).toBe('visible');
|
||||
expect(marks[0].leftPercent).toBe(
|
||||
((visibleExpiry - span.startMs) / (span.endMs - span.startMs)) * 100
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickLaneWindow', () => {
|
||||
@@ -230,7 +254,10 @@ describe('buildTimelineLane', () => {
|
||||
|
||||
// Fortnight view: the weekly window, even though the 5-hour resets sooner.
|
||||
const weekly = buildTimelineLane({
|
||||
...base, provider: 'claude', quota, maxPeriodHours: 14 * 24,
|
||||
...base,
|
||||
provider: 'claude',
|
||||
quota,
|
||||
maxPeriodHours: 14 * 24,
|
||||
});
|
||||
expect(weekly.anchorMs).toBe(later);
|
||||
expect(weekly.periodHours).toBe(168);
|
||||
@@ -238,7 +265,10 @@ describe('buildTimelineLane', () => {
|
||||
|
||||
// Three-day view: the weekly window doesn't fit, so the short one is used.
|
||||
const session = buildTimelineLane({
|
||||
...base, provider: 'claude', quota, maxPeriodHours: 3 * 24,
|
||||
...base,
|
||||
provider: 'claude',
|
||||
quota,
|
||||
maxPeriodHours: 3 * 24,
|
||||
});
|
||||
expect(session.anchorMs).toBe(soon);
|
||||
expect(session.periodHours).toBe(5);
|
||||
@@ -251,6 +281,41 @@ describe('buildTimelineLane', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('codex: includes available reset credits with parseable expiry dates', () => {
|
||||
const expiresAt = '2026-08-02T12:00:00Z';
|
||||
const lane = buildTimelineLane({
|
||||
...base,
|
||||
provider: 'codex',
|
||||
quota: {
|
||||
status: 'success',
|
||||
windows: [{ label: '7-day', usedPercent: 90, resetAtMs: 5000, periodHours: 168 }],
|
||||
rateLimitResetCredits: [
|
||||
{
|
||||
id: 'credit-1',
|
||||
status: 'available',
|
||||
grantedAt: '2026-07-01T12:00:00Z',
|
||||
expiresAt,
|
||||
},
|
||||
{
|
||||
id: 'spent',
|
||||
status: 'consumed',
|
||||
grantedAt: '2026-07-01T12:00:00Z',
|
||||
expiresAt,
|
||||
},
|
||||
{ id: 'invalid', status: 'available', grantedAt: '', expiresAt: 'not-a-date' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(lane.resetCredits).toEqual([
|
||||
{
|
||||
id: 'credit-1',
|
||||
grantedAtMs: new Date('2026-07-01T12:00:00Z').getTime(),
|
||||
expiresAtMs: new Date(expiresAt).getTime(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('kimi: derives remaining from raw used/limit counts', () => {
|
||||
const lane = buildTimelineLane({
|
||||
...base,
|
||||
@@ -381,9 +446,9 @@ describe('buildTimelineLane', () => {
|
||||
});
|
||||
expect(laneHasWindow(drawable)).toBe(true);
|
||||
// Unloaded quota has nothing to show yet, so it takes no row.
|
||||
expect(laneHasWindow(buildTimelineLane({ ...base, provider: 'claude', quota: undefined }))).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
laneHasWindow(buildTimelineLane({ ...base, provider: 'claude', quota: undefined }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('providers with no usable reset produce an empty lane, not a dropped one', () => {
|
||||
@@ -402,7 +467,9 @@ describe('buildTimelineLane', () => {
|
||||
});
|
||||
|
||||
test('unloaded or errored quota produces an empty lane', () => {
|
||||
expect(buildTimelineLane({ ...base, provider: 'claude', quota: undefined }).anchorMs).toBeNull();
|
||||
expect(
|
||||
buildTimelineLane({ ...base, provider: 'claude', quota: undefined }).anchorMs
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildTimelineLane({ ...base, provider: 'claude', quota: { status: 'error' } }).anchorMs
|
||||
).toBeNull();
|
||||
|
||||
@@ -86,6 +86,44 @@ describe('QuotaTimeline rendering', () => {
|
||||
expect(markup).not.toContain('role="status"');
|
||||
});
|
||||
|
||||
test('renders an unexpired Codex reset credit as an expiry tick', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(QuotaTimeline, {
|
||||
entries: [
|
||||
{
|
||||
file: { name: 'codex-credit.json', type: 'codex' },
|
||||
type: 'codex',
|
||||
},
|
||||
],
|
||||
displayNameFor: (name: string) => name,
|
||||
resolvedTheme: 'light',
|
||||
now: new Date(2026, 6, 29, 12).getTime(),
|
||||
quotaFor: () => ({
|
||||
status: 'success',
|
||||
windows: [
|
||||
{
|
||||
label: '7-day',
|
||||
usedPercent: 90,
|
||||
resetAtMs: new Date(2026, 7, 1, 12).getTime(),
|
||||
periodHours: 168,
|
||||
},
|
||||
],
|
||||
rateLimitResetCredits: [
|
||||
{
|
||||
id: 'credit-1',
|
||||
status: 'available',
|
||||
grantedAt: '2026-07-20T12:00:00Z',
|
||||
expiresAt: '2026-08-03T12:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
expect(markup).toContain('role="img"');
|
||||
expect(markup).toContain('08/03 12:00');
|
||||
});
|
||||
|
||||
test('stays hidden before any credential exposes a usable quota window', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(QuotaTimeline, {
|
||||
|
||||
Reference in New Issue
Block a user