Skip to content
Merged
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
199 changes: 96 additions & 103 deletions app/account-status/account-status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import {
Alert,
Block,
Collapse,
Empty,
Flexbox,
SkeletonButton,
Expand All @@ -15,17 +14,18 @@ import {
Tooltip,
} from '@lobehub/ui';
import { Button } from '@lobehub/ui/base-ui';
import { RefreshCw } from 'lucide-react';
import { Check, Copy, RefreshCw } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';

import type { CredentialSummary } from '@/app/credentials/credentials';

interface AccountStatusProps {
credentials: CredentialSummary[];
initialStatuses?: AccountStatusSnapshot[];
}

interface AccountStatusSnapshot {
export interface AccountStatusSnapshot {
checkin: { claimed: boolean | null; message: string | null };
credits: {
total: number | null;
Expand Down Expand Up @@ -70,39 +70,48 @@ const failedSnapshot = (
});

const quotaPercent = (snapshot: AccountStatusSnapshot): number | null => {
const { total, used } = snapshot.credits;
if (total === null || total <= 0 || used === null) return null;
return Math.min(100, Math.max(0, (used / total) * 100));
const { total, remaining } = snapshot.credits;
if (total === null || total <= 0 || remaining === null) return null;
return Math.min(100, Math.max(0, (remaining / total) * 100));
};

const QuotaProgress = ({
plan,
snapshot,
unknownLabel,
usedLabel,
remainingLabel,
}: {
plan: string;
snapshot: AccountStatusSnapshot;
unknownLabel: string;
usedLabel: (percent: number) => string;
remainingLabel: (percent: number) => string;
}) => {
const percent = quotaPercent(snapshot);
const hasQuota =
snapshot.credits.total !== null && snapshot.credits.total > 0;
const tone =
percent === null
? 'unknown'
: percent >= 100
: percent <= 0
? 'exhausted'
: percent >= 80
: percent <= 20
? 'warning'
: 'normal';
return (
<Flexbox direction="vertical" gap={8}>
<Flexbox align="center" distribution="space-between" horizontal>
<Text>{percent === null ? '—' : `${percent.toFixed(0)}%`}</Text>
<Text type="secondary">
{snapshot.credits.used ?? '—'} / {snapshot.credits.total ?? '—'}
</Text>
<Text strong>{plan}</Text>
<Flexbox align="center" distribution="flex-end" gap={8} horizontal>
<Text type="secondary">
{hasQuota && snapshot.credits.remaining !== null
? `${snapshot.credits.remaining} / ${snapshot.credits.total}`
: '— / —'}
</Text>
<Text>{percent === null ? '—' : `${percent.toFixed(0)}%`}</Text>
</Flexbox>
</Flexbox>
<progress
aria-label={percent === null ? unknownLabel : usedLabel(percent)}
aria-label={percent === null ? unknownLabel : remainingLabel(percent)}
className={`account-status-progress account-status-progress-${tone}`}
max={100}
value={percent ?? 0}
Expand All @@ -111,6 +120,27 @@ const QuotaProgress = ({
);
};

const CopyableModel = ({ model }: { model: string }) => {
const [copied, setCopied] = useState(false);
const text = useTranslations('Admin');
const copy = async () => {
if (!navigator.clipboard) return;
await navigator.clipboard.writeText(model);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
return (
<Tooltip title={copied ? text('common.copy') : text('common.copy')}>
<Tag onClick={() => void copy()}>
<Flexbox align="center" gap={4} horizontal>
{copied ? <Check size={12} /> : <Copy size={12} />}
<span data-model-id={model}>{model}</span>
</Flexbox>
</Tag>
</Tooltip>
);
};

const AccountStatusSkeleton = () => (
<Block direction="vertical" gap={16} padding={20} variant="outlined">
<SkeletonTitle />
Expand All @@ -135,10 +165,7 @@ const AccountStatusCard = ({
onCheckin: () => void;
}) => {
const text = useTranslations('Admin');
const percent = quotaPercent(snapshot);
const quotaUnknown = text('accountStatus.quotaUnknown');
const models = snapshot.models.slice(0, 8);
const hasMoreModels = snapshot.models.length > models.length;
return (
<Block
className={
Expand All @@ -151,52 +178,40 @@ const AccountStatusCard = ({
padding={20}
variant="outlined"
>
<Flexbox direction="vertical" gap={4}>
<Tooltip title={credential.email || credential.user_id}>
<Text strong>{credential.email || credential.user_id}</Text>
</Tooltip>
<Tooltip title={credential.filename}>
<Text ellipsis type="secondary">
{credential.filename}
</Text>
</Tooltip>
<Flexbox align="flex-start" distribution="space-between" horizontal>
<Flexbox direction="vertical" gap={4}>
<Tooltip title={credential.email || credential.user_id}>
<Text strong>{credential.email || credential.user_id}</Text>
</Tooltip>
<Tooltip title={credential.filename}>
<Text ellipsis type="secondary">
{credential.filename}
</Text>
</Tooltip>
</Flexbox>
<Button
aria-label={text('accountStatus.refresh')}
icon={RefreshCw}
loading={busy === 'refresh'}
disabled={Boolean(busy)}
onClick={onRefresh}
>
{text('accountStatus.refresh')}
</Button>
</Flexbox>
{snapshot.error ? <Alert type="error" title={snapshot.error} /> : null}
<Flexbox direction="vertical" gap={8}>
<Text strong>{text('accountStatus.quota')}</Text>
<QuotaProgress
plan={snapshot.credits.plan ?? '—'}
snapshot={snapshot}
unknownLabel={quotaUnknown}
usedLabel={(value) =>
remainingLabel={(value) =>
text('accountStatus.quotaUsed', { percent: value.toFixed(0) })
}
/>
<Flexbox
className="account-status-quota-values"
gap={16}
horizontal
wrap="wrap"
>
<Text type="secondary">
{text('accountStatus.total')}: {snapshot.credits.total ?? '—'}
</Text>
<Text type="secondary">
{text('accountStatus.used')}: {snapshot.credits.used ?? '—'}
</Text>
<Text type="secondary">
{text('accountStatus.remaining')}:{' '}
{snapshot.credits.remaining ?? '—'}
</Text>
</Flexbox>
<Text type="secondary">
{text('accountStatus.plan')}: {snapshot.credits.plan ?? '—'}
</Text>
<Text type="secondary">
{text('accountStatus.resetAt')}: {snapshot.credits.resetAt ?? '—'}
</Text>
{percent !== null && percent >= 100 ? (
<Text type="secondary">{text('accountStatus.exhausted')}</Text>
) : null}
</Flexbox>
<Flexbox align="center" distribution="space-between" horizontal>
<Text type="secondary">
Expand All @@ -217,57 +232,32 @@ const AccountStatusCard = ({
</Flexbox>
<Flexbox direction="vertical" gap={8}>
<Text strong>{text('accountStatus.models')}</Text>
{models.length ? (
<Collapse
items={[
{
key: 'models',
label: hasMoreModels
? text('accountStatus.showModels', {
count: snapshot.models.length,
})
: text('accountStatus.modelCount', {
count: snapshot.models.length,
}),
children: (
<Flexbox gap={8} wrap="wrap">
{snapshot.models.map((model) => (
<Tag key={model}>{model}</Tag>
))}
</Flexbox>
),
},
]}
variant="borderless"
/>
{snapshot.models.length ? (
<Flexbox gap={8} horizontal wrap="wrap">
{snapshot.models.map((model) => (
<CopyableModel key={model} model={model} />
))}
</Flexbox>
) : (
<Text type="secondary">{text('accountStatus.noModels')}</Text>
)}
</Flexbox>
<Flexbox align="center" distribution="space-between" horizontal>
<Text type="secondary">
{snapshot.queriedAt
? new Date(snapshot.queriedAt).toLocaleString()
: '—'}
</Text>
<Button
icon={RefreshCw}
loading={busy === 'refresh'}
disabled={Boolean(busy)}
onClick={onRefresh}
>
{text('accountStatus.refresh')}
</Button>
</Flexbox>
</Block>
);
};

const AccountStatus = ({ credentials }: AccountStatusProps) => {
const AccountStatus = ({
credentials,
initialStatuses = [],
}: AccountStatusProps) => {
const text = useTranslations('Admin');
const [snapshots, setSnapshots] = useState<
Record<string, AccountStatusSnapshot>
>({});
>(() =>
Object.fromEntries(
initialStatuses.map((status) => [status.filename, status]),
),
);
const [busy, setBusy] = useState<Record<string, string>>({});
const [page, setPage] = useState(1);
const [batchBusy, setBatchBusy] = useState<string | null>(null);
Expand All @@ -293,7 +283,13 @@ const AccountStatus = ({ credentials }: AccountStatusProps) => {
} catch (error) {
setSnapshots((current) => ({
...current,
[filename]: current[filename] ?? failedSnapshot(filename, error),
[filename]: {
...(current[filename] ?? failedSnapshot(filename, error)),
error:
error instanceof Error
? error.message
: 'Account status query failed',
},
}));
} finally {
setBusy((current) => {
Expand All @@ -311,21 +307,19 @@ const AccountStatus = ({ credentials }: AccountStatusProps) => {
try {
await Promise.all(
credentials
.filter((credential) => !credential.is_expired)
.filter((credential) => {
if (credential.is_expired) return false;
if (action !== 'checkin') return true;
return snapshots[credential.filename]?.checkin.claimed !== true;
})
.map((credential) => loadOne(credential.filename, action)),
);
} finally {
setBatchBusy(null);
}
},
[credentials, loadOne],
[credentials, loadOne, snapshots],
);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadAll('refresh');
}, 0);
return () => window.clearTimeout(timer);
}, [loadAll]);
const pageCredentials = useMemo(
() =>
credentials.length > 50
Expand All @@ -343,7 +337,6 @@ const AccountStatus = ({ credentials }: AccountStatusProps) => {
horizontal
wrap="wrap"
>
<Text strong>{text('accountStatus.title')}</Text>
<Flexbox gap={8} horizontal>
<Button
loading={batchBusy === 'refresh'}
Expand Down
15 changes: 12 additions & 3 deletions app/account-status/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { AdminPage } from '@/app/page';
import { getAccountStatusCredentials } from '@/lib/server/domain/account-status';
import {
getAccountStatus,
getAccountStatusCredentials,
} from '@/lib/server/domain/account-status';
import AccountStatus from './account-status';

const AccountStatusPage = async () => {
const credentials = await getAccountStatusCredentials();
const [credentials, statuses] = await Promise.all([
getAccountStatusCredentials(),
getAccountStatus(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid blocking page rendering on every upstream status fetch

For authenticated users with slow or unreachable upstreams, awaiting getAccountStatus() prevents any account-status UI from rendering. Each credential sequentially performs quota, check-in, and possibly model requests with 15-second timeouts, while getAccountStatus() processes groups of four serially, so one group can delay the page by roughly 45 seconds and additional groups multiply that delay; load these statuses through streaming/Suspense or after rendering the page shell instead.

Useful? React with 👍 / 👎.

]);
Comment on lines +9 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Authenticate before querying account statuses

When an admin account is configured, an unauthenticated request to /account-status executes this query before <AdminPage> checks the session and redirects in app/page.tsx. getAccountStatus() then sends eligible stored bearer tokens to the upstream quota, check-in, and model endpoints, allowing unauthenticated callers to trigger authenticated upstream traffic and consume server resources; move this loading behind the session check or into an authenticated loader.

Useful? React with 👍 / 👎.


return (
<AdminPage initialTab="account-status">
<AccountStatus credentials={credentials as never} />
<AccountStatus
credentials={credentials as never}
initialStatuses={statuses}
/>
</AdminPage>
);
};
Expand Down
12 changes: 10 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { cookies, headers } from 'next/headers';
import './globals.scss';
import LobeUiProvider from '@/app/lobe-ui-provider';
import LobeStyleRegistry from '@/app/lobe-style-registry';
import { resolveThemeMode, resolvedThemeCookieName } from '@/lib/theme';
import {
parseThemeMode,
resolveThemeMode,
resolvedThemeCookieName,
themeCookieName,
} from '@/lib/theme';
import {
localeCookieName,
localePreferenceCookieName,
Expand Down Expand Up @@ -38,6 +43,9 @@ const RootLayout = async ({
: localePreference,
);
const messages = getMessages(locale);
const themePreference = parseThemeMode(
cookieStore.get(themeCookieName)?.value,
);
const theme = resolveThemeMode(
cookieStore.get(resolvedThemeCookieName)?.value,
);
Expand All @@ -52,7 +60,7 @@ const RootLayout = async ({
<body>
<AntdRegistry>
<LobeStyleRegistry>
<LobeUiProvider initialTheme={theme}>
<LobeUiProvider initialTheme={themePreference}>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
Expand Down
Loading
Loading