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:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user