mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat: update monitor data & quato list
This commit is contained in:
@@ -4,8 +4,7 @@ import {
|
||||
detectOutputFormat,
|
||||
type Client,
|
||||
} from "bailian-cli-core";
|
||||
import { ansi, emitResult } from "bailian-cli-runtime";
|
||||
import { displayWidth, padEnd } from "bailian-cli-runtime";
|
||||
import { ansi, emitResult, renderBoxTable } from "bailian-cli-runtime";
|
||||
|
||||
const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
|
||||
const MONITOR_API = "zeldaEasy.bailian-telemetry.monitor.getMonitorData";
|
||||
@@ -49,24 +48,6 @@ function calculateTPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): n
|
||||
return Math.floor((item.usage_limit * 60) / period);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
function formatRatio(usage: number, limit: number): string {
|
||||
if (limit <= 0) return "-";
|
||||
const pct = Math.round((usage / limit) * 100);
|
||||
return `${formatNumber(usage)}/${formatNumber(limit)} (${pct}%)`;
|
||||
}
|
||||
|
||||
function getStatus(usage: number, limit: number): string {
|
||||
if (limit <= 0) return "-";
|
||||
const pct = (usage / limit) * 100;
|
||||
if (pct >= 100) return "Rate Limited";
|
||||
if (pct >= 80) return "Near limit";
|
||||
return "Normal";
|
||||
}
|
||||
|
||||
function getNestedRecord(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
@@ -157,7 +138,11 @@ async function fetchMonitorData(
|
||||
}
|
||||
|
||||
return { rpm, tpm };
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Re-throw console authentication errors, but catch other errors
|
||||
if (error instanceof Error && error.message?.includes("Console session")) {
|
||||
throw error;
|
||||
}
|
||||
return { rpm: -1, tpm: -1 };
|
||||
}
|
||||
}
|
||||
@@ -168,16 +153,24 @@ interface CheckRow {
|
||||
rpmLimit: number;
|
||||
tpmUsage: number;
|
||||
tpmLimit: number;
|
||||
rpmQuotaLeft: number | null;
|
||||
tpmQuotaLeft: number | null;
|
||||
rpmQuotaLabel: string | null;
|
||||
tpmQuotaLabel: string | null;
|
||||
}
|
||||
|
||||
function printTable(rows: CheckRow[]): void {
|
||||
const color = ansi(process.stdout);
|
||||
const headers = ["Model", "RPM Used/Limit", "TPM Used/Limit", "Status", "RPM Left", "TPM Left"];
|
||||
|
||||
const headers = ["Model", "RPM Usage/Limit", "TPM Usage/Limit", "Status"];
|
||||
const rpmPercents = rows.map((r) => r.rpmQuotaLeft);
|
||||
const rpmLabels = rows.map((r) => r.rpmQuotaLabel);
|
||||
const tpmPercents = rows.map((r) => r.tpmQuotaLeft);
|
||||
const tpmLabels = rows.map((r) => r.tpmQuotaLabel);
|
||||
|
||||
const tableRows = rows.map((r) => {
|
||||
const rpmStr = r.rpmUsage < 0 ? "-" : formatRatio(r.rpmUsage, r.rpmLimit);
|
||||
const tpmStr = r.tpmUsage < 0 ? "-" : formatRatio(r.tpmUsage, r.tpmLimit);
|
||||
const rpmStr = r.rpmUsage < 0 ? "-" : `${r.rpmUsage}/${r.rpmLimit}`;
|
||||
const tpmStr = r.tpmUsage < 0 ? "-" : `${r.tpmUsage}/${r.tpmLimit}`;
|
||||
const maxPct = Math.max(
|
||||
r.rpmLimit > 0 ? (r.rpmUsage / r.rpmLimit) * 100 : 0,
|
||||
r.tpmLimit > 0 ? (r.tpmUsage / r.tpmLimit) * 100 : 0,
|
||||
@@ -185,39 +178,34 @@ function printTable(rows: CheckRow[]): void {
|
||||
const status =
|
||||
r.rpmUsage < 0
|
||||
? "-"
|
||||
: getStatus(Math.max(r.rpmUsage, r.tpmUsage), Math.max(r.rpmLimit, r.tpmLimit));
|
||||
return { cells: [r.model, rpmStr, tpmStr, status], maxPct };
|
||||
: maxPct >= 100
|
||||
? "Rate Limited"
|
||||
: maxPct >= 80
|
||||
? "Near limit"
|
||||
: "Normal";
|
||||
return [r.model, rpmStr, tpmStr, status, "", ""];
|
||||
});
|
||||
|
||||
if (tableRows.length === 0) {
|
||||
process.stdout.write("No models found.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const widths = headers.map((label, col) =>
|
||||
Math.max(displayWidth(label), ...tableRows.map((r) => displayWidth(r.cells[col]))),
|
||||
);
|
||||
|
||||
const headerLine = headers.map((label, col) => color.bold(padEnd(label, widths[col]))).join(" ");
|
||||
const separator = widths.map((w) => color.dim("─".repeat(w))).join("──");
|
||||
|
||||
process.stdout.write(headerLine + "\n");
|
||||
process.stdout.write(separator + "\n");
|
||||
|
||||
const statusCol = 3;
|
||||
for (const r of tableRows) {
|
||||
const cells = r.cells.map((cell, col) => {
|
||||
if (col === statusCol) {
|
||||
if (cell === "Rate Limited") return color.red(padEnd(cell, widths[col]));
|
||||
if (cell === "Near limit") return color.yellow(padEnd(cell, widths[col]));
|
||||
if (cell === "Normal") return color.green(padEnd(cell, widths[col]));
|
||||
const lines = renderBoxTable({
|
||||
headers,
|
||||
rows: tableRows,
|
||||
align: ["left", "right", "right", "left", "left", "left"],
|
||||
barColumns: [
|
||||
{ index: 4, percents: rpmPercents, labels: rpmLabels, width: 15 },
|
||||
{ index: 5, percents: tpmPercents, labels: tpmLabels, width: 15 },
|
||||
],
|
||||
cellColor: (rowIndex, colIndex, value) => {
|
||||
if (colIndex === 3) {
|
||||
// Status 列着色
|
||||
if (value === "Rate Limited") return color.red(value);
|
||||
if (value === "Near limit") return color.yellow(value);
|
||||
if (value === "Normal") return color.green(value);
|
||||
}
|
||||
return padEnd(cell, widths[col]);
|
||||
});
|
||||
process.stdout.write(cells.join(" ") + "\n");
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
process.stdout.write(color.dim(`\nTotal: ${rows.length} models`) + "\n");
|
||||
for (const line of lines) process.stdout.write(line + "\n");
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
@@ -295,12 +283,35 @@ export default defineCommand({
|
||||
const tpmLimit =
|
||||
calculateTPM(userSpec, modelDefault?.usage_limit_period) || calculateTPM(modelDefault);
|
||||
|
||||
const rpmUsage = monitorResults[idx].rpm;
|
||||
const tpmUsage = monitorResults[idx].tpm;
|
||||
|
||||
// RPM Quota Left = 1 - (rpmUsage / rpmLimit) in percentage
|
||||
let rpmQuotaPercent: number | null = null;
|
||||
let rpmQuotaLabel: string | null = null;
|
||||
if (rpmUsage >= 0 && rpmLimit > 0) {
|
||||
rpmQuotaPercent = Math.max(0, 100 - (rpmUsage / rpmLimit) * 100);
|
||||
rpmQuotaLabel = rpmQuotaPercent.toFixed(1) + "%";
|
||||
}
|
||||
|
||||
// TPM Quota Left = 1 - (tpmUsage / tpmLimit) in percentage
|
||||
let tpmQuotaPercent: number | null = null;
|
||||
let tpmQuotaLabel: string | null = null;
|
||||
if (tpmUsage >= 0 && tpmLimit > 0) {
|
||||
tpmQuotaPercent = Math.max(0, 100 - (tpmUsage / tpmLimit) * 100);
|
||||
tpmQuotaLabel = tpmQuotaPercent.toFixed(1) + "%";
|
||||
}
|
||||
|
||||
return {
|
||||
model: m.model,
|
||||
rpmUsage: monitorResults[idx].rpm,
|
||||
rpmUsage,
|
||||
rpmLimit,
|
||||
tpmUsage: monitorResults[idx].tpm,
|
||||
tpmUsage,
|
||||
tpmLimit,
|
||||
rpmQuotaLeft: rpmQuotaPercent,
|
||||
tpmQuotaLeft: tpmQuotaPercent,
|
||||
rpmQuotaLabel,
|
||||
tpmQuotaLabel,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { defineCommand, BailianError, detectOutputFormat, type Client } from "bailian-cli-core";
|
||||
import { ansi, emitResult } from "bailian-cli-runtime";
|
||||
import { displayWidth, padEnd } from "bailian-cli-runtime";
|
||||
import { emitResult, renderBoxTable } from "bailian-cli-runtime";
|
||||
|
||||
const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
|
||||
const MONITOR_API = "zeldaEasy.bailian-telemetry.monitor.getMonitorData";
|
||||
|
||||
interface QpmInfoItem {
|
||||
count_limit: number;
|
||||
@@ -18,6 +18,17 @@ interface ModelWithQpm {
|
||||
qpmInfo?: Record<string, QpmInfoItem>;
|
||||
}
|
||||
|
||||
interface MonitorPoint {
|
||||
value: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface MonitorMetric {
|
||||
aggMethod: string;
|
||||
metricName: string;
|
||||
points: MonitorPoint[];
|
||||
}
|
||||
|
||||
function calculateRPM(item: QpmInfoItem | undefined, fallbackPeriod?: number): number {
|
||||
if (!item) return 0;
|
||||
const period = item.count_limit_period || fallbackPeriod;
|
||||
@@ -36,6 +47,58 @@ function formatNumber(num: number): string {
|
||||
return num.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
async function fetchMonitorData(
|
||||
client: Client,
|
||||
modelName: string,
|
||||
windowMinutes: number,
|
||||
): Promise<{ rpm: number; tpm: number }> {
|
||||
const now = Date.now();
|
||||
const startTime = now - windowMinutes * 60 * 1000;
|
||||
|
||||
try {
|
||||
const raw = await client.console(MONITOR_API, {
|
||||
reqDTO: {
|
||||
monitorType: "Advanced",
|
||||
metricFilters: [
|
||||
{ aggMethod: "sum_pm", metricName: "model_total_amount" },
|
||||
{ aggMethod: "sum_pm", metricName: "model_call_count" },
|
||||
],
|
||||
labelFilters: {
|
||||
resourceId: modelName,
|
||||
resourceType: "model",
|
||||
},
|
||||
startTime,
|
||||
endTime: now,
|
||||
},
|
||||
});
|
||||
|
||||
const resp = extractResponseData(raw as Record<string, unknown>);
|
||||
const metrics = (resp.data ?? resp) as MonitorMetric[] | Record<string, unknown>;
|
||||
if (!Array.isArray(metrics)) {
|
||||
return { rpm: 0, tpm: 0 };
|
||||
}
|
||||
|
||||
let rpm = 0;
|
||||
let tpm = 0;
|
||||
|
||||
for (const metric of metrics) {
|
||||
if (metric.aggMethod !== "sum_pm" || !metric.points?.length) continue;
|
||||
const lastValue = metric.points[metric.points.length - 1].value ?? 0;
|
||||
if (metric.metricName === "model_call_count") rpm = Math.round(lastValue);
|
||||
if (metric.metricName === "model_total_amount") tpm = Math.round(lastValue);
|
||||
}
|
||||
|
||||
return { rpm, tpm };
|
||||
} catch (error) {
|
||||
// Re-throw console authentication errors, but catch other errors
|
||||
if (error instanceof Error && error.message?.includes("Console session")) {
|
||||
throw error;
|
||||
}
|
||||
// Return -1 to indicate no data (same as check.ts for consistency)
|
||||
return { rpm: -1, tpm: -1 };
|
||||
}
|
||||
}
|
||||
|
||||
function getNestedRecord(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
@@ -58,10 +121,7 @@ function extractResponseData(result: Record<string, unknown>): Record<string, un
|
||||
return direct ?? data;
|
||||
}
|
||||
|
||||
async function fetchAllModelsWithQpm(
|
||||
client: Client,
|
||||
onlySelfService: boolean,
|
||||
): Promise<ModelWithQpm[]> {
|
||||
async function fetchAllModelsWithQpm(client: Client): Promise<ModelWithQpm[]> {
|
||||
const allModels: ModelWithQpm[] = [];
|
||||
let pageNo = 1;
|
||||
|
||||
@@ -72,10 +132,8 @@ async function fetchAllModelsWithQpm(
|
||||
group: false,
|
||||
queryQpmInfo: true,
|
||||
ignoreWorkspaceServiceSite: true,
|
||||
supports: { selfServiceLimitIncrease: true },
|
||||
};
|
||||
if (onlySelfService) {
|
||||
input.supports = { selfServiceLimitIncrease: true };
|
||||
}
|
||||
|
||||
const raw = await client.console(MODEL_LIST_API, { input });
|
||||
|
||||
@@ -91,50 +149,38 @@ async function fetchAllModelsWithQpm(
|
||||
return allModels;
|
||||
}
|
||||
|
||||
function printTable(models: ModelWithQpm[]): void {
|
||||
const color = ansi(process.stdout);
|
||||
interface ListRow {
|
||||
model: string;
|
||||
rpm: string;
|
||||
tpm: string;
|
||||
maxTpm: string;
|
||||
rpmQuotaLeft: number | null;
|
||||
tpmQuotaLeft: number | null;
|
||||
rpmQuotaLabel: string | null;
|
||||
tpmQuotaLabel: string | null;
|
||||
}
|
||||
|
||||
const headers = ["Model", "Req/min", "Token/min", "Max TPM"];
|
||||
function printTable(rows: ListRow[]): void {
|
||||
const headers = ["Model", "Req/min", "Token/min", "RPM Left", "TPM Left"];
|
||||
|
||||
const rows = models.map((m) => {
|
||||
const qpm = m.qpmInfo;
|
||||
const modelDefault = qpm?.["model-default"];
|
||||
const userSpec = qpm?.["user-spec"];
|
||||
const rpmPercents = rows.map((r) => r.rpmQuotaLeft);
|
||||
const rpmLabels = rows.map((r) => r.rpmQuotaLabel);
|
||||
const tpmPercents = rows.map((r) => r.tpmQuotaLeft);
|
||||
const tpmLabels = rows.map((r) => r.tpmQuotaLabel);
|
||||
|
||||
const defaultRPM = calculateRPM(modelDefault);
|
||||
const defaultTPM = calculateTPM(modelDefault);
|
||||
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
|
||||
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
|
||||
const maxTPM = defaultTPM * 2;
|
||||
const tableRows = rows.map((r) => [r.model, r.rpm, r.tpm, "", ""]);
|
||||
|
||||
return [
|
||||
m.model,
|
||||
currentRPM > 0 ? formatNumber(currentRPM) : "-",
|
||||
currentTPM > 0 ? formatNumber(currentTPM) : "-",
|
||||
maxTPM > 0 ? formatNumber(maxTPM) : "-",
|
||||
];
|
||||
const lines = renderBoxTable({
|
||||
headers,
|
||||
rows: tableRows,
|
||||
align: ["left", "right", "right", "left", "left"],
|
||||
barColumns: [
|
||||
{ index: 3, percents: rpmPercents, labels: rpmLabels, width: 15 },
|
||||
{ index: 4, percents: tpmPercents, labels: tpmLabels, width: 15 },
|
||||
],
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
process.stdout.write("No models found.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const widths = headers.map((label, col) =>
|
||||
Math.max(displayWidth(label), ...rows.map((row) => displayWidth(row[col]))),
|
||||
);
|
||||
|
||||
const headerLine = headers.map((label, col) => color.bold(padEnd(label, widths[col]))).join(" ");
|
||||
const separator = widths.map((w) => color.dim("─".repeat(w))).join("──");
|
||||
|
||||
process.stdout.write(headerLine + "\n");
|
||||
process.stdout.write(separator + "\n");
|
||||
|
||||
for (const row of rows) {
|
||||
process.stdout.write(row.map((cell, col) => padEnd(cell, widths[col])).join(" ") + "\n");
|
||||
}
|
||||
|
||||
process.stdout.write(color.dim(`\nTotal: ${models.length} models`) + "\n");
|
||||
for (const line of lines) process.stdout.write(line + "\n");
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
@@ -178,7 +224,7 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
let models = await fetchAllModelsWithQpm(ctx.client, !showAll);
|
||||
let models = await fetchAllModelsWithQpm(ctx.client);
|
||||
|
||||
if (modelFlag) {
|
||||
const names = new Set(
|
||||
@@ -216,6 +262,58 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
printTable(models);
|
||||
// For text output with gauges, we need monitor data
|
||||
const monitorResults = await Promise.all(
|
||||
models.map((m) => fetchMonitorData(ctx.client, m.model, 2)),
|
||||
);
|
||||
|
||||
const rows: ListRow[] = models.map((m, idx) => {
|
||||
const qpm = m.qpmInfo;
|
||||
const modelDefault = qpm?.["model-default"];
|
||||
const userSpec = qpm?.["user-spec"];
|
||||
|
||||
const defaultRPM = calculateRPM(modelDefault);
|
||||
const defaultTPM = calculateTPM(modelDefault);
|
||||
const currentRPM = calculateRPM(userSpec, modelDefault?.count_limit_period) || defaultRPM;
|
||||
const currentTPM = calculateTPM(userSpec, modelDefault?.usage_limit_period) || defaultTPM;
|
||||
const maxTPM = defaultTPM * 2;
|
||||
|
||||
const rpmUsage = monitorResults[idx].rpm;
|
||||
const tpmUsage = monitorResults[idx].tpm;
|
||||
|
||||
// RPM Quota Left = 1 - (rpmUsage / currentRPM) in percentage
|
||||
let rpmQuotaPercent: number | null = null;
|
||||
let rpmQuotaLabel: string | null = null;
|
||||
if (rpmUsage >= 0 && currentRPM > 0) {
|
||||
rpmQuotaPercent = Math.max(0, 100 - (rpmUsage / currentRPM) * 100);
|
||||
rpmQuotaLabel = rpmQuotaPercent.toFixed(1) + "%";
|
||||
}
|
||||
|
||||
// TPM Quota Left = 1 - (tpmUsage / currentTPM) in percentage
|
||||
let tpmQuotaPercent: number | null = null;
|
||||
let tpmQuotaLabel: string | null = null;
|
||||
if (tpmUsage >= 0 && currentTPM > 0) {
|
||||
tpmQuotaPercent = Math.max(0, 100 - (tpmUsage / currentTPM) * 100);
|
||||
tpmQuotaLabel = tpmQuotaPercent.toFixed(1) + "%";
|
||||
}
|
||||
|
||||
return {
|
||||
model: m.model,
|
||||
rpm: currentRPM > 0 ? formatNumber(currentRPM) : "-",
|
||||
tpm: currentTPM > 0 ? formatNumber(currentTPM) : "-",
|
||||
maxTpm: maxTPM > 0 ? formatNumber(maxTPM) : "-",
|
||||
rpmQuotaLeft: rpmQuotaPercent,
|
||||
tpmQuotaLeft: tpmQuotaPercent,
|
||||
rpmQuotaLabel,
|
||||
tpmQuotaLabel,
|
||||
};
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
process.stdout.write("No models found.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
printTable(rows);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -118,7 +118,10 @@ export interface BoxTableOptions {
|
||||
rows: string[][];
|
||||
/** Per-column alignment; defaults to left. */
|
||||
align?: ("left" | "right")[];
|
||||
/** Single gauge column (legacy; use barColumns for multiple). */
|
||||
barColumn?: BarColumn;
|
||||
/** Multiple gauge columns (overrides barColumn if provided). */
|
||||
barColumns?: BarColumn[];
|
||||
/** Return a colored variant of a plain cell value (must keep the same visible width). */
|
||||
cellColor?: (rowIndex: number, colIndex: number, value: string) => string | undefined;
|
||||
/** Stream used for color capability detection (default process.stdout). */
|
||||
@@ -173,14 +176,19 @@ export function renderBoxTable(options: BoxTableOptions): string[] {
|
||||
const out = options.out ?? process.stdout;
|
||||
const level = colorLevel(out);
|
||||
const align = options.align ?? [];
|
||||
const barCol = options.barColumn;
|
||||
const barWidth = barCol?.width ?? DEFAULT_BAR_WIDTH;
|
||||
|
||||
// Support both single barColumn (legacy) and multiple barColumns (new)
|
||||
const barColumns = options.barColumns ?? (options.barColumn ? [options.barColumn] : []);
|
||||
|
||||
const columnCount = options.headers.length;
|
||||
|
||||
// Precompute bar cells so widths use their plain representation.
|
||||
const barCells: RenderedCell[] = [];
|
||||
if (barCol) {
|
||||
// Precompute bar cells for all gauge columns
|
||||
type BarCellsMap = Record<number, RenderedCell[]>;
|
||||
const allBarCells: BarCellsMap = {};
|
||||
|
||||
for (const barCol of barColumns) {
|
||||
const barWidth = barCol.width ?? DEFAULT_BAR_WIDTH;
|
||||
const barCells: RenderedCell[] = [];
|
||||
for (let rowIndex = 0; rowIndex < options.rows.length; rowIndex++) {
|
||||
const percent = barCol.percents[rowIndex] ?? null;
|
||||
const explicitLabel = barCol.labels?.[rowIndex];
|
||||
@@ -192,10 +200,11 @@ export function renderBoxTable(options: BoxTableOptions): string[] {
|
||||
: `${Number.isInteger(percent) ? percent : percent.toFixed(1)}%`;
|
||||
barCells.push(buildBarCell(percent, label, barWidth, level));
|
||||
}
|
||||
allBarCells[barCol.index] = barCells;
|
||||
}
|
||||
|
||||
const plainCell = (rowIndex: number, colIndex: number): string => {
|
||||
if (barCol && colIndex === barCol.index) return barCells[rowIndex].plain;
|
||||
if (allBarCells[colIndex]) return allBarCells[colIndex][rowIndex].plain;
|
||||
return options.rows[rowIndex][colIndex] ?? "";
|
||||
};
|
||||
|
||||
@@ -236,8 +245,9 @@ export function renderBoxTable(options: BoxTableOptions): string[] {
|
||||
const cells: string[] = [];
|
||||
for (let colIndex = 0; colIndex < columnCount; colIndex++) {
|
||||
const width = widths[colIndex];
|
||||
if (barCol && colIndex === barCol.index) {
|
||||
const bar = barCells[rowIndex];
|
||||
// Check if this column is a gauge column
|
||||
if (allBarCells[colIndex]) {
|
||||
const bar = allBarCells[colIndex][rowIndex];
|
||||
const gap = width - displayWidth(bar.plain);
|
||||
cells.push(gap > 0 ? bar.colored + " ".repeat(gap) : bar.colored);
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user