diff --git a/app.py b/app.py index d7db3a3..b6a372c 100644 --- a/app.py +++ b/app.py @@ -3,6 +3,7 @@ import datetime import os +import subprocess import time from pathlib import Path @@ -36,6 +37,112 @@ app = Flask(__name__) +REPO_ROOT = Path(__file__).resolve().parent +UPDATE_REMOTE = "origin" +UPDATE_BRANCH = "main" +UPDATE_TARGET_REF = f"{UPDATE_REMOTE}/{UPDATE_BRANCH}" +UPDATE_FALLBACK_COMMAND = f"git pull --ff-only {UPDATE_REMOTE} {UPDATE_BRANCH} && uv sync" +UPDATE_HEADER_NAME = "X-Dashboard-Update" +UPDATE_HEADER_VALUE = "1" +APP_COMMAND_TIMEOUT_SECONDS = 120 + + +def run_app_command(args: list[str], *, timeout: int = APP_COMMAND_TIMEOUT_SECONDS) -> subprocess.CompletedProcess: + """Run a fixed app-maintenance command from the repository root.""" + return subprocess.run( + args, + cwd=REPO_ROOT, + timeout=timeout, + capture_output=True, + text=True, + ) + + +def command_text(result: subprocess.CompletedProcess | None) -> str: + if result is None: + return "" + output = "\n".join(part.strip() for part in [result.stdout, result.stderr] if part and part.strip()) + return output[-1200:] + + +def git_value(args: list[str]) -> str | None: + result = run_app_command(["git", *args], timeout=20) + if result.returncode != 0: + return None + value = result.stdout.strip() + return value or None + + +def fetch_update_target() -> subprocess.CompletedProcess: + return run_app_command( + ["git", "fetch", UPDATE_REMOTE, f"{UPDATE_BRANCH}:refs/remotes/{UPDATE_TARGET_REF}", "--quiet"], + timeout=60, + ) + + +def is_git_ancestor(base_ref: str, tip_ref: str) -> bool: + result = run_app_command(["git", "merge-base", "--is-ancestor", base_ref, tip_ref], timeout=20) + return result.returncode == 0 + + +def app_dirty_state() -> tuple[bool, str]: + result = run_app_command(["git", "status", "--porcelain"], timeout=20) + if result.returncode != 0: + return True, command_text(result) or "Unable to inspect local changes." + details = result.stdout.strip() + return bool(details), details + + +def app_version_payload() -> dict: + fetch = fetch_update_target() + branch = git_value(["rev-parse", "--abbrev-ref", "HEAD"]) or "unknown" + sha = git_value(["rev-parse", "--short", "HEAD"]) or "unknown" + full_sha = git_value(["rev-parse", "HEAD"]) + target_sha = git_value(["rev-parse", "--short", UPDATE_TARGET_REF]) + target_full_sha = git_value(["rev-parse", UPDATE_TARGET_REF]) + dirty, dirty_details = app_dirty_state() + update_available = False + status = "check_failed" + message = f"Could not check latest {UPDATE_BRANCH}." + if fetch.returncode != 0: + message = command_text(fetch) or message + elif full_sha and target_full_sha: + if full_sha == target_full_sha or is_git_ancestor(UPDATE_TARGET_REF, "HEAD"): + status = "current" + message = f"Current version · {target_sha or sha}" + elif is_git_ancestor("HEAD", UPDATE_TARGET_REF): + update_available = True + status = "blocked_dirty" if dirty else "update_available" + message = ( + f"New version available · {target_sha} blocked by local changes" + if dirty + else f"New version available · {target_sha}" + ) + else: + status = "manual_required" + message = f"Manual update required · {UPDATE_TARGET_REF}" + return { + "branch": branch, + "sha": sha, + "target_branch": UPDATE_BRANCH, + "target_ref": UPDATE_TARGET_REF, + "target_sha": target_sha, + "update_available": update_available, + "status": status, + "message": message, + "dirty": dirty, + "dirty_details": dirty_details, + "fallback_command": UPDATE_FALLBACK_COMMAND, + } + + +def is_local_request() -> bool: + return (request.remote_addr or "") in {"127.0.0.1", "::1", "localhost"} + + +def has_update_header() -> bool: + return request.headers.get(UPDATE_HEADER_NAME) == UPDATE_HEADER_VALUE + def parse_days(default: int | None = 30) -> int | None: """Parse a days=N query param. 0/all means all time.""" @@ -801,6 +908,179 @@ def api_refresh(): return jsonify({"last_updated": row["max_ts"] or 0}) +@app.route("/api/app-version") +def api_app_version(): + """Return local dashboard version metadata for the update control.""" + if not is_local_request(): + return ( + jsonify({ + "status": "forbidden", + "error": "App version is only available from localhost.", + }), + 403, + ) + if not has_update_header(): + return ( + jsonify({ + "status": "forbidden", + "error": "App version requires a dashboard UI request.", + }), + 403, + ) + return jsonify(app_version_payload()) + + +@app.route("/api/update", methods=["POST"]) +def api_update(): + """Fast-forward this local dashboard checkout, then sync dependencies.""" + if not is_local_request(): + return ( + jsonify({ + "status": "forbidden", + "error": "Update is only available from localhost.", + "fallback_command": UPDATE_FALLBACK_COMMAND, + }), + 403, + ) + + if not has_update_header(): + return ( + jsonify({ + "status": "forbidden", + "error": "Update requires a dashboard UI request.", + "fallback_command": UPDATE_FALLBACK_COMMAND, + }), + 403, + ) + + fetch = fetch_update_target() + old_sha = git_value(["rev-parse", "--short", "HEAD"]) or "unknown" + old_full_sha = git_value(["rev-parse", "HEAD"]) + target_sha = git_value(["rev-parse", "--short", UPDATE_TARGET_REF]) + target_full_sha = git_value(["rev-parse", UPDATE_TARGET_REF]) + if fetch.returncode != 0 or not old_full_sha or not target_full_sha: + return ( + jsonify({ + "status": "failure", + "old_sha": old_sha, + "new_sha": old_sha, + "error": f"Could not check latest {UPDATE_BRANCH}.", + "output": command_text(fetch), + "fallback_command": UPDATE_FALLBACK_COMMAND, + }), + 500, + ) + + if old_full_sha == target_full_sha or is_git_ancestor(UPDATE_TARGET_REF, "HEAD"): + return jsonify({ + "status": "already_current", + "old_sha": old_sha, + "new_sha": old_sha, + "target_sha": target_sha, + "restart_required": False, + "output": f"Already up to date with {UPDATE_TARGET_REF}.", + "fallback_command": UPDATE_FALLBACK_COMMAND, + }) + + dirty, dirty_details = app_dirty_state() + if dirty: + return ( + jsonify({ + "status": "local_changes", + "old_sha": old_sha, + "new_sha": old_sha, + "target_sha": target_sha, + "error": "Local changes detected. Update manually to avoid overwriting work.", + "output": dirty_details, + "fallback_command": UPDATE_FALLBACK_COMMAND, + }), + 409, + ) + + if not is_git_ancestor("HEAD", UPDATE_TARGET_REF): + return ( + jsonify({ + "status": "manual_required", + "old_sha": old_sha, + "new_sha": old_sha, + "target_sha": target_sha, + "error": f"This checkout cannot fast-forward to {UPDATE_TARGET_REF}.", + "fallback_command": UPDATE_FALLBACK_COMMAND, + }), + 409, + ) + + try: + pull = run_app_command(["git", "pull", "--ff-only", UPDATE_REMOTE, UPDATE_BRANCH], timeout=120) + except subprocess.TimeoutExpired: + return ( + jsonify({ + "status": "failure", + "old_sha": old_sha, + "new_sha": old_sha, + "target_sha": target_sha, + "error": f"Update failed. git pull --ff-only {UPDATE_REMOTE} {UPDATE_BRANCH} timed out.", + "fallback_command": UPDATE_FALLBACK_COMMAND, + }), + 500, + ) + if pull.returncode != 0: + return ( + jsonify({ + "status": "failure", + "old_sha": old_sha, + "new_sha": old_sha, + "target_sha": target_sha, + "error": f"Update failed during git pull --ff-only {UPDATE_REMOTE} {UPDATE_BRANCH}.", + "output": command_text(pull), + "fallback_command": UPDATE_FALLBACK_COMMAND, + }), + 500, + ) + + try: + sync = run_app_command(["uv", "sync"], timeout=180) + except subprocess.TimeoutExpired: + new_sha = git_value(["rev-parse", "--short", "HEAD"]) or old_sha + return ( + jsonify({ + "status": "failure", + "old_sha": old_sha, + "new_sha": new_sha, + "target_sha": target_sha, + "error": "Update failed. uv sync timed out.", + "output": command_text(pull), + "fallback_command": UPDATE_FALLBACK_COMMAND, + }), + 500, + ) + new_sha = git_value(["rev-parse", "--short", "HEAD"]) or old_sha + if sync.returncode != 0: + return ( + jsonify({ + "status": "failure", + "old_sha": old_sha, + "new_sha": new_sha, + "target_sha": target_sha, + "error": "Update failed during uv sync.", + "output": command_text(sync), + "fallback_command": UPDATE_FALLBACK_COMMAND, + }), + 500, + ) + + status = "already_current" if old_sha == new_sha else "updated" + return jsonify({ + "status": status, + "old_sha": old_sha, + "new_sha": new_sha, + "target_sha": target_sha, + "restart_required": status == "updated", + "output": "\n".join(part for part in [command_text(pull), command_text(sync)] if part), + "fallback_command": UPDATE_FALLBACK_COMMAND, + }) + + # ── Page Routes ────────────────────────────────────────────────────────────── diff --git a/templates/index.html b/templates/index.html index 47de21e..5a9bb3d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -115,6 +115,93 @@ margin-top: 4px; } + .app-update-panel { + min-width: 0; + align-self: stretch; + display: flex; + flex-direction: column; + justify-content: center; + border-left: 1px solid rgb(56 189 248 / 0.35); + padding: 4px 0 4px 22px; + } + .app-update-title { + color: var(--text-primary); + font-size: 12px; + font-weight: 700; + line-height: 1.2; + margin-bottom: 8px; + text-transform: uppercase; + } + .app-update-main { + display: flex; + align-items: center; + gap: 8px; + } + .app-update-button { + flex: 0 0 160px; + min-height: 44px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-secondary); + color: var(--text-secondary); + font: inherit; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s; + } + .app-update-button[data-update-status="update_available"] { + border-color: var(--accent); + background: var(--accent); + color: #041016; + } + .app-update-button[data-update-status="update_available"]:hover:not(:disabled), + .app-update-button[data-update-status="update_available"]:focus-visible:not(:disabled) { + outline: none; + background: var(--accent-hover); + border-color: var(--accent-hover); + } + .app-update-button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .app-update-button:disabled { + cursor: default; + opacity: 1; + } + .app-update-button[data-update-status="update_available"]:disabled { + opacity: 0.72; + } + .app-update-status { + min-height: 17px; + margin-top: 7px; + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.45; + } + .app-update-status.success { color: var(--green); } + .app-update-status.warning { color: var(--orange); } + .app-update-status.error { color: var(--red); } + .app-update-fallback { + display: none; + align-items: center; + gap: 8px; + margin-top: 8px; + } + .app-update-fallback.visible { display: flex; } + .app-update-fallback code { + min-width: 0; + flex: 1; + overflow: auto; + white-space: nowrap; + color: var(--accent-hover); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 8px; + font-family: var(--font-mono); + font-size: 11px; + } /* Controls bar */ .controls { display: flex; @@ -147,7 +234,7 @@ .usage-summary { display: grid; - grid-template-columns: minmax(140px, 180px) minmax(0, 1fr); + grid-template-columns: minmax(140px, 180px) minmax(0, 1fr) minmax(260px, 320px); gap: 18px; align-items: stretch; margin-bottom: 18px; @@ -813,12 +900,15 @@ @media (max-width: 1280px) { .stat-cards { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } .tool-source-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } + .usage-summary { grid-template-columns: minmax(140px, 180px) minmax(0, 1fr); } + .app-update-panel { grid-column: 1 / -1; border-left: 0; border-top: 1px solid rgb(56 189 248 / 0.35); padding: 14px 0 0; } } @media (max-width: 800px) { .app-layout { display: block; } .sidebar { display: none; } .main-area { padding: 24px 16px; } + .app-update-button { width: 100%; } .usage-summary { grid-template-columns: 1fr; gap: 10px; } .usage-summary-eyebrow { border-right: 0; border-bottom: 1px solid var(--border); padding-bottom: 8px; } .stat-cards { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } @@ -869,6 +959,16 @@

Overview

Pricing and peak-day coverage load with chart data.
Next: inspect the dominant model once data loads.
+ @@ -1007,6 +1107,7 @@

Usage History

let currentDaily = null; let overviewDailySummary = null; let currentCostCoverage = null; +let currentAppVersion = null; const FORMATTER = new Intl.NumberFormat('en-US'); const DATE_FORMATTER = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }); function fmtNum(n) { return FORMATTER.format(n); } @@ -1106,6 +1207,112 @@

Usage History

return Number(value?.tokens_effective_total ?? value?.tokens_total ?? 0); } +function setAppUpdateFallback(command, visible = false) { + const fallback = document.getElementById('app-update-fallback'); + const code = document.getElementById('app-update-command'); + if (!fallback || !code) return; + code.textContent = command || 'git pull --ff-only origin main && uv sync'; + fallback.classList.toggle('visible', Boolean(visible)); +} + +function appUpdateButtonLabel(version) { + if (!version) return 'Checking...'; + if (version.status === 'current') return 'Up to Date'; + if (version.status === 'update_available') return 'Update App'; + if (version.status === 'blocked_dirty') return 'Blocked'; + if (version.status === 'manual_required') return 'Manual Update'; + return 'Check Failed'; +} + +function renderAppVersion(version, options = {}) { + const button = document.getElementById('app-update-button'); + const status = document.getElementById('app-update-status'); + if (!button || !status) return; + + currentAppVersion = version || currentAppVersion; + const fallbackCommand = currentAppVersion?.fallback_command || 'git pull --ff-only origin main && uv sync'; + const canUpdate = currentAppVersion?.status === 'update_available'; + button.dataset.updateStatus = options.running && canUpdate ? 'update_available' : currentAppVersion?.status || 'checking'; + button.disabled = Boolean(options.running || !canUpdate); + button.textContent = options.running ? 'Updating...' : appUpdateButtonLabel(currentAppVersion); + button.title = currentAppVersion + ? `${currentAppVersion.message || ''} Current ${currentAppVersion.branch || 'branch'}@${currentAppVersion.sha || 'unknown'}; latest ${currentAppVersion.target_ref || 'origin/main'}@${currentAppVersion.target_sha || 'unknown'}.`.trim() + : 'Checking latest main branch.'; + status.className = `app-update-status ${options.tone || ''}`.trim(); + status.textContent = options.message || currentAppVersion?.message || 'Checking latest main branch...'; + setAppUpdateFallback(fallbackCommand, Boolean(options.showFallback)); +} + +async function loadAppVersion() { + renderAppVersion(null, { message: 'Checking app version...' }); + try { + const response = await fetch('/api/app-version', { + headers: { 'X-Dashboard-Update': '1' }, + }); + const version = await response.json(); + renderAppVersion(version); + } catch (error) { + console.error('app version', error); + renderAppVersion(currentAppVersion, { + message: 'Unable to check app version.', + tone: 'error', + showFallback: true, + }); + } +} + +async function updateApp() { + renderAppVersion(currentAppVersion, { running: true, message: 'Updating...', tone: '' }); + try { + const response = await fetch('/api/update', { + method: 'POST', + headers: { 'X-Dashboard-Update': '1' }, + }); + const result = await response.json().catch(() => ({})); + const fallbackCommand = result.fallback_command || currentAppVersion?.fallback_command || 'git pull --ff-only origin main && uv sync'; + setAppUpdateFallback(fallbackCommand, false); + if (response.ok && result.status === 'updated') { + renderAppVersion({ ...currentAppVersion, sha: result.new_sha, target_sha: result.target_sha, status: 'current', update_available: false, dirty: false }, { + message: `Updated to ${result.new_sha}. Restart required.`, + tone: 'success', + }); + return; + } + if (response.ok && result.status === 'already_current') { + renderAppVersion({ ...currentAppVersion, sha: result.new_sha, target_sha: result.target_sha, status: 'current', update_available: false }, { + message: 'Already up to date.', + tone: 'success', + }); + return; + } + if (result.status === 'local_changes') { + renderAppVersion({ + ...currentAppVersion, + status: 'blocked_dirty', + update_available: true, + dirty: true, + target_sha: result.target_sha || currentAppVersion?.target_sha, + }, { + message: 'Local changes detected. Update manually to avoid overwriting work.', + tone: 'warning', + }); + return; + } + renderAppVersion(currentAppVersion, { + message: 'Update failed. Run: git pull --ff-only origin main && uv sync', + tone: 'error', + showFallback: true, + }); + } catch (error) { + console.error('update app', error); + renderAppVersion(currentAppVersion, { + message: 'Update failed. Run: git pull --ff-only origin main && uv sync', + tone: 'error', + showFallback: true, + }); + } +} + function computeDailySummary(data) { const dates = data?.dates || []; let peak = { date: null, total: 0 }; @@ -1451,6 +1658,8 @@

Usage History

clearChartModelFilter(); }); +document.getElementById('app-update-button').addEventListener('click', updateApp); + // ── Load everything ───────────────────────────────────────────────────────── async function loadAll() { await Promise.all([loadOverview(currentDays), loadModels(currentDays), loadDaily(currentDays), loadHistory()]); @@ -2089,6 +2298,7 @@

Usage History

}); // ── Init ──────────────────────────────────────────────────────────────────── +loadAppVersion(); loadAll(); // Auto-refresh every 60s diff --git a/tests/test_app.py b/tests/test_app.py index c520938..e8f79a3 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -4,6 +4,7 @@ import tempfile import time import unittest +from unittest import mock from pathlib import Path import app as dashboard_app @@ -13,6 +14,7 @@ HOME_PREFIX = f"{Path.home()}/" +UPDATE_HEADERS = {"X-Dashboard-Update": "1"} def display_like_app(path: Path) -> str: @@ -828,6 +830,26 @@ def test_dashboard_template_uses_button_based_accessible_controls(self): self.assertIn('active sources:', html) self.assertIn("document.getElementById('db-path-display').textContent = `${sourceLabel} · ${sourcePath}`", html) self.assertNotIn('OpenCode · ~/.local/share/opencode/opencode.db', html) + self.assertIn('App Update', html) + self.assertIn('id="app-update-button"', html) + self.assertIn("fetch('/api/app-version', {", html) + self.assertIn("headers: { 'X-Dashboard-Update': '1' }", html) + self.assertIn("fetch('/api/update', {", html) + self.assertIn("method: 'POST'", html) + self.assertIn("headers: { 'X-Dashboard-Update': '1' }", html) + self.assertIn('Local changes detected. Update manually to avoid overwriting work.', html) + self.assertIn('Update failed. Run: git pull --ff-only origin main && uv sync', html) + self.assertIn('Updated to ${result.new_sha}. Restart required.', html) + self.assertIn('function appUpdateButtonLabel(version)', html) + self.assertIn("if (version.status === 'current') return 'Up to Date';", html) + self.assertIn("if (version.status === 'update_available') return 'Update App';", html) + self.assertNotIn('id="app-version-pill"', html) + self.assertNotIn('id="app-update-copy-command"', html) + self.assertIn('flex: 0 0 160px;', html) + self.assertIn('min-height: 44px;', html) + self.assertIn('data-update-status="update_available"', html) + self.assertIn('background: var(--bg-secondary);', html) + self.assertIn('font-size: 12px;', html) def test_dashboard_template_includes_cost_breakdown_tooltip_logic(self): response = self.client.get('/') @@ -840,6 +862,261 @@ def test_dashboard_template_includes_cost_breakdown_tooltip_logic(self): self.assertIn("const sessionAccounting = m.session_accounting_note ? `; ${m.session_accounting_note}` : '';", html) self.assertIn("known subtotal ${fmtCost(m.partial_cost_usd)}", html) + def _fake_command_runner(self, command_results, calls): + def runner(args, *, timeout=dashboard_app.APP_COMMAND_TIMEOUT_SECONDS): + args = tuple(args) + calls.append(args) + if args not in command_results: + raise AssertionError(f'unexpected command: {args!r}') + result = command_results[args] + if isinstance(result, list): + if not result: + raise AssertionError(f'no command result left for {args!r}') + result = result.pop(0) + return subprocess.CompletedProcess( + list(args), + result.get('returncode', 0), + stdout=result.get('stdout', ''), + stderr=result.get('stderr', ''), + ) + return runner + + def test_app_version_checks_origin_main_and_reports_blocked_dirty_update(self): + calls = [] + command_results = { + ('git', 'fetch', 'origin', 'main:refs/remotes/origin/main', '--quiet'): {'stdout': ''}, + ('git', 'rev-parse', '--abbrev-ref', 'HEAD'): {'stdout': 'main\n'}, + ('git', 'rev-parse', '--short', 'HEAD'): {'stdout': 'abc1234\n'}, + ('git', 'rev-parse', 'HEAD'): {'stdout': 'abc1234-full\n'}, + ('git', 'rev-parse', '--short', 'origin/main'): {'stdout': 'def5678\n'}, + ('git', 'rev-parse', 'origin/main'): {'stdout': 'def5678-full\n'}, + ('git', 'status', '--porcelain'): {'stdout': ' M app.py\n'}, + ('git', 'merge-base', '--is-ancestor', 'origin/main', 'HEAD'): {'returncode': 1}, + ('git', 'merge-base', '--is-ancestor', 'HEAD', 'origin/main'): {'returncode': 0}, + } + with mock.patch.object(dashboard_app, 'run_app_command', self._fake_command_runner(command_results, calls)): + response = self.client.get('/api/app-version', headers=UPDATE_HEADERS) + + self.assertEqual(response.status_code, 200) + payload = response.get_json() + self.assertEqual(payload['branch'], 'main') + self.assertEqual(payload['sha'], 'abc1234') + self.assertEqual(payload['target_ref'], 'origin/main') + self.assertEqual(payload['target_branch'], 'main') + self.assertEqual(payload['target_sha'], 'def5678') + self.assertEqual(payload['status'], 'blocked_dirty') + self.assertTrue(payload['update_available']) + self.assertTrue(payload['dirty']) + self.assertIn('M app.py', payload['dirty_details']) + self.assertEqual(payload['fallback_command'], 'git pull --ff-only origin main && uv sync') + + def test_app_version_reports_current_even_when_worktree_is_dirty(self): + calls = [] + command_results = { + ('git', 'fetch', 'origin', 'main:refs/remotes/origin/main', '--quiet'): {'stdout': ''}, + ('git', 'rev-parse', '--abbrev-ref', 'HEAD'): {'stdout': 'issue-34-update-app\n'}, + ('git', 'rev-parse', '--short', 'HEAD'): {'stdout': 'abc1234\n'}, + ('git', 'rev-parse', 'HEAD'): {'stdout': 'abc1234-full\n'}, + ('git', 'rev-parse', '--short', 'origin/main'): {'stdout': 'abc1234\n'}, + ('git', 'rev-parse', 'origin/main'): {'stdout': 'abc1234-full\n'}, + ('git', 'status', '--porcelain'): {'stdout': ' M templates/index.html\n'}, + } + with mock.patch.object(dashboard_app, 'run_app_command', self._fake_command_runner(command_results, calls)): + response = self.client.get('/api/app-version', headers=UPDATE_HEADERS) + + self.assertEqual(response.status_code, 200) + payload = response.get_json() + self.assertEqual(payload['status'], 'current') + self.assertFalse(payload['update_available']) + self.assertTrue(payload['dirty']) + self.assertEqual(payload['message'], 'Current version · abc1234') + + def test_app_version_rejects_non_local_requests_without_running_git(self): + calls = [] + with mock.patch.object(dashboard_app, 'run_app_command', self._fake_command_runner({}, calls)): + response = self.client.get('/api/app-version', environ_base={'REMOTE_ADDR': '192.0.2.10'}) + + self.assertEqual(response.status_code, 403) + payload = response.get_json() + self.assertEqual(payload['status'], 'forbidden') + self.assertIn('localhost', payload['error']) + self.assertEqual(calls, []) + + def test_app_version_rejects_missing_dashboard_header_without_running_git(self): + calls = [] + with mock.patch.object(dashboard_app, 'run_app_command', self._fake_command_runner({}, calls)): + response = self.client.get('/api/app-version') + + self.assertEqual(response.status_code, 403) + payload = response.get_json() + self.assertEqual(payload['status'], 'forbidden') + self.assertIn('dashboard UI request', payload['error']) + self.assertEqual(calls, []) + + def test_update_rejects_missing_dashboard_header_without_running_git(self): + calls = [] + with mock.patch.object(dashboard_app, 'run_app_command', self._fake_command_runner({}, calls)): + response = self.client.post('/api/update') + + self.assertEqual(response.status_code, 403) + payload = response.get_json() + self.assertEqual(payload['status'], 'forbidden') + self.assertIn('dashboard UI request', payload['error']) + self.assertEqual(calls, []) + + def test_update_refuses_dirty_worktree_when_main_update_is_available(self): + calls = [] + command_results = { + ('git', 'fetch', 'origin', 'main:refs/remotes/origin/main', '--quiet'): {'stdout': ''}, + ('git', 'rev-parse', '--short', 'HEAD'): {'stdout': 'abc1234\n'}, + ('git', 'rev-parse', 'HEAD'): {'stdout': 'abc1234-full\n'}, + ('git', 'rev-parse', '--short', 'origin/main'): {'stdout': 'def5678\n'}, + ('git', 'rev-parse', 'origin/main'): {'stdout': 'def5678-full\n'}, + ('git', 'merge-base', '--is-ancestor', 'origin/main', 'HEAD'): {'returncode': 1}, + ('git', 'status', '--porcelain'): {'stdout': ' M app.py\n'}, + } + with mock.patch.object(dashboard_app, 'run_app_command', self._fake_command_runner(command_results, calls)): + response = self.client.post('/api/update', headers=UPDATE_HEADERS) + + self.assertEqual(response.status_code, 409) + payload = response.get_json() + self.assertEqual(payload['status'], 'local_changes') + self.assertEqual(payload['old_sha'], 'abc1234') + self.assertEqual(payload['new_sha'], 'abc1234') + self.assertIn('Local changes detected', payload['error']) + self.assertNotIn(('git', 'pull', '--ff-only', 'origin', 'main'), calls) + self.assertNotIn(('uv', 'sync'), calls) + + def test_update_refuses_checkout_that_cannot_fast_forward_to_main(self): + calls = [] + command_results = { + ('git', 'fetch', 'origin', 'main:refs/remotes/origin/main', '--quiet'): {'stdout': ''}, + ('git', 'rev-parse', '--short', 'HEAD'): {'stdout': 'abc1234\n'}, + ('git', 'rev-parse', 'HEAD'): {'stdout': 'abc1234-full\n'}, + ('git', 'rev-parse', '--short', 'origin/main'): {'stdout': 'def5678\n'}, + ('git', 'rev-parse', 'origin/main'): {'stdout': 'def5678-full\n'}, + ('git', 'merge-base', '--is-ancestor', 'origin/main', 'HEAD'): {'returncode': 1}, + ('git', 'status', '--porcelain'): {'stdout': ''}, + ('git', 'merge-base', '--is-ancestor', 'HEAD', 'origin/main'): {'returncode': 1}, + } + with mock.patch.object(dashboard_app, 'run_app_command', self._fake_command_runner(command_results, calls)): + response = self.client.post('/api/update', headers=UPDATE_HEADERS) + + self.assertEqual(response.status_code, 409) + payload = response.get_json() + self.assertEqual(payload['status'], 'manual_required') + self.assertEqual(payload['old_sha'], 'abc1234') + self.assertIn('cannot fast-forward to origin/main', payload['error']) + self.assertNotIn(('git', 'pull', '--ff-only', 'origin', 'main'), calls) + self.assertNotIn(('uv', 'sync'), calls) + + def test_update_runs_fast_forward_pull_then_uv_sync_success(self): + calls = [] + command_results = { + ('git', 'fetch', 'origin', 'main:refs/remotes/origin/main', '--quiet'): {'stdout': ''}, + ('git', 'rev-parse', '--short', 'HEAD'): [ + {'stdout': 'abc1234\n'}, + {'stdout': 'def5678\n'}, + ], + ('git', 'rev-parse', 'HEAD'): {'stdout': 'abc1234-full\n'}, + ('git', 'rev-parse', '--short', 'origin/main'): {'stdout': 'def5678\n'}, + ('git', 'rev-parse', 'origin/main'): {'stdout': 'def5678-full\n'}, + ('git', 'merge-base', '--is-ancestor', 'origin/main', 'HEAD'): {'returncode': 1}, + ('git', 'status', '--porcelain'): {'stdout': ''}, + ('git', 'merge-base', '--is-ancestor', 'HEAD', 'origin/main'): {'returncode': 0}, + ('git', 'pull', '--ff-only', 'origin', 'main'): {'stdout': 'Fast-forward\n'}, + ('uv', 'sync'): {'stdout': 'Resolved 1 package\n'}, + } + with mock.patch.object(dashboard_app, 'run_app_command', self._fake_command_runner(command_results, calls)): + response = self.client.post('/api/update', headers=UPDATE_HEADERS) + + self.assertEqual(response.status_code, 200) + payload = response.get_json() + self.assertEqual(payload['status'], 'updated') + self.assertEqual(payload['old_sha'], 'abc1234') + self.assertEqual(payload['new_sha'], 'def5678') + self.assertTrue(payload['restart_required']) + self.assertLess(calls.index(('git', 'pull', '--ff-only', 'origin', 'main')), calls.index(('uv', 'sync'))) + + def test_update_reports_already_current_without_running_pull_or_uv_sync(self): + calls = [] + command_results = { + ('git', 'fetch', 'origin', 'main:refs/remotes/origin/main', '--quiet'): {'stdout': ''}, + ('git', 'rev-parse', '--short', 'HEAD'): {'stdout': 'abc1234\n'}, + ('git', 'rev-parse', 'HEAD'): {'stdout': 'abc1234-full\n'}, + ('git', 'rev-parse', '--short', 'origin/main'): {'stdout': 'abc1234\n'}, + ('git', 'rev-parse', 'origin/main'): {'stdout': 'abc1234-full\n'}, + } + with mock.patch.object(dashboard_app, 'run_app_command', self._fake_command_runner(command_results, calls)): + response = self.client.post('/api/update', headers=UPDATE_HEADERS) + + self.assertEqual(response.status_code, 200) + payload = response.get_json() + self.assertEqual(payload['status'], 'already_current') + self.assertFalse(payload['restart_required']) + self.assertNotIn(('git', 'pull', '--ff-only', 'origin', 'main'), calls) + self.assertNotIn(('uv', 'sync'), calls) + + def test_update_reports_git_pull_failure_without_uv_sync(self): + calls = [] + command_results = { + ('git', 'fetch', 'origin', 'main:refs/remotes/origin/main', '--quiet'): {'stdout': ''}, + ('git', 'rev-parse', '--short', 'HEAD'): {'stdout': 'abc1234\n'}, + ('git', 'rev-parse', 'HEAD'): {'stdout': 'abc1234-full\n'}, + ('git', 'rev-parse', '--short', 'origin/main'): {'stdout': 'def5678\n'}, + ('git', 'rev-parse', 'origin/main'): {'stdout': 'def5678-full\n'}, + ('git', 'merge-base', '--is-ancestor', 'origin/main', 'HEAD'): {'returncode': 1}, + ('git', 'status', '--porcelain'): {'stdout': ''}, + ('git', 'merge-base', '--is-ancestor', 'HEAD', 'origin/main'): {'returncode': 0}, + ('git', 'pull', '--ff-only', 'origin', 'main'): {'returncode': 128, 'stderr': 'fatal: not possible to fast-forward\n'}, + } + with mock.patch.object(dashboard_app, 'run_app_command', self._fake_command_runner(command_results, calls)): + response = self.client.post('/api/update', headers=UPDATE_HEADERS) + + self.assertEqual(response.status_code, 500) + payload = response.get_json() + self.assertEqual(payload['status'], 'failure') + self.assertIn('git pull --ff-only', payload['error']) + self.assertIn('fatal: not possible to fast-forward', payload['output']) + self.assertNotIn(('uv', 'sync'), calls) + + def test_update_reports_uv_sync_failure_after_successful_pull(self): + calls = [] + command_results = { + ('git', 'fetch', 'origin', 'main:refs/remotes/origin/main', '--quiet'): {'stdout': ''}, + ('git', 'rev-parse', '--short', 'HEAD'): [ + {'stdout': 'abc1234\n'}, + {'stdout': 'def5678\n'}, + ], + ('git', 'rev-parse', 'HEAD'): {'stdout': 'abc1234-full\n'}, + ('git', 'rev-parse', '--short', 'origin/main'): {'stdout': 'def5678\n'}, + ('git', 'rev-parse', 'origin/main'): {'stdout': 'def5678-full\n'}, + ('git', 'merge-base', '--is-ancestor', 'origin/main', 'HEAD'): {'returncode': 1}, + ('git', 'status', '--porcelain'): {'stdout': ''}, + ('git', 'merge-base', '--is-ancestor', 'HEAD', 'origin/main'): {'returncode': 0}, + ('git', 'pull', '--ff-only', 'origin', 'main'): {'stdout': 'Fast-forward\n'}, + ('uv', 'sync'): {'returncode': 2, 'stderr': 'sync failed\n'}, + } + with mock.patch.object(dashboard_app, 'run_app_command', self._fake_command_runner(command_results, calls)): + response = self.client.post('/api/update', headers=UPDATE_HEADERS) + + self.assertEqual(response.status_code, 500) + payload = response.get_json() + self.assertEqual(payload['status'], 'failure') + self.assertEqual(payload['old_sha'], 'abc1234') + self.assertEqual(payload['new_sha'], 'def5678') + self.assertIn('uv sync', payload['error']) + self.assertIn('sync failed', payload['output']) + self.assertLess(calls.index(('git', 'pull', '--ff-only', 'origin', 'main')), calls.index(('uv', 'sync'))) + + def test_update_rejects_non_local_requests(self): + response = self.client.post('/api/update', headers=UPDATE_HEADERS, environ_base={'REMOTE_ADDR': '192.0.2.10'}) + + self.assertEqual(response.status_code, 403) + payload = response.get_json() + self.assertEqual(payload['status'], 'forbidden') + self.assertIn('localhost', payload['error']) + def test_tool_source_render_moves_source_path_into_info_tooltip(self): response = self.client.get('/') self.assertEqual(response.status_code, 200)