This commit is contained in:
@@ -146,7 +146,9 @@ export function SkillsForm() {
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (detailQuery.data) {
|
||||
setEditContent(detailQuery.data.content);
|
||||
// Edit the FULL file (frontmatter + body). Loading body-only `content`
|
||||
// and saving it back drops the frontmatter and deletes the skill.
|
||||
setEditContent(detailQuery.data.raw);
|
||||
setEditMode(true);
|
||||
setError(null);
|
||||
}
|
||||
@@ -370,7 +372,7 @@ export function SkillsForm() {
|
||||
</div>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-xs font-mono text-slate-700 bg-surface/50 border border-hairline rounded p-3 max-h-[400px] overflow-y-auto">
|
||||
{detailQuery.data.content}
|
||||
{detailQuery.data.raw}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getUsageDaily, type UsageBucket, type UsageCounters } from '../../api';
|
||||
import { getUsageDaily, type UsageBucket, type UsageCounters, type UsageGroupBy } from '../../api';
|
||||
|
||||
type Preset = 'last7' | 'last30' | 'last90' | 'ytd' | 'custom';
|
||||
type Gran = 'day' | 'week' | 'month';
|
||||
|
||||
function utcToday(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
/** Viewer's local calendar today as 'YYYY-MM-DD' (the server re-buckets UTC
|
||||
* hours into this local frame via tzOffset). */
|
||||
function localToday(): string {
|
||||
const d = new Date();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
/** Minutes east of UTC for the viewer (JST → +540). */
|
||||
function localTzOffset(): number {
|
||||
return -new Date().getTimezoneOffset();
|
||||
}
|
||||
function shiftDay(day: string, delta: number): string {
|
||||
const x = new Date(`${day}T00:00:00.000Z`);
|
||||
@@ -15,7 +24,7 @@ function shiftDay(day: string, delta: number): string {
|
||||
return x.toISOString().slice(0, 10);
|
||||
}
|
||||
function yearStart(): string {
|
||||
return `${new Date().getUTCFullYear()}-01-01`;
|
||||
return `${new Date().getFullYear()}-01-01`;
|
||||
}
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
||||
@@ -28,6 +37,12 @@ function total(c: UsageCounters): number {
|
||||
function zeroCounters(): UsageCounters {
|
||||
return { tokensIn: 0, tokensOut: 0, requests: 0 };
|
||||
}
|
||||
/** Total tokens across all series segments in a bucket. */
|
||||
function bucketTotal(b: UsageBucket): number {
|
||||
let s = 0;
|
||||
for (const k of Object.keys(b.segments)) s += total(b.segments[k]);
|
||||
return s;
|
||||
}
|
||||
|
||||
// Mirror the server's bucket keys (usage-api.ts) so we can fill empty buckets
|
||||
// and keep the chart's time axis linear instead of index-based.
|
||||
@@ -57,7 +72,7 @@ function denseBuckets(series: UsageBucket[], from: string, to: string, g: Gran):
|
||||
const key = bucketKey(day, g);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
out.push(byKey.get(key) ?? { bucket: key, gateway: zeroCounters(), direct: zeroCounters() });
|
||||
out.push(byKey.get(key) ?? { bucket: key, segments: {} });
|
||||
}
|
||||
day = shiftDay(day, 1);
|
||||
}
|
||||
@@ -65,7 +80,7 @@ function denseBuckets(series: UsageBucket[], from: string, to: string, g: Gran):
|
||||
}
|
||||
|
||||
function rangeFor(preset: Preset, customFrom: string, customTo: string): { from: string; to: string } {
|
||||
const to = utcToday();
|
||||
const to = localToday();
|
||||
switch (preset) {
|
||||
case 'last7': return { from: shiftDay(to, -6), to };
|
||||
case 'last90': return { from: shiftDay(to, -89), to };
|
||||
@@ -76,27 +91,43 @@ function rangeFor(preset: Preset, customFrom: string, customTo: string): { from:
|
||||
}
|
||||
}
|
||||
|
||||
const GW = '#6366f1'; // indigo-500 — gateway
|
||||
const DR = '#22c55e'; // green-500 — direct
|
||||
const GW = '#6366f1'; // indigo-500 — gateway (source axis)
|
||||
const DR = '#22c55e'; // green-500 — direct (source axis)
|
||||
const OTHER_COLOR = '#94a3b8'; // slate-400 — folded 'other' bucket
|
||||
// Distinct palette for dynamic axes (model / route / user / org).
|
||||
const PALETTE = [
|
||||
'#6366f1', '#22c55e', '#f59e0b', '#ec4899', '#06b6d4', '#a855f7',
|
||||
'#ef4444', '#14b8a6', '#eab308', '#3b82f6', '#f97316', '#8b5cf6',
|
||||
];
|
||||
|
||||
function colorFor(key: string, index: number, groupBy: UsageGroupBy): string {
|
||||
if (key === 'other') return OTHER_COLOR;
|
||||
if (groupBy === 'source') return key === 'gateway' ? GW : DR;
|
||||
return PALETTE[index % PALETTE.length];
|
||||
}
|
||||
|
||||
/** i18next t with interpolation support (widened from the bare key signature). */
|
||||
type TFn = (key: string, opts?: Record<string, unknown>) => string;
|
||||
|
||||
const GROUP_BYS: UsageGroupBy[] = ['source', 'model', 'route', 'user', 'org'];
|
||||
|
||||
export function UsagePage() {
|
||||
const { t } = useTranslation('usage');
|
||||
const [preset, setPreset] = useState<Preset>('last30');
|
||||
const [granularity, setGranularity] = useState<Gran>('day');
|
||||
const [groupBy, setGroupBy] = useState<UsageGroupBy>('source');
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
|
||||
const { from, to } = rangeFor(preset, customFrom, customTo);
|
||||
const tzOffset = localTzOffset();
|
||||
// Client-side guard: don't fire a request the server would 400 on; show an
|
||||
// inline message instead of a generic error.
|
||||
const customInvalid = preset === 'custom' && !!customFrom && !!customTo && customFrom > customTo;
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['usage-daily', from, to, granularity],
|
||||
queryFn: () => getUsageDaily({ from, to, granularity }),
|
||||
queryKey: ['usage-daily', from, to, granularity, groupBy, tzOffset],
|
||||
queryFn: () => getUsageDaily({ from, to, granularity, groupBy, tzOffset }),
|
||||
enabled: !customInvalid,
|
||||
});
|
||||
|
||||
@@ -104,23 +135,42 @@ export function UsagePage() {
|
||||
const grans: Gran[] = ['day', 'week', 'month'];
|
||||
|
||||
const series: UsageBucket[] = data?.series ?? [];
|
||||
const keys = data?.keys ?? [];
|
||||
// Gap-free buckets so the bar/line x-axis is time-linear, not index-based.
|
||||
const dense = useMemo(
|
||||
() => (data ? denseBuckets(series, data.from, data.to, granularity) : []),
|
||||
[series, data, granularity],
|
||||
);
|
||||
const maxBucket = useMemo(
|
||||
() => dense.reduce((m, b) => Math.max(m, total(b.gateway) + total(b.direct)), 0),
|
||||
() => dense.reduce((m, b) => Math.max(m, bucketTotal(b)), 0),
|
||||
[dense],
|
||||
);
|
||||
const cumulative = useMemo(() => {
|
||||
let run = 0;
|
||||
return dense.map((b) => {
|
||||
run += total(b.gateway) + total(b.direct);
|
||||
return run;
|
||||
});
|
||||
}, [dense]);
|
||||
const maxCumulative = cumulative.length ? cumulative[cumulative.length - 1] : 0;
|
||||
// Per-series running cumulative (one independent line each).
|
||||
// cumByKey[key][i] = that series' cumulative tokens through bucket i. The
|
||||
// shared y-axis is the largest single-series final cumulative.
|
||||
const { cumByKey, maxCumulative, grandCumulative } = useMemo(() => {
|
||||
const run: Record<string, number> = {};
|
||||
const acc: Record<string, number[]> = {};
|
||||
for (const k of keys) { run[k] = 0; acc[k] = []; }
|
||||
for (const b of dense) {
|
||||
for (const k of keys) {
|
||||
run[k] += total(b.segments[k] ?? zeroCounters());
|
||||
acc[k].push(run[k]);
|
||||
}
|
||||
}
|
||||
const maxLine = keys.reduce((m, k) => Math.max(m, run[k] ?? 0), 0);
|
||||
const grand = keys.reduce((s, k) => s + (run[k] ?? 0), 0);
|
||||
return { cumByKey: acc, maxCumulative: maxLine, grandCumulative: grand };
|
||||
}, [dense, keys]);
|
||||
|
||||
// Display label for a series key (resolve user ids, localize sentinels).
|
||||
const keyLabel = (key: string): string => {
|
||||
if (data?.labels?.[key]) return data.labels[key];
|
||||
if (groupBy === 'source') return t(key === 'gateway' ? 'chart.legendGateway' : 'chart.legendDirect');
|
||||
if (key === 'no-org') return t('axis.noOrg');
|
||||
if (key === 'other') return t('axis.other');
|
||||
return key;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
@@ -130,7 +180,7 @@ export function UsagePage() {
|
||||
<p className="text-[13px] text-slate-500 dark:text-slate-400 mt-1">{t('subtitle')}</p>
|
||||
</header>
|
||||
|
||||
{/* Controls */}
|
||||
{/* Controls: range presets + custom + granularity */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex gap-1">
|
||||
{presets.map((p) => (
|
||||
@@ -176,22 +226,41 @@ export function UsagePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group-by selector — the breakdown dimension for every chart below. */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span className="text-xs text-slate-400 mr-1">{t('groupBy.label')}</span>
|
||||
{GROUP_BYS.map((gb) => (
|
||||
<button
|
||||
key={gb}
|
||||
onClick={() => setGroupBy(gb)}
|
||||
className={`px-2 py-1 text-xs rounded-md border transition-colors ${
|
||||
groupBy === gb
|
||||
? 'bg-accent text-white border-accent'
|
||||
: 'border-hairline text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
{t(`groupBy.${gb}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{customInvalid && <div className="text-sm text-amber-600 dark:text-amber-400">{t('range.invalid')}</div>}
|
||||
{!customInvalid && isLoading && <div className="text-sm text-slate-400 italic">{t('loading')}</div>}
|
||||
{!customInvalid && error && <div className="text-sm text-red-600">{t('error')}</div>}
|
||||
|
||||
{!customInvalid && data && (
|
||||
<>
|
||||
<TotalsCards totals={data.totals} t={t} />
|
||||
<TotalsCards totals={data.totals} keys={keys} groupBy={groupBy} keyLabel={keyLabel} t={t} />
|
||||
|
||||
{series.length === 0 ? (
|
||||
{series.length === 0 || keys.length === 0 ? (
|
||||
<div className="border border-hairline rounded-lg p-8 text-center text-sm text-slate-400 italic">
|
||||
{t('empty')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<StackedBars series={dense} maxBucket={maxBucket} t={t} />
|
||||
<CumulativeLine series={dense} cumulative={cumulative} max={maxCumulative} t={t} />
|
||||
<SeriesLegend keys={keys} groupBy={groupBy} keyLabel={keyLabel} />
|
||||
<StackedBars series={dense} keys={keys} maxBucket={maxBucket} groupBy={groupBy} keyLabel={keyLabel} t={t} />
|
||||
<CumulativeLines series={dense} keys={keys} cumByKey={cumByKey} max={maxCumulative} grand={grandCumulative} groupBy={groupBy} t={t} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -203,17 +272,27 @@ export function UsagePage() {
|
||||
);
|
||||
}
|
||||
|
||||
function TotalsCards({ totals, t }: { totals: { gateway: UsageCounters; direct: UsageCounters }; t: TFn }) {
|
||||
const combined: UsageCounters = {
|
||||
tokensIn: totals.gateway.tokensIn + totals.direct.tokensIn,
|
||||
tokensOut: totals.gateway.tokensOut + totals.direct.tokensOut,
|
||||
requests: totals.gateway.requests + totals.direct.requests,
|
||||
};
|
||||
function TotalsCards({
|
||||
totals, keys, groupBy, keyLabel, t,
|
||||
}: {
|
||||
totals: Record<string, UsageCounters>;
|
||||
keys: string[];
|
||||
groupBy: UsageGroupBy;
|
||||
keyLabel: (k: string) => string;
|
||||
t: TFn;
|
||||
}) {
|
||||
const combined: UsageCounters = { tokensIn: 0, tokensOut: 0, requests: 0 };
|
||||
for (const k of keys) {
|
||||
const c = totals[k] ?? zeroCounters();
|
||||
combined.tokensIn += c.tokensIn;
|
||||
combined.tokensOut += c.tokensOut;
|
||||
combined.requests += c.requests;
|
||||
}
|
||||
const card = (label: string, c: UsageCounters, dot?: string) => (
|
||||
<div className="border border-hairline rounded-lg p-3">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
{dot && <span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: dot }} />}
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{label}</span>
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide truncate">{label}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div>
|
||||
@@ -231,44 +310,77 @@ function TotalsCards({ totals, t }: { totals: { gateway: UsageCounters; direct:
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Combined card always; for the source axis keep the familiar gateway/direct
|
||||
// pair. For dynamic axes show the top few series so the cards don't explode.
|
||||
const detailKeys = groupBy === 'source' ? keys : keys.slice(0, 2);
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{card(t('totals.combined'), combined)}
|
||||
{card(t('totals.gateway'), totals.gateway, GW)}
|
||||
{card(t('totals.direct'), totals.direct, DR)}
|
||||
{detailKeys.map((k, i) => card(keyLabel(k), totals[k] ?? zeroCounters(), colorFor(k, i, groupBy)))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StackedBars({ series, maxBucket, t }: { series: UsageBucket[]; maxBucket: number; t: TFn }) {
|
||||
const grand = series.reduce((s, b) => s + total(b.gateway) + total(b.direct), 0);
|
||||
function SeriesLegend({
|
||||
keys, groupBy, keyLabel,
|
||||
}: {
|
||||
keys: string[];
|
||||
groupBy: UsageGroupBy;
|
||||
keyLabel: (k: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 flex-wrap text-[11px] text-slate-500">
|
||||
{keys.map((k, i) => (
|
||||
<span key={k} className="flex items-center gap-1">
|
||||
<span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: colorFor(k, i, groupBy) }} />
|
||||
<span className="truncate max-w-[160px]">{keyLabel(k)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StackedBars({
|
||||
series, keys, maxBucket, groupBy, keyLabel, t,
|
||||
}: {
|
||||
series: UsageBucket[];
|
||||
keys: string[];
|
||||
maxBucket: number;
|
||||
groupBy: UsageGroupBy;
|
||||
keyLabel: (k: string) => string;
|
||||
t: TFn;
|
||||
}) {
|
||||
const grand = series.reduce((s, b) => s + bucketTotal(b), 0);
|
||||
const ariaLabel = t('chart.barsAria', { title: t('chart.tokensTitle'), total: fmtTokens(grand), count: series.length });
|
||||
return (
|
||||
<div className="border border-hairline rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{t('chart.tokensTitle')}</span>
|
||||
<Legend t={t} />
|
||||
</div>
|
||||
<div className="flex items-end gap-1 h-44" role="img" aria-label={ariaLabel}>
|
||||
{series.map((b) => {
|
||||
const g = total(b.gateway);
|
||||
const d = total(b.direct);
|
||||
const sum = g + d;
|
||||
const sum = bucketTotal(b);
|
||||
const hPct = maxBucket > 0 ? (sum / maxBucket) * 100 : 0;
|
||||
const gPct = sum > 0 ? (g / sum) * 100 : 0;
|
||||
return (
|
||||
<div key={b.bucket} className="flex-1 min-w-0 flex flex-col items-center group relative">
|
||||
<div className="w-full flex flex-col justify-end" style={{ height: '100%' }}>
|
||||
<div className="w-full rounded-t-sm overflow-hidden flex flex-col" style={{ height: `${Math.max(hPct, sum > 0 ? 2 : 0)}%` }}>
|
||||
<div style={{ height: `${gPct}%`, background: GW }} />
|
||||
<div style={{ height: `${100 - gPct}%`, background: DR }} />
|
||||
{keys.map((k, i) => {
|
||||
const v = total(b.segments[k] ?? zeroCounters());
|
||||
const segPct = sum > 0 ? (v / sum) * 100 : 0;
|
||||
return segPct > 0 ? <div key={k} style={{ height: `${segPct}%`, background: colorFor(k, i, groupBy) }} /> : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Tooltip (decorative — chart summary is exposed via aria-label) */}
|
||||
<div aria-hidden="true" className="pointer-events-none absolute bottom-full mb-1 hidden group-hover:block z-10 whitespace-nowrap bg-slate-800 text-white text-[10px] rounded px-1.5 py-1 shadow">
|
||||
<div className="font-mono">{b.bucket}</div>
|
||||
<div><span style={{ color: GW }}>■</span> {fmtTokens(g)}</div>
|
||||
<div><span style={{ color: DR }}>■</span> {fmtTokens(d)}</div>
|
||||
<div className="font-mono mb-0.5">{b.bucket}</div>
|
||||
{keys.map((k, i) => {
|
||||
const v = total(b.segments[k] ?? zeroCounters());
|
||||
return v > 0 ? (
|
||||
<div key={k}><span style={{ color: colorFor(k, i, groupBy) }}>■</span> {keyLabel(k)}: {fmtTokens(v)}</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -282,31 +394,40 @@ function StackedBars({ series, maxBucket, t }: { series: UsageBucket[]; maxBucke
|
||||
);
|
||||
}
|
||||
|
||||
function CumulativeLine({ series, cumulative, max, t }: { series: UsageBucket[]; cumulative: number[]; max: number; t: TFn }) {
|
||||
function CumulativeLines({
|
||||
series, keys, cumByKey, max, grand, groupBy, t,
|
||||
}: {
|
||||
series: UsageBucket[];
|
||||
keys: string[];
|
||||
cumByKey: Record<string, number[]>;
|
||||
max: number;
|
||||
grand: number;
|
||||
groupBy: UsageGroupBy;
|
||||
t: TFn;
|
||||
}) {
|
||||
const W = 600;
|
||||
const H = 140;
|
||||
const pad = 4;
|
||||
const n = cumulative.length;
|
||||
const coords = cumulative.map((v, i) => {
|
||||
const x = n <= 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad);
|
||||
const y = max > 0 ? H - pad - (v / max) * (H - 2 * pad) : H - pad;
|
||||
return { x, y };
|
||||
});
|
||||
const points = coords.map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(' ');
|
||||
const ariaLabel = t('chart.lineAria', { total: fmtTokens(max) });
|
||||
const n = series.length;
|
||||
const xAt = (i: number) => (n <= 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad));
|
||||
const yAt = (v: number) => (max > 0 ? H - pad - (v / max) * (H - 2 * pad) : H - pad);
|
||||
const ariaLabel = t('chart.lineAria', { total: fmtTokens(grand) });
|
||||
return (
|
||||
<div className="border border-hairline rounded-lg p-4">
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{t('chart.cumulativeTitle')}</span>
|
||||
<span className="text-[11px] text-slate-500 font-mono">{t('chart.total', { value: fmtTokens(max) })}</span>
|
||||
<span className="text-[11px] text-slate-500 font-mono">{t('chart.total', { value: fmtTokens(grand) })}</span>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-36" role="img" aria-label={ariaLabel}>
|
||||
{/* A single bucket can't form a line — draw the point so it's visible. */}
|
||||
{n === 1 ? (
|
||||
<circle cx={coords[0].x} cy={coords[0].y} r={3} fill="var(--accent, #6366f1)" vectorEffect="non-scaling-stroke" />
|
||||
) : (
|
||||
<polyline points={points} fill="none" stroke="var(--accent, #6366f1)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||
)}
|
||||
{keys.map((k, ki) => {
|
||||
const vals = cumByKey[k] ?? [];
|
||||
const color = colorFor(k, ki, groupBy);
|
||||
if (n === 1) {
|
||||
return <circle key={k} cx={xAt(0)} cy={yAt(vals[0] ?? 0)} r={3} fill={color} vectorEffect="non-scaling-stroke" />;
|
||||
}
|
||||
const pts = vals.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(' ');
|
||||
return <polyline key={k} points={pts} fill="none" stroke={color} strokeWidth={2} vectorEffect="non-scaling-stroke" />;
|
||||
})}
|
||||
</svg>
|
||||
<div className="flex justify-between mt-1 text-[10px] text-slate-400 font-mono">
|
||||
<span>{series[0]?.bucket}</span>
|
||||
@@ -316,15 +437,6 @@ function CumulativeLine({ series, cumulative, max, t }: { series: UsageBucket[];
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ t }: { t: TFn }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-[11px] text-slate-500">
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: GW }} />{t('chart.legendGateway')}</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: DR }} />{t('chart.legendDirect')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ByUserTable({ rows, t }: { rows: Array<{ userId: string; displayName: string } & UsageCounters>; t: TFn }) {
|
||||
// Localize the sentinels; real users show their resolved name with the id as
|
||||
// a secondary line.
|
||||
|
||||
Reference in New Issue
Block a user