diff --git a/docs/explore-notes/usage-monitoring.md b/docs/explore-notes/usage-monitoring.md index 4ba1d7b1..f775d108 100644 --- a/docs/explore-notes/usage-monitoring.md +++ b/docs/explore-notes/usage-monitoring.md @@ -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

--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:

/` (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. diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index d9e89b21..fb62675d 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -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}" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index ffc6ff06..cf0514e4 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -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}" }, diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index 24f961df..62b9cce8 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -210,4 +210,8 @@ public interface IWorkerClient : INotifyPropertyChanged Task GetTokenTrackerStatusAsync(); Task RefreshTokenTrackerAsync(); Task InstallTokenTrackerAsync(); + + ///

Starts TokenTracker's local dashboard on the worker and returns its URL; opening + /// the browser is the caller's job. + Task OpenTokenTrackerDashboardAsync(); } diff --git a/src/ClaudeDo.Ui/Services/ShellOpen.cs b/src/ClaudeDo.Ui/Services/ShellOpen.cs index e4168349..406d5654 100644 --- a/src/ClaudeDo.Ui/Services/ShellOpen.cs +++ b/src/ClaudeDo.Ui/Services/ShellOpen.cs @@ -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. + /// 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. + 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); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index dcc2b98e..c0a3ab0d 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -698,6 +698,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public Task InstallTokenTrackerAsync() => TryInvokeAsync("InstallTokenTracker"); + public Task OpenTokenTrackerDashboardAsync() + => TryInvokeAsync("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, diff --git a/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs index 0a9268fc..0d358aa8 100644 --- a/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs @@ -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; } } + /// 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. + [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); diff --git a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml index 2fff755b..8512d352 100644 --- a/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml +++ b/src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml @@ -180,6 +180,12 @@