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
+17 -1
View File
@@ -162,6 +162,17 @@ The per-model / per-task / cost view comes from **TokenTracker**, an external MI
- **Fail-open everywhere:** `TokenTrackerState` mirrors `UsageState` — a failed or unparseable - **Fail-open everywhere:** `TokenTrackerState` mirrors `UsageState` — a failed or unparseable
fetch never overwrites a good export, it only sets `LastError`. CLI missing → empty analytics fetch never overwrites a good export, it only sets `LastError`. CLI missing → empty analytics
plus a hint card in the UI. Nothing here can throw into the queue. plus a hint card in the UI. Nothing here can throw into the queue.
- **Local dashboard:** `tokentracker serve --port <p> --no-open --no-sync`, started by the worker on
demand (`ITokenTrackerClient.StartDashboardAsync`) for the "Open TokenTracker dashboard" button.
Three things this path has to get right: TokenTracker's default port **7680 is not free** on
Windows (Delivery Optimization holds `[::]:7680`) and `serve` exits instead of falling back, so
we scan 76807689 with a **dual-stack** bind probe (an IPv4-only probe calls 7680 free and the
spawn then dies); `--no-open` because the CLI would open the browser before the server answers,
so we poll `GET http://127.0.0.1:<p>/` (30 s cap) and let the **UI** open the URL; and the child
is a `cmd.exe` shim, so shutdown kills the **process tree** or node keeps listening. The
spawned server is reused while alive and dies with the worker (`TokenTrackerClient` is
`IDisposable`, registered as a singleton) — a worker *crash* leaks it until the port is scanned
past.
- **Limits, gate and throttle do *not* come from TokenTracker.** They are the OAuth poll above. - **Limits, gate and throttle do *not* come from TokenTracker.** They are the OAuth poll above.
A broken or absent TokenTracker costs you the cost columns, nothing else. A broken or absent TokenTracker costs you the cost columns, nothing else.
@@ -199,7 +210,9 @@ The per-model / per-task / cost view comes from **TokenTracker**, an external MI
(`GetTaskUsageAsync`) over a 7d/30d preset or custom range — both with a **cost** column, the (`GetTaskUsageAsync`) over a 7d/30d preset or custom range — both with a **cost** column, the
task table additionally with retries. task table additionally with retries.
- **Analytics header vs. hint card** — the modal shows exactly one of the two. With TokenTracker - **Analytics header vs. hint card** — the modal shows exactly one of the two. With TokenTracker
present: total tokens + total cost, a freshness stamp, and a manual `RefreshAnalytics` button. present: total tokens + total cost, a freshness stamp, a manual `RefreshAnalytics` button and an
**Open TokenTracker dashboard** button (spinner while the server boots; everything this modal
deliberately doesn't show lives there).
Without it: a hint card explaining what TokenTracker is (local only, no `init`, no cloud sync) Without it: a hint card explaining what TokenTracker is (local only, no `init`, no cloud sync)
plus an `Install TokenTracker` button — which is hidden and replaced by a Node-version hint when plus an `Install TokenTracker` button — which is hidden and replaced by a Node-version hint when
Node < 20 or Node is missing, because the install would just fail. Install progress and every Node < 20 or Node is missing, because the install would just fail. Install progress and every
@@ -223,6 +236,9 @@ The per-model / per-task / cost view comes from **TokenTracker**, an external MI
NodeVersion, LastFetchedUtc, LastError, FormatVersion, SessionCount)`. Never throws; an NodeVersion, LastFetchedUtc, LastError, FormatVersion, SessionCount)`. Never throws; an
unconfigured worker comes back as not-installed with a reason. unconfigured worker comes back as not-installed with a reason.
- `RefreshTokenTracker()` — forces a fetch, then re-probes and returns the status. - `RefreshTokenTracker()` — forces a fetch, then re-probes and returns the status.
- `OpenTokenTrackerDashboard()` — brings up the local dashboard server and returns
`TokenTrackerDashboardDto(Ok, Url, Error)`; the UI opens the browser (`ShellOpen.Url`, which
refuses anything that isn't http/https). Never throws — a failure also lands in `WorkerLog`.
- `InstallTokenTracker()` — `npm i -g tokentracker-cli`, streaming npm's output line by line into - `InstallTokenTracker()` — `npm i -g tokentracker-cli`, streaming npm's output line by line into
`WorkerLog` (npm writes progress to stderr; forwarding it is what keeps a multi-minute global `WorkerLog` (npm writes progress to stderr; forwarding it is what keeps a multi-minute global
install from looking hung), then force-re-probes. install from looking hung), then force-re-probes.
+5 -1
View File
@@ -535,6 +535,8 @@
"analyticsStampFormat": "Auswertung: Stand {0}", "analyticsStampFormat": "Auswertung: Stand {0}",
"analyticsNever": "Auswertung noch nicht geladen", "analyticsNever": "Auswertung noch nicht geladen",
"analyticsRefresh": "Auswertung aktualisieren", "analyticsRefresh": "Auswertung aktualisieren",
"openDashboard": "TokenTracker-Dashboard öffnen",
"openDashboardTip": "Startet das lokale TokenTracker-Dashboard (nur localhost, kein Cloud-Sync) und öffnet es im Browser. Der erste Start dauert ein paar Sekunden.",
"analyticsTotalsFormat": "{0} Tokens · {1} $", "analyticsTotalsFormat": "{0} Tokens · {1} $",
"ttMissingTitle": "Die Token-Auswertung braucht TokenTracker", "ttMissingTitle": "Die Token-Auswertung braucht TokenTracker",
"ttMissingBody": "TokenTracker (MIT) liest deine lokalen Claude-Transkripte und sieht keine Prompts. ClaudeDo ruft nur den lokalen Export auf kein `init`, keine Hooks, kein Cloud-Sync.", "ttMissingBody": "TokenTracker (MIT) liest deine lokalen Claude-Transkripte und sieht keine Prompts. ClaudeDo ruft nur den lokalen Export auf kein `init`, keine Hooks, kein Cloud-Sync.",
@@ -750,7 +752,9 @@
"thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}", "thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}",
"installStarted": "TokenTracker wird installiert — der Fortschritt läuft in der Log-Leiste.", "installStarted": "TokenTracker wird installiert — der Fortschritt läuft in der Log-Leiste.",
"installFailed": "TokenTracker konnte nicht installiert werden: {0}", "installFailed": "TokenTracker konnte nicht installiert werden: {0}",
"analyticsRefreshFailed": "Die Auswertung konnte nicht aktualisiert werden: {0}" "analyticsRefreshFailed": "Die Auswertung konnte nicht aktualisiert werden: {0}",
"dashboardFailed": "Das TokenTracker-Dashboard konnte nicht geöffnet werden: {0}",
"dashboardUnavailable": "der lokale Dashboard-Server ist nicht gestartet"
}, },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." },
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" }, "sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
+5 -1
View File
@@ -535,6 +535,8 @@
"analyticsStampFormat": "Analytics as of {0}", "analyticsStampFormat": "Analytics as of {0}",
"analyticsNever": "Analytics not loaded yet", "analyticsNever": "Analytics not loaded yet",
"analyticsRefresh": "Refresh analytics", "analyticsRefresh": "Refresh analytics",
"openDashboard": "Open TokenTracker dashboard",
"openDashboardTip": "Starts TokenTracker's local dashboard (localhost only, no cloud sync) and opens it in your browser. The first start takes a few seconds.",
"analyticsTotalsFormat": "{0} tokens · ${1}", "analyticsTotalsFormat": "{0} tokens · ${1}",
"ttMissingTitle": "Token analytics need TokenTracker", "ttMissingTitle": "Token analytics need TokenTracker",
"ttMissingBody": "TokenTracker (MIT) reads your local Claude transcripts and never sees prompts. ClaudeDo only calls its local export — it does not run `init`, installs no hooks and enables no cloud sync.", "ttMissingBody": "TokenTracker (MIT) reads your local Claude transcripts and never sees prompts. ClaudeDo only calls its local export — it does not run `init`, installs no hooks and enables no cloud sync.",
@@ -750,7 +752,9 @@
"thresholdSaveFailed": "Couldn't save the threshold: {0}", "thresholdSaveFailed": "Couldn't save the threshold: {0}",
"installStarted": "Installing TokenTracker — watch the log strip for progress.", "installStarted": "Installing TokenTracker — watch the log strip for progress.",
"installFailed": "Couldn't install TokenTracker: {0}", "installFailed": "Couldn't install TokenTracker: {0}",
"analyticsRefreshFailed": "Couldn't refresh the analytics: {0}" "analyticsRefreshFailed": "Couldn't refresh the analytics: {0}",
"dashboardFailed": "Couldn't open the TokenTracker dashboard: {0}",
"dashboardUnavailable": "the local dashboard server did not start"
}, },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "resetToDefault": "Reset to the bundled default." }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "resetToDefault": "Reset to the bundled default." },
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" }, "sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
@@ -210,4 +210,8 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<TokenTrackerStatusDto?> GetTokenTrackerStatusAsync(); Task<TokenTrackerStatusDto?> GetTokenTrackerStatusAsync();
Task<TokenTrackerStatusDto?> RefreshTokenTrackerAsync(); Task<TokenTrackerStatusDto?> RefreshTokenTrackerAsync();
Task<TokenTrackerStatusDto?> InstallTokenTrackerAsync(); Task<TokenTrackerStatusDto?> InstallTokenTrackerAsync();
/// <summary>Starts TokenTracker's local dashboard on the worker and returns its URL; opening
/// the browser is the caller's job.</summary>
Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync();
} }
+22
View File
@@ -1,3 +1,4 @@
using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
@@ -7,6 +8,27 @@ public static class ShellOpen
{ {
// "nothing to open" (blank/missing path) is not an error — callers stay silent, matching the // "nothing to open" (blank/missing path) is not an error — callers stay silent, matching the
// existing Directory.Exists/File.Exists guards this helper replaces. // existing Directory.Exists/File.Exists guards this helper replaces.
/// <summary>Opens an http(s) URL in the default browser. Anything else is refused rather than
/// handed to the shell — a URL comes from the worker, and `UseShellExecute` would happily run
/// a local executable path.</summary>
public static (bool Ok, string? Error) Url(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return (false, null);
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
return (false, $"Not a web URL: {url}");
try
{
Process.Start(new ProcessStartInfo { FileName = uri.AbsoluteUri, UseShellExecute = true });
return (true, null);
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
public static (bool Ok, string? Error) Path(string? path) public static (bool Ok, string? Error) Path(string? path)
{ {
if (string.IsNullOrWhiteSpace(path)) return (false, null); if (string.IsNullOrWhiteSpace(path)) return (false, null);
+5
View File
@@ -698,6 +698,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task<TokenTrackerStatusDto?> InstallTokenTrackerAsync() public Task<TokenTrackerStatusDto?> InstallTokenTrackerAsync()
=> TryInvokeAsync<TokenTrackerStatusDto>("InstallTokenTracker"); => TryInvokeAsync<TokenTrackerStatusDto>("InstallTokenTracker");
public Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync()
=> TryInvokeAsync<TokenTrackerDashboardDto>("OpenTokenTrackerDashboard");
// IWorkerClient explicit implementations (drop typed return values) // IWorkerClient explicit implementations (drop typed return values)
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct) async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
=> await StartPlanningSessionAsync(taskId, ct); => await StartPlanningSessionAsync(taskId, ct);
@@ -872,6 +875,8 @@ public sealed record TaskUsageRowDto(
bool? Productive = null, bool? Productive = null,
bool? OneShot = null); bool? OneShot = null);
public sealed record TokenTrackerDashboardDto(bool Ok, string? Url, string? Error);
public sealed record TokenTrackerStatusDto( public sealed record TokenTrackerStatusDto(
bool Installed, bool Installed,
string? Version, string? Version,
@@ -68,6 +68,8 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
[NotifyPropertyChangedFor(nameof(ShowInstallButton))] [NotifyPropertyChangedFor(nameof(ShowInstallButton))]
private bool _isInstalling; private bool _isInstalling;
[ObservableProperty] private bool _isOpeningDashboard;
public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0; public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0;
public bool TasksEmpty => !IsBusy && TaskRows.Count == 0; public bool TasksEmpty => !IsBusy && TaskRows.Count == 0;
@@ -229,6 +231,35 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
finally { IsInstalling = false; } finally { IsInstalling = false; }
} }
/// <summary>Hands off to TokenTracker's own dashboard for everything this modal deliberately
/// doesn't show. The worker starts the local server (first start takes a few seconds), we only
/// open the browser.</summary>
[RelayCommand]
private async Task OpenDashboard()
{
IsOpeningDashboard = true;
try
{
var dashboard = await _worker.OpenTokenTrackerDashboardAsync();
if (dashboard is not { Ok: true, Url: { Length: > 0 } url })
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.dashboardFailed",
dashboard?.Error ?? Loc.T("vm.usageMonitor.dashboardUnavailable")));
return;
}
var (ok, error) = ShellOpen.Url(url);
if (!ok)
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.dashboardFailed",
error ?? Loc.T("vm.usageMonitor.dashboardUnavailable")));
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.dashboardFailed", ex.Message));
}
finally { IsOpeningDashboard = false; }
}
private Task SetPreset(int days) private Task SetPreset(int days)
{ {
ApplyPresetRange(days); ApplyPresetRange(days);
@@ -180,6 +180,12 @@
<TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding AnalyticsStampText}"/> <TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding AnalyticsStampText}"/>
<Button Classes="btn" Content="{loc:Tr modals.usageMonitor.analyticsRefresh}" <Button Classes="btn" Content="{loc:Tr modals.usageMonitor.analyticsRefresh}"
Command="{Binding RefreshAnalyticsCommand}"/> Command="{Binding RefreshAnalyticsCommand}"/>
<Button Classes="btn" Content="{loc:Tr modals.usageMonitor.openDashboard}"
ToolTip.Tip="{loc:Tr modals.usageMonitor.openDashboardTip}"
IsEnabled="{Binding !IsOpeningDashboard}"
Command="{Binding OpenDashboardCommand}"/>
<Ellipse Classes="spinner" Width="14" Height="14" VerticalAlignment="Center"
IsVisible="{Binding IsOpeningDashboard}"/>
</StackPanel> </StackPanel>
<TabControl Padding="20,12" TabStripPlacement="Top"> <TabControl Padding="20,12" TabStripPlacement="Top">
+22
View File
@@ -184,6 +184,8 @@ public record TaskUsageRowDto(
bool? Productive = null, bool? Productive = null,
bool? OneShot = null); bool? OneShot = null);
public record TokenTrackerDashboardDto(bool Ok, string? Url, string? Error);
public record TokenTrackerStatusDto( public record TokenTrackerStatusDto(
bool Installed, bool Installed,
string? Version, string? Version,
@@ -1233,6 +1235,26 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return BuildTokenTrackerStatus(await _tokenTracker.ProbeAsync(ct: Context.ConnectionAborted)); 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) private TokenTrackerStatusDto BuildTokenTrackerStatus(TokenTrackerProbe probe)
{ {
var state = _tokenTracker!.State; 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> /// <summary>Runs <c>npm i -g tokentracker-cli</c>, reporting each output line.</summary>
Task<TokenTrackerRunResult> InstallAsync(IProgress<string>? output = null, CancellationToken ct = default); 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 NpmCommand = "npm";
public const string PackageName = "tokentracker-cli"; 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) => public static string[] Export(DateOnly from, DateOnly to) =>
[ [
"sessions", "sessions",
@@ -27,5 +35,17 @@ public static class TokenTrackerArgs
public static string[] Version() => ["-v"]; 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]; public static string[] Install() => ["i", "-g", PackageName];
} }
@@ -1,5 +1,8 @@
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using ClaudeDo.Data.Environment; using ClaudeDo.Data.Environment;
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces; 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 /// directly. We deliberately never fall back to <c>npx</c> — that would silently download a
/// package behind the user's back. /// package behind the user's back.
/// </summary> /// </summary>
public sealed class TokenTrackerClient : ITokenTrackerClient public sealed class TokenTrackerClient : ITokenTrackerClient, IDisposable
{ {
private static readonly TimeSpan ExportTimeout = TimeSpan.FromSeconds(120); private static readonly TimeSpan ExportTimeout = TimeSpan.FromSeconds(120);
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(20); private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan InstallTimeout = TimeSpan.FromMinutes(5); private static readonly TimeSpan InstallTimeout = TimeSpan.FromMinutes(5);
private static readonly TimeSpan DashboardReadyTimeout = TimeSpan.FromSeconds(30);
private const int MinimumNodeMajor = 20; 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) public async Task<TokenTrackerProbe> ProbeAsync(CancellationToken ct = default)
{ {
var node = await RunAsync("node", ["--version"], ProbeTimeout, null, ct); 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) => public Task<TokenTrackerRunResult> InstallAsync(IProgress<string>? output = null, CancellationToken ct = default) =>
RunAsync(TokenTrackerArgs.NpmCommand, TokenTrackerArgs.Install(), InstallTimeout, output, ct); 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> /// <summary>Visible for the probe's version parsing; returns 0 when unreadable.</summary>
internal static int NodeMajor(string? version) 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); 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 /// <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> /// <paramref name="Error"/> — callers turn that into state, never into an exception.</summary>
public sealed record TokenTrackerRunResult(bool Ok, string StdOut, string? Error); 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( public async Task<TokenTrackerRunResult> InstallAsync(
IProgress<string>? output = null, CancellationToken ct = default) IProgress<string>? output = null, CancellationToken ct = default)
{ {
@@ -194,6 +194,10 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task<TokenTrackerStatusDto?> RefreshTokenTrackerAsync() => GetTokenTrackerStatusAsync(); public virtual Task<TokenTrackerStatusDto?> RefreshTokenTrackerAsync() => GetTokenTrackerStatusAsync();
public virtual Task<TokenTrackerStatusDto?> InstallTokenTrackerAsync() => GetTokenTrackerStatusAsync(); public virtual Task<TokenTrackerStatusDto?> InstallTokenTrackerAsync() => GetTokenTrackerStatusAsync();
public virtual Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync() =>
Task.FromResult<TokenTrackerDashboardDto?>(new TokenTrackerDashboardDto(true, "http://127.0.0.1:7681/", null));
public void RaiseUsageUpdated(UsageSnapshotDto snapshot) => UsageUpdatedEvent?.Invoke(snapshot); public void RaiseUsageUpdated(UsageSnapshotDto snapshot) => UsageUpdatedEvent?.Invoke(snapshot);
protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
@@ -180,6 +180,9 @@ sealed class FakeWorkerClient : IWorkerClient
public Task<TokenTrackerStatusDto?> RefreshTokenTrackerAsync() => GetTokenTrackerStatusAsync(); public Task<TokenTrackerStatusDto?> RefreshTokenTrackerAsync() => GetTokenTrackerStatusAsync();
public Task<TokenTrackerStatusDto?> InstallTokenTrackerAsync() => GetTokenTrackerStatusAsync(); public Task<TokenTrackerStatusDto?> InstallTokenTrackerAsync() => GetTokenTrackerStatusAsync();
public Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync() =>
Task.FromResult<TokenTrackerDashboardDto?>(new TokenTrackerDashboardDto(false, null, "not configured"));
} }
// ── Helper to build VM with pre-seeded Items ────────────────────────────────── // ── Helper to build VM with pre-seeded Items ──────────────────────────────────
@@ -38,6 +38,15 @@ public sealed class FakeTokenTrackerClient : ITokenTrackerClient
return ExportResult; return ExportResult;
} }
public TokenTrackerDashboard DashboardResult { get; set; } = new(true, "http://127.0.0.1:7680/", null);
public int DashboardCalls;
public Task<TokenTrackerDashboard> StartDashboardAsync(CancellationToken ct = default)
{
Interlocked.Increment(ref DashboardCalls);
return Task.FromResult(DashboardResult);
}
public Task<TokenTrackerRunResult> InstallAsync(IProgress<string>? output = null, CancellationToken ct = default) public Task<TokenTrackerRunResult> InstallAsync(IProgress<string>? output = null, CancellationToken ct = default)
{ {
Interlocked.Increment(ref InstallCalls); Interlocked.Increment(ref InstallCalls);
@@ -39,4 +39,12 @@ public sealed class TokenTrackerArgsTests
{ {
Assert.Equal(new[] { "i", "-g", "tokentracker-cli" }, TokenTrackerArgs.Install()); Assert.Equal(new[] { "i", "-g", "tokentracker-cli" }, TokenTrackerArgs.Install());
} }
[Fact]
public void Serve_PinsThePortAndOptsOutOfBrowserAndCloudSync()
{
Assert.Equal(
new[] { "serve", "--port", "7681", "--no-open", "--no-sync" },
TokenTrackerArgs.Serve(7681));
}
} }
@@ -13,6 +13,32 @@ public sealed class TokenTrackerServiceTests
return (new TokenTrackerService(client, new TokenTrackerState(), () => clock), client); return (new TokenTrackerService(client, new TokenTrackerState(), () => clock), client);
} }
[Fact]
public async Task OpenDashboardAsync_ReturnsTheUrlWhenTheCliIsInstalled()
{
var (service, client) = Build();
var dashboard = await service.OpenDashboardAsync();
Assert.True(dashboard.Ok);
Assert.Equal("http://127.0.0.1:7680/", dashboard.Url);
Assert.Equal(1, client.DashboardCalls);
}
[Fact]
public async Task OpenDashboardAsync_MissingCli_ReportsTheProbeErrorWithoutSpawning()
{
var (service, client) = Build();
client.Probe = new TokenTrackerProbe(false, null, true, "v22.1.0", "'tokentracker' not found on PATH.");
var dashboard = await service.OpenDashboardAsync();
Assert.False(dashboard.Ok);
Assert.Null(dashboard.Url);
Assert.Equal("'tokentracker' not found on PATH.", dashboard.Error);
Assert.Equal(0, client.DashboardCalls);
}
[Fact] [Fact]
public async Task RefreshAsync_StoresParsedExport() public async Task RefreshAsync_StoresParsedExport()
{ {