feat: initial commit of dsh-plugin-token-heatmap
This commit is contained in:
commit
33dda8f123
14 changed files with 2223 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
node_modules/\n.DS_Store
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 oiupoyt
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
78
README.md
Normal file
78
README.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# dsh-plugin-token-heatmap
|
||||||
|
|
||||||
|
> GitHub-style token usage contribution heatmap and analytics for [DeepSeek Harness](https://github.com/deepseek-ai/dsh).
|
||||||
|
|
||||||
|
Visualizes your daily token consumption, prompt intensity, cache hit efficiency, and streaks across all sessions in a 52-week contribution calendar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **52-Week GitHub Contribution Calendar:** 7 rows × 52 columns tracking daily token activity with 4-level color intensity.
|
||||||
|
- **Detailed Summary Cards:**
|
||||||
|
- **Total Tokens:** Complete volume of input, output, and cached tokens.
|
||||||
|
- **Cache Hit Ratio:** Percentage of tokens served from KV cache reads.
|
||||||
|
- **Daily Average & Active Days:** Real workload metrics.
|
||||||
|
- **Streak Tracker:** Current and longest active day streaks.
|
||||||
|
- **Peak Day Callout:** Date and token count for your heaviest day.
|
||||||
|
- **Dual Color Palette:** Switch between **GitHub Emerald** and **DeepSeek Indigo**.
|
||||||
|
- **Interactive Tooltips:** Hover over any day to see token breakdown (billed input/output vs. cache reads and step counts).
|
||||||
|
- **Session Token Meter Integration:** Integrates into session stats pills with a *"View 12-Month Activity Heatmap"* button that opens the interactive modal dialog anywhere.
|
||||||
|
- **High-Performance Parser:** Aggregates zstandard (`.jsonl.zstd`) session logs with timestamp and mtime caching for near-instant rendering.
|
||||||
|
- **DeepSeek Native Theme:** Automatically adapts to DSH dark and light themes using `--dsw-alias-*` design tokens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Method 1: Using pnpm in your DSH Web profile
|
||||||
|
|
||||||
|
Inside your DSH profile directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.dsh/profiles/web
|
||||||
|
pnpm add dsh-plugin-token-heatmap
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add `"dsh-plugin-token-heatmap"` to `~/.dsh/profiles/web/package.json` under `dsh.profile.bundles`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dsh": {
|
||||||
|
"profile": {
|
||||||
|
"bundles": [
|
||||||
|
"@deepseek-ai/dsh-base",
|
||||||
|
"@deepseek-ai/dsh-web-app",
|
||||||
|
"dsh-plugin-token-heatmap"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 2: Local Development Link
|
||||||
|
|
||||||
|
Clone the repository locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/oiupoyt/dsh-plugin-token-heatmap.git ~/projects/dsh-plugin-token-heatmap
|
||||||
|
cd ~/projects/dsh-plugin-token-heatmap
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
cd ~/.dsh/profiles/web
|
||||||
|
pnpm add --link ~/projects/dsh-plugin-token-heatmap
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Open the DeepSeek Harness Web GUI (`http://127.0.0.1:3080`).
|
||||||
|
2. Go to **Settings** (gear icon at the bottom left) → **Token Heatmap**.
|
||||||
|
3. Or during any session, click on the token stats pill at the bottom of the composer to view token details and tap **"View 12-Month Activity Heatmap →"**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT © [oiupoyt](https://github.com/oiupoyt)
|
||||||
4
cordis.patch.yml
Normal file
4
cordis.patch.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# dsh-plugin-token-heatmap bundle patch
|
||||||
|
- insert:
|
||||||
|
- id: token-heatmap
|
||||||
|
name: 'dsh-plugin-token-heatmap'
|
||||||
230
lib/aggregator.js
Normal file
230
lib/aggregator.js
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
const fileCache = /* @__PURE__ */ new Map();
|
||||||
|
function findSessionFiles(dir) {
|
||||||
|
const results = [];
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
results.push(...findSessionFiles(fullPath));
|
||||||
|
} else if (entry.name.endsWith(".jsonl.zstd") || entry.name.endsWith(".jsonl")) {
|
||||||
|
results.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
async function parseSessionFile(filePath) {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
const cached = fileCache.get(filePath);
|
||||||
|
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
||||||
|
return cached.days;
|
||||||
|
}
|
||||||
|
const isZstd = filePath.endsWith(".zstd");
|
||||||
|
const days = {};
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
let buffer = "";
|
||||||
|
const handleLine = (line) => {
|
||||||
|
if (!line.includes('"usage"')) return;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line);
|
||||||
|
const time = obj.time || obj.data && obj.data.time;
|
||||||
|
const usage = obj.usage || obj.data && obj.data.usage;
|
||||||
|
if (time && usage) {
|
||||||
|
const d = new Date(time);
|
||||||
|
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
const input = Number(usage.inputTokens) || 0;
|
||||||
|
const output = Number(usage.outputTokens) || 0;
|
||||||
|
const cacheRead = Number(usage.cacheReadTokens) || 0;
|
||||||
|
const cacheWrite = Number(usage.cacheWriteTokens) || 0;
|
||||||
|
const total = input + output + cacheRead;
|
||||||
|
if (!days[dateKey]) {
|
||||||
|
days[dateKey] = {
|
||||||
|
totalTokens: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
turns: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
days[dateKey].totalTokens += total;
|
||||||
|
days[dateKey].inputTokens += input;
|
||||||
|
days[dateKey].outputTokens += output;
|
||||||
|
days[dateKey].cacheReadTokens += cacheRead;
|
||||||
|
days[dateKey].cacheWriteTokens += cacheWrite;
|
||||||
|
days[dateKey].turns += 1;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isZstd) {
|
||||||
|
const proc = spawn("zstd", ["-dc", filePath], { stdio: ["ignore", "pipe", "ignore"] });
|
||||||
|
proc.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString("utf8");
|
||||||
|
let idx;
|
||||||
|
while ((idx = buffer.indexOf("\n")) !== -1) {
|
||||||
|
const line = buffer.slice(0, idx);
|
||||||
|
buffer = buffer.slice(idx + 1);
|
||||||
|
handleLine(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
proc.on("close", () => {
|
||||||
|
if (buffer.trim().length > 0) handleLine(buffer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
proc.on("error", () => resolve());
|
||||||
|
} else {
|
||||||
|
const stream = fs.createReadStream(filePath, { encoding: "utf8" });
|
||||||
|
stream.on("data", (chunk) => {
|
||||||
|
buffer += chunk;
|
||||||
|
let idx;
|
||||||
|
while ((idx = buffer.indexOf("\n")) !== -1) {
|
||||||
|
const line = buffer.slice(0, idx);
|
||||||
|
buffer = buffer.slice(idx + 1);
|
||||||
|
handleLine(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stream.on("end", () => {
|
||||||
|
if (buffer.trim().length > 0) handleLine(buffer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
stream.on("error", () => resolve());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fileCache.set(filePath, { mtimeMs: stat.mtimeMs, days });
|
||||||
|
return days;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function aggregateTokens(customSessionsRoot) {
|
||||||
|
const sessionsRoot = customSessionsRoot || path.join(os.homedir(), ".dsh", "sessions");
|
||||||
|
const files = findSessionFiles(sessionsRoot);
|
||||||
|
const parsedResults = await Promise.all(files.map(parseSessionFile));
|
||||||
|
const days = {};
|
||||||
|
for (const fileDays of parsedResults) {
|
||||||
|
for (const [date, stats] of Object.entries(fileDays)) {
|
||||||
|
if (!days[date]) {
|
||||||
|
days[date] = {
|
||||||
|
totalTokens: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
turns: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
days[date].totalTokens += stats.totalTokens;
|
||||||
|
days[date].inputTokens += stats.inputTokens;
|
||||||
|
days[date].outputTokens += stats.outputTokens;
|
||||||
|
days[date].cacheReadTokens += stats.cacheReadTokens;
|
||||||
|
days[date].cacheWriteTokens += stats.cacheWriteTokens;
|
||||||
|
days[date].turns += stats.turns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const positiveValues = Object.values(days).map((d) => d.totalTokens).filter((t) => t > 0).sort((a, b) => a - b);
|
||||||
|
let q1 = 5e6;
|
||||||
|
let q2 = 25e6;
|
||||||
|
let q3 = 75e6;
|
||||||
|
let q4 = 15e7;
|
||||||
|
if (positiveValues.length >= 4) {
|
||||||
|
q1 = positiveValues[Math.floor(positiveValues.length * 0.25)];
|
||||||
|
q2 = positiveValues[Math.floor(positiveValues.length * 0.5)];
|
||||||
|
q3 = positiveValues[Math.floor(positiveValues.length * 0.75)];
|
||||||
|
q4 = positiveValues[Math.floor(positiveValues.length * 0.9)];
|
||||||
|
}
|
||||||
|
for (const day of Object.values(days)) {
|
||||||
|
if (day.totalTokens === 0) day.level = 0;
|
||||||
|
else if (day.totalTokens <= q1) day.level = 1;
|
||||||
|
else if (day.totalTokens <= q2) day.level = 2;
|
||||||
|
else if (day.totalTokens <= q3) day.level = 3;
|
||||||
|
else day.level = 4;
|
||||||
|
}
|
||||||
|
let totalTokens = 0;
|
||||||
|
let totalInputTokens = 0;
|
||||||
|
let totalOutputTokens = 0;
|
||||||
|
let totalCacheReadTokens = 0;
|
||||||
|
let totalTurns = 0;
|
||||||
|
let peakDay = null;
|
||||||
|
for (const [date, stats] of Object.entries(days)) {
|
||||||
|
totalTokens += stats.totalTokens;
|
||||||
|
totalInputTokens += stats.inputTokens;
|
||||||
|
totalOutputTokens += stats.outputTokens;
|
||||||
|
totalCacheReadTokens += stats.cacheReadTokens;
|
||||||
|
totalTurns += stats.turns;
|
||||||
|
if (!peakDay || stats.totalTokens > peakDay.tokens) {
|
||||||
|
peakDay = { date, tokens: stats.totalTokens, turns: stats.turns };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const activeDays = Object.values(days).filter((d) => d.totalTokens > 0).length;
|
||||||
|
const dailyAverage = activeDays > 0 ? Math.round(totalTokens / activeDays) : 0;
|
||||||
|
const now = /* @__PURE__ */ new Date();
|
||||||
|
const formatDate = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
let currentStreak = 0;
|
||||||
|
let cursor = new Date(now);
|
||||||
|
const todayKey = formatDate(cursor);
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
const yesterdayKey = formatDate(cursor);
|
||||||
|
let streakDate = (days[todayKey]?.totalTokens ?? 0) > 0 ? new Date(now) : (days[yesterdayKey]?.totalTokens ?? 0) > 0 ? new Date(cursor) : null;
|
||||||
|
if (streakDate) {
|
||||||
|
while (true) {
|
||||||
|
const key = formatDate(streakDate);
|
||||||
|
if ((days[key]?.totalTokens ?? 0) > 0) {
|
||||||
|
currentStreak++;
|
||||||
|
streakDate.setDate(streakDate.getDate() - 1);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sortedDates = Object.keys(days).filter((k) => days[k].totalTokens > 0).sort();
|
||||||
|
let longestStreak = 0;
|
||||||
|
let tempStreak = 0;
|
||||||
|
let prevDate = null;
|
||||||
|
for (const dateStr of sortedDates) {
|
||||||
|
const cur = /* @__PURE__ */ new Date(dateStr + "T00:00:00");
|
||||||
|
if (!prevDate) {
|
||||||
|
tempStreak = 1;
|
||||||
|
} else {
|
||||||
|
const diffDays = Math.round((cur.getTime() - prevDate.getTime()) / (1e3 * 60 * 60 * 24));
|
||||||
|
if (diffDays === 1) {
|
||||||
|
tempStreak++;
|
||||||
|
} else {
|
||||||
|
tempStreak = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tempStreak > longestStreak) longestStreak = tempStreak;
|
||||||
|
prevDate = cur;
|
||||||
|
}
|
||||||
|
const endDate = formatDate(now);
|
||||||
|
const start = new Date(now);
|
||||||
|
start.setDate(start.getDate() - 364);
|
||||||
|
const startDate = formatDate(start);
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
totalTokens,
|
||||||
|
totalInputTokens,
|
||||||
|
totalOutputTokens,
|
||||||
|
totalCacheReadTokens,
|
||||||
|
totalTurns,
|
||||||
|
activeDays,
|
||||||
|
currentStreak,
|
||||||
|
longestStreak: Math.max(longestStreak, currentStreak),
|
||||||
|
peakDay,
|
||||||
|
dailyAverage
|
||||||
|
},
|
||||||
|
days,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export {
|
||||||
|
aggregateTokens
|
||||||
|
};
|
||||||
643
lib/client.js
Normal file
643
lib/client.js
Normal file
|
|
@ -0,0 +1,643 @@
|
||||||
|
// src/client/index.tsx
|
||||||
|
import React3, { useState as useState2, useEffect as useEffect2 } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
// src/client/HeatmapView.tsx
|
||||||
|
import React, { useEffect, useState, useMemo } from "react";
|
||||||
|
function formatNumber(num) {
|
||||||
|
if (num >= 1e9) return (num / 1e9).toFixed(2) + "B";
|
||||||
|
if (num >= 1e6) return (num / 1e6).toFixed(1) + "M";
|
||||||
|
if (num >= 1e3) return (num / 1e3).toFixed(1) + "k";
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
function formatExact(num) {
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
var MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
|
var DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""];
|
||||||
|
var COLOR_THEMES = {
|
||||||
|
green: {
|
||||||
|
name: "GitHub Emerald",
|
||||||
|
levels: [
|
||||||
|
"var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.08))",
|
||||||
|
"#0e4429",
|
||||||
|
"#006d32",
|
||||||
|
"#26a641",
|
||||||
|
"#39d353"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
deepseek: {
|
||||||
|
name: "DeepSeek Indigo",
|
||||||
|
levels: [
|
||||||
|
"var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.08))",
|
||||||
|
"#1e1e4a",
|
||||||
|
"#2e3188",
|
||||||
|
"#434fc7",
|
||||||
|
"#6366f1"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function HeatmapView() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [themeKey, setThemeKey] = useState("green");
|
||||||
|
const [hoveredDay, setHoveredDay] = useState(null);
|
||||||
|
const [tooltipPos, setTooltipPos] = useState(null);
|
||||||
|
const fetchStats = () => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch("/api/dsh-token-heatmap/stats").then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP error ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
}).then((res) => {
|
||||||
|
setData(res);
|
||||||
|
setLoading(false);
|
||||||
|
}).catch((err) => {
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
const calendarGrid = useMemo(() => {
|
||||||
|
if (!data) return null;
|
||||||
|
const weeks = [];
|
||||||
|
const now = /* @__PURE__ */ new Date();
|
||||||
|
const end = new Date(now);
|
||||||
|
const start = new Date(now);
|
||||||
|
start.setDate(start.getDate() - 364);
|
||||||
|
start.setDate(start.getDate() - start.getDay());
|
||||||
|
let current = new Date(start);
|
||||||
|
let currentWeek = [];
|
||||||
|
while (current <= end || currentWeek.length > 0) {
|
||||||
|
const dateKey = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, "0")}-${String(current.getDate()).padStart(2, "0")}`;
|
||||||
|
const dayStats = data.days[dateKey] || {
|
||||||
|
totalTokens: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
turns: 0,
|
||||||
|
level: 0
|
||||||
|
};
|
||||||
|
if (current <= end) {
|
||||||
|
currentWeek.push({ date: dateKey, stats: dayStats });
|
||||||
|
} else {
|
||||||
|
currentWeek.push(null);
|
||||||
|
}
|
||||||
|
if (currentWeek.length === 7) {
|
||||||
|
weeks.push(currentWeek);
|
||||||
|
currentWeek = [];
|
||||||
|
}
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
if (current > end && currentWeek.length === 0) break;
|
||||||
|
}
|
||||||
|
return weeks;
|
||||||
|
}, [data]);
|
||||||
|
const monthHeaders = useMemo(() => {
|
||||||
|
if (!calendarGrid) return [];
|
||||||
|
const headers = [];
|
||||||
|
let lastMonth = -1;
|
||||||
|
calendarGrid.forEach((week, colIdx) => {
|
||||||
|
const firstValidDay = week.find((d) => d !== null);
|
||||||
|
if (firstValidDay) {
|
||||||
|
const d = /* @__PURE__ */ new Date(firstValidDay.date + "T00:00:00");
|
||||||
|
const month = d.getMonth();
|
||||||
|
if (month !== lastMonth) {
|
||||||
|
headers.push({ label: MONTH_NAMES[month], col: colIdx });
|
||||||
|
lastMonth = month;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return headers;
|
||||||
|
}, [calendarGrid]);
|
||||||
|
const activeTheme = COLOR_THEMES[themeKey];
|
||||||
|
if (loading && !data) {
|
||||||
|
return /* @__PURE__ */ React.createElement("div", { style: { padding: "40px 20px", textAlign: "center", color: "var(--dsw-alias-label-secondary)" } }, "Loading token usage heatmap...");
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return /* @__PURE__ */ React.createElement("div", { style: { padding: "20px", color: "var(--dsw-alias-state-error-primary, #ef4444)" } }, "Failed to load heatmap: ", error, /* @__PURE__ */ React.createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
onClick: fetchStats,
|
||||||
|
style: {
|
||||||
|
marginLeft: 12,
|
||||||
|
padding: "4px 10px",
|
||||||
|
background: "var(--dsw-alias-bg-layer-2)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2)",
|
||||||
|
color: "var(--dsw-alias-label-primary)",
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: "pointer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Retry"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (!data) return null;
|
||||||
|
const { summary } = data;
|
||||||
|
const cacheHitPercent = summary.totalTokens > 0 ? Math.round(summary.totalCacheReadTokens / summary.totalTokens * 100) : 0;
|
||||||
|
return /* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
boxSizing: "border-box",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "960px",
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "16px 0 40px",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
color: "var(--dsw-alias-label-primary, #eeeeee)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement(
|
||||||
|
"h2",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 650,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Token Usage Activity",
|
||||||
|
/* @__PURE__ */ React.createElement(
|
||||||
|
"span",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.15))",
|
||||||
|
color: "var(--dsw-alias-label-secondary)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Last 12 Months"
|
||||||
|
)
|
||||||
|
), /* @__PURE__ */ React.createElement(
|
||||||
|
"p",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
margin: "6px 0 0",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--dsw-alias-label-tertiary, #a0a0a0)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Daily token volume and request step intensity across all sessions"
|
||||||
|
)),
|
||||||
|
/* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
display: "inline-flex",
|
||||||
|
background: "var(--dsw-alias-bg-layer-2, #2c2c2c)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* @__PURE__ */ React.createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
onClick: () => setThemeKey("green"),
|
||||||
|
style: {
|
||||||
|
border: 0,
|
||||||
|
background: themeKey === "green" ? "var(--dsw-alias-bg-layer-1, #1e1e1e)" : "transparent",
|
||||||
|
color: themeKey === "green" ? "var(--dsw-alias-label-primary)" : "var(--dsw-alias-label-tertiary)",
|
||||||
|
fontSize: 12,
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: themeKey === "green" ? 600 : 400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Emerald"
|
||||||
|
),
|
||||||
|
/* @__PURE__ */ React.createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
onClick: () => setThemeKey("deepseek"),
|
||||||
|
style: {
|
||||||
|
border: 0,
|
||||||
|
background: themeKey === "deepseek" ? "var(--dsw-alias-bg-layer-1, #1e1e1e)" : "transparent",
|
||||||
|
color: themeKey === "deepseek" ? "var(--dsw-alias-label-primary)" : "var(--dsw-alias-label-tertiary)",
|
||||||
|
fontSize: 12,
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: themeKey === "deepseek" ? 600 : 400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Indigo"
|
||||||
|
)
|
||||||
|
), /* @__PURE__ */ React.createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
onClick: fetchStats,
|
||||||
|
title: "Refresh statistics",
|
||||||
|
style: {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
background: "var(--dsw-alias-bg-layer-2, #2c2c2c)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
color: "var(--dsw-alias-label-primary)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "6px 12px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"\u21BB Refresh"
|
||||||
|
))
|
||||||
|
),
|
||||||
|
/* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
background: "var(--dsw-alias-bg-layer-2, #2c2c2c)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #a0a0a0)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 } }, "Total Tokens"),
|
||||||
|
/* @__PURE__ */ React.createElement("div", { style: { fontSize: 24, fontWeight: 700, margin: "6px 0 2px", color: "var(--dsw-alias-label-primary, #ffffff)" } }, formatNumber(summary.totalTokens)),
|
||||||
|
/* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-secondary, #b9b9b9)" } }, formatExact(summary.totalTokens), " total")
|
||||||
|
),
|
||||||
|
/* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
background: "var(--dsw-alias-bg-layer-2, #2c2c2c)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #a0a0a0)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 } }, "Cache Hit Ratio"),
|
||||||
|
/* @__PURE__ */ React.createElement("div", { style: { fontSize: 24, fontWeight: 700, margin: "6px 0 2px", color: "#10b981" } }, cacheHitPercent, "%"),
|
||||||
|
/* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-secondary, #b9b9b9)" } }, formatNumber(summary.totalCacheReadTokens), " cached reads")
|
||||||
|
),
|
||||||
|
/* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
background: "var(--dsw-alias-bg-layer-2, #2c2c2c)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #a0a0a0)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 } }, "Daily Average"),
|
||||||
|
/* @__PURE__ */ React.createElement("div", { style: { fontSize: 24, fontWeight: 700, margin: "6px 0 2px", color: "var(--dsw-alias-label-primary, #ffffff)" } }, formatNumber(summary.dailyAverage)),
|
||||||
|
/* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-secondary, #b9b9b9)" } }, "Across ", summary.activeDays, " active days")
|
||||||
|
),
|
||||||
|
/* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
background: "var(--dsw-alias-bg-layer-2, #2c2c2c)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary, #a0a0a0)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 } }, "Active Streak"),
|
||||||
|
/* @__PURE__ */ React.createElement("div", { style: { fontSize: 24, fontWeight: 700, margin: "6px 0 2px", color: "var(--dsw-alias-label-primary, #ffffff)" } }, summary.currentStreak, " ", summary.currentStreak === 1 ? "day" : "days"),
|
||||||
|
/* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-secondary, #b9b9b9)" } }, "Longest streak: ", summary.longestStreak, " days")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
/* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
background: "var(--dsw-alias-bg-layer-2, #2c2c2c)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "20px 22px",
|
||||||
|
position: "relative",
|
||||||
|
overflowX: "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 14,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* @__PURE__ */ React.createElement("span", null, "Contribution Calendar"),
|
||||||
|
summary.peakDay && /* @__PURE__ */ React.createElement("span", { style: { fontSize: 12, fontWeight: 400, color: "var(--dsw-alias-label-secondary)" } }, "Peak day: ", /* @__PURE__ */ React.createElement("strong", { style: { color: "var(--dsw-alias-label-primary)" } }, summary.peakDay.date), " (", formatNumber(summary.peakDay.tokens), " tokens)")
|
||||||
|
),
|
||||||
|
calendarGrid && /* @__PURE__ */ React.createElement("div", { style: { display: "inline-block", minWidth: 780 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", marginLeft: 32, marginBottom: 4, height: 16 } }, monthHeaders.map((m, i) => /* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
key: i,
|
||||||
|
style: {
|
||||||
|
position: "relative",
|
||||||
|
left: m.col * 14,
|
||||||
|
width: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontSize: 10,
|
||||||
|
color: "var(--dsw-alias-label-tertiary, #a0a0a0)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
m.label
|
||||||
|
))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6 } }, /* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 3,
|
||||||
|
width: 26,
|
||||||
|
fontSize: 9,
|
||||||
|
color: "var(--dsw-alias-label-tertiary, #a0a0a0)",
|
||||||
|
textAlign: "right",
|
||||||
|
paddingTop: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DAY_LABELS.map((lbl, idx) => /* @__PURE__ */ React.createElement("div", { key: idx, style: { height: 11, lineHeight: "11px" } }, lbl))
|
||||||
|
), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 3 } }, calendarGrid.map((week, colIdx) => /* @__PURE__ */ React.createElement("div", { key: colIdx, style: { display: "flex", flexDirection: "column", gap: 3 } }, week.map((day, rowIdx) => {
|
||||||
|
if (!day) {
|
||||||
|
return /* @__PURE__ */ React.createElement("div", { key: rowIdx, style: { width: 11, height: 11 } });
|
||||||
|
}
|
||||||
|
const lvl = day.stats.level || 0;
|
||||||
|
const bg = activeTheme.levels[lvl];
|
||||||
|
return /* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
key: rowIdx,
|
||||||
|
onMouseEnter: (e) => {
|
||||||
|
setHoveredDay({ date: day.date, stats: day.stats });
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top - 8 });
|
||||||
|
},
|
||||||
|
onMouseLeave: () => {
|
||||||
|
setHoveredDay(null);
|
||||||
|
setTooltipPos(null);
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
width: 11,
|
||||||
|
height: 11,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: bg,
|
||||||
|
border: lvl === 0 ? "1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.06))" : "1px solid transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "transform 0.1s ease, filter 0.1s ease"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}))))), /* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 14,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--dsw-alias-label-tertiary, #a0a0a0)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* @__PURE__ */ React.createElement("span", null, "Less"),
|
||||||
|
activeTheme.levels.map((c, i) => /* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
key: i,
|
||||||
|
style: {
|
||||||
|
width: 11,
|
||||||
|
height: 11,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: c,
|
||||||
|
border: i === 0 ? "1px solid var(--dsw-alias-border-l1)" : "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)),
|
||||||
|
/* @__PURE__ */ React.createElement("span", null, "More")
|
||||||
|
))
|
||||||
|
),
|
||||||
|
hoveredDay && tooltipPos && /* @__PURE__ */ React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
position: "fixed",
|
||||||
|
left: tooltipPos.x,
|
||||||
|
top: tooltipPos.y,
|
||||||
|
transform: "translate(-50%, -100%)",
|
||||||
|
background: "var(--dsw-alias-bg-layer-1, #1e1e1e)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l3, #555555)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "8px 12px",
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
boxShadow: "0 6px 18px rgba(0,0,0,0.35)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: 9999,
|
||||||
|
whiteSpace: "nowrap"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* @__PURE__ */ React.createElement("div", { style: { fontWeight: 650, color: "var(--dsw-alias-label-primary, #ffffff)", marginBottom: 4 } }, (/* @__PURE__ */ new Date(hoveredDay.date + "T00:00:00")).toLocaleDateString(void 0, {
|
||||||
|
weekday: "short",
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric"
|
||||||
|
})),
|
||||||
|
hoveredDay.stats.totalTokens === 0 ? /* @__PURE__ */ React.createElement("div", { style: { color: "var(--dsw-alias-label-tertiary)" } }, "No activity recorded") : /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { style: { color: "#34d399", fontWeight: 600 } }, formatExact(hoveredDay.stats.totalTokens), " tokens"), /* @__PURE__ */ React.createElement("div", { style: { fontSize: 11, color: "var(--dsw-alias-label-secondary)", marginTop: 2 } }, hoveredDay.stats.turns, " requests / steps"), /* @__PURE__ */ React.createElement("div", { style: { fontSize: 10, color: "var(--dsw-alias-label-tertiary)", marginTop: 4 } }, "Billed: ", formatNumber(hoveredDay.stats.inputTokens + hoveredDay.stats.outputTokens), " \xB7 Cache: ", formatNumber(hoveredDay.stats.cacheReadTokens)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/client/HeatmapModal.tsx
|
||||||
|
import React2 from "react";
|
||||||
|
function HeatmapModal({ open, onClose }) {
|
||||||
|
if (!open) return null;
|
||||||
|
return /* @__PURE__ */ React2.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
role: "dialog",
|
||||||
|
"aria-modal": "true",
|
||||||
|
"aria-label": "Token Usage Heatmap",
|
||||||
|
onClick: onClose,
|
||||||
|
style: {
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1e4,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.65)",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "20px",
|
||||||
|
overflowY: "auto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* @__PURE__ */ React2.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
onClick: (e) => e.stopPropagation(),
|
||||||
|
style: {
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "960px",
|
||||||
|
maxHeight: "90vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
background: "var(--dsw-alias-bg-layer-1, #1e1e1e)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
borderRadius: "14px",
|
||||||
|
boxShadow: "0 16px 40px rgba(0, 0, 0, 0.5)",
|
||||||
|
padding: "24px 28px"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* @__PURE__ */ React2.createElement(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
onClick: onClose,
|
||||||
|
"aria-label": "Close dialog",
|
||||||
|
style: {
|
||||||
|
position: "absolute",
|
||||||
|
top: "16px",
|
||||||
|
right: "18px",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--dsw-alias-label-tertiary, #a0a0a0)",
|
||||||
|
fontSize: "20px",
|
||||||
|
cursor: "pointer",
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: "6px"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"\u2715"
|
||||||
|
),
|
||||||
|
/* @__PURE__ */ React2.createElement(HeatmapView, null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/client/index.tsx
|
||||||
|
var name = "dsh-plugin-token-heatmap-client";
|
||||||
|
var inject = ["slots"];
|
||||||
|
function apply(ctx) {
|
||||||
|
if (ctx.slots && typeof ctx.slots.inject === "function") {
|
||||||
|
ctx.slots.inject("settings.section", () => {
|
||||||
|
return ctx.slots.register(
|
||||||
|
{
|
||||||
|
name: "settings.section",
|
||||||
|
id: "token-heatmap",
|
||||||
|
order: 14,
|
||||||
|
label: () => "Token Heatmap",
|
||||||
|
icon: "chart"
|
||||||
|
},
|
||||||
|
HeatmapView
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
let ModalController = function() {
|
||||||
|
const [isOpen, setIsOpen] = useState2(false);
|
||||||
|
useEffect2(() => {
|
||||||
|
const handleOpen = () => setIsOpen(true);
|
||||||
|
const handleClose = () => setIsOpen(false);
|
||||||
|
window.addEventListener("dsh:open-token-heatmap", handleOpen);
|
||||||
|
window.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") handleClose();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("dsh:open-token-heatmap", handleOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return /* @__PURE__ */ React3.createElement(HeatmapModal, { open: isOpen, onClose: () => setIsOpen(false) });
|
||||||
|
};
|
||||||
|
const modalContainer = document.createElement("div");
|
||||||
|
modalContainer.id = "dsh-token-heatmap-modal-root";
|
||||||
|
document.body.appendChild(modalContainer);
|
||||||
|
const root = createRoot(modalContainer);
|
||||||
|
root.render(/* @__PURE__ */ React3.createElement(ModalController, null));
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const statDialogs = document.querySelectorAll(
|
||||||
|
'div[role="dialog"][aria-label*="usage" i], div[role="dialog"][aria-label*="Token" i], [data-session-stats-usage]'
|
||||||
|
);
|
||||||
|
statDialogs.forEach((dialog) => {
|
||||||
|
if (dialog.querySelector(".dsh-heatmap-trigger-btn")) return;
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = "dsh-heatmap-trigger-btn";
|
||||||
|
button.type = "button";
|
||||||
|
button.innerHTML = "<span>\u{1F4CA} View 12-Month Activity Heatmap \u2192</span>";
|
||||||
|
button.style.cssText = `
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.12));
|
||||||
|
border: 1px solid var(--dsw-alias-border-l2, #494949);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--dsw-alias-brand-primary, #6366f1);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 550;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
`;
|
||||||
|
button.onmouseenter = () => {
|
||||||
|
button.style.background = "var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.2))";
|
||||||
|
button.style.borderColor = "var(--dsw-alias-brand-primary, #6366f1)";
|
||||||
|
};
|
||||||
|
button.onmouseleave = () => {
|
||||||
|
button.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.12))";
|
||||||
|
button.style.borderColor = "var(--dsw-alias-border-l2, #494949)";
|
||||||
|
};
|
||||||
|
button.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
window.dispatchEvent(new CustomEvent("dsh:open-token-heatmap"));
|
||||||
|
};
|
||||||
|
dialog.appendChild(button);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export {
|
||||||
|
apply,
|
||||||
|
inject,
|
||||||
|
name
|
||||||
|
};
|
||||||
44
lib/index.js
Normal file
44
lib/index.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { aggregateTokens } from "./aggregator.js";
|
||||||
|
const name = "dsh-plugin-token-heatmap";
|
||||||
|
const inject = ["webServer"];
|
||||||
|
function apply(ctx) {
|
||||||
|
ctx.effect(
|
||||||
|
() => ctx.webServer.register({
|
||||||
|
kind: "prefix",
|
||||||
|
path: "/api/dsh-token-heatmap",
|
||||||
|
handler: async (req, res) => {
|
||||||
|
const url = new URL(req.url, "http://localhost");
|
||||||
|
const pathname = url.pathname.replace(/\/+$/, "");
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.writeHead(204);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === "GET" && (pathname === "/api/dsh-token-heatmap" || pathname === "/api/dsh-token-heatmap/stats")) {
|
||||||
|
try {
|
||||||
|
const data = await aggregateTokens();
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: err?.message || "Failed to aggregate token stats" }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Not found" }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export {
|
||||||
|
apply,
|
||||||
|
inject,
|
||||||
|
name
|
||||||
|
};
|
||||||
47
package.json
Normal file
47
package.json
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"name": "dsh-plugin-token-heatmap",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "GitHub-style token usage contribution heatmap and analytics for DeepSeek Harness",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./lib/index.js",
|
||||||
|
"exports": {
|
||||||
|
".": "./lib/index.js",
|
||||||
|
"./client": "./lib/client.js",
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"lib",
|
||||||
|
"cordis.patch.yml",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"keywords": [
|
||||||
|
"deepseek-harness",
|
||||||
|
"dsh",
|
||||||
|
"dsh-plugin",
|
||||||
|
"cordis",
|
||||||
|
"heatmap",
|
||||||
|
"token-usage",
|
||||||
|
"analytics"
|
||||||
|
],
|
||||||
|
"dsh": {
|
||||||
|
"bundle": {
|
||||||
|
"patch": "./cordis.patch.yml"
|
||||||
|
},
|
||||||
|
"client": {
|
||||||
|
"inject": [
|
||||||
|
"@deepseek-ai/dsh-client-ui-slots"
|
||||||
|
],
|
||||||
|
"platform": "web"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "node scripts/build.mjs"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@deepseek-ai/cordis": ">=4.0.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
9
scripts/build.mjs
Normal file
9
scripts/build.mjs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
|
console.log("Building server backend...");
|
||||||
|
execSync("npx esbuild src/server/index.ts src/server/aggregator.ts --outdir=lib --platform=node --format=esm --target=node20 --sourcemap", { stdio: "inherit" });
|
||||||
|
|
||||||
|
console.log("Building client frontend bundle...");
|
||||||
|
execSync("npx esbuild src/client/index.tsx --outfile=lib/client.js --platform=browser --format=esm --target=es2022 --bundle --external:react --external:react-dom --external:react-dom/client --sourcemap", { stdio: "inherit" });
|
||||||
|
|
||||||
|
console.log("Build complete!");
|
||||||
74
src/client/HeatmapModal.tsx
Normal file
74
src/client/HeatmapModal.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import React from "react";
|
||||||
|
import { HeatmapView } from "./HeatmapView.js";
|
||||||
|
|
||||||
|
interface HeatmapModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeatmapModal({ open, onClose }: HeatmapModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Token Usage Heatmap"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 10000,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.65)",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "20px",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "960px",
|
||||||
|
maxHeight: "90vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
background: "var(--dsw-alias-bg-layer-1, #1e1e1e)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
borderRadius: "14px",
|
||||||
|
boxShadow: "0 16px 40px rgba(0, 0, 0, 0.5)",
|
||||||
|
padding: "24px 28px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close dialog"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "16px",
|
||||||
|
right: "18px",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--dsw-alias-label-tertiary, #a0a0a0)",
|
||||||
|
fontSize: "20px",
|
||||||
|
cursor: "pointer",
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<HeatmapView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
617
src/client/HeatmapView.tsx
Normal file
617
src/client/HeatmapView.tsx
Normal file
|
|
@ -0,0 +1,617 @@
|
||||||
|
import React, { useEffect, useState, useMemo } from "react";
|
||||||
|
|
||||||
|
export interface DayStats {
|
||||||
|
totalTokens: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
cacheReadTokens: number;
|
||||||
|
cacheWriteTokens: number;
|
||||||
|
turns: number;
|
||||||
|
level?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SummaryStats {
|
||||||
|
totalTokens: number;
|
||||||
|
totalInputTokens: number;
|
||||||
|
totalOutputTokens: number;
|
||||||
|
totalCacheReadTokens: number;
|
||||||
|
totalTurns: number;
|
||||||
|
activeDays: number;
|
||||||
|
currentStreak: number;
|
||||||
|
longestStreak: number;
|
||||||
|
peakDay: { date: string; tokens: number; turns: number } | null;
|
||||||
|
dailyAverage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeatmapResponse {
|
||||||
|
summary: SummaryStats;
|
||||||
|
days: Record<string, DayStats>;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
if (num >= 1_000_000_000) return (num / 1_000_000_000).toFixed(2) + "B";
|
||||||
|
if (num >= 1_000_000) return (num / 1_000_000).toFixed(1) + "M";
|
||||||
|
if (num >= 1_000) return (num / 1_000).toFixed(1) + "k";
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExact(num: number): string {
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
|
const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""];
|
||||||
|
|
||||||
|
// Themes: Green (GitHub classic) and DeepSeek Blue/Indigo
|
||||||
|
const COLOR_THEMES = {
|
||||||
|
green: {
|
||||||
|
name: "GitHub Emerald",
|
||||||
|
levels: [
|
||||||
|
"var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.08))",
|
||||||
|
"#0e4429",
|
||||||
|
"#006d32",
|
||||||
|
"#26a641",
|
||||||
|
"#39d353",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
deepseek: {
|
||||||
|
name: "DeepSeek Indigo",
|
||||||
|
levels: [
|
||||||
|
"var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.08))",
|
||||||
|
"#1e1e4a",
|
||||||
|
"#2e3188",
|
||||||
|
"#434fc7",
|
||||||
|
"#6366f1",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HeatmapView() {
|
||||||
|
const [data, setData] = useState<HeatmapResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [themeKey, setThemeKey] = useState<"green" | "deepseek">("green");
|
||||||
|
const [hoveredDay, setHoveredDay] = useState<{ date: string; stats: DayStats } | null>(null);
|
||||||
|
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
const fetchStats = () => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch("/api/dsh-token-heatmap/stats")
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP error ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
setData(res);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Compute 52-week calendar grid: 7 rows x 53 columns
|
||||||
|
const calendarGrid = useMemo(() => {
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const weeks: Array<Array<{ date: string; stats: DayStats } | null>> = [];
|
||||||
|
const now = new Date();
|
||||||
|
// End on current week Saturday
|
||||||
|
const end = new Date(now);
|
||||||
|
// Start 52 weeks back on Sunday
|
||||||
|
const start = new Date(now);
|
||||||
|
start.setDate(start.getDate() - 364);
|
||||||
|
// Align start to nearest previous Sunday
|
||||||
|
start.setDate(start.getDate() - start.getDay());
|
||||||
|
|
||||||
|
let current = new Date(start);
|
||||||
|
let currentWeek: Array<{ date: string; stats: DayStats } | null> = [];
|
||||||
|
|
||||||
|
while (current <= end || currentWeek.length > 0) {
|
||||||
|
const dateKey = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, "0")}-${String(current.getDate()).padStart(2, "0")}`;
|
||||||
|
const dayStats = data.days[dateKey] || {
|
||||||
|
totalTokens: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
turns: 0,
|
||||||
|
level: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (current <= end) {
|
||||||
|
currentWeek.push({ date: dateKey, stats: dayStats });
|
||||||
|
} else {
|
||||||
|
currentWeek.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentWeek.length === 7) {
|
||||||
|
weeks.push(currentWeek);
|
||||||
|
currentWeek = [];
|
||||||
|
}
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
if (current > end && currentWeek.length === 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return weeks;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Compute month headers
|
||||||
|
const monthHeaders = useMemo(() => {
|
||||||
|
if (!calendarGrid) return [];
|
||||||
|
const headers: Array<{ label: string; col: number }> = [];
|
||||||
|
let lastMonth = -1;
|
||||||
|
|
||||||
|
calendarGrid.forEach((week, colIdx) => {
|
||||||
|
const firstValidDay = week.find((d) => d !== null);
|
||||||
|
if (firstValidDay) {
|
||||||
|
const d = new Date(firstValidDay.date + "T00:00:00");
|
||||||
|
const month = d.getMonth();
|
||||||
|
if (month !== lastMonth) {
|
||||||
|
headers.push({ label: MONTH_NAMES[month], col: colIdx });
|
||||||
|
lastMonth = month;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}, [calendarGrid]);
|
||||||
|
|
||||||
|
const activeTheme = COLOR_THEMES[themeKey];
|
||||||
|
|
||||||
|
if (loading && !data) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "40px 20px", textAlign: "center", color: "var(--dsw-alias-label-secondary)" }}>
|
||||||
|
Loading token usage heatmap...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "20px", color: "var(--dsw-alias-state-error-primary, #ef4444)" }}>
|
||||||
|
Failed to load heatmap: {error}
|
||||||
|
<button
|
||||||
|
onClick={fetchStats}
|
||||||
|
style={{
|
||||||
|
marginLeft: 12,
|
||||||
|
padding: "4px 10px",
|
||||||
|
background: "var(--dsw-alias-bg-layer-2)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2)",
|
||||||
|
color: "var(--dsw-alias-label-primary)",
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const { summary } = data;
|
||||||
|
const cacheHitPercent =
|
||||||
|
summary.totalTokens > 0
|
||||||
|
? Math.round((summary.totalCacheReadTokens / summary.totalTokens) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
boxSizing: "border-box",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: "960px",
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "16px 0 40px",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
color: "var(--dsw-alias-label-primary, #eeeeee)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Title Bar */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 650,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Token Usage Activity
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.15))",
|
||||||
|
color: "var(--dsw-alias-label-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Last 12 Months
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "6px 0 0",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--dsw-alias-label-tertiary, #a0a0a0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Daily token volume and request step intensity across all sessions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Controls */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
{/* Theme Switcher */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
background: "var(--dsw-alias-bg-layer-2, #2c2c2c)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setThemeKey("green")}
|
||||||
|
style={{
|
||||||
|
border: 0,
|
||||||
|
background: themeKey === "green" ? "var(--dsw-alias-bg-layer-1, #1e1e1e)" : "transparent",
|
||||||
|
color: themeKey === "green" ? "var(--dsw-alias-label-primary)" : "var(--dsw-alias-label-tertiary)",
|
||||||
|
fontSize: 12,
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: themeKey === "green" ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Emerald
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setThemeKey("deepseek")}
|
||||||
|
style={{
|
||||||
|
border: 0,
|
||||||
|
background: themeKey === "deepseek" ? "var(--dsw-alias-bg-layer-1, #1e1e1e)" : "transparent",
|
||||||
|
color: themeKey === "deepseek" ? "var(--dsw-alias-label-primary)" : "var(--dsw-alias-label-tertiary)",
|
||||||
|
fontSize: 12,
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: themeKey === "deepseek" ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Indigo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={fetchStats}
|
||||||
|
title="Refresh statistics"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
background: "var(--dsw-alias-bg-layer-2, #2c2c2c)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
color: "var(--dsw-alias-label-primary)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "6px 12px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↻ Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Card 1: Total Tokens */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--dsw-alias-bg-layer-2, #2c2c2c)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--dsw-alias-label-tertiary, #a0a0a0)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 }}>
|
||||||
|
Total Tokens
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, margin: "6px 0 2px", color: "var(--dsw-alias-label-primary, #ffffff)" }}>
|
||||||
|
{formatNumber(summary.totalTokens)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--dsw-alias-label-secondary, #b9b9b9)" }}>
|
||||||
|
{formatExact(summary.totalTokens)} total
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 2: Cache Efficiency */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--dsw-alias-bg-layer-2, #2c2c2c)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--dsw-alias-label-tertiary, #a0a0a0)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 }}>
|
||||||
|
Cache Hit Ratio
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, margin: "6px 0 2px", color: "#10b981" }}>
|
||||||
|
{cacheHitPercent}%
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--dsw-alias-label-secondary, #b9b9b9)" }}>
|
||||||
|
{formatNumber(summary.totalCacheReadTokens)} cached reads
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 3: Daily Average */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--dsw-alias-bg-layer-2, #2c2c2c)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--dsw-alias-label-tertiary, #a0a0a0)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 }}>
|
||||||
|
Daily Average
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, margin: "6px 0 2px", color: "var(--dsw-alias-label-primary, #ffffff)" }}>
|
||||||
|
{formatNumber(summary.dailyAverage)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--dsw-alias-label-secondary, #b9b9b9)" }}>
|
||||||
|
Across {summary.activeDays} active days
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 4: Streaks */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--dsw-alias-bg-layer-2, #2c2c2c)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--dsw-alias-label-tertiary, #a0a0a0)", textTransform: "uppercase", letterSpacing: "0.06em", fontWeight: 600 }}>
|
||||||
|
Active Streak
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 24, fontWeight: 700, margin: "6px 0 2px", color: "var(--dsw-alias-label-primary, #ffffff)" }}>
|
||||||
|
{summary.currentStreak} {summary.currentStreak === 1 ? "day" : "days"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--dsw-alias-label-secondary, #b9b9b9)" }}>
|
||||||
|
Longest streak: {summary.longestStreak} days
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heatmap Card */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "var(--dsw-alias-bg-layer-2, #2c2c2c)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l2, #494949)",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: "20px 22px",
|
||||||
|
position: "relative",
|
||||||
|
overflowX: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 14,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Contribution Calendar</span>
|
||||||
|
{summary.peakDay && (
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 400, color: "var(--dsw-alias-label-secondary)" }}>
|
||||||
|
Peak day: <strong style={{ color: "var(--dsw-alias-label-primary)" }}>{summary.peakDay.date}</strong> ({formatNumber(summary.peakDay.tokens)} tokens)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SVG Heatmap */}
|
||||||
|
{calendarGrid && (
|
||||||
|
<div style={{ display: "inline-block", minWidth: 780 }}>
|
||||||
|
{/* Month labels */}
|
||||||
|
<div style={{ display: "flex", marginLeft: 32, marginBottom: 4, height: 16 }}>
|
||||||
|
{monthHeaders.map((m, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
left: m.col * 14,
|
||||||
|
width: 0,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontSize: 10,
|
||||||
|
color: "var(--dsw-alias-label-tertiary, #a0a0a0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid with day labels */}
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
{/* Day of week labels */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 3,
|
||||||
|
width: 26,
|
||||||
|
fontSize: 9,
|
||||||
|
color: "var(--dsw-alias-label-tertiary, #a0a0a0)",
|
||||||
|
textAlign: "right",
|
||||||
|
paddingTop: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DAY_LABELS.map((lbl, idx) => (
|
||||||
|
<div key={idx} style={{ height: 11, lineHeight: "11px" }}>
|
||||||
|
{lbl}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Columns of weeks */}
|
||||||
|
<div style={{ display: "flex", gap: 3 }}>
|
||||||
|
{calendarGrid.map((week, colIdx) => (
|
||||||
|
<div key={colIdx} style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||||
|
{week.map((day, rowIdx) => {
|
||||||
|
if (!day) {
|
||||||
|
return <div key={rowIdx} style={{ width: 11, height: 11 }} />;
|
||||||
|
}
|
||||||
|
const lvl = day.stats.level || 0;
|
||||||
|
const bg = activeTheme.levels[lvl];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rowIdx}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
setHoveredDay({ date: day.date, stats: day.stats });
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top - 8 });
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHoveredDay(null);
|
||||||
|
setTooltipPos(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 11,
|
||||||
|
height: 11,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: bg,
|
||||||
|
border:
|
||||||
|
lvl === 0
|
||||||
|
? "1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.06))"
|
||||||
|
: "1px solid transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "transform 0.1s ease, filter 0.1s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend & Footnote */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 14,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--dsw-alias-label-tertiary, #a0a0a0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Less</span>
|
||||||
|
{activeTheme.levels.map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: 11,
|
||||||
|
height: 11,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: c,
|
||||||
|
border: i === 0 ? "1px solid var(--dsw-alias-border-l1)" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span>More</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Hover Tooltip */}
|
||||||
|
{hoveredDay && tooltipPos && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
left: tooltipPos.x,
|
||||||
|
top: tooltipPos.y,
|
||||||
|
transform: "translate(-50%, -100%)",
|
||||||
|
background: "var(--dsw-alias-bg-layer-1, #1e1e1e)",
|
||||||
|
border: "1px solid var(--dsw-alias-border-l3, #555555)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "8px 12px",
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
boxShadow: "0 6px 18px rgba(0,0,0,0.35)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: 9999,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 650, color: "var(--dsw-alias-label-primary, #ffffff)", marginBottom: 4 }}>
|
||||||
|
{new Date(hoveredDay.date + "T00:00:00").toLocaleDateString(undefined, {
|
||||||
|
weekday: "short",
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{hoveredDay.stats.totalTokens === 0 ? (
|
||||||
|
<div style={{ color: "var(--dsw-alias-label-tertiary)" }}>No activity recorded</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#34d399", fontWeight: 600 }}>
|
||||||
|
{formatExact(hoveredDay.stats.totalTokens)} tokens
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--dsw-alias-label-secondary)", marginTop: 2 }}>
|
||||||
|
{hoveredDay.stats.turns} requests / steps
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: "var(--dsw-alias-label-tertiary)", marginTop: 4 }}>
|
||||||
|
Billed: {formatNumber(hoveredDay.stats.inputTokens + hoveredDay.stats.outputTokens)} · Cache: {formatNumber(hoveredDay.stats.cacheReadTokens)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/client/index.tsx
Normal file
106
src/client/index.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { HeatmapView } from "./HeatmapView.js";
|
||||||
|
import { HeatmapModal } from "./HeatmapModal.js";
|
||||||
|
|
||||||
|
export const name = "dsh-plugin-token-heatmap-client";
|
||||||
|
export const inject = ["slots"];
|
||||||
|
|
||||||
|
export function apply(ctx: any) {
|
||||||
|
// 1. Register under Settings -> "Token Heatmap"
|
||||||
|
if (ctx.slots && typeof ctx.slots.inject === "function") {
|
||||||
|
ctx.slots.inject("settings.section", () => {
|
||||||
|
return ctx.slots.register(
|
||||||
|
{
|
||||||
|
name: "settings.section",
|
||||||
|
id: "token-heatmap",
|
||||||
|
order: 14,
|
||||||
|
label: () => "Token Heatmap",
|
||||||
|
icon: "chart",
|
||||||
|
},
|
||||||
|
HeatmapView
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Global Modal Host & In-Session Token Meter Integration
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
const modalContainer = document.createElement("div");
|
||||||
|
modalContainer.id = "dsh-token-heatmap-modal-root";
|
||||||
|
document.body.appendChild(modalContainer);
|
||||||
|
|
||||||
|
const root = createRoot(modalContainer);
|
||||||
|
|
||||||
|
function ModalController() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOpen = () => setIsOpen(true);
|
||||||
|
const handleClose = () => setIsOpen(false);
|
||||||
|
|
||||||
|
window.addEventListener("dsh:open-token-heatmap", handleOpen);
|
||||||
|
window.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") handleClose();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("dsh:open-token-heatmap", handleOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <HeatmapModal open={isOpen} onClose={() => setIsOpen(false)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.render(<ModalController />);
|
||||||
|
|
||||||
|
// 3. Observe DOM for in-session token usage dialog to attach "View Heatmap" button
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
// Find the open session stats dialog
|
||||||
|
const statDialogs = document.querySelectorAll(
|
||||||
|
'div[role="dialog"][aria-label*="usage" i], div[role="dialog"][aria-label*="Token" i], [data-session-stats-usage]'
|
||||||
|
);
|
||||||
|
|
||||||
|
statDialogs.forEach((dialog) => {
|
||||||
|
if (dialog.querySelector(".dsh-heatmap-trigger-btn")) return;
|
||||||
|
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = "dsh-heatmap-trigger-btn";
|
||||||
|
button.type = "button";
|
||||||
|
button.innerHTML = "<span>📊 View 12-Month Activity Heatmap →</span>";
|
||||||
|
button.style.cssText = `
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.12));
|
||||||
|
border: 1px solid var(--dsw-alias-border-l2, #494949);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--dsw-alias-brand-primary, #6366f1);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 550;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease;
|
||||||
|
`;
|
||||||
|
button.onmouseenter = () => {
|
||||||
|
button.style.background = "var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.2))";
|
||||||
|
button.style.borderColor = "var(--dsw-alias-brand-primary, #6366f1)";
|
||||||
|
};
|
||||||
|
button.onmouseleave = () => {
|
||||||
|
button.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.12))";
|
||||||
|
button.style.borderColor = "var(--dsw-alias-border-l2, #494949)";
|
||||||
|
};
|
||||||
|
button.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
window.dispatchEvent(new CustomEvent("dsh:open-token-heatmap"));
|
||||||
|
};
|
||||||
|
|
||||||
|
dialog.appendChild(button);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
303
src/server/aggregator.ts
Normal file
303
src/server/aggregator.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
export interface DayStats {
|
||||||
|
totalTokens: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
cacheReadTokens: number;
|
||||||
|
cacheWriteTokens: number;
|
||||||
|
turns: number;
|
||||||
|
level?: number; // 0..4
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SummaryStats {
|
||||||
|
totalTokens: number;
|
||||||
|
totalInputTokens: number;
|
||||||
|
totalOutputTokens: number;
|
||||||
|
totalCacheReadTokens: number;
|
||||||
|
totalTurns: number;
|
||||||
|
activeDays: number;
|
||||||
|
currentStreak: number;
|
||||||
|
longestStreak: number;
|
||||||
|
peakDay: { date: string; tokens: number; turns: number } | null;
|
||||||
|
dailyAverage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeatmapResponse {
|
||||||
|
summary: SummaryStats;
|
||||||
|
days: Record<string, DayStats>;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
mtimeMs: number;
|
||||||
|
days: Record<string, DayStats>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
function findSessionFiles(dir: string): string[] {
|
||||||
|
const results: string[] = [];
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
results.push(...findSessionFiles(fullPath));
|
||||||
|
} else if (entry.name.endsWith(".jsonl.zstd") || entry.name.endsWith(".jsonl")) {
|
||||||
|
results.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore unreadable dirs
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseSessionFile(filePath: string): Promise<Record<string, DayStats>> {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
const cached = fileCache.get(filePath);
|
||||||
|
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
||||||
|
return cached.days;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isZstd = filePath.endsWith(".zstd");
|
||||||
|
const days: Record<string, DayStats> = {};
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let buffer = "";
|
||||||
|
const handleLine = (line: string) => {
|
||||||
|
if (!line.includes('"usage"')) return;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line);
|
||||||
|
const time = obj.time || (obj.data && obj.data.time);
|
||||||
|
const usage = obj.usage || (obj.data && obj.data.usage);
|
||||||
|
if (time && usage) {
|
||||||
|
const d = new Date(time);
|
||||||
|
const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
const input = Number(usage.inputTokens) || 0;
|
||||||
|
const output = Number(usage.outputTokens) || 0;
|
||||||
|
const cacheRead = Number(usage.cacheReadTokens) || 0;
|
||||||
|
const cacheWrite = Number(usage.cacheWriteTokens) || 0;
|
||||||
|
const total = input + output + cacheRead;
|
||||||
|
|
||||||
|
if (!days[dateKey]) {
|
||||||
|
days[dateKey] = {
|
||||||
|
totalTokens: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
turns: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
days[dateKey].totalTokens += total;
|
||||||
|
days[dateKey].inputTokens += input;
|
||||||
|
days[dateKey].outputTokens += output;
|
||||||
|
days[dateKey].cacheReadTokens += cacheRead;
|
||||||
|
days[dateKey].cacheWriteTokens += cacheWrite;
|
||||||
|
days[dateKey].turns += 1;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed lines
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isZstd) {
|
||||||
|
const proc = spawn("zstd", ["-dc", filePath], { stdio: ["ignore", "pipe", "ignore"] });
|
||||||
|
proc.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
buffer += chunk.toString("utf8");
|
||||||
|
let idx: number;
|
||||||
|
while ((idx = buffer.indexOf("\n")) !== -1) {
|
||||||
|
const line = buffer.slice(0, idx);
|
||||||
|
buffer = buffer.slice(idx + 1);
|
||||||
|
handleLine(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
proc.on("close", () => {
|
||||||
|
if (buffer.trim().length > 0) handleLine(buffer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
proc.on("error", () => resolve());
|
||||||
|
} else {
|
||||||
|
const stream = fs.createReadStream(filePath, { encoding: "utf8" });
|
||||||
|
stream.on("data", (chunk: string) => {
|
||||||
|
buffer += chunk;
|
||||||
|
let idx: number;
|
||||||
|
while ((idx = buffer.indexOf("\n")) !== -1) {
|
||||||
|
const line = buffer.slice(0, idx);
|
||||||
|
buffer = buffer.slice(idx + 1);
|
||||||
|
handleLine(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stream.on("end", () => {
|
||||||
|
if (buffer.trim().length > 0) handleLine(buffer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
stream.on("error", () => resolve());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileCache.set(filePath, { mtimeMs: stat.mtimeMs, days });
|
||||||
|
return days;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aggregateTokens(customSessionsRoot?: string): Promise<HeatmapResponse> {
|
||||||
|
const sessionsRoot = customSessionsRoot || path.join(os.homedir(), ".dsh", "sessions");
|
||||||
|
const files = findSessionFiles(sessionsRoot);
|
||||||
|
const parsedResults = await Promise.all(files.map(parseSessionFile));
|
||||||
|
|
||||||
|
const days: Record<string, DayStats> = {};
|
||||||
|
|
||||||
|
for (const fileDays of parsedResults) {
|
||||||
|
for (const [date, stats] of Object.entries(fileDays)) {
|
||||||
|
if (!days[date]) {
|
||||||
|
days[date] = {
|
||||||
|
totalTokens: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheWriteTokens: 0,
|
||||||
|
turns: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
days[date].totalTokens += stats.totalTokens;
|
||||||
|
days[date].inputTokens += stats.inputTokens;
|
||||||
|
days[date].outputTokens += stats.outputTokens;
|
||||||
|
days[date].cacheReadTokens += stats.cacheReadTokens;
|
||||||
|
days[date].cacheWriteTokens += stats.cacheWriteTokens;
|
||||||
|
days[date].turns += stats.turns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate Levels (GitHub 0..4 intensity scale based on positive token days)
|
||||||
|
const positiveValues = Object.values(days)
|
||||||
|
.map((d) => d.totalTokens)
|
||||||
|
.filter((t) => t > 0)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
let q1 = 5_000_000;
|
||||||
|
let q2 = 25_000_000;
|
||||||
|
let q3 = 75_000_000;
|
||||||
|
let q4 = 150_000_000;
|
||||||
|
|
||||||
|
if (positiveValues.length >= 4) {
|
||||||
|
q1 = positiveValues[Math.floor(positiveValues.length * 0.25)];
|
||||||
|
q2 = positiveValues[Math.floor(positiveValues.length * 0.50)];
|
||||||
|
q3 = positiveValues[Math.floor(positiveValues.length * 0.75)];
|
||||||
|
q4 = positiveValues[Math.floor(positiveValues.length * 0.90)];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const day of Object.values(days)) {
|
||||||
|
if (day.totalTokens === 0) day.level = 0;
|
||||||
|
else if (day.totalTokens <= q1) day.level = 1;
|
||||||
|
else if (day.totalTokens <= q2) day.level = 2;
|
||||||
|
else if (day.totalTokens <= q3) day.level = 3;
|
||||||
|
else day.level = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate Summary & Streaks
|
||||||
|
let totalTokens = 0;
|
||||||
|
let totalInputTokens = 0;
|
||||||
|
let totalOutputTokens = 0;
|
||||||
|
let totalCacheReadTokens = 0;
|
||||||
|
let totalTurns = 0;
|
||||||
|
let peakDay: { date: string; tokens: number; turns: number } | null = null;
|
||||||
|
|
||||||
|
for (const [date, stats] of Object.entries(days)) {
|
||||||
|
totalTokens += stats.totalTokens;
|
||||||
|
totalInputTokens += stats.inputTokens;
|
||||||
|
totalOutputTokens += stats.outputTokens;
|
||||||
|
totalCacheReadTokens += stats.cacheReadTokens;
|
||||||
|
totalTurns += stats.turns;
|
||||||
|
|
||||||
|
if (!peakDay || stats.totalTokens > peakDay.tokens) {
|
||||||
|
peakDay = { date, tokens: stats.totalTokens, turns: stats.turns };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeDays = Object.values(days).filter((d) => d.totalTokens > 0).length;
|
||||||
|
const dailyAverage = activeDays > 0 ? Math.round(totalTokens / activeDays) : 0;
|
||||||
|
|
||||||
|
// Streak calculation (iterating backwards from today)
|
||||||
|
const now = new Date();
|
||||||
|
const formatDate = (d: Date) =>
|
||||||
|
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
let currentStreak = 0;
|
||||||
|
let cursor = new Date(now);
|
||||||
|
const todayKey = formatDate(cursor);
|
||||||
|
cursor.setDate(cursor.getDate() - 1);
|
||||||
|
const yesterdayKey = formatDate(cursor);
|
||||||
|
|
||||||
|
// If active today, start from today; else if active yesterday, start from yesterday
|
||||||
|
let streakDate = (days[todayKey]?.totalTokens ?? 0) > 0 ? new Date(now) : (days[yesterdayKey]?.totalTokens ?? 0) > 0 ? new Date(cursor) : null;
|
||||||
|
|
||||||
|
if (streakDate) {
|
||||||
|
while (true) {
|
||||||
|
const key = formatDate(streakDate);
|
||||||
|
if ((days[key]?.totalTokens ?? 0) > 0) {
|
||||||
|
currentStreak++;
|
||||||
|
streakDate.setDate(streakDate.getDate() - 1);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longest streak
|
||||||
|
const sortedDates = Object.keys(days)
|
||||||
|
.filter((k) => days[k].totalTokens > 0)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
let longestStreak = 0;
|
||||||
|
let tempStreak = 0;
|
||||||
|
let prevDate: Date | null = null;
|
||||||
|
|
||||||
|
for (const dateStr of sortedDates) {
|
||||||
|
const cur = new Date(dateStr + "T00:00:00");
|
||||||
|
if (!prevDate) {
|
||||||
|
tempStreak = 1;
|
||||||
|
} else {
|
||||||
|
const diffDays = Math.round((cur.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
if (diffDays === 1) {
|
||||||
|
tempStreak++;
|
||||||
|
} else {
|
||||||
|
tempStreak = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tempStreak > longestStreak) longestStreak = tempStreak;
|
||||||
|
prevDate = cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDate = formatDate(now);
|
||||||
|
const start = new Date(now);
|
||||||
|
start.setDate(start.getDate() - 364); // 52 weeks = 364 days
|
||||||
|
const startDate = formatDate(start);
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
totalTokens,
|
||||||
|
totalInputTokens,
|
||||||
|
totalOutputTokens,
|
||||||
|
totalCacheReadTokens,
|
||||||
|
totalTurns,
|
||||||
|
activeDays,
|
||||||
|
currentStreak,
|
||||||
|
longestStreak: Math.max(longestStreak, currentStreak),
|
||||||
|
peakDay,
|
||||||
|
dailyAverage,
|
||||||
|
},
|
||||||
|
days,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
46
src/server/index.ts
Normal file
46
src/server/index.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { aggregateTokens } from "./aggregator.js";
|
||||||
|
|
||||||
|
export const name = "dsh-plugin-token-heatmap";
|
||||||
|
export const inject = ["webServer"];
|
||||||
|
|
||||||
|
export function apply(ctx: any) {
|
||||||
|
ctx.effect(() =>
|
||||||
|
ctx.webServer.register({
|
||||||
|
kind: "prefix",
|
||||||
|
path: "/api/dsh-token-heatmap",
|
||||||
|
handler: async (req: any, res: any) => {
|
||||||
|
const url = new URL(req.url, "http://localhost");
|
||||||
|
const pathname = url.pathname.replace(/\/+$/, "");
|
||||||
|
|
||||||
|
// Set common headers
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.writeHead(204);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && (pathname === "/api/dsh-token-heatmap" || pathname === "/api/dsh-token-heatmap/stats")) {
|
||||||
|
try {
|
||||||
|
const data = await aggregateTokens();
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
} catch (err: any) {
|
||||||
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: err?.message || "Failed to aggregate token stats" }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Not found" }));
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue