mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(ui): correct usage range totals and muted styles
This commit is contained in:
@@ -17,6 +17,8 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### 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.
|
||||
- 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)
|
||||
|
||||
@@ -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 readline from "node:readline";
|
||||
import type { NormalizedUsage, UsageLike } from "../agents/usage.js";
|
||||
import { normalizeUsage } from "../agents/usage.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 { countToolResults, extractToolCallNames } from "../utils/transcript-tools.js";
|
||||
import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js";
|
||||
import type {
|
||||
CostBreakdown,
|
||||
CostUsageTotals,
|
||||
@@ -31,6 +24,13 @@ import type {
|
||||
SessionUsageTimePoint,
|
||||
SessionUsageTimeSeries,
|
||||
} 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 {
|
||||
CostUsageDailyEntry,
|
||||
@@ -799,12 +799,44 @@ export async function loadSessionUsageTimeSeries(params: {
|
||||
if (sortedPoints.length > maxPoints) {
|
||||
const step = Math.ceil(sortedPoints.length / maxPoints);
|
||||
const downsampled: SessionUsageTimePoint[] = [];
|
||||
let downsampledCumulativeTokens = 0;
|
||||
let downsampledCumulativeCost = 0;
|
||||
for (let i = 0; i < sortedPoints.length; i += step) {
|
||||
downsampled.push(sortedPoints[i]);
|
||||
}
|
||||
// Always include the last point
|
||||
if (downsampled[downsampled.length - 1] !== sortedPoints[sortedPoints.length - 1]) {
|
||||
downsampled.push(sortedPoints[sortedPoints.length - 1]);
|
||||
const bucket = sortedPoints.slice(i, i + step);
|
||||
const bucketLast = bucket[bucket.length - 1];
|
||||
if (!bucketLast) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -513,7 +513,7 @@ export const usageStylesPart3 = `
|
||||
/* ===== CHART AXIS ===== */
|
||||
.ts-axis-label {
|
||||
font-size: 5px;
|
||||
fill: var(--text-muted);
|
||||
fill: var(--muted);
|
||||
}
|
||||
|
||||
/* ===== RANGE SELECTION HANDLES ===== */
|
||||
@@ -537,7 +537,7 @@ export const usageStylesPart3 = `
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
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; 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;" />
|
||||
<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;" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -592,7 +592,7 @@ export function renderUsage(props: UsageProps) {
|
||||
title="Start Date"
|
||||
@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
|
||||
type="date"
|
||||
.value=${props.endDate}
|
||||
|
||||
@@ -33,7 +33,12 @@ export default defineConfig({
|
||||
unstubGlobals: true,
|
||||
pool: "forks",
|
||||
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"],
|
||||
exclude: [
|
||||
"dist/**",
|
||||
|
||||
Reference in New Issue
Block a user