mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 02:16:27 +00:00
Merge remote-tracking branch 'origin/dev' into codex/fix-logs-filtering-to-show-dashboard-audits
This commit is contained in:
commit
3d9636351e
156 files changed
+4169
-625
No files matched your search
@@ -29,7 +29,7 @@ import { Loader2, ExternalLink, User, Download, Copy, Check, ShieldAlert } from
|
||||
import { useKiroImport } from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
|
||||
import { applyDefaultPreset } from '@/lib/preset-utils';
|
||||
import type { CliproxyProviderCatalog } from '@/lib/api-client';
|
||||
import type { CliproxyProviderCatalog, OAuthAccount } from '@/lib/api-client';
|
||||
import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card';
|
||||
import { AntigravityResponsibilityChecklist } from '@/components/account/antigravity-responsibility-checklist';
|
||||
import {
|
||||
@@ -58,6 +58,8 @@ interface AddAccountDialogProps {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
catalog?: CliproxyProviderCatalog;
|
||||
/** Existing account to reauthenticate instead of adding a new account. */
|
||||
account?: OAuthAccount;
|
||||
/** Whether this is the first account being added (shows different toast message) */
|
||||
isFirstAccount?: boolean;
|
||||
}
|
||||
@@ -77,6 +79,7 @@ export function AddAccountDialog({
|
||||
provider,
|
||||
displayName,
|
||||
catalog,
|
||||
account,
|
||||
isFirstAccount = false,
|
||||
}: AddAccountDialogProps) {
|
||||
const [nickname, setNickname] = useState('');
|
||||
@@ -127,6 +130,8 @@ export function AddAccountDialog({
|
||||
const gitlabBaseUrlTrimmed = gitlabBaseUrl.trim();
|
||||
const gitlabPersonalAccessTokenTrimmed = gitlabPersonalAccessToken.trim();
|
||||
const errorMessage = localError || authFlow.error;
|
||||
const isReauth = Boolean(account);
|
||||
const accountLabel = account?.email || account?.nickname || account?.id || displayName;
|
||||
|
||||
const fetchPowerUserModeState = useCallback(async (): Promise<boolean> => {
|
||||
const response = await fetch('/api/settings/auth/antigravity-risk');
|
||||
@@ -325,7 +330,8 @@ export function AddAccountDialog({
|
||||
}
|
||||
wasAuthenticatingRef.current = true;
|
||||
authFlow.startAuth(provider, {
|
||||
nickname: nicknameTrimmed || undefined,
|
||||
accountId: account?.id,
|
||||
nickname: account ? account.nickname : nicknameTrimmed || undefined,
|
||||
kiroMethod: isKiro ? kiroAuthMethod : undefined,
|
||||
kiroIDCStartUrl: isKiroIdc ? kiroIDCStartUrlTrimmed : undefined,
|
||||
kiroIDCRegion: isKiroIdc && kiroIDCRegionTrimmed ? kiroIDCRegionTrimmed : undefined,
|
||||
@@ -385,13 +391,19 @@ export function AddAccountDialog({
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="min-w-0 px-6 pt-6 pr-12">
|
||||
<DialogTitle>{t('addAccountDialog.title', { displayName })}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{isReauth
|
||||
? `Reauthenticate ${displayName}`
|
||||
: t('addAccountDialog.title', { displayName })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isKiro
|
||||
? t('addAccountDialog.descKiro')
|
||||
: isDeviceCode
|
||||
? t('addAccountDialog.descDeviceCode')
|
||||
: t('addAccountDialog.descOauth')}
|
||||
{isReauth
|
||||
? `Refresh credentials for ${accountLabel}.`
|
||||
: isKiro
|
||||
? t('addAccountDialog.descKiro')
|
||||
: isDeviceCode
|
||||
? t('addAccountDialog.descDeviceCode')
|
||||
: t('addAccountDialog.descOauth')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -600,7 +612,16 @@ export function AddAccountDialog({
|
||||
)}
|
||||
|
||||
{/* Nickname input - only show before auth starts */}
|
||||
{!showAuthUI && (
|
||||
{!showAuthUI && isReauth && (
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||
<div className="font-medium">{accountLabel}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
This sign-in updates the selected account instead of creating another account.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showAuthUI && !isReauth && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nickname">{t('addAccountDialog.nicknameOptional')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -784,7 +805,7 @@ export function AddAccountDialog({
|
||||
}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
{t('addAccountDialog.authenticate')}
|
||||
{isReauth ? 'Reauthenticate' : t('addAccountDialog.authenticate')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,10 @@ function clearLocalControlPanelSession(): void {
|
||||
window.localStorage.removeItem(CONTROL_PANEL_LOGIN_FLAG_KEY);
|
||||
}
|
||||
|
||||
function clearPersistedLocalControlPanelSecret(): void {
|
||||
window.localStorage.removeItem(CONTROL_PANEL_MANAGEMENT_KEY);
|
||||
}
|
||||
|
||||
export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) {
|
||||
const { t } = useTranslation();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
@@ -162,7 +166,13 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
return;
|
||||
}
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
clearLocalControlPanelSession();
|
||||
};
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
clearLocalControlPanelSession();
|
||||
};
|
||||
}, [isRemote]);
|
||||
@@ -266,9 +276,15 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
const handleIframeLoad = () => {
|
||||
setLoadedFrameKey(iframeKey);
|
||||
postRemoteAutoLoginCredentials();
|
||||
if (!isRemote) {
|
||||
clearPersistedLocalControlPanelSecret();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (!isRemote) {
|
||||
clearLocalControlPanelSession();
|
||||
}
|
||||
setLoadedFrameKey(null);
|
||||
setIframeRevision((value) => value + 1);
|
||||
setError(null);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Star,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
@@ -87,6 +88,7 @@ export function AccountItem({
|
||||
account,
|
||||
onSetDefault,
|
||||
onRemove,
|
||||
onReauth,
|
||||
onPauseToggle,
|
||||
isRemoving,
|
||||
isPausingAccount,
|
||||
@@ -167,6 +169,12 @@ export function AccountItem({
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onReauth && (
|
||||
<DropdownMenuItem onClick={onReauth}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reauthenticate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onRemove}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next';
|
||||
interface AccountsSectionProps {
|
||||
accounts: OAuthAccount[];
|
||||
onAddAccount: () => void;
|
||||
onReauthAccount?: (account: OAuthAccount) => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
@@ -48,6 +49,7 @@ interface AccountsSectionProps {
|
||||
export function AccountsSection({
|
||||
accounts,
|
||||
onAddAccount,
|
||||
onReauthAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
onPauseToggle,
|
||||
@@ -175,6 +177,7 @@ export function AccountsSection({
|
||||
account={account}
|
||||
onSetDefault={() => onSetDefault(account.id)}
|
||||
onRemove={() => onRemoveAccount(account.id)}
|
||||
onReauth={onReauthAccount ? () => onReauthAccount(account) : undefined}
|
||||
onPauseToggle={
|
||||
onPauseToggle ? (paused) => onPauseToggle(account.id, paused) : undefined
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export function ProviderEditor({
|
||||
defaultTarget,
|
||||
topNotice,
|
||||
onAddAccount,
|
||||
onReauthAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
onPauseToggle,
|
||||
@@ -92,6 +93,7 @@ export function ProviderEditor({
|
||||
qwen: ['alibaba', 'qwen'],
|
||||
iflow: ['iflow'],
|
||||
kilo: ['kilo'],
|
||||
qoder: ['qoder'],
|
||||
kiro: ['kiro', 'aws'],
|
||||
ghcp: ['github', 'copilot'],
|
||||
kimi: ['kimi', 'moonshot'],
|
||||
@@ -282,6 +284,7 @@ export function ProviderEditor({
|
||||
isDeletePending={deletePresetMutation.isPending}
|
||||
accounts={accounts}
|
||||
onAddAccount={onAddAccount}
|
||||
onReauthAccount={onReauthAccount}
|
||||
onSetDefault={onSetDefault}
|
||||
onRemoveAccount={onRemoveAccount}
|
||||
onPauseToggle={onPauseToggle}
|
||||
|
||||
@@ -40,6 +40,7 @@ interface ModelConfigTabProps {
|
||||
isDeletePending?: boolean;
|
||||
accounts: OAuthAccount[];
|
||||
onAddAccount: () => void;
|
||||
onReauthAccount?: (account: OAuthAccount) => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
@@ -82,6 +83,7 @@ export function ModelConfigTab({
|
||||
isDeletePending,
|
||||
accounts,
|
||||
onAddAccount,
|
||||
onReauthAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
onPauseToggle,
|
||||
@@ -176,6 +178,7 @@ export function ModelConfigTab({
|
||||
<AccountsSection
|
||||
accounts={accounts}
|
||||
onAddAccount={onAddAccount}
|
||||
onReauthAccount={onReauthAccount}
|
||||
onSetDefault={onSetDefault}
|
||||
onRemoveAccount={onRemoveAccount}
|
||||
onPauseToggle={onPauseToggle}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface ProviderEditorProps {
|
||||
/** Optional contextual notice shown directly under the editor header */
|
||||
topNotice?: ReactNode;
|
||||
onAddAccount: () => void;
|
||||
onReauthAccount?: (account: OAuthAccount) => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
@@ -63,6 +64,7 @@ export interface AccountItemProps {
|
||||
account: OAuthAccount;
|
||||
onSetDefault: () => void;
|
||||
onRemove: () => void;
|
||||
onReauth?: () => void;
|
||||
onPauseToggle?: (paused: boolean) => void;
|
||||
/** Solo mode: activate this account, pause all others */
|
||||
onSoloMode?: () => void;
|
||||
|
||||
@@ -17,20 +17,30 @@ export interface LeafItem {
|
||||
|
||||
export type DerivedItem = LeafItem | TraceGroup;
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === undefined) return '';
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
|
||||
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuple key for coalescing standalone leaves. Includes `message` and
|
||||
* `source` so two adjacent logs that share event/module/level but report
|
||||
* different content (or come from a different service) stay distinct
|
||||
* (e.g. `User logged in: alice` and `User logged in: bob`). Excludes
|
||||
* `latencyMs` and `metadata` because those drift per request even on
|
||||
* truly redundant polls — including them would prevent any coalescing.
|
||||
* Tuple key for coalescing standalone leaves. The row/detail UI exposes more
|
||||
* than event/module/level, so every inspectable payload field must participate
|
||||
* in equality; otherwise two adjacent logs with different metadata, context,
|
||||
* error, or latency could collapse into one selectable row. `timestamp` stays
|
||||
* out of the key because collapsed rows surface their time span separately.
|
||||
*
|
||||
* NB: this only applies to *leaves* (entries without `requestId`). Trace
|
||||
* children render uncoalesced so retries and duplicated-stage emissions
|
||||
* stay individually inspectable.
|
||||
*/
|
||||
function coalesceKey(entry: LogsEntry): string {
|
||||
return [
|
||||
return JSON.stringify([
|
||||
entry.event ?? '',
|
||||
entry.message ?? '',
|
||||
entry.stage ?? '',
|
||||
@@ -38,7 +48,13 @@ function coalesceKey(entry: LogsEntry): string {
|
||||
entry.level,
|
||||
entry.requestId ?? '',
|
||||
entry.source ?? '',
|
||||
].join(' ');
|
||||
entry.runId ?? '',
|
||||
String(entry.processId ?? ''),
|
||||
String(entry.latencyMs ?? ''),
|
||||
stableStringify(entry.context),
|
||||
stableStringify(entry.metadata),
|
||||
stableStringify(entry.error),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import { LogLevelBadge } from './log-level-badge';
|
||||
import { LogsEmpty } from './logs-empty';
|
||||
import {
|
||||
formatJson,
|
||||
formatLogTimestampIso,
|
||||
getDisplayLatency,
|
||||
getDisplayModule,
|
||||
getDisplayRequestId,
|
||||
@@ -34,7 +35,7 @@ interface OverviewRow {
|
||||
function buildOverviewRows(entry: LogsEntry, sourceLabel?: string): OverviewRow[] {
|
||||
// Use shared accessors so this panel and the list row never diverge.
|
||||
return [
|
||||
{ label: 'Time', value: new Date(entry.timestamp).toISOString(), mono: true },
|
||||
{ label: 'Time', value: formatLogTimestampIso(entry.timestamp), mono: true },
|
||||
{ label: 'Level', value: entry.level },
|
||||
{ label: 'Module', value: getDisplayModule(entry, sourceLabel) },
|
||||
{ label: 'Stage', value: getDisplayStage(entry) },
|
||||
|
||||
@@ -21,6 +21,19 @@ export function formatLogTimestamp(timestamp: string | null | undefined) {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatLogTimestampIso(timestamp: string | null | undefined) {
|
||||
if (!timestamp) {
|
||||
return 'No activity yet';
|
||||
}
|
||||
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export function formatRelativeLogTime(timestamp: string | null | undefined) {
|
||||
if (!timestamp) {
|
||||
return 'No activity yet';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
|
||||
import { buildAccountVisualGroups } from '@/lib/account-visual-groups';
|
||||
import { getProviderDisplayName } from '@/lib/provider-config';
|
||||
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||
@@ -26,14 +26,25 @@ export interface AuthMonitorData {
|
||||
/** Hook for computing auth monitor data from CLIProxy auth and stats */
|
||||
export function useAuthMonitorData(): AuthMonitorData {
|
||||
const { data, isLoading, error } = useCliproxyAuth();
|
||||
const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats();
|
||||
const { data: proxyStatus } = useCliproxyStatus();
|
||||
const statsEnabled = proxyStatus?.running === true;
|
||||
const {
|
||||
data: statsData,
|
||||
isLoading: statsLoading,
|
||||
dataUpdatedAt,
|
||||
} = useCliproxyStats(statsEnabled);
|
||||
const activeStatsData = statsEnabled ? statsData : undefined;
|
||||
const activeStatsUpdatedAt = statsEnabled ? dataUpdatedAt : 0;
|
||||
const [timeSinceUpdate, setTimeSinceUpdate] = useState('');
|
||||
|
||||
// Live countdown showing time since last data update
|
||||
useEffect(() => {
|
||||
if (!dataUpdatedAt) return;
|
||||
if (!activeStatsUpdatedAt) {
|
||||
setTimeSinceUpdate('');
|
||||
return;
|
||||
}
|
||||
const updateTime = () => {
|
||||
const diff = Math.floor((Date.now() - dataUpdatedAt) / 1000);
|
||||
const diff = Math.floor((Date.now() - activeStatsUpdatedAt) / 1000);
|
||||
if (diff < 60) {
|
||||
setTimeSinceUpdate(`${diff}s ago`);
|
||||
} else {
|
||||
@@ -43,7 +54,7 @@ export function useAuthMonitorData(): AuthMonitorData {
|
||||
updateTime();
|
||||
const interval = setInterval(updateTime, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [dataUpdatedAt]);
|
||||
}, [activeStatsUpdatedAt]);
|
||||
|
||||
// Transform auth status data into account rows
|
||||
const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => {
|
||||
@@ -79,7 +90,7 @@ export function useAuthMonitorData(): AuthMonitorData {
|
||||
provider: account.provider || status.provider,
|
||||
}));
|
||||
|
||||
buildAccountVisualGroups(normalizedAccounts, statsData).forEach((groupedAccount) => {
|
||||
buildAccountVisualGroups(normalizedAccounts, activeStatsData).forEach((groupedAccount) => {
|
||||
tSuccess += groupedAccount.successCount;
|
||||
tFailure += groupedAccount.failureCount;
|
||||
providerData.success += groupedAccount.successCount;
|
||||
@@ -131,7 +142,7 @@ export function useAuthMonitorData(): AuthMonitorData {
|
||||
totalRequests: tSuccess + tFailure,
|
||||
providerStats: providerStatsArr,
|
||||
};
|
||||
}, [data?.authStatus, statsData]);
|
||||
}, [data?.authStatus, activeStatsData]);
|
||||
|
||||
const overallSuccessRate =
|
||||
totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100;
|
||||
@@ -143,7 +154,7 @@ export function useAuthMonitorData(): AuthMonitorData {
|
||||
totalRequests,
|
||||
providerStats,
|
||||
overallSuccessRate,
|
||||
isLoading: isLoading || statsLoading,
|
||||
isLoading: isLoading || (statsEnabled && statsLoading),
|
||||
error: error ?? null,
|
||||
timeSinceUpdate,
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
usePauseAccount,
|
||||
useResumeAccount,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react';
|
||||
import { Activity, AlertTriangle, CheckCircle2, XCircle, Radio } from 'lucide-react';
|
||||
|
||||
import { useAuthMonitorData } from './hooks';
|
||||
import { LivePulse } from './components/live-pulse';
|
||||
@@ -86,6 +86,8 @@ export function AuthMonitor() {
|
||||
const displayedSuccessRate = selectedProviderData
|
||||
? getSuccessRate(selectedProviderData.successCount, selectedProviderData.failureCount)
|
||||
: overallSuccessRate;
|
||||
const showHighFailureGuidance =
|
||||
displayedAccountCount >= 5 && displayedFailure >= 20 && displayedSuccessRate < 50;
|
||||
|
||||
const handlePauseToggle = (accountIds: string[], paused: boolean) => {
|
||||
if (
|
||||
@@ -204,6 +206,21 @@ export function AuthMonitor() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showHighFailureGuidance && (
|
||||
<div className="flex items-start gap-3 border-b border-amber-500/30 bg-amber-500/10 px-4 py-3 text-xs">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-foreground">High CLIProxy failure rate detected</p>
|
||||
<p className="text-muted-foreground">
|
||||
Select the affected provider, pause failing accounts, then check{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono">ccs cliproxy routing</code>.
|
||||
For large Codex pools, prefer a smaller healthy active set and attach CLIProxy logs if
|
||||
failures continue.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flow Visualization */}
|
||||
<div className="relative overflow-hidden">
|
||||
{selectedProviderData ? (
|
||||
|
||||
@@ -25,6 +25,7 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [
|
||||
'iflow',
|
||||
'kilo',
|
||||
'kiro',
|
||||
'qoder',
|
||||
];
|
||||
|
||||
export const PROVIDERS: ProviderOption[] = WIZARD_PROVIDER_ORDER.map((id) => ({
|
||||
|
||||
@@ -378,7 +378,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
if (isGeminiQuotaResult(quota)) {
|
||||
const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime);
|
||||
const hasEntitlementTier =
|
||||
!!quota.entitlement?.rawTierLabel || quota.entitlement?.normalizedTier !== 'unknown';
|
||||
!!quota.entitlement?.rawTierLabel ||
|
||||
(!!quota.entitlement && quota.entitlement.normalizedTier !== 'unknown');
|
||||
const distinctTokenTypes = Array.from(
|
||||
new Set(
|
||||
quota.buckets
|
||||
|
||||
Reference in new issue
Block a user