feat(usage): open the TokenTracker dashboard from the usage monitor

The modal deliberately shows only a slice of the analytics; this hands off
to TokenTracker's own local dashboard for the rest. The worker starts
`tokentracker serve` on demand and returns the URL, the UI opens the browser.

Three things the spawn has to get right: port 7680 is not free on Windows
(Delivery Optimization holds [::]:7680) and serve does not fall back, so we
scan 7680-7689 with a dual-stack bind probe; --no-open because the CLI would
open the browser before the server answers; and the child is a cmd.exe shim,
so shutdown kills the process tree. --no-sync keeps our no-cloud-sync rule.
This commit is contained in:
mika kuns
2026-08-24 16:51:00 +02:00
parent 2dec590dfc
commit 3003cfa561
19 changed files with 413 additions and 4 deletions
+22
View File
@@ -184,6 +184,8 @@ public record TaskUsageRowDto(
bool? Productive = null,
bool? OneShot = null);
public record TokenTrackerDashboardDto(bool Ok, string? Url, string? Error);
public record TokenTrackerStatusDto(
bool Installed,
string? Version,
@@ -1233,6 +1235,26 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return BuildTokenTrackerStatus(await _tokenTracker.ProbeAsync(ct: Context.ConnectionAborted));
});
/// <summary>
/// Brings up TokenTracker's own local dashboard (<c>tokentracker serve</c>) and hands the URL
/// back — the UI opens the browser, since that is a user-session concern. The server is a
/// child of this worker and dies with it. Never throws: a failure is a message the caller
/// flashes in the footer.
/// </summary>
public Task<TokenTrackerDashboardDto> OpenTokenTrackerDashboard() => HubGuard(async () =>
{
if (_tokenTracker is null)
return new TokenTrackerDashboardDto(false, null, "TokenTracker is not configured on this worker.");
var dashboard = await _tokenTracker.OpenDashboardAsync(Context.ConnectionAborted);
if (!dashboard.Ok)
await _broadcaster.WorkerLog(
dashboard.Error ?? "TokenTracker dashboard failed to start.",
WorkerLogLevel.Error, DateTime.UtcNow);
return new TokenTrackerDashboardDto(dashboard.Ok, dashboard.Url, dashboard.Error);
});
private TokenTrackerStatusDto BuildTokenTrackerStatus(TokenTrackerProbe probe)
{
var state = _tokenTracker!.State;
@@ -11,4 +11,8 @@ public interface ITokenTrackerClient
/// <summary>Runs <c>npm i -g tokentracker-cli</c>, reporting each output line.</summary>
Task<TokenTrackerRunResult> InstallAsync(IProgress<string>? output = null, CancellationToken ct = default);
/// <summary>Makes sure the local dashboard server is up and returns its URL. Idempotent — a
/// server this worker already started is reused instead of spawning a second one.</summary>
Task<TokenTrackerDashboard> StartDashboardAsync(CancellationToken ct = default);
}
@@ -16,6 +16,14 @@ public static class TokenTrackerArgs
public const string NpmCommand = "npm";
public const string PackageName = "tokentracker-cli";
/// <summary>TokenTracker's own default dashboard port. It is <b>not</b> reliably free —
/// Windows Delivery Optimization listens on 7680 — and <c>serve</c> does not fall back on its
/// own, it just exits with "port is still in use", so we scan
/// <see cref="DashboardPortSpan"/> ports upward from here.</summary>
public const int DefaultDashboardPort = 7680;
public const int DashboardPortSpan = 10;
public static string[] Export(DateOnly from, DateOnly to) =>
[
"sessions",
@@ -27,5 +35,17 @@ public static class TokenTrackerArgs
public static string[] Version() => ["-v"];
/// <summary>The local dashboard server. <c>--no-open</c> because we open the browser
/// ourselves once the port answers (the CLI would open it before the server is up), and
/// <c>--no-sync</c> for the same reason we never call <c>init</c>: we do not push the user's
/// session data to TokenTracker's cloud on their behalf.</summary>
public static string[] Serve(int port) =>
[
"serve",
"--port", port.ToString(CultureInfo.InvariantCulture),
"--no-open",
"--no-sync",
];
public static string[] Install() => ["i", "-g", PackageName];
}
@@ -1,5 +1,8 @@
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using ClaudeDo.Data.Environment;
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
@@ -11,13 +14,20 @@ namespace ClaudeDo.Worker.Usage.TokenTracker;
/// directly. We deliberately never fall back to <c>npx</c> — that would silently download a
/// package behind the user's back.
/// </summary>
public sealed class TokenTrackerClient : ITokenTrackerClient
public sealed class TokenTrackerClient : ITokenTrackerClient, IDisposable
{
private static readonly TimeSpan ExportTimeout = TimeSpan.FromSeconds(120);
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan InstallTimeout = TimeSpan.FromMinutes(5);
private static readonly TimeSpan DashboardReadyTimeout = TimeSpan.FromSeconds(30);
private const int MinimumNodeMajor = 20;
private static readonly HttpClient ReadinessProbe = new() { Timeout = TimeSpan.FromSeconds(2) };
private readonly SemaphoreSlim _dashboardLock = new(1, 1);
private Process? _dashboardProcess;
private string? _dashboardUrl;
public async Task<TokenTrackerProbe> ProbeAsync(CancellationToken ct = default)
{
var node = await RunAsync("node", ["--version"], ProbeTimeout, null, ct);
@@ -36,6 +46,201 @@ public sealed class TokenTrackerClient : ITokenTrackerClient
public Task<TokenTrackerRunResult> InstallAsync(IProgress<string>? output = null, CancellationToken ct = default) =>
RunAsync(TokenTrackerArgs.NpmCommand, TokenTrackerArgs.Install(), InstallTimeout, output, ct);
public async Task<TokenTrackerDashboard> StartDashboardAsync(CancellationToken ct = default)
{
await _dashboardLock.WaitAsync(ct);
try
{
if (_dashboardProcess is { HasExited: false } && _dashboardUrl is { } running)
return new TokenTrackerDashboard(true, running, null);
KillDashboard();
var resolved = ExecutableResolver.Resolve(TokenTrackerArgs.Command);
if (resolved is null)
return new TokenTrackerDashboard(false, null,
$"'{TokenTrackerArgs.Command}' not found on PATH.");
var port = FindFreePort();
if (port is null)
return new TokenTrackerDashboard(false, null,
$"No free port between {TokenTrackerArgs.DefaultDashboardPort} and " +
$"{TokenTrackerArgs.DefaultDashboardPort + TokenTrackerArgs.DashboardPortSpan - 1}.");
var arguments = TokenTrackerArgs.Serve(port.Value);
var startInfo = new ProcessStartInfo
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
if (resolved.IsShim)
{
var shim = ExecutableResolver.BuildShimStartInfo(resolved.Path, arguments);
startInfo.FileName = shim.FileName;
startInfo.Arguments = shim.Arguments;
}
else
{
startInfo.FileName = resolved.Path;
foreach (var arg in arguments) startInfo.ArgumentList.Add(arg);
}
Process process;
try
{
process = Process.Start(startInfo)
?? throw new InvalidOperationException("Process.Start returned null.");
}
catch (Exception ex)
{
return new TokenTrackerDashboard(false, null, $"Could not start the dashboard: {ex.Message}");
}
// The server is long-lived, so both pipes must keep draining — a full pipe buffer
// would block the very server we are waiting for. The head is kept for the error
// message ("port is still in use" arrives on stdout before the process exits).
var output = new DashboardOutput();
_ = DrainAsync(process.StandardOutput, output);
_ = DrainAsync(process.StandardError, output);
var url = $"http://127.0.0.1:{port.Value}/";
bool serving;
try
{
serving = await WaitUntilServingAsync(process, url, ct);
}
catch (OperationCanceledException)
{
// A caller that walked away must not leave a server we no longer track behind.
KillProcessTree(process);
throw;
}
if (!serving)
{
var reason = output.Head() is { Length: > 0 } head
? head
: $"the dashboard did not answer on port {port.Value} within " +
$"{DashboardReadyTimeout.TotalSeconds:0}s.";
KillProcessTree(process);
return new TokenTrackerDashboard(false, null, $"TokenTracker dashboard failed: {reason}");
}
_dashboardProcess = process;
_dashboardUrl = url;
return new TokenTrackerDashboard(true, url, null);
}
finally
{
_dashboardLock.Release();
}
}
public void Dispose() => KillDashboard();
private void KillDashboard()
{
if (_dashboardProcess is { } process) KillProcessTree(process);
_dashboardProcess = null;
_dashboardUrl = null;
}
/// <summary>An npm CLI is launched through <c>cmd.exe</c>, so the process we hold is the shim,
/// not node — killing it alone would leave the server listening.</summary>
private static void KillProcessTree(Process process)
{
try
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
}
catch { /* already gone, or we never had rights to it */ }
finally { process.Dispose(); }
}
private static async Task<bool> WaitUntilServingAsync(Process process, string url, CancellationToken ct)
{
var deadline = DateTime.UtcNow + DashboardReadyTimeout;
while (DateTime.UtcNow < deadline)
{
if (process.HasExited) return false;
try
{
using var response = await ReadinessProbe.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
if (response.IsSuccessStatusCode) return true;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch { /* not listening yet */ }
await Task.Delay(250, ct);
}
return false;
}
/// <summary>First free port at or above TokenTracker's default. The probe binds dual-stack
/// because node listens on <c>[::]</c>: an IPv4-only check calls 7680 free on a machine where
/// Windows Delivery Optimization already holds <c>[::]:7680</c>, and <c>serve</c> then dies.</summary>
private static int? FindFreePort()
{
for (var port = TokenTrackerArgs.DefaultDashboardPort;
port < TokenTrackerArgs.DefaultDashboardPort + TokenTrackerArgs.DashboardPortSpan;
port++)
{
if (IsPortFree(port)) return port;
}
return null;
}
private static bool IsPortFree(int port)
{
try
{
using var socket = new Socket(AddressFamily.InterNetworkV6, SocketType.Stream, ProtocolType.Tcp)
{
DualMode = true,
ExclusiveAddressUse = true,
};
socket.Bind(new IPEndPoint(IPAddress.IPv6Any, port));
return true;
}
catch (Exception)
{
return false;
}
}
private static async Task DrainAsync(StreamReader reader, DashboardOutput sink)
{
try
{
while (await reader.ReadLineAsync() is { } line) sink.Add(line);
}
catch { /* the process went away; nothing left to drain */ }
}
/// <summary>Keeps only the first few lines — enough to explain a failed start, bounded so a
/// server running for hours cannot grow it.</summary>
private sealed class DashboardOutput
{
private const int MaxLines = 10;
private readonly List<string> _lines = [];
public void Add(string line)
{
lock (_lines)
{
if (_lines.Count < MaxLines && !string.IsNullOrWhiteSpace(line)) _lines.Add(line.Trim());
}
}
public string Head()
{
lock (_lines) return string.Join(" ", _lines);
}
}
/// <summary>Visible for the probe's version parsing; returns 0 when unreadable.</summary>
internal static int NodeMajor(string? version)
{
@@ -39,6 +39,10 @@ public sealed record TokenTrackerProbe(
public static TokenTrackerProbe Unknown { get; } = new(false, null, false, null, null);
}
/// <summary>Where the local dashboard is reachable, or why it isn't. Same contract as
/// <see cref="TokenTrackerRunResult"/>: a failure is a value, never an exception.</summary>
public sealed record TokenTrackerDashboard(bool Ok, string? Url, string? Error);
/// <summary>Outcome of one CLI invocation. <paramref name="Ok"/> false always carries an
/// <paramref name="Error"/> — callers turn that into state, never into an exception.</summary>
public sealed record TokenTrackerRunResult(bool Ok, string StdOut, string? Error);
@@ -88,6 +88,18 @@ public sealed class TokenTrackerService
}
}
/// <summary>Starts (or reuses) the local dashboard server. Probes first so a missing CLI comes
/// back as a message instead of a failed process spawn.</summary>
public async Task<TokenTrackerDashboard> OpenDashboardAsync(CancellationToken ct = default)
{
var probe = await ProbeAsync(ct: ct);
if (!probe.Installed)
return new TokenTrackerDashboard(false, null,
probe.Error ?? "TokenTracker CLI is not installed.");
return await _client.StartDashboardAsync(ct);
}
public async Task<TokenTrackerRunResult> InstallAsync(
IProgress<string>? output = null, CancellationToken ct = default)
{