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:
@@ -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
|
||||
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.
|
||||
- **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 7680–7689 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.
|
||||
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
|
||||
task table additionally with retries.
|
||||
- **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)
|
||||
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
|
||||
@@ -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
|
||||
unconfigured worker comes back as not-installed with a reason.
|
||||
- `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
|
||||
`WorkerLog` (npm writes progress to stderr; forwarding it is what keeps a multi-minute global
|
||||
install from looking hung), then force-re-probes.
|
||||
|
||||
@@ -535,6 +535,8 @@
|
||||
"analyticsStampFormat": "Auswertung: Stand {0}",
|
||||
"analyticsNever": "Auswertung noch nicht geladen",
|
||||
"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} $",
|
||||
"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.",
|
||||
@@ -750,7 +752,9 @@
|
||||
"thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}",
|
||||
"installStarted": "TokenTracker wird installiert — der Fortschritt läuft in der Log-Leiste.",
|
||||
"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." },
|
||||
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
|
||||
|
||||
@@ -535,6 +535,8 @@
|
||||
"analyticsStampFormat": "Analytics as of {0}",
|
||||
"analyticsNever": "Analytics not loaded yet",
|
||||
"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}",
|
||||
"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.",
|
||||
@@ -750,7 +752,9 @@
|
||||
"thresholdSaveFailed": "Couldn't save the threshold: {0}",
|
||||
"installStarted": "Installing TokenTracker — watch the log strip for progress.",
|
||||
"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." },
|
||||
"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?> RefreshTokenTrackerAsync();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
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
|
||||
// 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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return (false, null);
|
||||
|
||||
@@ -698,6 +698,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public Task<TokenTrackerStatusDto?> InstallTokenTrackerAsync()
|
||||
=> TryInvokeAsync<TokenTrackerStatusDto>("InstallTokenTracker");
|
||||
|
||||
public Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync()
|
||||
=> TryInvokeAsync<TokenTrackerDashboardDto>("OpenTokenTrackerDashboard");
|
||||
|
||||
// IWorkerClient explicit implementations (drop typed return values)
|
||||
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
|
||||
=> await StartPlanningSessionAsync(taskId, ct);
|
||||
@@ -872,6 +875,8 @@ public sealed record TaskUsageRowDto(
|
||||
bool? Productive = null,
|
||||
bool? OneShot = null);
|
||||
|
||||
public sealed record TokenTrackerDashboardDto(bool Ok, string? Url, string? Error);
|
||||
|
||||
public sealed record TokenTrackerStatusDto(
|
||||
bool Installed,
|
||||
string? Version,
|
||||
|
||||
@@ -68,6 +68,8 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
[NotifyPropertyChangedFor(nameof(ShowInstallButton))]
|
||||
private bool _isInstalling;
|
||||
|
||||
[ObservableProperty] private bool _isOpeningDashboard;
|
||||
|
||||
public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0;
|
||||
public bool TasksEmpty => !IsBusy && TaskRows.Count == 0;
|
||||
|
||||
@@ -229,6 +231,35 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
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)
|
||||
{
|
||||
ApplyPresetRange(days);
|
||||
|
||||
@@ -180,6 +180,12 @@
|
||||
<TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding AnalyticsStampText}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr modals.usageMonitor.analyticsRefresh}"
|
||||
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>
|
||||
|
||||
<TabControl Padding="20,12" TabStripPlacement="Top">
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -194,6 +194,10 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public virtual Task<TokenTrackerStatusDto?> RefreshTokenTrackerAsync() => 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);
|
||||
|
||||
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?> InstallTokenTrackerAsync() => GetTokenTrackerStatusAsync();
|
||||
|
||||
public Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync() =>
|
||||
Task.FromResult<TokenTrackerDashboardDto?>(new TokenTrackerDashboardDto(false, null, "not configured"));
|
||||
}
|
||||
|
||||
// ── Helper to build VM with pre-seeded Items ──────────────────────────────────
|
||||
|
||||
@@ -38,6 +38,15 @@ public sealed class FakeTokenTrackerClient : ITokenTrackerClient
|
||||
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)
|
||||
{
|
||||
Interlocked.Increment(ref InstallCalls);
|
||||
|
||||
@@ -39,4 +39,12 @@ public sealed class TokenTrackerArgsTests
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public async Task RefreshAsync_StoresParsedExport()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user