feat: Claude usage API + multi-account dashboard display
- Replace expect/CLI hack with direct HTTP API call (GET api.anthropic.com/api/oauth/usage with OAuth token) - Add fetchAllClaudeUsage() for per-token usage fetching - Export getAllTokens() from token-rotation for usage queries - Dashboard shows each account separately (Claude1⚡, Claude2) with ⚡ for active token, 🚫 for rate-limited
This commit is contained in:
@@ -1,53 +1,28 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { parseClaudeUsagePanel } from './claude-usage.js';
|
import type { ClaudeUsageData } from './claude-usage.js';
|
||||||
|
|
||||||
describe('parseClaudeUsagePanel', () => {
|
describe('ClaudeUsageData', () => {
|
||||||
it('parses session and weekly usage from Claude CLI panel output', () => {
|
it('represents the API response structure correctly', () => {
|
||||||
const sample = `
|
const data: ClaudeUsageData = {
|
||||||
Settings: Status Config Usage
|
five_hour: { utilization: 45.2, resets_at: '2026-03-23T17:00:00Z' },
|
||||||
Loading usage data...
|
seven_day: { utilization: 72.1, resets_at: '2026-03-29T00:00:00Z' },
|
||||||
|
seven_day_sonnet: { utilization: 60.0, resets_at: '2026-03-29T00:00:00Z' },
|
||||||
|
seven_day_opus: { utilization: 80.0, resets_at: '2026-03-29T00:00:00Z' },
|
||||||
|
};
|
||||||
|
|
||||||
Current session
|
expect(data.five_hour?.utilization).toBe(45.2);
|
||||||
████ 4% used
|
expect(data.seven_day?.utilization).toBe(72.1);
|
||||||
Resets in 8m (Asia/Seoul)
|
expect(data.seven_day_sonnet?.utilization).toBe(60.0);
|
||||||
|
expect(data.seven_day_opus?.utilization).toBe(80.0);
|
||||||
Current week (all models)
|
|
||||||
███████████████████████████████████████ 78% used
|
|
||||||
Resets Mar 17 at 10pm (Asia/Seoul)
|
|
||||||
|
|
||||||
Current week (Sonnet only)
|
|
||||||
███ 6% used
|
|
||||||
Resets Mar 17 at 11pm (Asia/Seoul)
|
|
||||||
|
|
||||||
Extra usage
|
|
||||||
Extra usage not enabled • /extra-usage to enable
|
|
||||||
`;
|
|
||||||
|
|
||||||
expect(parseClaudeUsagePanel(sample)).toEqual({
|
|
||||||
five_hour: {
|
|
||||||
utilization: 4,
|
|
||||||
resets_at: 'Resets in 8m (Asia/Seoul)',
|
|
||||||
},
|
|
||||||
seven_day: {
|
|
||||||
utilization: 78,
|
|
||||||
resets_at: 'Resets Mar 17 at 10pm (Asia/Seoul)',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('converts percent left into used percent', () => {
|
it('allows partial data (only five_hour)', () => {
|
||||||
const sample = `
|
const data: ClaudeUsageData = {
|
||||||
Current session
|
five_hour: { utilization: 10, resets_at: '2026-03-23T17:00:00Z' },
|
||||||
60% left
|
};
|
||||||
Resets in 1h
|
|
||||||
`;
|
|
||||||
|
|
||||||
expect(parseClaudeUsagePanel(sample)).toEqual({
|
expect(data.five_hour?.utilization).toBe(10);
|
||||||
five_hour: {
|
expect(data.seven_day).toBeUndefined();
|
||||||
utilization: 40,
|
|
||||||
resets_at: 'Resets in 1h',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,183 +1,148 @@
|
|||||||
import { spawn } from 'child_process';
|
/**
|
||||||
|
* Claude Usage API
|
||||||
|
*
|
||||||
|
* Fetches usage data directly from the Anthropic OAuth API.
|
||||||
|
* Supports multiple tokens for rotation-aware usage checking.
|
||||||
|
*/
|
||||||
|
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import { getCurrentToken, getAllTokens } from './token-rotation.js';
|
||||||
|
|
||||||
export interface ClaudeUsageData {
|
export interface ClaudeUsageData {
|
||||||
five_hour?: { utilization: number; resets_at: string };
|
five_hour?: { utilization: number; resets_at: string };
|
||||||
seven_day?: { utilization: number; resets_at: string };
|
seven_day?: { utilization: number; resets_at: string };
|
||||||
|
seven_day_sonnet?: { utilization: number; resets_at: string };
|
||||||
|
seven_day_opus?: { utilization: number; resets_at: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLAUDE_EXPECT_TIMEOUT_MS = 25000;
|
const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
|
||||||
const ANSI_RE = /\u001b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
const FETCH_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
const EXPECT_PROGRAM = `
|
interface UsageApiResponse {
|
||||||
set timeout 20
|
five_hour?: { utilization: number; resets_at?: string };
|
||||||
log_user 1
|
seven_day?: { utilization: number; resets_at?: string };
|
||||||
match_max 200000
|
seven_day_sonnet?: { utilization: number; resets_at?: string };
|
||||||
set binary $env(CLAUDE_BINARY)
|
seven_day_opus?: { utilization: number; resets_at?: string };
|
||||||
spawn -noecho -- $binary --setting-sources user --allowed-tools ""
|
|
||||||
expect {
|
|
||||||
-re "Do you trust the files in this folder\\\\?" { send "y\\r"; exp_continue }
|
|
||||||
-re "Quick safety check:" { send "\\r"; exp_continue }
|
|
||||||
-re "Yes, I trust this folder" { send "\\r"; exp_continue }
|
|
||||||
-re "Ready to code here\\\\?" { send "\\r"; exp_continue }
|
|
||||||
-re "Press Enter to continue" { send "\\r"; exp_continue }
|
|
||||||
timeout {}
|
|
||||||
}
|
|
||||||
send "/usage\\r"
|
|
||||||
set deadline [expr {[clock seconds] + 20}]
|
|
||||||
while {[clock seconds] < $deadline} {
|
|
||||||
expect {
|
|
||||||
-re "Do you trust the files in this folder\\\\?" { send "y\\r"; exp_continue }
|
|
||||||
-re "Quick safety check:" { send "\\r"; exp_continue }
|
|
||||||
-re "Yes, I trust this folder" { send "\\r"; exp_continue }
|
|
||||||
-re "Ready to code here\\\\?" { send "\\r"; exp_continue }
|
|
||||||
-re "Press Enter to continue" { send "\\r"; exp_continue }
|
|
||||||
-re "Current session" { after 2000; exit 0 }
|
|
||||||
-re "Failed to load usage data" { after 500; exit 2 }
|
|
||||||
eof { exit 3 }
|
|
||||||
timeout { send "\\r" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
exit 4
|
|
||||||
`;
|
|
||||||
|
|
||||||
function normalizeLines(rawText: string): string[] {
|
|
||||||
return rawText
|
|
||||||
.replace(ANSI_RE, '')
|
|
||||||
.replace(/\r/g, '\n')
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => line.replace(/\s+/g, ' ').trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePercent(windowText: string): number | null {
|
function mapWindow(
|
||||||
const match = windowText.match(/(\d{1,3})%\s*(used|left)\b/i);
|
w?: { utilization: number; resets_at?: string },
|
||||||
if (!match) return null;
|
): { utilization: number; resets_at: string } | undefined {
|
||||||
const value = parseInt(match[1], 10);
|
if (!w) return undefined;
|
||||||
if (Number.isNaN(value)) return null;
|
return { utilization: w.utilization, resets_at: w.resets_at || '' };
|
||||||
return match[2].toLowerCase() === 'left' ? 100 - value : value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseWindow(
|
async function fetchUsageForToken(
|
||||||
lines: string[],
|
token: string,
|
||||||
labels: string[],
|
): Promise<ClaudeUsageData | null> {
|
||||||
): { utilization: number; resets_at: string } | null {
|
const controller = new AbortController();
|
||||||
const normalizedLabels = labels.map((label) => label.toLowerCase());
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i].toLowerCase();
|
|
||||||
if (!normalizedLabels.some((label) => line.includes(label))) continue;
|
|
||||||
|
|
||||||
const windowLines = lines.slice(i, i + 6);
|
try {
|
||||||
const windowText = windowLines.join('\n');
|
const res = await fetch(USAGE_ENDPOINT, {
|
||||||
const utilization = parsePercent(windowText);
|
method: 'GET',
|
||||||
if (utilization === null) continue;
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'anthropic-beta': 'oauth-2025-04-20',
|
||||||
|
'User-Agent': 'ejclaw/1.0',
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
const resetLine = windowLines.find((candidate) =>
|
if (res.status === 401) {
|
||||||
candidate.toLowerCase().startsWith('resets'),
|
logger.warn('Claude usage API: token expired or invalid (401)');
|
||||||
);
|
return null;
|
||||||
|
}
|
||||||
|
if (res.status === 429) {
|
||||||
|
logger.warn('Claude usage API: rate limited (429)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn(
|
||||||
|
{ status: res.status },
|
||||||
|
`Claude usage API: unexpected status ${res.status}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as UsageApiResponse;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
utilization,
|
five_hour: mapWindow(data.five_hour),
|
||||||
resets_at: resetLine || '',
|
seven_day: mapWindow(data.seven_day),
|
||||||
|
seven_day_sonnet: mapWindow(data.seven_day_sonnet),
|
||||||
|
seven_day_opus: mapWindow(data.seven_day_opus),
|
||||||
};
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === 'AbortError') {
|
||||||
|
logger.warn('Claude usage API: request timed out');
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
{ err },
|
||||||
|
'Claude usage API: fetch failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseClaudeUsagePanel(rawText: string): ClaudeUsageData | null {
|
/**
|
||||||
const lines = normalizeLines(rawText);
|
* Fetch Claude usage via the OAuth API.
|
||||||
if (lines.length === 0) return null;
|
* Uses the current active token from rotation.
|
||||||
if (
|
*/
|
||||||
lines.some((line) =>
|
export async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
||||||
line.toLowerCase().includes('failed to load usage data'),
|
const token =
|
||||||
)
|
getCurrentToken() || process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
) {
|
if (!token) {
|
||||||
|
logger.debug('No Claude OAuth token available for usage check');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return fetchUsageForToken(token);
|
||||||
const fiveHour = parseWindow(lines, ['Current session']);
|
|
||||||
if (!fiveHour) return null;
|
|
||||||
|
|
||||||
const sevenDay =
|
|
||||||
parseWindow(lines, ['Current week (all models)']) ||
|
|
||||||
parseWindow(lines, [
|
|
||||||
'Current week (Sonnet only)',
|
|
||||||
'Current week (Sonnet)',
|
|
||||||
]) ||
|
|
||||||
parseWindow(lines, ['Current week (Opus)']);
|
|
||||||
|
|
||||||
return {
|
|
||||||
five_hour: fiveHour,
|
|
||||||
...(sevenDay ? { seven_day: sevenDay } : {}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchClaudeUsageViaCli(
|
export interface ClaudeAccountUsage {
|
||||||
binary = 'claude',
|
index: number;
|
||||||
): Promise<ClaudeUsageData | null> {
|
masked: string;
|
||||||
return new Promise((resolve) => {
|
isActive: boolean;
|
||||||
let output = '';
|
isRateLimited: boolean;
|
||||||
let finished = false;
|
usage: ClaudeUsageData | null;
|
||||||
|
|
||||||
const finish = (value: ClaudeUsageData | null) => {
|
|
||||||
if (finished) return;
|
|
||||||
finished = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
let proc: ReturnType<typeof spawn> | null = null;
|
|
||||||
try {
|
|
||||||
proc = spawn('expect', ['-c', EXPECT_PROGRAM], {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
env: {
|
|
||||||
...(process.env as Record<string, string>),
|
|
||||||
CLAUDE_BINARY: binary,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug({ err }, 'Claude CLI PTY probe unavailable');
|
|
||||||
finish(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
try {
|
|
||||||
proc?.kill('SIGTERM');
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
finish(null);
|
|
||||||
}, CLAUDE_EXPECT_TIMEOUT_MS);
|
|
||||||
|
|
||||||
if (!proc.stdout || !proc.stderr) {
|
|
||||||
finish(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
proc.stdout.setEncoding('utf8');
|
|
||||||
proc.stderr.setEncoding('utf8');
|
|
||||||
proc.stdout.on('data', (chunk: string) => {
|
|
||||||
output += chunk;
|
|
||||||
});
|
|
||||||
proc.stderr.on('data', (chunk: string) => {
|
|
||||||
output += chunk;
|
|
||||||
});
|
|
||||||
proc.on('error', (err) => {
|
|
||||||
logger.debug({ err }, 'Claude CLI PTY probe failed to start');
|
|
||||||
finish(null);
|
|
||||||
});
|
|
||||||
proc.on('close', () => {
|
|
||||||
const parsed = parseClaudeUsagePanel(output);
|
|
||||||
if (!parsed && output.trim()) {
|
|
||||||
logger.debug(
|
|
||||||
{ tail: output.slice(-400) },
|
|
||||||
'Claude CLI PTY probe produced unparsable output',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
finish(parsed);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch usage for ALL configured tokens.
|
||||||
|
* Returns per-account usage for dashboard display.
|
||||||
|
*/
|
||||||
|
export async function fetchAllClaudeUsage(): Promise<ClaudeAccountUsage[]> {
|
||||||
|
const allTokens = getAllTokens();
|
||||||
|
if (allTokens.length === 0) {
|
||||||
|
// Single token fallback
|
||||||
|
const token = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
if (!token) return [];
|
||||||
|
const usage = await fetchUsageForToken(token);
|
||||||
|
return [{
|
||||||
|
index: 0,
|
||||||
|
masked: `${token.slice(0, 20)}...${token.slice(-4)}`,
|
||||||
|
isActive: true,
|
||||||
|
isRateLimited: false,
|
||||||
|
usage,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: ClaudeAccountUsage[] = [];
|
||||||
|
for (const t of allTokens) {
|
||||||
|
const usage = await fetchUsageForToken(t.token);
|
||||||
|
results.push({
|
||||||
|
index: t.index,
|
||||||
|
masked: t.masked,
|
||||||
|
isActive: t.isActive,
|
||||||
|
isRateLimited: t.isRateLimited,
|
||||||
|
usage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy alias
|
||||||
|
export const fetchClaudeUsageViaCli = fetchClaudeUsage;
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ export function initTokenRotation(): void {
|
|||||||
const single = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
const single = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
|
||||||
const raw = multi
|
const raw = multi
|
||||||
? multi.split(',').map((t) => t.trim()).filter(Boolean)
|
? multi
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
: single
|
: single
|
||||||
? [single]
|
? [single]
|
||||||
: [];
|
: [];
|
||||||
@@ -99,6 +102,18 @@ export function getTokenCount(): number {
|
|||||||
return tokens.length;
|
return tokens.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get all configured tokens (masked for display, raw for API calls). */
|
||||||
|
export function getAllTokens(): { index: number; token: string; masked: string; isActive: boolean; isRateLimited: boolean }[] {
|
||||||
|
const now = Date.now();
|
||||||
|
return tokens.map((t, i) => ({
|
||||||
|
index: i,
|
||||||
|
token: t.token,
|
||||||
|
masked: `${t.token.slice(0, 20)}...${t.token.slice(-4)}`,
|
||||||
|
isActive: i === currentIndex,
|
||||||
|
isRateLimited: Boolean(t.rateLimitedUntil && t.rateLimitedUntil > now),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** Diagnostic info. */
|
/** Diagnostic info. */
|
||||||
export function getTokenRotationInfo(): {
|
export function getTokenRotationInfo(): {
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import path from 'path';
|
|||||||
|
|
||||||
import { STATUS_SHOW_ROOMS, USAGE_DASHBOARD_ENABLED } from './config.js';
|
import { STATUS_SHOW_ROOMS, USAGE_DASHBOARD_ENABLED } from './config.js';
|
||||||
import {
|
import {
|
||||||
fetchClaudeUsageViaCli,
|
fetchClaudeUsage as fetchClaudeUsageApi,
|
||||||
|
fetchAllClaudeUsage,
|
||||||
type ClaudeUsageData,
|
type ClaudeUsageData,
|
||||||
|
type ClaudeAccountUsage,
|
||||||
} from './claude-usage.js';
|
} from './claude-usage.js';
|
||||||
import {
|
import {
|
||||||
composeDashboardContent,
|
composeDashboardContent,
|
||||||
@@ -349,10 +351,10 @@ async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cliUsage = await fetchClaudeUsageViaCli();
|
const apiUsage = await fetchClaudeUsageApi();
|
||||||
if (cliUsage) {
|
if (apiUsage) {
|
||||||
usageApi429Streak = 0;
|
usageApi429Streak = 0;
|
||||||
return cliUsage;
|
return apiUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -520,20 +522,18 @@ async function buildUsageContent(): Promise<string> {
|
|||||||
);
|
);
|
||||||
const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED;
|
const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED;
|
||||||
|
|
||||||
const [liveClaudeUsage, codexUsage] = await Promise.all([
|
const [liveClaudeAccounts, codexUsage] = await Promise.all([
|
||||||
shouldFetchClaudeUsage && !hasActiveClaudeWork
|
shouldFetchClaudeUsage && !hasActiveClaudeWork
|
||||||
? fetchClaudeUsage()
|
? fetchAllClaudeUsage()
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
fetchCodexUsage(),
|
fetchCodexUsage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const claudeUsage = shouldFetchClaudeUsage
|
// Update cache with first account's data for backward compat
|
||||||
? liveClaudeUsage || cachedClaudeUsageData
|
|
||||||
: null;
|
|
||||||
const claudeUsageIsCached =
|
const claudeUsageIsCached =
|
||||||
shouldFetchClaudeUsage && !liveClaudeUsage && !!cachedClaudeUsageData;
|
shouldFetchClaudeUsage && !liveClaudeAccounts && !!cachedClaudeUsageData;
|
||||||
if (shouldFetchClaudeUsage && liveClaudeUsage) {
|
if (shouldFetchClaudeUsage && liveClaudeAccounts?.length) {
|
||||||
cachedClaudeUsageData = liveClaudeUsage;
|
cachedClaudeUsageData = liveClaudeAccounts[0].usage;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines: string[] = ['📊 *사용량*'];
|
const lines: string[] = ['📊 *사용량*'];
|
||||||
@@ -551,11 +551,19 @@ async function buildUsageContent(): Promise<string> {
|
|||||||
};
|
};
|
||||||
const rows: UsageRow[] = [];
|
const rows: UsageRow[] = [];
|
||||||
|
|
||||||
if (claudeUsage) {
|
const claudeAccounts = liveClaudeAccounts
|
||||||
const h5 = claudeUsage.five_hour;
|
|| (cachedClaudeUsageData ? [{ index: 0, masked: '', isActive: true, isRateLimited: false, usage: cachedClaudeUsageData }] : []);
|
||||||
const d7 = claudeUsage.seven_day;
|
|
||||||
|
for (const account of claudeAccounts) {
|
||||||
|
const u = account.usage;
|
||||||
|
if (!u) continue;
|
||||||
|
const h5 = u.five_hour;
|
||||||
|
const d7 = u.seven_day;
|
||||||
|
const label = claudeAccounts.length > 1
|
||||||
|
? `Claude${account.index + 1}${account.isActive ? '⚡' : ''}${account.isRateLimited ? '🚫' : ''}`
|
||||||
|
: claudeUsageIsCached ? 'Claude*' : 'Claude';
|
||||||
rows.push({
|
rows.push({
|
||||||
name: claudeUsageIsCached ? 'Claude*' : 'Claude',
|
name: label,
|
||||||
h5pct: h5
|
h5pct: h5
|
||||||
? h5.utilization > 1
|
? h5.utilization > 1
|
||||||
? Math.round(h5.utilization)
|
? Math.round(h5.utilization)
|
||||||
|
|||||||
Reference in New Issue
Block a user