feat: show live Codex usage status in dashboard
Store Codex wham/usage live plan and rate-limit state during account refresh, surface safe dashboard badges, and keep JWT subscription expiry as stale fallback only. Redacts balance and spend limit amounts from API/UI exposure.
This commit is contained in:
@@ -20,6 +20,12 @@ import {
|
||||
updateFastMode,
|
||||
updateModels,
|
||||
} from './api';
|
||||
import {
|
||||
formatDateTime,
|
||||
formatJwtCacheExpiry,
|
||||
formatLiveStatusBadge,
|
||||
formatUsageBadge,
|
||||
} from './codexAccountBadges';
|
||||
import { type Locale, type Messages } from './i18n';
|
||||
import { MoaSettingsPanel } from './MoaSettingsPanel';
|
||||
import { RuntimeInventorySettings } from './RuntimeInventorySettings';
|
||||
@@ -410,37 +416,6 @@ function CodexFeatureSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
function formatExpiry(
|
||||
iso: string | null,
|
||||
): { label: string; cls: string } | null {
|
||||
if (!iso) return null;
|
||||
const dt = new Date(iso);
|
||||
if (Number.isNaN(dt.getTime())) return null;
|
||||
const days = (dt.getTime() - Date.now()) / 86400000;
|
||||
const dateStr = dt.toLocaleDateString('ko-KR', {
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
if (days < 0) {
|
||||
const ago = Math.ceil(-days);
|
||||
return {
|
||||
label: `결제 만료 ${dateStr} (${ago}일 전)`,
|
||||
cls: 'is-expired',
|
||||
};
|
||||
}
|
||||
if (days < 7) {
|
||||
return {
|
||||
label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`,
|
||||
cls: 'is-soon',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`,
|
||||
cls: 'is-active',
|
||||
};
|
||||
}
|
||||
|
||||
function AccountSettings() {
|
||||
const [data, setData] = useState<AccountData | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -702,8 +677,9 @@ function CodexAccounts({
|
||||
</ul>
|
||||
)}
|
||||
<p className="settings-hint">
|
||||
OAuth 토큰은 6시간마다 자동 갱신됩니다. plan 변경/해지가 즉시 반영되게
|
||||
하려면 수동으로 “전체 갱신”을 누르세요.
|
||||
“갱신”은 chatgpt.com wham/usage live plan·rate limit·지출 제한을
|
||||
저장합니다. JWT 만료일 캐시는 live 값이 없을 때만 fallback으로
|
||||
표시합니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -726,7 +702,14 @@ function CodexAccountRow({
|
||||
onSwitch: (index: number) => void;
|
||||
perRowBusy: string | null;
|
||||
}) {
|
||||
const expiry = formatExpiry(acc.subscriptionUntil);
|
||||
const liveBadge = formatLiveStatusBadge(acc.liveStatus);
|
||||
const usageBadge = formatUsageBadge(acc.liveStatus);
|
||||
const cachedExpiry = acc.liveStatus
|
||||
? null
|
||||
: formatJwtCacheExpiry(acc.subscriptionUntil);
|
||||
const checkedAt = acc.subscriptionLastChecked
|
||||
? formatDateTime(acc.subscriptionLastChecked)
|
||||
: null;
|
||||
const refreshing = perRowBusy === `refresh:${acc.index}`;
|
||||
const switching = perRowBusy === `switch:${acc.index}`;
|
||||
|
||||
@@ -746,16 +729,39 @@ function CodexAccountRow({
|
||||
<span className="settings-account-plan">
|
||||
{acc.planType ?? 'unknown'}
|
||||
</span>
|
||||
{expiry ? (
|
||||
{liveBadge ? (
|
||||
<span
|
||||
className={`settings-account-badge ${expiry.cls}`}
|
||||
className={`settings-account-badge ${liveBadge.cls}`}
|
||||
title={liveBadge.title}
|
||||
>
|
||||
{liveBadge.label}
|
||||
</span>
|
||||
) : cachedExpiry ? (
|
||||
<span
|
||||
className={`settings-account-badge ${cachedExpiry.cls}`}
|
||||
title={cachedExpiry.title}
|
||||
>
|
||||
{cachedExpiry.label}
|
||||
</span>
|
||||
) : null}
|
||||
{usageBadge ? (
|
||||
<span
|
||||
className="settings-account-badge is-muted"
|
||||
title={usageBadge.title}
|
||||
>
|
||||
{usageBadge.label}
|
||||
</span>
|
||||
) : null}
|
||||
{checkedAt ? (
|
||||
<span
|
||||
className="settings-account-badge is-muted"
|
||||
title={
|
||||
acc.subscriptionLastChecked
|
||||
? `last checked: ${acc.subscriptionLastChecked.slice(0, 10)}`
|
||||
: undefined
|
||||
acc.liveStatus
|
||||
? 'wham/usage live checked_at'
|
||||
: 'JWT subscription_last_checked cache'
|
||||
}
|
||||
>
|
||||
{expiry.label}
|
||||
갱신 {checkedAt}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -764,7 +770,7 @@ function CodexAccountRow({
|
||||
className="settings-secondary"
|
||||
disabled={busy || perRowBusy !== null}
|
||||
onClick={() => onRefresh(acc.index)}
|
||||
title="OAuth 토큰을 다시 받아 구독 상태를 갱신합니다"
|
||||
title="OAuth 토큰을 다시 받고 wham/usage live plan·limit 상태를 갱신합니다"
|
||||
type="button"
|
||||
>
|
||||
{refreshing ? '갱신중…' : '갱신'}
|
||||
|
||||
@@ -362,6 +362,48 @@ export interface ClaudeAccountSummary {
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export interface CodexRateLimitWindowSummary {
|
||||
limitWindowSeconds: number | null;
|
||||
resetAfterSeconds: number | null;
|
||||
resetAt: string | null;
|
||||
usedPercent: number | null;
|
||||
}
|
||||
|
||||
export interface CodexRateLimitSummary {
|
||||
allowed: boolean | null;
|
||||
limitReached: boolean | null;
|
||||
primaryWindow: CodexRateLimitWindowSummary | null;
|
||||
secondaryWindow: CodexRateLimitWindowSummary | null;
|
||||
}
|
||||
|
||||
export interface CodexAdditionalRateLimitSummary {
|
||||
limitName: string | null;
|
||||
meteredFeature: string | null;
|
||||
rateLimit: CodexRateLimitSummary | null;
|
||||
}
|
||||
|
||||
export interface CodexCreditsSummary {
|
||||
hasCredits: boolean | null;
|
||||
overageLimitReached: boolean | null;
|
||||
unlimited: boolean | null;
|
||||
}
|
||||
|
||||
export interface CodexSpendControlSummary {
|
||||
reached: boolean | null;
|
||||
}
|
||||
|
||||
export interface CodexLiveStatusSummary {
|
||||
checkedAt: string;
|
||||
source: 'wham/usage';
|
||||
planType: string | null;
|
||||
email: string | null;
|
||||
rateLimit: CodexRateLimitSummary | null;
|
||||
rateLimitReachedType: string | null;
|
||||
additionalRateLimits: CodexAdditionalRateLimitSummary[];
|
||||
credits: CodexCreditsSummary | null;
|
||||
spendControl: CodexSpendControlSummary | null;
|
||||
}
|
||||
|
||||
export interface CodexAccountSummary {
|
||||
index: number;
|
||||
accountId: string | null;
|
||||
@@ -369,6 +411,8 @@ export interface CodexAccountSummary {
|
||||
planType: string | null;
|
||||
subscriptionUntil: string | null;
|
||||
subscriptionLastChecked: string | null;
|
||||
subscriptionSource: 'jwt-cache' | null;
|
||||
liveStatus: CodexLiveStatusSummary | null;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
|
||||
120
apps/dashboard/src/codexAccountBadges.ts
Normal file
120
apps/dashboard/src/codexAccountBadges.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
type CodexLiveStatusSummary,
|
||||
type CodexRateLimitSummary,
|
||||
type CodexRateLimitWindowSummary,
|
||||
} from './api';
|
||||
|
||||
export function formatDateTime(iso: string): string | null {
|
||||
const dt = new Date(iso);
|
||||
if (Number.isNaN(dt.getTime())) return null;
|
||||
return dt.toLocaleString('ko-KR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatJwtCacheExpiry(
|
||||
iso: string | null,
|
||||
): { label: string; cls: string; title: string } | null {
|
||||
if (!iso) return null;
|
||||
const dt = new Date(iso);
|
||||
if (Number.isNaN(dt.getTime())) return null;
|
||||
const days = (dt.getTime() - Date.now()) / 86400000;
|
||||
const dateStr = dt.toLocaleDateString('ko-KR', {
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
const suffix =
|
||||
days < 0 ? `${Math.ceil(-days)}일 전` : `${Math.floor(days)}일 남음`;
|
||||
return {
|
||||
label: `JWT 캐시 ${dateStr}`,
|
||||
cls: 'is-stale',
|
||||
title: `OpenAI/Auth0 JWT 캐시 만료일입니다. live wham/usage 갱신값이 있으면 이 값은 표시하지 않습니다. (${suffix})`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatWindowName(seconds: number | null | undefined): string {
|
||||
if (!seconds || !Number.isFinite(seconds)) return 'limit';
|
||||
if (seconds % 86400 === 0) return `${Math.round(seconds / 86400)}d`;
|
||||
if (seconds % 3600 === 0) return `${Math.round(seconds / 3600)}h`;
|
||||
if (seconds % 60 === 0) return `${Math.round(seconds / 60)}m`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null | undefined): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '?%';
|
||||
return `${Math.round(value)}%`;
|
||||
}
|
||||
|
||||
function formatRateLimitWindow(
|
||||
window: CodexRateLimitWindowSummary | null,
|
||||
): string | null {
|
||||
if (!window) return null;
|
||||
return `${formatWindowName(window.limitWindowSeconds)} ${formatPercent(window.usedPercent)}`;
|
||||
}
|
||||
|
||||
export function formatLiveStatusBadge(
|
||||
live: CodexLiveStatusSummary | null,
|
||||
): { label: string; cls: string; title: string } | null {
|
||||
if (!live) return null;
|
||||
const limits = [
|
||||
live.rateLimit,
|
||||
...live.additionalRateLimits.map((limit) => limit.rateLimit),
|
||||
].filter((limit): limit is CodexRateLimitSummary => limit !== null);
|
||||
const anyReached = limits.some((limit) => limit.limitReached === true);
|
||||
const anyBlocked = limits.some((limit) => limit.allowed === false);
|
||||
const checkedAt = formatDateTime(live.checkedAt) ?? live.checkedAt;
|
||||
if (anyReached || anyBlocked || live.rateLimitReachedType) {
|
||||
return {
|
||||
label: 'live 제한 도달',
|
||||
cls: 'is-expired',
|
||||
title: `wham/usage live 확인: ${checkedAt}${
|
||||
live.rateLimitReachedType ? ` · ${live.rateLimitReachedType}` : ''
|
||||
}`,
|
||||
};
|
||||
}
|
||||
if (live.spendControl?.reached || live.credits?.overageLimitReached) {
|
||||
return {
|
||||
label: 'live 지출 제한',
|
||||
cls: 'is-expired',
|
||||
title: `wham/usage live 확인: ${checkedAt}`,
|
||||
};
|
||||
}
|
||||
if (limits.some((limit) => limit.allowed === true)) {
|
||||
return {
|
||||
label: 'live 사용 가능',
|
||||
cls: 'is-active',
|
||||
title: `wham/usage live 확인: ${checkedAt}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: 'live 확인됨',
|
||||
cls: 'is-soon',
|
||||
title: `wham/usage live 확인: ${checkedAt}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatUsageBadge(
|
||||
live: CodexLiveStatusSummary | null,
|
||||
): { label: string; title: string } | null {
|
||||
if (!live?.rateLimit) return null;
|
||||
const primary = formatRateLimitWindow(live.rateLimit.primaryWindow);
|
||||
const secondary = formatRateLimitWindow(live.rateLimit.secondaryWindow);
|
||||
const label = [primary, secondary].filter(Boolean).join(' · ');
|
||||
if (!label) return null;
|
||||
const resetParts = [
|
||||
live.rateLimit.primaryWindow?.resetAt
|
||||
? `primary reset ${formatDateTime(live.rateLimit.primaryWindow.resetAt) ?? live.rateLimit.primaryWindow.resetAt}`
|
||||
: null,
|
||||
live.rateLimit.secondaryWindow?.resetAt
|
||||
? `secondary reset ${formatDateTime(live.rateLimit.secondaryWindow.resetAt) ?? live.rateLimit.secondaryWindow.resetAt}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
return {
|
||||
label,
|
||||
title: resetParts.join(' · ') || 'wham/usage live rate_limit',
|
||||
};
|
||||
}
|
||||
@@ -1127,6 +1127,16 @@ button:disabled {
|
||||
color: var(--status-critical, #e0644b);
|
||||
}
|
||||
|
||||
.settings-account-badge.is-muted {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--muted-soft);
|
||||
}
|
||||
|
||||
.settings-account-badge.is-stale {
|
||||
background: rgba(148, 169, 210, 0.14);
|
||||
color: var(--muted-soft);
|
||||
}
|
||||
|
||||
.settings-account-meta {
|
||||
font-size: 12px;
|
||||
color: var(--bg-ink);
|
||||
|
||||
Reference in New Issue
Block a user