feat(dashboard): focus ops usage signals (#25)
* feat(dashboard): focus ops usage signals * style(dashboard): apply usage formatting * fix(dashboard): hide refresh timestamp noise
This commit is contained in:
@@ -36,7 +36,7 @@ type HealthLevel = 'ok' | 'stale' | 'down';
|
|||||||
|
|
||||||
const REFRESH_INTERVAL_MS = 15_000;
|
const REFRESH_INTERVAL_MS = 15_000;
|
||||||
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale';
|
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale';
|
||||||
const DEFAULT_VIEW: DashboardView = 'usage';
|
const DEFAULT_VIEW: DashboardView = 'inbox';
|
||||||
const HEALTH_STALE_MS = 5 * 60_000;
|
const HEALTH_STALE_MS = 5 * 60_000;
|
||||||
const HEALTH_DOWN_MS = 15 * 60_000;
|
const HEALTH_DOWN_MS = 15 * 60_000;
|
||||||
|
|
||||||
@@ -139,6 +139,12 @@ function usagePeak(row: UsageRow): number {
|
|||||||
return Math.max(row.h5pct, row.d7pct);
|
return Math.max(row.h5pct, row.d7pct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usageRemaining(row: UsageRow): number | null {
|
||||||
|
const peak = usagePeak(row);
|
||||||
|
if (peak < 0) return null;
|
||||||
|
return Math.max(0, 100 - peak);
|
||||||
|
}
|
||||||
|
|
||||||
function usageRiskLevel(row: UsageRow): RiskLevel {
|
function usageRiskLevel(row: UsageRow): RiskLevel {
|
||||||
const peak = usagePeak(row);
|
const peak = usagePeak(row);
|
||||||
if (peak >= 85) return 'critical';
|
if (peak >= 85) return 'critical';
|
||||||
@@ -146,6 +152,49 @@ function usageRiskLevel(row: UsageRow): RiskLevel {
|
|||||||
return 'ok';
|
return 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usageActive(row: UsageRow): boolean {
|
||||||
|
return row.name.includes('*');
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageLimited(row: UsageRow): boolean {
|
||||||
|
return row.name.includes('!');
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageNameParts(row: UsageRow): {
|
||||||
|
account: string;
|
||||||
|
plan: string | null;
|
||||||
|
} {
|
||||||
|
const cleaned = row.name.replace(/[*!]/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
const parts = cleaned.split(' ');
|
||||||
|
const plan = parts.at(-1) ?? null;
|
||||||
|
if (plan && ['max', 'mid', 'pro', 'team'].includes(plan.toLowerCase())) {
|
||||||
|
return { account: parts.slice(0, -1).join(' ') || cleaned, plan };
|
||||||
|
}
|
||||||
|
return { account: cleaned, plan: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageLimitReset(row: UsageRow): string {
|
||||||
|
return (row.d7pct >= row.h5pct ? row.d7reset : row.h5reset).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageBurnRate(row: UsageRow): number | null {
|
||||||
|
if (row.h5pct < 0) return null;
|
||||||
|
return row.h5pct / 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageSpeedLevel(rate: number | null): RiskLevel {
|
||||||
|
if (rate === null) return 'ok';
|
||||||
|
if (rate >= 12) return 'critical';
|
||||||
|
if (rate >= 7) return 'warn';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUsageRate(rate: number | null): string {
|
||||||
|
if (rate === null) return '-';
|
||||||
|
if (rate > 0 && rate < 1) return '<1%/h';
|
||||||
|
return `${Math.round(rate)}%/h`;
|
||||||
|
}
|
||||||
|
|
||||||
function usageGroup(row: UsageRow): UsageGroup {
|
function usageGroup(row: UsageRow): UsageGroup {
|
||||||
return row.name.toLowerCase().startsWith('codex') ? 'codex' : 'primary';
|
return row.name.toLowerCase().startsWith('codex') ? 'codex' : 'primary';
|
||||||
}
|
}
|
||||||
@@ -322,7 +371,6 @@ function LoadingSkeleton({ t }: { t: Messages }) {
|
|||||||
|
|
||||||
function SideRail({
|
function SideRail({
|
||||||
activeView,
|
activeView,
|
||||||
lastRefreshed,
|
|
||||||
locale,
|
locale,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
onLocaleChange,
|
onLocaleChange,
|
||||||
@@ -331,7 +379,6 @@ function SideRail({
|
|||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
activeView: DashboardView;
|
activeView: DashboardView;
|
||||||
lastRefreshed: string | null;
|
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
onNavigate: (view: DashboardView) => void;
|
onNavigate: (view: DashboardView) => void;
|
||||||
onLocaleChange: (locale: Locale) => void;
|
onLocaleChange: (locale: Locale) => void;
|
||||||
@@ -368,10 +415,6 @@ function SideRail({
|
|||||||
>
|
>
|
||||||
{refreshing ? t.actions.refreshing : t.actions.refresh}
|
{refreshing ? t.actions.refreshing : t.actions.refresh}
|
||||||
</button>
|
</button>
|
||||||
<div className="drawer-meta">
|
|
||||||
<span>{t.nav.updated}</span>
|
|
||||||
<strong>{formatDate(lastRefreshed, locale)}</strong>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -379,7 +422,6 @@ function SideRail({
|
|||||||
function SectionNav({
|
function SectionNav({
|
||||||
activeView,
|
activeView,
|
||||||
drawerOpen,
|
drawerOpen,
|
||||||
lastRefreshed,
|
|
||||||
locale,
|
locale,
|
||||||
onCloseDrawer,
|
onCloseDrawer,
|
||||||
onLocaleChange,
|
onLocaleChange,
|
||||||
@@ -391,7 +433,6 @@ function SectionNav({
|
|||||||
}: {
|
}: {
|
||||||
activeView: DashboardView;
|
activeView: DashboardView;
|
||||||
drawerOpen: boolean;
|
drawerOpen: boolean;
|
||||||
lastRefreshed: string | null;
|
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
onCloseDrawer: () => void;
|
onCloseDrawer: () => void;
|
||||||
onLocaleChange: (locale: Locale) => void;
|
onLocaleChange: (locale: Locale) => void;
|
||||||
@@ -430,9 +471,6 @@ function SectionNav({
|
|||||||
>
|
>
|
||||||
{refreshing ? '...' : t.actions.refresh}
|
{refreshing ? '...' : t.actions.refresh}
|
||||||
</button>
|
</button>
|
||||||
<span>
|
|
||||||
{t.nav.updated} {formatDate(lastRefreshed, locale)}
|
|
||||||
</span>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{drawerOpen ? (
|
{drawerOpen ? (
|
||||||
@@ -484,10 +522,6 @@ function SectionNav({
|
|||||||
onLocaleChange={onLocaleChange}
|
onLocaleChange={onLocaleChange}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
<div className="drawer-meta">
|
|
||||||
<span>{t.nav.updated}</span>
|
|
||||||
<strong>{formatDate(lastRefreshed, locale)}</strong>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -631,9 +665,14 @@ function InboxPanel({
|
|||||||
<span className="eyebrow">{t.inbox.kinds[item.kind]}</span>
|
<span className="eyebrow">{t.inbox.kinds[item.kind]}</span>
|
||||||
<strong>{item.title}</strong>
|
<strong>{item.title}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="inbox-card-badges">
|
||||||
<span className={`pill pill-${item.severity}`}>
|
<span className={`pill pill-${item.severity}`}>
|
||||||
{t.inbox.severity[item.severity]}
|
{t.inbox.severity[item.severity]}
|
||||||
</span>
|
</span>
|
||||||
|
{item.occurrences > 1 ? (
|
||||||
|
<span className="pill pill-info">x{item.occurrences}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p>{item.summary || t.inbox.noSummary}</p>
|
<p>{item.summary || t.inbox.noSummary}</p>
|
||||||
<div className="inbox-meta">
|
<div className="inbox-meta">
|
||||||
@@ -696,9 +735,11 @@ function HealthPanel({
|
|||||||
},
|
},
|
||||||
{ pendingTasks: 0, pendingMessageRooms: 0 },
|
{ pendingTasks: 0, pendingMessageRooms: 0 },
|
||||||
);
|
);
|
||||||
const ciFailures = data.overview.inbox.filter(
|
const ciFailures = data.overview.inbox.reduce(
|
||||||
(item) => item.kind === 'ci-failure',
|
(count, item) =>
|
||||||
).length;
|
item.kind === 'ci-failure' ? count + item.occurrences : count,
|
||||||
|
0,
|
||||||
|
);
|
||||||
const healthLevel: HealthLevel =
|
const healthLevel: HealthLevel =
|
||||||
down > 0 ? 'down' : stale > 0 || ciFailures > 0 ? 'stale' : 'ok';
|
down > 0 ? 'down' : stale > 0 || ciFailures > 0 ? 'stale' : 'ok';
|
||||||
const affectedServices = serviceLevels.filter((item) => item.level !== 'ok');
|
const affectedServices = serviceLevels.filter((item) => item.level !== 'ok');
|
||||||
@@ -735,9 +776,6 @@ function HealthPanel({
|
|||||||
<div>
|
<div>
|
||||||
<span>{t.health.ciFailures}</span>
|
<span>{t.health.ciFailures}</span>
|
||||||
<strong>{ciFailures}</strong>
|
<strong>{ciFailures}</strong>
|
||||||
<small>
|
|
||||||
{data.overview.tasks.watchers.paused} {t.status.paused}
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -803,10 +841,9 @@ function RoomPanel({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t.rooms.room}</th>
|
<th>{t.rooms.room}</th>
|
||||||
<th>{t.rooms.service}</th>
|
|
||||||
<th>{t.rooms.agent}</th>
|
|
||||||
<th>{t.rooms.status}</th>
|
<th>{t.rooms.status}</th>
|
||||||
<th>{t.rooms.queue}</th>
|
<th>{t.rooms.queue}</th>
|
||||||
|
<th>{t.rooms.elapsed}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -814,21 +851,23 @@ function RoomPanel({
|
|||||||
<tr key={`${entry.serviceId}:${entry.jid}`}>
|
<tr key={`${entry.serviceId}:${entry.jid}`}>
|
||||||
<td>
|
<td>
|
||||||
<strong>{entry.name}</strong>
|
<strong>{entry.name}</strong>
|
||||||
|
<details className="record-details">
|
||||||
|
<summary>{t.rooms.details}</summary>
|
||||||
<span>
|
<span>
|
||||||
{entry.folder} · {entry.jid}
|
{entry.folder} · {entry.jid} · {entry.serviceId} ·{' '}
|
||||||
|
{entry.agentType}
|
||||||
</span>
|
</span>
|
||||||
|
</details>
|
||||||
</td>
|
</td>
|
||||||
<td>{entry.serviceId}</td>
|
|
||||||
<td>{entry.agentType}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<span className={`pill pill-${entry.status}`}>
|
<span className={`pill pill-${entry.status}`}>
|
||||||
{statusLabel(entry.status, t)}
|
{statusLabel(entry.status, t)}
|
||||||
</span>
|
</span>
|
||||||
<small>{formatDuration(entry.elapsedMs, t)}</small>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{queueLabel(entry.pendingTasks, entry.pendingMessages, t)}
|
{queueLabel(entry.pendingTasks, entry.pendingMessages, t)}
|
||||||
</td>
|
</td>
|
||||||
|
<td>{formatDuration(entry.elapsedMs, t)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -857,20 +896,18 @@ function RoomPanel({
|
|||||||
{queueLabel(entry.pendingTasks, entry.pendingMessages, t)}
|
{queueLabel(entry.pendingTasks, entry.pendingMessages, t)}
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
|
||||||
<small>{t.rooms.agent}</small>
|
|
||||||
<strong>{entry.agentType}</strong>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<small>{t.rooms.service}</small>
|
|
||||||
<strong>{entry.serviceId}</strong>
|
|
||||||
</span>
|
|
||||||
<span>
|
<span>
|
||||||
<small>{t.rooms.elapsed}</small>
|
<small>{t.rooms.elapsed}</small>
|
||||||
<strong>{formatDuration(entry.elapsedMs, t)}</strong>
|
<strong>{formatDuration(entry.elapsedMs, t)}</strong>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="record-id">{entry.jid}</p>
|
<details className="record-details">
|
||||||
|
<summary>{t.rooms.details}</summary>
|
||||||
|
<p className="record-id">
|
||||||
|
{entry.folder} · {entry.jid} · {entry.serviceId} ·{' '}
|
||||||
|
{entry.agentType}
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -878,54 +915,80 @@ function RoomPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UsageMeter({
|
function UsageRemainingMeter({
|
||||||
label,
|
row,
|
||||||
pct,
|
|
||||||
reset,
|
|
||||||
rowName,
|
rowName,
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
row: UsageRow;
|
||||||
pct: number;
|
|
||||||
reset: string;
|
|
||||||
rowName: string;
|
rowName: string;
|
||||||
t: Messages;
|
t: Messages;
|
||||||
}) {
|
}) {
|
||||||
|
const remaining = usageRemaining(row);
|
||||||
|
const reset = usageLimitReset(row);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="usage-window">
|
<div className="usage-remaining">
|
||||||
<div>
|
<div>
|
||||||
<span>{label}</span>
|
<span>{t.usage.remaining}</span>
|
||||||
<strong>{formatPct(pct)}</strong>
|
<strong>{remaining === null ? '-' : formatPct(remaining)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<progress
|
<progress
|
||||||
aria-label={`${rowName} ${label} ${t.usage.usage} ${formatPct(pct)}`}
|
aria-label={`${rowName} ${t.usage.remaining} ${
|
||||||
|
remaining === null ? '-' : formatPct(remaining)
|
||||||
|
}`}
|
||||||
max={100}
|
max={100}
|
||||||
value={Math.max(0, pct)}
|
value={remaining ?? 0}
|
||||||
/>
|
/>
|
||||||
<small>
|
<small>{reset ? `${t.usage.reset} ${reset}` : t.usage.noReset}</small>
|
||||||
{t.usage.reset} {reset || '-'}
|
</div>
|
||||||
</small>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageSpeed({ row, t }: { row: UsageRow; t: Messages }) {
|
||||||
|
const rate = usageBurnRate(row);
|
||||||
|
const level = usageSpeedLevel(rate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`usage-speed usage-speed-${level}`}>
|
||||||
|
<span>{t.usage.speed}</span>
|
||||||
|
<strong>{formatUsageRate(rate)}</strong>
|
||||||
|
<small>{t.usage.speedLabel[level]}</small>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UsagePanel({
|
function UsagePanel({
|
||||||
overview,
|
overview,
|
||||||
locale,
|
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
overview: DashboardOverview;
|
overview: DashboardOverview;
|
||||||
locale: Locale;
|
|
||||||
t: Messages;
|
t: Messages;
|
||||||
}) {
|
}) {
|
||||||
const rows = useMemo(() => [...overview.usage.rows], [overview.usage.rows]);
|
const rows = useMemo(
|
||||||
|
() =>
|
||||||
|
[...overview.usage.rows].sort((a, b) => {
|
||||||
|
if (usageActive(a) !== usageActive(b)) return usageActive(a) ? -1 : 1;
|
||||||
|
return usagePeak(b) - usagePeak(a);
|
||||||
|
}),
|
||||||
|
[overview.usage.rows],
|
||||||
|
);
|
||||||
const watched = rows.filter((row) => usagePeak(row) >= 65).length;
|
const watched = rows.filter((row) => usagePeak(row) >= 65).length;
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return <EmptyState>{t.usage.empty}</EmptyState>;
|
return <EmptyState>{t.usage.empty}</EmptyState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const highest = rows[0];
|
const activeRows = rows.filter(usageActive);
|
||||||
|
const focusRows = activeRows.length > 0 ? activeRows : rows.slice(0, 1);
|
||||||
|
const focusLabel = activeRows.length > 0 ? t.usage.current : t.usage.tightest;
|
||||||
|
const focusValue = focusRows
|
||||||
|
.map((row) => {
|
||||||
|
const { account } = usageNameParts(row);
|
||||||
|
const remaining = usageRemaining(row);
|
||||||
|
return `${account} ${remaining === null ? '-' : formatPct(remaining)}`;
|
||||||
|
})
|
||||||
|
.join(' · ');
|
||||||
const groups = [
|
const groups = [
|
||||||
{
|
{
|
||||||
key: 'primary' as const,
|
key: 'primary' as const,
|
||||||
@@ -943,26 +1006,20 @@ function UsagePanel({
|
|||||||
<div className="usage-dashboard">
|
<div className="usage-dashboard">
|
||||||
<div className="usage-summary">
|
<div className="usage-summary">
|
||||||
<div>
|
<div>
|
||||||
<span>{t.usage.highest}</span>
|
<span>{focusLabel}</span>
|
||||||
<strong>
|
<strong>{focusValue}</strong>
|
||||||
{highest.name} · {formatPct(usagePeak(highest))}
|
|
||||||
</strong>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>{t.usage.watch}</span>
|
<span>{t.usage.watch}</span>
|
||||||
<strong>{watched}</strong>
|
<strong>{watched}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span>{t.usage.updated}</span>
|
|
||||||
<strong>{formatDate(overview.usage.fetchedAt, locale)}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="usage-matrix" role="table" aria-label={t.panels.usage}>
|
<div className="usage-matrix" role="table" aria-label={t.panels.usage}>
|
||||||
<div className="usage-matrix-head" role="row">
|
<div className="usage-matrix-head" role="row">
|
||||||
<span>{t.usage.usage}</span>
|
<span>{t.usage.usage}</span>
|
||||||
<span>{t.usage.window5h}</span>
|
<span>{t.usage.remaining}</span>
|
||||||
<span>{t.usage.window7d}</span>
|
<span>{t.usage.speed}</span>
|
||||||
</div>
|
</div>
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<div className="usage-group" key={group.key} role="rowgroup">
|
<div className="usage-group" key={group.key} role="rowgroup">
|
||||||
@@ -971,28 +1028,25 @@ function UsagePanel({
|
|||||||
</div>
|
</div>
|
||||||
{group.rows.map((row) => {
|
{group.rows.map((row) => {
|
||||||
const risk = usageRiskLevel(row);
|
const risk = usageRiskLevel(row);
|
||||||
|
const { account, plan } = usageNameParts(row);
|
||||||
return (
|
return (
|
||||||
<section className={`usage-row usage-${risk}`} key={row.name}>
|
<section className={`usage-row usage-${risk}`} key={row.name}>
|
||||||
<div className="usage-account">
|
<div className="usage-account">
|
||||||
<strong>{row.name}</strong>
|
<strong>{account}</strong>
|
||||||
|
<div>
|
||||||
|
{usageActive(row) ? (
|
||||||
|
<span className="pill pill-info">{t.usage.inUse}</span>
|
||||||
|
) : null}
|
||||||
|
{plan ? <span className="mono-chip">{plan}</span> : null}
|
||||||
|
{usageLimited(row) || risk !== 'ok' ? (
|
||||||
<span className={`pill pill-${risk}`}>
|
<span className={`pill pill-${risk}`}>
|
||||||
{t.usage.risk[risk]}
|
{t.usage.risk[risk]}
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<UsageMeter
|
</div>
|
||||||
label={t.usage.window5h}
|
<UsageRemainingMeter row={row} rowName={account} t={t} />
|
||||||
pct={row.h5pct}
|
<UsageSpeed row={row} t={t} />
|
||||||
reset={row.h5reset}
|
|
||||||
rowName={row.name}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
<UsageMeter
|
|
||||||
label={t.usage.window7d}
|
|
||||||
pct={row.d7pct}
|
|
||||||
reset={row.d7reset}
|
|
||||||
rowName={row.name}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1192,7 +1246,6 @@ function App() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [lastRefreshed, setLastRefreshed] = useState<string | null>(null);
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [activeView, setActiveView] = useState<DashboardView>(readViewFromHash);
|
const [activeView, setActiveView] = useState<DashboardView>(readViewFromHash);
|
||||||
const [locale, setLocale] = useState<Locale>(readInitialLocale);
|
const [locale, setLocale] = useState<Locale>(readInitialLocale);
|
||||||
@@ -1215,7 +1268,6 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const nextData = await fetchDashboardData();
|
const nextData = await fetchDashboardData();
|
||||||
setData(nextData);
|
setData(nextData);
|
||||||
setLastRefreshed(new Date().toISOString());
|
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -1266,7 +1318,6 @@ function App() {
|
|||||||
<div className="shell">
|
<div className="shell">
|
||||||
<SideRail
|
<SideRail
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
lastRefreshed={lastRefreshed}
|
|
||||||
locale={locale}
|
locale={locale}
|
||||||
onNavigate={navigateToView}
|
onNavigate={navigateToView}
|
||||||
onLocaleChange={setDashboardLocale}
|
onLocaleChange={setDashboardLocale}
|
||||||
@@ -1278,7 +1329,6 @@ function App() {
|
|||||||
<SectionNav
|
<SectionNav
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
drawerOpen={drawerOpen}
|
drawerOpen={drawerOpen}
|
||||||
lastRefreshed={lastRefreshed}
|
|
||||||
locale={locale}
|
locale={locale}
|
||||||
onCloseDrawer={() => setDrawerOpen(false)}
|
onCloseDrawer={() => setDrawerOpen(false)}
|
||||||
onLocaleChange={setDashboardLocale}
|
onLocaleChange={setDashboardLocale}
|
||||||
@@ -1309,7 +1359,7 @@ function App() {
|
|||||||
<h2>{t.panels.usage}</h2>
|
<h2>{t.panels.usage}</h2>
|
||||||
<span>{t.panels.usageWindow}</span>
|
<span>{t.panels.usageWindow}</span>
|
||||||
</div>
|
</div>
|
||||||
<UsagePanel locale={locale} overview={data.overview} t={t} />
|
<UsagePanel overview={data.overview} t={t} />
|
||||||
</section>
|
</section>
|
||||||
<ControlRail data={data} t={t} />
|
<ControlRail data={data} t={t} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface DashboardOverview {
|
|||||||
};
|
};
|
||||||
inbox: Array<{
|
inbox: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
groupKey: string;
|
||||||
kind:
|
kind:
|
||||||
| 'pending-room'
|
| 'pending-room'
|
||||||
| 'reviewer-request'
|
| 'reviewer-request'
|
||||||
@@ -48,7 +49,9 @@ export interface DashboardOverview {
|
|||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
|
lastOccurredAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
occurrences: number;
|
||||||
source: 'status-snapshot' | 'paired-task' | 'scheduled-task';
|
source: 'status-snapshot' | 'paired-task' | 'scheduled-task';
|
||||||
roomJid?: string;
|
roomJid?: string;
|
||||||
roomName?: string;
|
roomName?: string;
|
||||||
|
|||||||
@@ -114,17 +114,19 @@ export interface Messages {
|
|||||||
status: string;
|
status: string;
|
||||||
queue: string;
|
queue: string;
|
||||||
elapsed: string;
|
elapsed: string;
|
||||||
|
details: string;
|
||||||
};
|
};
|
||||||
usage: {
|
usage: {
|
||||||
empty: string;
|
empty: string;
|
||||||
highest: string;
|
current: string;
|
||||||
|
tightest: string;
|
||||||
watch: string;
|
watch: string;
|
||||||
updated: string;
|
remaining: string;
|
||||||
peak: string;
|
speed: string;
|
||||||
|
inUse: string;
|
||||||
reset: string;
|
reset: string;
|
||||||
|
noReset: string;
|
||||||
usage: string;
|
usage: string;
|
||||||
window5h: string;
|
|
||||||
window7d: string;
|
|
||||||
groupPrimary: string;
|
groupPrimary: string;
|
||||||
groupCodex: string;
|
groupCodex: string;
|
||||||
risk: {
|
risk: {
|
||||||
@@ -132,6 +134,11 @@ export interface Messages {
|
|||||||
warn: string;
|
warn: string;
|
||||||
critical: string;
|
critical: string;
|
||||||
};
|
};
|
||||||
|
speedLabel: {
|
||||||
|
ok: string;
|
||||||
|
warn: string;
|
||||||
|
critical: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
tasks: {
|
tasks: {
|
||||||
empty: string;
|
empty: string;
|
||||||
@@ -244,7 +251,7 @@ export const messages = {
|
|||||||
inbox: '인입',
|
inbox: '인입',
|
||||||
inboxQueue: '대기 항목',
|
inboxQueue: '대기 항목',
|
||||||
usage: '사용량',
|
usage: '사용량',
|
||||||
usageWindow: '5시간 / 7일',
|
usageWindow: '남음 / 속도',
|
||||||
rooms: '룸',
|
rooms: '룸',
|
||||||
queue: '큐',
|
queue: '큐',
|
||||||
scheduled: '예약',
|
scheduled: '예약',
|
||||||
@@ -308,17 +315,19 @@ export const messages = {
|
|||||||
status: '상태',
|
status: '상태',
|
||||||
queue: '큐',
|
queue: '큐',
|
||||||
elapsed: '경과',
|
elapsed: '경과',
|
||||||
|
details: '세부',
|
||||||
},
|
},
|
||||||
usage: {
|
usage: {
|
||||||
empty: '사용량 스냅샷 없음. 수집기 확인.',
|
empty: '사용량 스냅샷 없음. 수집기 확인.',
|
||||||
highest: '최고',
|
current: '사용중',
|
||||||
|
tightest: '가장 적음',
|
||||||
watch: '주의',
|
watch: '주의',
|
||||||
updated: '갱신',
|
remaining: '남음',
|
||||||
peak: '피크',
|
speed: '속도',
|
||||||
|
inUse: '사용중',
|
||||||
reset: '리셋',
|
reset: '리셋',
|
||||||
|
noReset: '리셋 없음',
|
||||||
usage: '사용량',
|
usage: '사용량',
|
||||||
window5h: '5시간',
|
|
||||||
window7d: '7일',
|
|
||||||
groupPrimary: 'Claude / Kimi',
|
groupPrimary: 'Claude / Kimi',
|
||||||
groupCodex: 'Codex',
|
groupCodex: 'Codex',
|
||||||
risk: {
|
risk: {
|
||||||
@@ -326,6 +335,11 @@ export const messages = {
|
|||||||
warn: '주의',
|
warn: '주의',
|
||||||
critical: '위험',
|
critical: '위험',
|
||||||
},
|
},
|
||||||
|
speedLabel: {
|
||||||
|
ok: '보통',
|
||||||
|
warn: '빠름',
|
||||||
|
critical: '너무 빠름',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tasks: {
|
tasks: {
|
||||||
empty: '예약 작업 없음.',
|
empty: '예약 작업 없음.',
|
||||||
@@ -422,7 +436,7 @@ export const messages = {
|
|||||||
inbox: 'Inbox',
|
inbox: 'Inbox',
|
||||||
inboxQueue: 'Work intake',
|
inboxQueue: 'Work intake',
|
||||||
usage: 'Usage',
|
usage: 'Usage',
|
||||||
usageWindow: '5h / 7d',
|
usageWindow: 'Remaining / speed',
|
||||||
rooms: 'Rooms',
|
rooms: 'Rooms',
|
||||||
queue: 'Queue',
|
queue: 'Queue',
|
||||||
scheduled: 'Scheduled',
|
scheduled: 'Scheduled',
|
||||||
@@ -486,17 +500,19 @@ export const messages = {
|
|||||||
status: 'status',
|
status: 'status',
|
||||||
queue: 'queue',
|
queue: 'queue',
|
||||||
elapsed: 'elapsed',
|
elapsed: 'elapsed',
|
||||||
|
details: 'details',
|
||||||
},
|
},
|
||||||
usage: {
|
usage: {
|
||||||
empty: 'No usage snapshot. Check collector.',
|
empty: 'No usage snapshot. Check collector.',
|
||||||
highest: 'Highest',
|
current: 'In use',
|
||||||
|
tightest: 'Lowest',
|
||||||
watch: 'Watch',
|
watch: 'Watch',
|
||||||
updated: 'Updated',
|
remaining: 'Left',
|
||||||
peak: 'Peak',
|
speed: 'Speed',
|
||||||
|
inUse: 'in use',
|
||||||
reset: 'reset',
|
reset: 'reset',
|
||||||
|
noReset: 'no reset',
|
||||||
usage: 'usage',
|
usage: 'usage',
|
||||||
window5h: '5h',
|
|
||||||
window7d: '7d',
|
|
||||||
groupPrimary: 'Claude / Kimi',
|
groupPrimary: 'Claude / Kimi',
|
||||||
groupCodex: 'Codex',
|
groupCodex: 'Codex',
|
||||||
risk: {
|
risk: {
|
||||||
@@ -504,6 +520,11 @@ export const messages = {
|
|||||||
warn: 'Watch',
|
warn: 'Watch',
|
||||||
critical: 'Limit risk',
|
critical: 'Limit risk',
|
||||||
},
|
},
|
||||||
|
speedLabel: {
|
||||||
|
ok: 'Normal',
|
||||||
|
warn: 'Fast',
|
||||||
|
critical: 'Too fast',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tasks: {
|
tasks: {
|
||||||
empty: 'No scheduled work.',
|
empty: 'No scheduled work.',
|
||||||
@@ -600,7 +621,7 @@ export const messages = {
|
|||||||
inbox: '收件',
|
inbox: '收件',
|
||||||
inboxQueue: '待处理',
|
inboxQueue: '待处理',
|
||||||
usage: '用量',
|
usage: '用量',
|
||||||
usageWindow: '5小时 / 7天',
|
usageWindow: '剩余 / 速度',
|
||||||
rooms: '房间',
|
rooms: '房间',
|
||||||
queue: '队列',
|
queue: '队列',
|
||||||
scheduled: '计划',
|
scheduled: '计划',
|
||||||
@@ -617,7 +638,7 @@ export const messages = {
|
|||||||
system: '系统',
|
system: '系统',
|
||||||
signals: '健康信号',
|
signals: '健康信号',
|
||||||
services: '服务',
|
services: '服务',
|
||||||
fresh: '心跳正常',
|
fresh: '正常',
|
||||||
stale: '延迟',
|
stale: '延迟',
|
||||||
queue: '队列',
|
queue: '队列',
|
||||||
ciFailures: 'CI 失败',
|
ciFailures: 'CI 失败',
|
||||||
@@ -664,17 +685,19 @@ export const messages = {
|
|||||||
status: '状态',
|
status: '状态',
|
||||||
queue: '队列',
|
queue: '队列',
|
||||||
elapsed: '耗时',
|
elapsed: '耗时',
|
||||||
|
details: '详情',
|
||||||
},
|
},
|
||||||
usage: {
|
usage: {
|
||||||
empty: '暂无用量快照。检查采集器。',
|
empty: '暂无用量快照。检查采集器。',
|
||||||
highest: '最高',
|
current: '使用中',
|
||||||
|
tightest: '剩余最少',
|
||||||
watch: '关注',
|
watch: '关注',
|
||||||
updated: '更新',
|
remaining: '剩余',
|
||||||
peak: '峰值',
|
speed: '速度',
|
||||||
|
inUse: '使用中',
|
||||||
reset: '重置',
|
reset: '重置',
|
||||||
|
noReset: '无重置',
|
||||||
usage: '用量',
|
usage: '用量',
|
||||||
window5h: '5小时',
|
|
||||||
window7d: '7天',
|
|
||||||
groupPrimary: 'Claude / Kimi',
|
groupPrimary: 'Claude / Kimi',
|
||||||
groupCodex: 'Codex',
|
groupCodex: 'Codex',
|
||||||
risk: {
|
risk: {
|
||||||
@@ -682,6 +705,11 @@ export const messages = {
|
|||||||
warn: '关注',
|
warn: '关注',
|
||||||
critical: '接近上限',
|
critical: '接近上限',
|
||||||
},
|
},
|
||||||
|
speedLabel: {
|
||||||
|
ok: '正常',
|
||||||
|
warn: '较快',
|
||||||
|
critical: '过快',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tasks: {
|
tasks: {
|
||||||
empty: '暂无计划任务。',
|
empty: '暂无计划任务。',
|
||||||
@@ -778,7 +806,7 @@ export const messages = {
|
|||||||
inbox: '受信',
|
inbox: '受信',
|
||||||
inboxQueue: '対応待ち',
|
inboxQueue: '対応待ち',
|
||||||
usage: '使用量',
|
usage: '使用量',
|
||||||
usageWindow: '5時間 / 7日',
|
usageWindow: '残量 / 速度',
|
||||||
rooms: 'ルーム',
|
rooms: 'ルーム',
|
||||||
queue: 'キュー',
|
queue: 'キュー',
|
||||||
scheduled: '予定',
|
scheduled: '予定',
|
||||||
@@ -842,17 +870,19 @@ export const messages = {
|
|||||||
status: '状態',
|
status: '状態',
|
||||||
queue: 'キュー',
|
queue: 'キュー',
|
||||||
elapsed: '経過',
|
elapsed: '経過',
|
||||||
|
details: '詳細',
|
||||||
},
|
},
|
||||||
usage: {
|
usage: {
|
||||||
empty: '使用量スナップショットなし。収集器を確認。',
|
empty: '使用量スナップショットなし。収集器を確認。',
|
||||||
highest: '最高',
|
current: '使用中',
|
||||||
|
tightest: '残量最少',
|
||||||
watch: '注意',
|
watch: '注意',
|
||||||
updated: '更新',
|
remaining: '残量',
|
||||||
peak: 'ピーク',
|
speed: '速度',
|
||||||
|
inUse: '使用中',
|
||||||
reset: 'リセット',
|
reset: 'リセット',
|
||||||
|
noReset: 'リセットなし',
|
||||||
usage: '使用量',
|
usage: '使用量',
|
||||||
window5h: '5時間',
|
|
||||||
window7d: '7日',
|
|
||||||
groupPrimary: 'Claude / Kimi',
|
groupPrimary: 'Claude / Kimi',
|
||||||
groupCodex: 'Codex',
|
groupCodex: 'Codex',
|
||||||
risk: {
|
risk: {
|
||||||
@@ -860,6 +890,11 @@ export const messages = {
|
|||||||
warn: '注意',
|
warn: '注意',
|
||||||
critical: '上限注意',
|
critical: '上限注意',
|
||||||
},
|
},
|
||||||
|
speedLabel: {
|
||||||
|
ok: '通常',
|
||||||
|
warn: '速い',
|
||||||
|
critical: '速すぎ',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tasks: {
|
tasks: {
|
||||||
empty: '予定作業なし。',
|
empty: '予定作業なし。',
|
||||||
|
|||||||
@@ -206,17 +206,6 @@ button:disabled {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-nav span {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 750;
|
|
||||||
text-align: right;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-rail {
|
.side-rail {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 18px;
|
top: 18px;
|
||||||
@@ -383,19 +372,6 @@ button:disabled {
|
|||||||
content: '↘';
|
content: '↘';
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-meta {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 18px;
|
|
||||||
color: var(--muted);
|
|
||||||
background: rgba(43, 55, 38, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-meta strong {
|
|
||||||
color: var(--bg-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-card,
|
.error-card,
|
||||||
.card,
|
.card,
|
||||||
.panel {
|
.panel {
|
||||||
@@ -627,7 +603,7 @@ td small {
|
|||||||
|
|
||||||
.usage-summary {
|
.usage-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.1fr 0.46fr 0.9fr;
|
grid-template-columns: minmax(0, 1fr) minmax(90px, 0.28fr);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,7 +622,7 @@ td small {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.usage-summary span,
|
.usage-summary span,
|
||||||
.usage-window small {
|
.usage-remaining small {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,7 +642,10 @@ td small {
|
|||||||
.usage-matrix-head,
|
.usage-matrix-head,
|
||||||
.usage-row {
|
.usage-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(120px, 0.72fr) minmax(0, 1fr) minmax(0, 1fr);
|
grid-template-columns: minmax(132px, 0.78fr) minmax(0, 1fr) minmax(
|
||||||
|
96px,
|
||||||
|
0.46fr
|
||||||
|
);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -714,7 +693,7 @@ td small {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.usage-account,
|
.usage-account,
|
||||||
.usage-window div {
|
.usage-remaining div {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -723,7 +702,16 @@ td small {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.usage-account {
|
.usage-account {
|
||||||
justify-content: flex-start;
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-account div {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-account strong {
|
.usage-account strong {
|
||||||
@@ -735,7 +723,8 @@ td small {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-window span {
|
.usage-remaining span,
|
||||||
|
.usage-speed span {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
@@ -743,12 +732,38 @@ td small {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-window {
|
.usage-remaining,
|
||||||
|
.usage-speed {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.usage-speed {
|
||||||
|
min-height: 54px;
|
||||||
|
align-content: center;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(43, 55, 38, 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-speed strong {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-speed small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-speed-warn {
|
||||||
|
background: rgba(181, 138, 34, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-speed-critical {
|
||||||
|
background: rgba(183, 71, 52, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
progress {
|
progress {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
@@ -904,6 +919,13 @@ progress::-moz-progress-bar {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inbox-card-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.inbox-card-head strong,
|
.inbox-card-head strong,
|
||||||
.inbox-card p,
|
.inbox-card p,
|
||||||
.inbox-meta strong,
|
.inbox-meta strong,
|
||||||
@@ -1028,6 +1050,23 @@ progress::-moz-progress-bar {
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.record-details {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-details summary {
|
||||||
|
display: inline-flex;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-details summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.task-board {
|
.task-board {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -1462,10 +1501,6 @@ progress::-moz-progress-bar {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-nav span {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-nav .menu-button span {
|
.section-nav .menu-button span {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -1520,11 +1555,7 @@ progress::-moz-progress-bar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.usage-summary {
|
.usage-summary {
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(78px, 0.55fr);
|
grid-template-columns: minmax(0, 1fr) minmax(78px, 0.4fr);
|
||||||
}
|
|
||||||
|
|
||||||
.usage-summary div:last-child {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-matrix-head {
|
.usage-matrix-head {
|
||||||
@@ -1532,7 +1563,7 @@ progress::-moz-progress-bar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.usage-row {
|
.usage-row {
|
||||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1fr) minmax(90px, 0.5fr);
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 9px;
|
padding: 9px;
|
||||||
}
|
}
|
||||||
@@ -1542,15 +1573,16 @@ progress::-moz-progress-bar {
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-window {
|
.usage-remaining,
|
||||||
|
.usage-speed {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-window div {
|
.usage-remaining div {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-window small {
|
.usage-remaining small {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -1650,7 +1682,6 @@ progress::-moz-progress-bar {
|
|||||||
|
|
||||||
.usage-account {
|
.usage-account {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-grid,
|
.skeleton-grid,
|
||||||
|
|||||||
@@ -291,6 +291,14 @@ describe('web dashboard data', () => {
|
|||||||
last_result: 'Error: BOT_TOKEN=plain-secret-value failed',
|
last_result: 'Error: BOT_TOKEN=plain-secret-value failed',
|
||||||
status: 'paused',
|
status: 'paused',
|
||||||
}),
|
}),
|
||||||
|
makeTask({
|
||||||
|
id: 'ci-2',
|
||||||
|
prompt:
|
||||||
|
'[BACKGROUND CI WATCH]\nWatch target:\nPR #22\n\nCheck instructions:\nwatch',
|
||||||
|
last_run: '2026-04-26T05:07:00.000Z',
|
||||||
|
last_result: 'Error: BOT_TOKEN=plain-secret-value failed',
|
||||||
|
status: 'active',
|
||||||
|
}),
|
||||||
makeTask({
|
makeTask({
|
||||||
id: 'cron-1',
|
id: 'cron-1',
|
||||||
prompt: 'regular cron',
|
prompt: 'regular cron',
|
||||||
@@ -329,10 +337,12 @@ describe('web dashboard data', () => {
|
|||||||
);
|
);
|
||||||
const ciFailure = overview.inbox.find((item) => item.kind === 'ci-failure');
|
const ciFailure = overview.inbox.find((item) => item.kind === 'ci-failure');
|
||||||
expect(ciFailure).toMatchObject({
|
expect(ciFailure).toMatchObject({
|
||||||
id: 'ci:ci-1',
|
id: 'ci:ci-2',
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
|
occurrences: 2,
|
||||||
source: 'scheduled-task',
|
source: 'scheduled-task',
|
||||||
taskId: 'ci-1',
|
taskId: 'ci-2',
|
||||||
|
lastOccurredAt: '2026-04-26T05:07:00.000Z',
|
||||||
});
|
});
|
||||||
expect(ciFailure?.summary).toContain('BOT_TOKEN=<redacted>');
|
expect(ciFailure?.summary).toContain('BOT_TOKEN=<redacted>');
|
||||||
expect(ciFailure?.summary).not.toContain('plain-secret-value');
|
expect(ciFailure?.summary).not.toContain('plain-secret-value');
|
||||||
|
|||||||
@@ -69,12 +69,15 @@ export type InboxItemSeverity = 'info' | 'warn' | 'error';
|
|||||||
|
|
||||||
export interface InboxItem {
|
export interface InboxItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
groupKey: string;
|
||||||
kind: InboxItemKind;
|
kind: InboxItemKind;
|
||||||
severity: InboxItemSeverity;
|
severity: InboxItemSeverity;
|
||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
|
lastOccurredAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
occurrences: number;
|
||||||
source: 'status-snapshot' | 'paired-task' | 'scheduled-task';
|
source: 'status-snapshot' | 'paired-task' | 'scheduled-task';
|
||||||
roomJid?: string;
|
roomJid?: string;
|
||||||
roomName?: string;
|
roomName?: string;
|
||||||
@@ -108,6 +111,14 @@ function buildInboxPreview(value: string): string {
|
|||||||
return truncateText(redactSensitiveText(value).replace(/\s+/g, ' ').trim());
|
return truncateText(redactSensitiveText(value).replace(/\s+/g, ' ').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stableHash(value: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
hash = (hash * 31 + value.charCodeAt(index)) | 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] {
|
function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] {
|
||||||
const rows: UsageRowSnapshot[] = [];
|
const rows: UsageRowSnapshot[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -174,12 +185,15 @@ function collectInboxItems(args: {
|
|||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
id: `room:${snapshot.serviceId}:${entry.jid}`,
|
id: `room:${snapshot.serviceId}:${entry.jid}`,
|
||||||
|
groupKey: `room:${snapshot.serviceId}:${entry.jid}`,
|
||||||
kind: 'pending-room',
|
kind: 'pending-room',
|
||||||
severity: entry.pendingTasks > 0 ? 'warn' : 'info',
|
severity: entry.pendingTasks > 0 ? 'warn' : 'info',
|
||||||
title: entry.name || entry.folder || entry.jid,
|
title: entry.name || entry.folder || entry.jid,
|
||||||
summary: parts.join(' · '),
|
summary: parts.join(' · '),
|
||||||
occurredAt: snapshot.updatedAt,
|
occurredAt: snapshot.updatedAt,
|
||||||
|
lastOccurredAt: snapshot.updatedAt,
|
||||||
createdAt: args.createdAt,
|
createdAt: args.createdAt,
|
||||||
|
occurrences: 1,
|
||||||
source: 'status-snapshot',
|
source: 'status-snapshot',
|
||||||
roomJid: entry.jid,
|
roomJid: entry.jid,
|
||||||
roomName: entry.name,
|
roomName: entry.name,
|
||||||
@@ -195,9 +209,9 @@ function collectInboxItems(args: {
|
|||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
id: `paired:${task.id}:${task.status}`,
|
id: `paired:${task.id}:${task.status}`,
|
||||||
|
groupKey: `paired:${task.id}:${task.status}`,
|
||||||
kind,
|
kind,
|
||||||
severity:
|
severity: kind === 'arbiter-request' ? 'error' : 'warn',
|
||||||
kind === 'approval' || kind === 'arbiter-request' ? 'error' : 'warn',
|
|
||||||
title: task.title || task.group_folder,
|
title: task.title || task.group_folder,
|
||||||
summary: task.status,
|
summary: task.status,
|
||||||
occurredAt:
|
occurredAt:
|
||||||
@@ -206,7 +220,14 @@ function collectInboxItems(args: {
|
|||||||
: kind === 'reviewer-request'
|
: kind === 'reviewer-request'
|
||||||
? (task.review_requested_at ?? task.updated_at)
|
? (task.review_requested_at ?? task.updated_at)
|
||||||
: task.updated_at,
|
: task.updated_at,
|
||||||
|
lastOccurredAt:
|
||||||
|
kind === 'arbiter-request'
|
||||||
|
? (task.arbiter_requested_at ?? task.updated_at)
|
||||||
|
: kind === 'reviewer-request'
|
||||||
|
? (task.review_requested_at ?? task.updated_at)
|
||||||
|
: task.updated_at,
|
||||||
createdAt: args.createdAt,
|
createdAt: args.createdAt,
|
||||||
|
occurrences: 1,
|
||||||
source: 'paired-task',
|
source: 'paired-task',
|
||||||
roomJid: task.chat_jid,
|
roomJid: task.chat_jid,
|
||||||
groupFolder: task.group_folder,
|
groupFolder: task.group_folder,
|
||||||
@@ -226,12 +247,15 @@ function collectInboxItems(args: {
|
|||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
id: `ci:${task.id}`,
|
id: `ci:${task.id}`,
|
||||||
|
groupKey: `ci:${stableHash(result.toLowerCase())}`,
|
||||||
kind: 'ci-failure',
|
kind: 'ci-failure',
|
||||||
severity: task.status === 'paused' ? 'error' : 'warn',
|
severity: task.status === 'paused' ? 'error' : 'warn',
|
||||||
title: 'CI watcher failed',
|
title: 'CI watcher failed',
|
||||||
summary: result,
|
summary: result,
|
||||||
occurredAt: task.last_run ?? task.created_at,
|
occurredAt: task.last_run ?? task.created_at,
|
||||||
|
lastOccurredAt: task.last_run ?? task.created_at,
|
||||||
createdAt: args.createdAt,
|
createdAt: args.createdAt,
|
||||||
|
occurrences: 1,
|
||||||
source: 'scheduled-task',
|
source: 'scheduled-task',
|
||||||
roomJid: task.chat_jid,
|
roomJid: task.chat_jid,
|
||||||
groupFolder: task.group_folder,
|
groupFolder: task.group_folder,
|
||||||
@@ -247,11 +271,40 @@ function collectInboxItems(args: {
|
|||||||
info: 2,
|
info: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
return items
|
const groupedItems = new Map<string, InboxItem>();
|
||||||
|
for (const item of items) {
|
||||||
|
const existing = groupedItems.get(item.groupKey);
|
||||||
|
if (!existing) {
|
||||||
|
groupedItems.set(item.groupKey, item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const occurrences = existing.occurrences + item.occurrences;
|
||||||
|
const severity =
|
||||||
|
severityRank[item.severity] < severityRank[existing.severity]
|
||||||
|
? item.severity
|
||||||
|
: existing.severity;
|
||||||
|
const latest =
|
||||||
|
item.lastOccurredAt.localeCompare(existing.lastOccurredAt) > 0
|
||||||
|
? item
|
||||||
|
: existing;
|
||||||
|
|
||||||
|
groupedItems.set(item.groupKey, {
|
||||||
|
...latest,
|
||||||
|
severity,
|
||||||
|
occurrences,
|
||||||
|
lastOccurredAt:
|
||||||
|
item.lastOccurredAt.localeCompare(existing.lastOccurredAt) > 0
|
||||||
|
? item.lastOccurredAt
|
||||||
|
: existing.lastOccurredAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...groupedItems.values()]
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const severityDelta = severityRank[a.severity] - severityRank[b.severity];
|
const severityDelta = severityRank[a.severity] - severityRank[b.severity];
|
||||||
if (severityDelta !== 0) return severityDelta;
|
if (severityDelta !== 0) return severityDelta;
|
||||||
return b.occurredAt.localeCompare(a.occurredAt);
|
return b.lastOccurredAt.localeCompare(a.lastOccurredAt);
|
||||||
})
|
})
|
||||||
.slice(0, 50);
|
.slice(0, 50);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user