sync: update from private repo (bfcd4d5)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-11 15:12:40 +00:00
parent c5be399fdd
commit 641fe0177d
15 changed files with 988 additions and 133 deletions
+4 -2
View File
@@ -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>
)}
+182 -70
View File
@@ -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.