fix(ui): correct usage range totals and muted styles
This commit is contained in:
@@ -17,6 +17,8 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- UI/Usage: replace lingering undefined `var(--text-muted)` usage with `var(--muted)` in usage date-range and chart styles to keep muted text visible across themes. (#17975) Thanks @jogelin.
|
||||||
|
- UI/Usage: preserve selected-range totals when timeline data is downsampled by bucket-aggregating timeseries points (instead of dropping intermediate points), so filtered tokens/cost stay accurate. (#17959) Thanks @jogelin.
|
||||||
- Mattermost: harden reaction handling by requiring an explicit boolean `remove` flag and routing reaction websocket events to the reaction handler, preventing string `"true"` values from being treated as removes and avoiding double-processing of reaction events as posts. (#18608) Thanks @echo931.
|
- Mattermost: harden reaction handling by requiring an explicit boolean `remove` flag and routing reaction websocket events to the reaction handler, preventing string `"true"` values from being treated as removes and avoiding double-processing of reaction events as posts. (#18608) Thanks @echo931.
|
||||||
- Scripts/UI/Windows: fix `pnpm ui:*` spawn `EINVAL` failures by restoring shell-backed launch for `.cmd`/`.bat` runners, narrowing shell usage to launcher types that require it, and rejecting unsafe forwarded shell metacharacters in UI script args. (#18594)
|
- Scripts/UI/Windows: fix `pnpm ui:*` spawn `EINVAL` failures by restoring shell-backed launch for `.cmd`/`.bat` runners, narrowing shell usage to launcher types that require it, and rejecting unsafe forwarded shell metacharacters in UI script args. (#18594)
|
||||||
- Hooks/Session-memory: recover `/new` conversation summaries when session pointers are reset-path or missing `sessionFile`, and consistently prefer the newest `.jsonl.reset.*` transcript candidate for fallback extraction. (#18088)
|
- Hooks/Session-memory: recover `/new` conversation summaries when session pointers are reset-path or missing `sessionFile`, and consistently prefer the newest `.jsonl.reset.*` transcript candidate for fallback extraction. (#18088)
|
||||||
|
|||||||
@@ -371,4 +371,57 @@ describe("session cost usage", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves totals and cumulative values when downsampling timeseries", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-timeseries-downsample-"));
|
||||||
|
const sessionsDir = path.join(root, "agents", "main", "sessions");
|
||||||
|
await fs.mkdir(sessionsDir, { recursive: true });
|
||||||
|
const sessionFile = path.join(sessionsDir, "sess-downsample.jsonl");
|
||||||
|
|
||||||
|
const entries = Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const idx = i + 1;
|
||||||
|
return {
|
||||||
|
type: "message",
|
||||||
|
timestamp: new Date(Date.UTC(2026, 1, 12, 10, idx, 0)).toISOString(),
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-5.2",
|
||||||
|
usage: {
|
||||||
|
input: idx,
|
||||||
|
output: idx * 2,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: idx * 3,
|
||||||
|
cost: { total: idx * 0.001 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
sessionFile,
|
||||||
|
entries.map((entry) => JSON.stringify(entry)).join("\n"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeseries = await loadSessionUsageTimeSeries({
|
||||||
|
sessionFile,
|
||||||
|
maxPoints: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(timeseries).toBeTruthy();
|
||||||
|
expect(timeseries?.points.length).toBe(3);
|
||||||
|
|
||||||
|
const points = timeseries?.points ?? [];
|
||||||
|
const totalTokens = points.reduce((sum, point) => sum + point.totalTokens, 0);
|
||||||
|
const totalCost = points.reduce((sum, point) => sum + point.cost, 0);
|
||||||
|
const lastPoint = points[points.length - 1];
|
||||||
|
|
||||||
|
// Full-series totals: sum(1..10)*3 = 165 tokens, sum(1..10)*0.001 = 0.055 cost.
|
||||||
|
expect(totalTokens).toBe(165);
|
||||||
|
expect(totalCost).toBeCloseTo(0.055, 8);
|
||||||
|
expect(lastPoint?.cumulativeTokens).toBe(165);
|
||||||
|
expect(lastPoint?.cumulativeCost).toBeCloseTo(0.055, 8);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,15 +2,8 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
import type { NormalizedUsage, UsageLike } from "../agents/usage.js";
|
import type { NormalizedUsage, UsageLike } from "../agents/usage.js";
|
||||||
import { normalizeUsage } from "../agents/usage.js";
|
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import {
|
|
||||||
resolveSessionFilePath,
|
|
||||||
resolveSessionTranscriptsDirForAgent,
|
|
||||||
} from "../config/sessions/paths.js";
|
|
||||||
import type { SessionEntry } from "../config/sessions/types.js";
|
import type { SessionEntry } from "../config/sessions/types.js";
|
||||||
import { countToolResults, extractToolCallNames } from "../utils/transcript-tools.js";
|
|
||||||
import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js";
|
|
||||||
import type {
|
import type {
|
||||||
CostBreakdown,
|
CostBreakdown,
|
||||||
CostUsageTotals,
|
CostUsageTotals,
|
||||||
@@ -31,6 +24,13 @@ import type {
|
|||||||
SessionUsageTimePoint,
|
SessionUsageTimePoint,
|
||||||
SessionUsageTimeSeries,
|
SessionUsageTimeSeries,
|
||||||
} from "./session-cost-usage.types.js";
|
} from "./session-cost-usage.types.js";
|
||||||
|
import { normalizeUsage } from "../agents/usage.js";
|
||||||
|
import {
|
||||||
|
resolveSessionFilePath,
|
||||||
|
resolveSessionTranscriptsDirForAgent,
|
||||||
|
} from "../config/sessions/paths.js";
|
||||||
|
import { countToolResults, extractToolCallNames } from "../utils/transcript-tools.js";
|
||||||
|
import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
CostUsageDailyEntry,
|
CostUsageDailyEntry,
|
||||||
@@ -799,12 +799,44 @@ export async function loadSessionUsageTimeSeries(params: {
|
|||||||
if (sortedPoints.length > maxPoints) {
|
if (sortedPoints.length > maxPoints) {
|
||||||
const step = Math.ceil(sortedPoints.length / maxPoints);
|
const step = Math.ceil(sortedPoints.length / maxPoints);
|
||||||
const downsampled: SessionUsageTimePoint[] = [];
|
const downsampled: SessionUsageTimePoint[] = [];
|
||||||
|
let downsampledCumulativeTokens = 0;
|
||||||
|
let downsampledCumulativeCost = 0;
|
||||||
for (let i = 0; i < sortedPoints.length; i += step) {
|
for (let i = 0; i < sortedPoints.length; i += step) {
|
||||||
downsampled.push(sortedPoints[i]);
|
const bucket = sortedPoints.slice(i, i + step);
|
||||||
}
|
const bucketLast = bucket[bucket.length - 1];
|
||||||
// Always include the last point
|
if (!bucketLast) {
|
||||||
if (downsampled[downsampled.length - 1] !== sortedPoints[sortedPoints.length - 1]) {
|
continue;
|
||||||
downsampled.push(sortedPoints[sortedPoints.length - 1]);
|
}
|
||||||
|
|
||||||
|
let bucketInput = 0;
|
||||||
|
let bucketOutput = 0;
|
||||||
|
let bucketCacheRead = 0;
|
||||||
|
let bucketCacheWrite = 0;
|
||||||
|
let bucketTotalTokens = 0;
|
||||||
|
let bucketCost = 0;
|
||||||
|
for (const point of bucket) {
|
||||||
|
bucketInput += point.input;
|
||||||
|
bucketOutput += point.output;
|
||||||
|
bucketCacheRead += point.cacheRead;
|
||||||
|
bucketCacheWrite += point.cacheWrite;
|
||||||
|
bucketTotalTokens += point.totalTokens;
|
||||||
|
bucketCost += point.cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
downsampledCumulativeTokens += bucketTotalTokens;
|
||||||
|
downsampledCumulativeCost += bucketCost;
|
||||||
|
|
||||||
|
downsampled.push({
|
||||||
|
timestamp: bucketLast.timestamp,
|
||||||
|
input: bucketInput,
|
||||||
|
output: bucketOutput,
|
||||||
|
cacheRead: bucketCacheRead,
|
||||||
|
cacheWrite: bucketCacheWrite,
|
||||||
|
totalTokens: bucketTotalTokens,
|
||||||
|
cost: bucketCost,
|
||||||
|
cumulativeTokens: downsampledCumulativeTokens,
|
||||||
|
cumulativeCost: downsampledCumulativeCost,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { sessionId: params.sessionId, points: downsampled };
|
return { sessionId: params.sessionId, points: downsampled };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -513,7 +513,7 @@ export const usageStylesPart3 = `
|
|||||||
/* ===== CHART AXIS ===== */
|
/* ===== CHART AXIS ===== */
|
||||||
.ts-axis-label {
|
.ts-axis-label {
|
||||||
font-size: 5px;
|
font-size: 5px;
|
||||||
fill: var(--text-muted);
|
fill: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== RANGE SELECTION HANDLES ===== */
|
/* ===== RANGE SELECTION HANDLES ===== */
|
||||||
@@ -537,7 +537,7 @@ export const usageStylesPart3 = `
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function renderUsage(props: UsageProps) {
|
|||||||
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 8px;">
|
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 8px;">
|
||||||
<div style="display: flex; gap: 8px; align-items: center;">
|
<div style="display: flex; gap: 8px; align-items: center;">
|
||||||
<input type="date" .value=${props.startDate} disabled style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); font-size: 13px; opacity: 0.6;" />
|
<input type="date" .value=${props.startDate} disabled style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); font-size: 13px; opacity: 0.6;" />
|
||||||
<span style="color: var(--text-muted);">to</span>
|
<span style="color: var(--muted);">to</span>
|
||||||
<input type="date" .value=${props.endDate} disabled style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); font-size: 13px; opacity: 0.6;" />
|
<input type="date" .value=${props.endDate} disabled style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); font-size: 13px; opacity: 0.6;" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -592,7 +592,7 @@ export function renderUsage(props: UsageProps) {
|
|||||||
title="Start Date"
|
title="Start Date"
|
||||||
@change=${(e: Event) => props.onStartDateChange((e.target as HTMLInputElement).value)}
|
@change=${(e: Event) => props.onStartDateChange((e.target as HTMLInputElement).value)}
|
||||||
/>
|
/>
|
||||||
<span style="color: var(--text-muted);">to</span>
|
<span style="color: var(--muted);">to</span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
.value=${props.endDate}
|
.value=${props.endDate}
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ export default defineConfig({
|
|||||||
unstubGlobals: true,
|
unstubGlobals: true,
|
||||||
pool: "forks",
|
pool: "forks",
|
||||||
maxWorkers: isCI ? ciWorkers : localWorkers,
|
maxWorkers: isCI ? ciWorkers : localWorkers,
|
||||||
include: ["src/**/*.test.ts", "extensions/**/*.test.ts", "test/**/*.test.ts"],
|
include: [
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"extensions/**/*.test.ts",
|
||||||
|
"test/**/*.test.ts",
|
||||||
|
"ui/src/ui/views/usage-render-details.test.ts",
|
||||||
|
],
|
||||||
setupFiles: ["test/setup.ts"],
|
setupFiles: ["test/setup.ts"],
|
||||||
exclude: [
|
exclude: [
|
||||||
"dist/**",
|
"dist/**",
|
||||||
|
|||||||
Reference in New Issue
Block a user