diff --git a/cleanup-services.ps1 b/cleanup-services.ps1 new file mode 100644 index 0000000..c5944f1 --- /dev/null +++ b/cleanup-services.ps1 @@ -0,0 +1,32 @@ +# Aggressive Service Cleanup Script +# Run this from an ELEVATED (Administrator) PowerShell session. + +Write-Host "Please ensure the 'Services' (services.msc) window and 'Task Manager' are CLOSED." -ForegroundColor Yellow + +$NSSM = "C:\Users\Paul\AppData\Local\Microsoft\WinGet\Packages\NSSM.NSSM_Microsoft.WinGet.Source_8wekyb3d8bbwe\nssm-2.24-101-g897c7ad\win64\nssm.exe" +$Services = @("onWatch", "onWatchApp", "onWatchService") + +foreach ($svc in $Services) { + Write-Host "`nCleaning up: $svc" -ForegroundColor Cyan + + # 1. Stop the service + & $NSSM stop $svc 2>$null + Start-Sleep -Seconds 1 + + # 2. Try removing via NSSM + & $NSSM remove $svc confirm 2>$null + + # 3. Try removing via SC + sc.exe delete $svc 2>$null + + # 4. Force delete from the Windows Registry to clear "marked for deletion" lock + $regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$svc" + if (Test-Path $regPath) { + Write-Host "Forcefully removing registry keys for $svc..." -ForegroundColor Yellow + Remove-Item -Path $regPath -Recurse -Force -ErrorAction SilentlyContinue + } else { + Write-Host "Registry keys already gone for $svc." -ForegroundColor DarkGray + } +} + +Write-Host "`nCleanup complete! To fully flush the Windows cache of these services, you must RESTART YOUR COMPUTER." -ForegroundColor Green diff --git a/docs/WINDOWS_SETUP.md b/docs/WINDOWS_SETUP.md index 0009fd4..48f0a9e 100644 --- a/docs/WINDOWS_SETUP.md +++ b/docs/WINDOWS_SETUP.md @@ -271,28 +271,48 @@ To run in debug mode (shows console window): ## Running as a Windows Service (Advanced) -For production deployments, you can run onWatch as a Windows Service using [NSSM](https://nssm.cc/) (Non-Sucking Service Manager): +For production deployments, you can run onWatch as a background Windows Service using [NSSM](https://nssm.cc/) (Non-Sucking Service Manager). We provide a robust setup script to handle common service pitfalls (like path resolution and lifecycle management). -1. Download NSSM from https://nssm.cc/download -2. Extract and open Command Prompt as Administrator -3. Install the service: +### Automatic Setup (Recommended) + +1. Ensure NSSM is installed via winget: + ```powershell + winget install nssm --accept-package-agreements --accept-source-agreements --silent + ``` +2. Open an **Elevated (Administrator) PowerShell** session. +3. Run the provided setup script: + ```powershell + # If you cloned the repository: + .\scripts\setup-windows-service.ps1 + + # Or download and review the script before running: + Invoke-WebRequest -Uri https://raw.githubusercontent.com/onllm-dev/onwatch/main/scripts/setup-windows-service.ps1 -OutFile setup-windows-service.ps1 + .\setup-windows-service.ps1 + ``` + +### Manual Setup (For Custom Environments) + +If you prefer to configure NSSM manually, open an Administrator prompt and use the following commands. **Note:** Using `--debugstdout` keeps the process attached to NSSM so it can monitor the application correctly, and setting `AppEnvironmentExtra` ensures the `LocalSystem` account can find your auto-detected AI API keys. ```cmd -nssm install onwatch "%USERPROFILE%\.onwatch\bin\onwatch.exe" -nssm set onwatch AppDirectory "%USERPROFILE%\.onwatch" -nssm set onwatch AppParameters "--debug" -nssm set onwatch DisplayName "onWatch API Quota Tracker" -nssm set onwatch Description "Tracks AI API quota usage" -nssm set onwatch Start SERVICE_AUTO_START -nssm start onwatch +nssm install onWatchService "%USERPROFILE%\.onwatch\bin\onwatch.exe" +nssm set onWatchService AppParameters "--debugstdout" +nssm set onWatchService AppDirectory "%USERPROFILE%\.onwatch" +nssm set onWatchService AppStdout "%USERPROFILE%\.onwatch\service.log" +nssm set onWatchService AppStderr "%USERPROFILE%\.onwatch\service.log" +nssm set onWatchService DisplayName "onWatch API Quota Tracker" +nssm set onWatchService Description "Tracks AI API quota usage" +nssm set onWatchService AppEnvironmentExtra "USERPROFILE=%USERPROFILE%\0HOME=%USERPROFILE%\0" +nssm set onWatchService Start SERVICE_AUTO_START +nssm start onWatchService ``` Manage the service: ```cmd -nssm status onwatch -nssm stop onwatch -nssm restart onwatch -nssm remove onwatch confirm +nssm status onWatchService +nssm stop onWatchService +nssm restart onWatchService +nssm remove onWatchService confirm ``` --- diff --git a/internal/api/anthropic_token_windows.go b/internal/api/anthropic_token_windows.go index e9feb49..c861187 100644 --- a/internal/api/anthropic_token_windows.go +++ b/internal/api/anthropic_token_windows.go @@ -21,6 +21,15 @@ func SetTestMode(enabled bool) { testMode = enabled } +// getCredentialsFilePath returns the path to the Claude credentials file on Windows. +func getCredentialsFilePath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".claude", ".credentials.json") +} + // detectAnthropicCredentialsPlatform tries to detect full OAuth credentials on Windows. func detectAnthropicCredentialsPlatform(logger *slog.Logger) *AnthropicCredentials { if logger == nil { diff --git a/internal/api/anthropic_types.go b/internal/api/anthropic_types.go index 566eaf2..8637913 100644 --- a/internal/api/anthropic_types.go +++ b/internal/api/anthropic_types.go @@ -22,9 +22,11 @@ type AnthropicQuotaResponse map[string]*AnthropicQuotaEntry // AnthropicQuota represents a single normalized quota for storage. type AnthropicQuota struct { - Name string - Utilization float64 - ResetsAt *time.Time + Name string + Utilization float64 + UsedCredits float64 + MonthlyLimit float64 + ResetsAt *time.Time } // AnthropicSnapshot represents a point-in-time capture of all Anthropic quotas. @@ -40,6 +42,8 @@ var anthropicDisplayNames = map[string]string{ "five_hour": "5-Hour Limit", "seven_day": "Weekly All-Model", "seven_day_sonnet": "Weekly Sonnet", + "seven_day_opus": "Weekly Opus", + "seven_day_design": "Claude Design", "monthly_limit": "Monthly Limit", "extra_usage": "Extra Usage", } @@ -98,6 +102,12 @@ func (r AnthropicQuotaResponse) ToSnapshot(capturedAt time.Time) *AnthropicSnaps Name: name, Utilization: *entry.Utilization, } + if entry.UsedCredits != nil { + q.UsedCredits = *entry.UsedCredits + } + if entry.MonthlyLimit != nil { + q.MonthlyLimit = *entry.MonthlyLimit + } if entry.ResetsAt != nil && *entry.ResetsAt != "" { if t, err := time.Parse(time.RFC3339, *entry.ResetsAt); err == nil { q.ResetsAt = &t @@ -106,9 +116,9 @@ func (r AnthropicQuotaResponse) ToSnapshot(capturedAt time.Time) *AnthropicSnaps snapshot.Quotas = append(snapshot.Quotas, q) } - // Store raw JSON for debugging/auditing - if raw, err := json.Marshal(r); err == nil { - snapshot.RawJSON = string(raw) + // Capture RawJSON for debugging and potential detail extraction + if data, err := json.Marshal(r); err == nil { + snapshot.RawJSON = string(data) } return snapshot diff --git a/internal/api/anthropic_types_coverage_test.go b/internal/api/anthropic_types_coverage_test.go index e8fef1d..c469ed9 100644 --- a/internal/api/anthropic_types_coverage_test.go +++ b/internal/api/anthropic_types_coverage_test.go @@ -13,6 +13,8 @@ func TestAnthropicDisplayName_KnownKeys(t *testing.T) { {"five_hour", "5-Hour Limit"}, {"seven_day", "Weekly All-Model"}, {"seven_day_sonnet", "Weekly Sonnet"}, + {"seven_day_opus", "Weekly Opus"}, + {"seven_day_design", "Claude Design"}, {"monthly_limit", "Monthly Limit"}, {"extra_usage", "Extra Usage"}, {"unknown_key", "unknown_key"}, @@ -35,6 +37,9 @@ func TestAnthropicQuotaResponse_ToSnapshot(t *testing.T) { fiveHour := 45.2 sevenDay := 12.8 + extraUsage := 15.0 + extraUsed := 11.25 + extraLimit := 75.0 boolTrue := true fiveHourResetStr := fiveHourReset.Format(time.RFC3339) sevenDayResetStr := sevenDayReset.Format(time.RFC3339) @@ -50,28 +55,41 @@ func TestAnthropicQuotaResponse_ToSnapshot(t *testing.T) { ResetsAt: &sevenDayResetStr, IsEnabled: &boolTrue, }, + "extra_usage": &AnthropicQuotaEntry{ + Utilization: &extraUsage, + UsedCredits: &extraUsed, + MonthlyLimit: &extraLimit, + IsEnabled: &boolTrue, + }, } snapshot := resp.ToSnapshot(now) if snapshot.CapturedAt != now { t.Errorf("CapturedAt = %v, want %v", snapshot.CapturedAt, now) } - if len(snapshot.Quotas) != 2 { - t.Fatalf("expected 2 quotas, got %d", len(snapshot.Quotas)) + if len(snapshot.Quotas) != 3 { + t.Fatalf("expected 3 quotas, got %d", len(snapshot.Quotas)) } if snapshot.RawJSON == "" { t.Error("RawJSON should not be empty") } - // Quotas should be sorted by name - if snapshot.Quotas[0].Name != "five_hour" { - t.Errorf("first quota = %q, want five_hour", snapshot.Quotas[0].Name) + // Quotas should be sorted by name: extra_usage, five_hour, seven_day + if snapshot.Quotas[0].Name != "extra_usage" { + t.Errorf("first quota = %q, want extra_usage", snapshot.Quotas[0].Name) + } + if snapshot.Quotas[0].UsedCredits != 11.25 { + t.Errorf("extra_usage usedCredits = %f, want 11.25", snapshot.Quotas[0].UsedCredits) } - if snapshot.Quotas[0].Utilization != 45.2 { - t.Errorf("five_hour utilization = %f, want 45.2", snapshot.Quotas[0].Utilization) + if snapshot.Quotas[0].MonthlyLimit != 75.0 { + t.Errorf("extra_usage monthlyLimit = %f, want 75.0", snapshot.Quotas[0].MonthlyLimit) + } + + if snapshot.Quotas[1].Name != "five_hour" { + t.Errorf("second quota = %q, want five_hour", snapshot.Quotas[1].Name) } - if snapshot.Quotas[0].ResetsAt == nil { - t.Error("five_hour ResetsAt should not be nil") + if snapshot.Quotas[1].Utilization != 45.2 { + t.Errorf("five_hour utilization = %f, want 45.2", snapshot.Quotas[1].Utilization) } } diff --git a/internal/config/config.go b/internal/config/config.go index 7d8c4b6..bdab9a4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -726,6 +726,10 @@ func OpenRotatingLogFile(path string) (*os.File, error) { return nil, fmt.Errorf("failed to stat log file %s: %w", path, err) } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return nil, fmt.Errorf("failed to create log directory %s: %w", filepath.Dir(path), err) + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { return nil, err diff --git a/internal/config/config_test.go b/internal/config/config_test.go index de8af87..0aaa12c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1530,3 +1530,18 @@ func TestConfig_LogFormat_AliasesAndCaseInsensitive(t *testing.T) { }) } } + +func TestOpenRotatingLogFile_CreatesDirectories(t *testing.T) { + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "nested", "dirs", "test.log") + + file, err := OpenRotatingLogFile(logPath) + if err != nil { + t.Fatalf("OpenRotatingLogFile() failed: %v", err) + } + defer file.Close() + + if _, err := os.Stat(filepath.Dir(logPath)); os.IsNotExist(err) { + t.Errorf("Directory was not created: %v", err) + } +} diff --git a/internal/store/anthropic_store.go b/internal/store/anthropic_store.go index 8b44c11..780a694 100644 --- a/internal/store/anthropic_store.go +++ b/internal/store/anthropic_store.go @@ -49,8 +49,8 @@ func (s *Store) InsertAnthropicSnapshot(snapshot *api.AnthropicSnapshot) (int64, resetsAt = q.ResetsAt.Format(time.RFC3339Nano) } _, err := tx.Exec( - `INSERT INTO anthropic_quota_values (snapshot_id, quota_name, utilization, resets_at) VALUES (?, ?, ?, ?)`, - snapshotID, q.Name, q.Utilization, resetsAt, + `INSERT INTO anthropic_quota_values (snapshot_id, quota_name, utilization, resets_at, used_credits, monthly_limit) VALUES (?, ?, ?, ?, ?, ?)`, + snapshotID, q.Name, q.Utilization, resetsAt, q.UsedCredits, q.MonthlyLimit, ) if err != nil { return 0, fmt.Errorf("failed to insert quota value %s: %w", q.Name, err) @@ -511,18 +511,20 @@ func (s *Store) QueryAnthropicCycleOverview(groupBy string, limit int) ([]CycleO // AnthropicLatestQuota holds the most recent value for a single quota across all snapshots. type AnthropicLatestQuota struct { - Name string - Utilization float64 - ResetsAt *time.Time - CapturedAt time.Time - Source string // "statusline" or "api" + Name string + Utilization float64 + UsedCredits float64 + MonthlyLimit float64 + ResetsAt *time.Time + CapturedAt time.Time + Source string // "statusline" or "api" } // QueryAnthropicLatestPerQuota returns the most recent value for each distinct quota name. // Scans only the last 50 snapshots (bounded) and merges in Go - fast even on large DBs. func (s *Store) QueryAnthropicLatestPerQuota() ([]AnthropicLatestQuota, error) { rows, err := s.db.Query(` - SELECT qv.quota_name, qv.utilization, qv.resets_at, s.captured_at, s.raw_json + SELECT qv.quota_name, qv.utilization, qv.resets_at, s.captured_at, s.raw_json, qv.used_credits, qv.monthly_limit FROM anthropic_quota_values qv JOIN anthropic_snapshots s ON s.id = qv.snapshot_id WHERE s.id >= (SELECT MAX(id) - 50 FROM anthropic_snapshots) @@ -537,10 +539,10 @@ func (s *Store) QueryAnthropicLatestPerQuota() ([]AnthropicLatestQuota, error) { var results []AnthropicLatestQuota for rows.Next() { var name string - var utilization float64 + var utilization, usedCredits, monthlyLimit float64 var resetsAt sql.NullString var capturedAt, rawJSON string - if err := rows.Scan(&name, &utilization, &resetsAt, &capturedAt, &rawJSON); err != nil { + if err := rows.Scan(&name, &utilization, &resetsAt, &capturedAt, &rawJSON, &usedCredits, &monthlyLimit); err != nil { return nil, fmt.Errorf("failed to scan latest quota: %w", err) } // Hide historical rows for experimental/unknown quota keys (pre-whitelist data). @@ -553,8 +555,10 @@ func (s *Store) QueryAnthropicLatestPerQuota() ([]AnthropicLatestQuota, error) { seen[name] = true q := AnthropicLatestQuota{ - Name: name, - Utilization: utilization, + Name: name, + Utilization: utilization, + UsedCredits: usedCredits, + MonthlyLimit: monthlyLimit, } q.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) if resetsAt.Valid && resetsAt.String != "" { diff --git a/internal/store/store.go b/internal/store/store.go index fabe1df..866e81e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -976,6 +976,19 @@ func (s *Store) migrateSchema() error { } } + // Add used_credits and monthly_limit to anthropic_quota_values + for _, col := range []string{ + "used_credits REAL NOT NULL DEFAULT 0", + "monthly_limit REAL NOT NULL DEFAULT 0", + } { + if _, err := s.db.Exec(`ALTER TABLE anthropic_quota_values ADD COLUMN ` + col); err != nil { + if !strings.Contains(err.Error(), "duplicate column name") && + !strings.Contains(err.Error(), "no such table") { + return fmt.Errorf("failed to add anthropic column: %w", err) + } + } + } + return nil } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index af38ea7..a927b8f 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -5017,6 +5017,8 @@ func (h *Handler) buildAnthropicCurrent() map[string]interface{} { "name": q.Name, "displayName": api.AnthropicDisplayName(q.Name), "utilization": q.Utilization, + "usedCredits": q.UsedCredits, + "monthlyLimit": q.MonthlyLimit, "status": anthropicUtilStatus(q.Utilization), "source": q.Source, "lastUpdatedAt": q.CapturedAt.Format(time.RFC3339), diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 9e9c755..692b131 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -759,6 +759,8 @@ const anthropicDisplayNames = { five_hour: '5-Hour Limit', seven_day: 'Weekly All-Model', seven_day_sonnet: 'Weekly Sonnet', + seven_day_opus: 'Weekly Opus', + seven_day_design: 'Claude Design', monthly_limit: 'Monthly Limit', extra_usage: 'Extra Usage' }; @@ -768,6 +770,8 @@ const anthropicQuotaIcons = { five_hour: '', // clock seven_day: '', // calendar seven_day_sonnet: '', // layers + seven_day_opus: '', // star + seven_day_design: '', // feather/design monthly_limit: '', // briefcase extra_usage: '' // pie-chart }; @@ -777,6 +781,8 @@ const anthropicChartColorMap = { five_hour: { border: '#D97757', bg: 'rgba(217, 119, 87, 0.08)' }, // coral seven_day: { border: '#10B981', bg: 'rgba(16, 185, 129, 0.08)' }, // emerald seven_day_sonnet: { border: '#3B82F6', bg: 'rgba(59, 130, 246, 0.08)' }, // blue + seven_day_opus: { border: '#6366F1', bg: 'rgba(99, 102, 241, 0.08)' }, // indigo + seven_day_design: { border: '#EC4899', bg: 'rgba(236, 72, 153, 0.08)' }, // pink monthly_limit: { border: '#A855F7', bg: 'rgba(168, 85, 247, 0.08)' }, // violet extra_usage: { border: '#F59E0B', bg: 'rgba(245, 158, 11, 0.08)' } // amber }; @@ -835,7 +841,7 @@ function codexVisibleQuotaNames(planType) { : ['five_hour', 'seven_day', 'code_review']; } -const anthropicQuotaOrder = ['five_hour', 'seven_day', 'seven_day_sonnet', 'monthly_limit', 'extra_usage']; +const anthropicQuotaOrder = ['five_hour', 'seven_day', 'seven_day_sonnet', 'seven_day_opus', 'seven_day_design', 'monthly_limit', 'extra_usage']; const codexQuotaOrder = ['five_hour', 'seven_day', 'code_review']; const cursorQuotaOrder = ['total_usage', 'auto_usage', 'api_usage', 'credits', 'on_demand']; @@ -1234,6 +1240,11 @@ function renderAnthropicQuotaCards(quotas, containerId) { const statusId = `status-anth-${q.name}`; const resetId = `reset-anth-${q.name}`; + let fractionText = cardLabel; + if (q.name === 'extra_usage' && q.monthlyLimit > 0) { + fractionText = `${formatCurrencyEUR(q.usedCredits || 0)} / ${formatCurrencyEUR(q.monthlyLimit)}`; + } + return `

@@ -1244,7 +1255,7 @@ function renderAnthropicQuotaCards(quotas, containerId) {

${utilPct}% - ${cardLabel} + ${fractionText}
@@ -1283,6 +1294,8 @@ function updateAnthropicCard(quota) { State.currentQuotas[key] = { percent: quota.utilization || 0, usage: quota.utilization || 0, + usedCredits: quota.usedCredits || 0, + monthlyLimit: quota.monthlyLimit || 0, limit: 100, status: quota.status || 'healthy', renewsAt: quota.resetsAt, @@ -2827,6 +2840,15 @@ function formatCurrencyUSD(num) { }).format(num || 0); } +function formatCurrencyEUR(num) { + return new Intl.NumberFormat('en-IE', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(num || 0); +} + function formatDateTime(isoString) { const d = new Date(isoString); const opts = { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }; @@ -3104,14 +3126,20 @@ function initTheme() { function setLayoutDensity(mode) { const aliases = { default: 'normal' }; const normalized = aliases[mode] || mode; - const valid = new Set(['compact', 'normal', 'wide']); + const valid = new Set(['compact', 'normal', 'wide', 'wall']); const next = valid.has(normalized) ? normalized : 'normal'; if (document.body) { - document.body.classList.remove('layout-compact', 'layout-normal', 'layout-default', 'layout-wide'); + document.body.classList.remove('layout-compact', 'layout-normal', 'layout-default', 'layout-wide', 'layout-wall'); document.body.classList.add(`layout-${next}`); } + // Wall Mode Button sync + const wallBtn = document.getElementById('wall-mode-toggle'); + if (wallBtn) { + wallBtn.classList.toggle('active', next === 'wall'); + } + const toggle = document.getElementById('layout-toggle'); if (toggle) { toggle.querySelectorAll('.layout-btn').forEach((btn) => { @@ -7728,6 +7756,16 @@ function setupHeaderActions() { }); } + // Wall Mode Toggle + const wallBtn = document.getElementById('wall-mode-toggle'); + if (wallBtn) { + wallBtn.addEventListener('click', () => { + const current = localStorage.getItem('onwatch-layout') || 'normal'; + const next = current === 'wall' ? 'normal' : 'wall'; + setLayoutDensity(next); + }); + } + // Manual refresh const refreshBtn = document.getElementById('refresh-btn'); if (refreshBtn) { @@ -7742,6 +7780,14 @@ function setupHeaderActions() { }); }); } + + // Exit Wall Mode (Floating Button) + const exitBtn = document.getElementById('exit-wall-btn'); + if (exitBtn) { + exitBtn.addEventListener('click', () => { + setLayoutDensity('normal'); + }); + } } function setupCardModals() { diff --git a/internal/web/static/style.css b/internal/web/static/style.css index e074eb3..a91c570 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -213,6 +213,212 @@ body.layout-compact { --layout-gap-sm: 6px; } +body.layout-wall { + --layout-max-width: 100%; + --layout-card-min: 280px; + --layout-insight-min: 100px; + --layout-gap: 10px; + --layout-gap-sm: 4px; + --surface-page: #050608 !important; /* Force black for wall mode */ + --surface-card: #121418; + --text-primary: #F1F3F5; + --text-secondary: #ABB5C2; + font-size: 13px; + overflow: hidden !important; /* NO SCROLLING */ +} + +body.layout-wall .app-header, +body.layout-wall .app-footer, +body.layout-wall .welcome-banner, +body.layout-wall .quota-subtitle, +body.layout-wall .promo-tag, +body.layout-wall .card-freshness, +body.layout-wall .usage-fraction:not(:has(span)), /* Hide 'Utilization' label */ +body.layout-wall .quota-desc { + display: none !important; +} + +/* Hide 'Utilization' text in Wall Mode specifically */ +body.layout-wall .usage-percent + .usage-fraction { + display: none !important; +} + +body.layout-wall .main-content { + padding: 8px; + max-width: 100%; + height: 100vh; + margin: 0; +} + +/* Redesigned Quota Cards (Mini Row Style) */ +body.layout-wall .quota-card { + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; + border-radius: var(--radius-md); + background: var(--surface-card); + border-color: #2F3440; +} + +body.layout-wall .card-header { + margin-bottom: 2px; + display: flex; + align-items: center; + justify-content: space-between; +} + +body.layout-wall .quota-title { + font-size: 10px; + color: var(--text-muted); +} + +body.layout-wall .countdown { + font-size: 10px; + padding: 0 4px; +} + +body.layout-wall .progress-stats { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 2px; +} + +body.layout-wall .usage-percent { + font-size: 18px; + font-weight: 800; +} + +/* Show currency for extra_usage */ +body.layout-wall .quota-card[data-quota="extra_usage"] .usage-fraction { + display: block !important; + font-size: 10px; + color: var(--text-muted); +} + +body.layout-wall .progress-wrapper { margin: 0; } +body.layout-wall .progress-bar { height: 3px; } + +/* Side-by-side on Wall Mode */ +@media (min-width: 1200px) { + body.layout-wall .main-content { + display: grid; + grid-template-columns: 420px 1fr; + grid-template-areas: + "quotas quotas" + "insights chart" + "overview overview"; + gap: var(--layout-gap); + align-items: start; + grid-template-rows: auto 580px 1fr; /* Increased Insights height to 580px */ + } + + body.layout-wall .quota-grid { + grid-area: quotas; + margin-bottom: 0; + grid-template-columns: repeat(5, 1fr); + gap: var(--layout-gap); + } + + body.layout-wall .insights-panel { + grid-area: insights; + margin-bottom: 0; + padding: 10px; + height: 580px; /* Matched to grid row */ + overflow-y: auto; + } + + body.layout-wall .chart-section { + grid-area: chart; + margin-bottom: 0; + padding: 10px; + height: 580px; + } + + body.layout-wall .cycle-overview-section { + grid-area: overview; + margin-bottom: 0; + padding: 6px 12px; + height: calc(100vh - 680px); /* Fill remaining space */ + overflow: hidden; + } + + body.layout-wall .section-header { margin-bottom: 4px; } + + /* Ultra Compact Insights for Wall Mode */ + body.layout-wall .insights-stats { grid-template-columns: repeat(3, 1fr); margin-bottom: 6px; } + body.layout-wall .insight-stat { padding: 4px; } + body.layout-wall .insight-stat-value { font-size: 14px; } + + body.layout-wall .insights-cards { + grid-template-columns: repeat(2, 1fr); + gap: 4px; + } + + body.layout-wall .insight-card { padding: 6px; } + body.layout-wall .insight-card-values { padding: 2px 0; } + body.layout-wall .insight-card-metric { font-size: 12px; } + + /* Compact Chart */ + body.layout-wall .provider-chart canvas { height: 500px !important; max-height: 500px; } + + /* Hide heavy elements in Wall Mode tables */ + body.layout-wall .table-footer, + body.layout-wall .table-legend, + body.layout-wall .table-controls, + body.layout-wall .delta { display: none !important; } + body.layout-wall .data-table td, body.layout-wall .data-table th { padding: 2px 8px; font-size: 11px; } +} + +.wall-mode-toggle.active { + background: var(--accent-teal-muted); + color: var(--accent-teal); + border-color: var(--accent-teal); +} + +/* Exit Wall Mode Floating Button */ +.exit-wall-btn { + position: fixed; + bottom: 20px; + right: 20px; + background: var(--surface-card); + border: 1px solid var(--border-default); + color: var(--text-secondary); + padding: 8px 16px; + border-radius: var(--radius-full); + box-shadow: var(--shadow-md); + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + z-index: 1000; + transition: all var(--transition-fast); + opacity: 0; + pointer-events: none; + font-size: 13px; + font-weight: 600; +} + +body.layout-wall .exit-wall-btn { + opacity: 0.6; + pointer-events: auto; +} + +.exit-wall-btn:hover { + opacity: 1 !important; + transform: translateY(-2px); + border-color: var(--accent-teal); + color: var(--text-primary); + box-shadow: var(--shadow-lg); +} + +.exit-wall-btn svg { + width: 16px; + height: 16px; +} + body.layout-wide { --layout-max-width: 1100px; --layout-card-min: 340px; diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 9d99db7..68e7c65 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -72,6 +72,13 @@ +
+ + diff --git a/scripts/setup-windows-service.ps1 b/scripts/setup-windows-service.ps1 new file mode 100644 index 0000000..8cbe843 --- /dev/null +++ b/scripts/setup-windows-service.ps1 @@ -0,0 +1,116 @@ +#Requires -RunAsAdministrator + +<# +.SYNOPSIS +Installs and configures onWatch as a Windows Service using NSSM. + +.DESCRIPTION +This script safely sets up onWatch to run in the background as a robust Windows Service. +It handles common pitfalls such as: + - Missing environment variables when running as LocalSystem. + - Ensuring the application stays in the foreground (--debugstdout) so NSSM can manage its lifecycle. + - Creating dedicated service log files. + - Avoiding "marked for deletion" lockups during re-installation. + +.PREREQUISITES + - You must run this script from an Elevated (Administrator) PowerShell session. + - NSSM (Non-Sucking Service Manager) must be installed (e.g. via `winget install nssm`). + - onWatch must be downloaded/built in the target directory. +#> + +param( + [string]$ServiceName = "onWatchService", + [string]$InstallDir = "$env:USERPROFILE\.onwatch", + [string]$Executable = "$env:USERPROFILE\.onwatch\bin\onwatch.exe" +) + +$ErrorActionPreference = 'Stop' + +Write-Host "=============================================" -ForegroundColor Cyan +Write-Host " onWatch Windows Service Setup " -ForegroundColor Cyan +Write-Host "=============================================" -ForegroundColor Cyan + +# 1. Locate NSSM +$NSSM = Get-Command "nssm" -ErrorAction SilentlyContinue +if (-not $NSSM) { + # Attempt to find it in common winget paths if the PATH hasn't updated yet + $wingetPaths = Get-ChildItem -Path "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" -Filter "nssm.exe" -Recurse -ErrorAction SilentlyContinue + if ($wingetPaths) { + $NSSM = $wingetPaths | Select-Object -First 1 | Select-Object -ExpandProperty FullName + } +} + +if (-not $NSSM) { + Write-Error "NSSM is not installed or not in your PATH.`nPlease run: winget install nssm --accept-package-agreements --accept-source-agreements --silent" + exit 1 +} + +Write-Host "Found NSSM at: $NSSM" -ForegroundColor DarkGray + +# 2. Validate Executable +if (-not (Test-Path $Executable)) { + Write-Error "onWatch executable not found at: $Executable.`nPlease install onWatch first or provide the correct path." + exit 1 +} + +# 3. Prepare Paths +$LogFile = Join-Path $InstallDir "service.log" +if (-not (Test-Path $InstallDir)) { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +} + +# 4. Stop and Remove Existing Service (if any) +Write-Host "`nCleaning up existing service (if any)..." +$existingStatus = & $NSSM status $ServiceName 2>$null +if ($LASTEXITCODE -eq 0 -and $existingStatus -like "*SERVICE_*") { + Write-Host "Stopping $ServiceName..." -ForegroundColor Yellow + & $NSSM stop $ServiceName 2>$null + Start-Sleep -Seconds 2 + Write-Host "Removing $ServiceName..." -ForegroundColor Yellow + & $NSSM remove $ServiceName confirm 2>$null + + # Give Windows SC a moment to clear the registry + Start-Sleep -Seconds 2 +} + +# 5. Install the Service +Write-Host "Installing $ServiceName..." -ForegroundColor Cyan +& $NSSM install $ServiceName "$Executable" + +# 6. Configure Service Parameters +Write-Host "Applying robust configuration..." -ForegroundColor DarkGray + +# Force foreground execution and pipe all logs so NSSM can manage the process state +& $NSSM set $ServiceName AppParameters "--debugstdout" +& $NSSM set $ServiceName AppDirectory "$InstallDir" +& $NSSM set $ServiceName AppStdout "$LogFile" +& $NSSM set $ServiceName AppStderr "$LogFile" + +# Set metadata +& $NSSM set $ServiceName DisplayName "onWatch API Quota Tracker" +& $NSSM set $ServiceName Description "Tracks AI API quota usage across providers" + +# Inject current user environment so LocalSystem can find ~/.codex, ~/.claude, and ~/.gemini +$envExtra = "USERPROFILE=$env:USERPROFILE\0HOME=$env:USERPROFILE\0" +& $NSSM set $ServiceName AppEnvironmentExtra $envExtra + +# Set startup type +& $NSSM set $ServiceName Start SERVICE_AUTO_START + +# 7. Start the Service +Write-Host "`nStarting $ServiceName..." -ForegroundColor Cyan +& $NSSM start $ServiceName + +# 8. Verify Status +Start-Sleep -Seconds 2 +$finalStatus = & $NSSM status $ServiceName + +if ($finalStatus -like "*SERVICE_RUNNING*") { + Write-Host "`nSUCCESS! onWatch is now running as a background service." -ForegroundColor Green + Write-Host "Dashboard: http://localhost:9211" -ForegroundColor Green + Write-Host "Logs: $LogFile" -ForegroundColor DarkGray +} else { + Write-Host "`nWARNING: Service installation completed, but it is not in a RUNNING state." -ForegroundColor Yellow + Write-Host "Current Status: $finalStatus" -ForegroundColor Yellow + Write-Host "Check the logs at: $LogFile" -ForegroundColor Yellow +} diff --git a/setup-service.ps1 b/setup-service.ps1 new file mode 100644 index 0000000..06bd978 --- /dev/null +++ b/setup-service.ps1 @@ -0,0 +1,57 @@ +# onWatch Service Setup Script +# Run this from an ELEVATED (Administrator) PowerShell session. + +$NSSM = "C:\Users\Paul\AppData\Local\Microsoft\WinGet\Packages\NSSM.NSSM_Microsoft.WinGet.Source_8wekyb3d8bbwe\nssm-2.24-101-g897c7ad\win64\nssm.exe" +$BIN = "C:\Projects\onllm\onwatch\onwatch.exe" +$DIR = "C:\Projects\onllm\onwatch" +$LOG = "C:\Projects\onllm\onwatch\service.log" + +# Using a brand new name to bypass the "marked for deletion" lock on the old one +$SVC = "onWatchService" + +if (-not (Test-Path $NSSM)) { + Write-Error "NSSM not found at $NSSM." + exit 1 +} + +Write-Host "Configuring $SVC..." -ForegroundColor Cyan + +# Stop and remove existing service if any +& $NSSM stop $SVC 2>$null +& $NSSM remove $SVC confirm 2>$null + +# Install the service +& $NSSM install $SVC "$BIN" +if ($LASTEXITCODE -ne 0) { Write-Error "Failed to install service."; exit 1 } + +# Set parameters +# --debugstdout forces all logs to the service.log file managed by NSSM +& $NSSM set $SVC AppParameters "--debugstdout" +& $NSSM set $SVC AppDirectory "$DIR" +& $NSSM set $SVC AppStdout "$LOG" +& $NSSM set $SVC AppStderr "$LOG" +& $NSSM set $SVC DisplayName "onWatch API Tracker" +& $NSSM set $SVC Description "Tracks AI API quota usage across providers" + +# Configure environment variables so that LocalSystem accesses Paul's home directory +# This allows auto-detecting Claude/Codex/Gemini credentials and saves data to Paul's .onwatch folder +& $NSSM set $SVC AppEnvironmentExtra "USERPROFILE=C:\Users\Paul`0HOME=C:\Users\Paul`0" + +# Use LocalSystem (the default, no password required) +& $NSSM set $SVC ObjectName LocalSystem "" +& $NSSM set $SVC Start SERVICE_AUTO_START + +# Start the service +Write-Host "`nStarting $SVC..." -ForegroundColor Cyan +& $NSSM start $SVC + +# Verify status +Start-Sleep -Seconds 2 +$status = & $NSSM status $SVC +Write-Host "Current Service Status: $status" -ForegroundColor Green + +if ($status -eq "SERVICE_RUNNING") { + Write-Host "Success! onWatch is running at http://localhost:9211" -ForegroundColor Green +} else { + Write-Host "Service is not running. Check logs at: $LOG" -ForegroundColor Red +}