feat: add web dashboard with Gitea OAuth authentication

Implements a full management dashboard for the Agent Runner at
https://agent.cleargrow.io with real-time monitoring and control.

Backend changes:
- Add oauth.py: Gitea OAuth2 authentication with session management
- Add api_server.py: REST API endpoints and static file serving
- Add dashboard_api.py: Data aggregation layer for dashboard
- Modify agent.py: Add kill_task() and get_task() methods
- Modify runner.py: Add event broadcasting and OAuth initialization
- Modify webhook_server.py: Integrate dashboard API handler

Frontend (SvelteKit + TypeScript):
- Dashboard overview with health status, agent pool, issue counts
- Agents page with active task list and kill functionality
- Issues page with state filtering and transitions
- Builds page with Woodpecker CI integration
- Config page for runtime settings

Features:
- Gitea OAuth2 login (same pattern as Woodpecker CI)
- Real-time status updates via polling (5s interval)
- Agent termination from dashboard
- Issue state transitions
- Service health monitoring

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CI System
2025-12-11 07:11:01 -07:00
parent c6d18d0b5e
commit 41d751b678
68 changed files with 8418 additions and 12 deletions

View File

@@ -261,19 +261,78 @@ class AgentPool:
with self._lock:
return {
"max_agents": self.max_agents,
"active": self.active_count,
"active": len(self._active),
"available": self.max_agents - len(self._active),
"tasks": [
{
"task_id": t.task_id,
"issue_id": t.issue_id,
"repo": t.repo,
"platform": t.platform,
"task_type": t.task_type,
"started": t.started_at.isoformat() if t.started_at else None,
}
for t in self._active.values()
]
}
def get_task(self, task_id: str) -> Optional[AgentTask]:
"""Get a task by ID. Returns None if not found."""
with self._lock:
return self._active.get(task_id)
def kill_task(self, task_id: str, reason: str = "manual") -> bool:
"""
Kill a running task by ID.
Thread-safe: acquires lock and terminates the subprocess.
Args:
task_id: The task ID to kill
reason: Reason for killing (for logging)
Returns:
True if task was found and killed, False otherwise
"""
with self._lock:
task = self._active.get(task_id)
if not task:
return False
if not task.process:
return False
logger.warning(f"Killing agent {task_id} (reason: {reason})")
try:
# Try graceful termination first
task.process.terminate()
try:
task.process.wait(timeout=5)
except subprocess.TimeoutExpired:
# Force kill if it doesn't terminate
task.process.kill()
task.process.wait()
# Mark as killed
task.returncode = -1
task.timed_out = False
task.stderr = f"Agent killed by dashboard (reason: {reason})"
task.completed_at = datetime.now()
# Remove from active
del self._active[task_id]
return True
except ProcessLookupError:
logger.warning(f"Agent {task_id} process already terminated")
del self._active[task_id]
return True
except (PermissionError, OSError) as e:
logger.error(f"Failed to kill agent {task_id}: {e}")
return False
def build_prompt(
issue_number: int,

648
api_server.py Normal file
View File

@@ -0,0 +1,648 @@
"""
Dashboard API server with REST endpoints and WebSocket support.
Extends the existing webhook server to add dashboard functionality.
"""
import asyncio
import json
import logging
import mimetypes
import threading
from datetime import datetime
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Optional, Set
from urllib.parse import parse_qs, urlparse
if TYPE_CHECKING:
from runner import Runner
logger = logging.getLogger(__name__)
# Static file directory for built Svelte app
STATIC_DIR = Path(__file__).parent / "static" / "dashboard"
class EventBroadcaster:
"""
Manages WebSocket-like event broadcasting.
Since we're using a simple HTTP server, we implement Server-Sent Events (SSE)
instead of WebSockets for simplicity. This avoids adding asyncio complexity
to the existing synchronous codebase.
"""
def __init__(self):
self._subscribers: Set[Callable[[dict], None]] = set()
self._lock = threading.Lock()
self._event_queue: list[dict] = []
self._max_queue_size = 100
def subscribe(self, callback: Callable[[dict], None]):
"""Add a subscriber callback."""
with self._lock:
self._subscribers.add(callback)
def unsubscribe(self, callback: Callable[[dict], None]):
"""Remove a subscriber callback."""
with self._lock:
self._subscribers.discard(callback)
def broadcast(self, event_type: str, data: dict):
"""
Broadcast an event to all subscribers.
Thread-safe: can be called from any thread.
"""
event = {
"type": event_type,
"timestamp": datetime.now().isoformat(),
"data": data,
}
with self._lock:
# Store in queue for SSE clients to poll
self._event_queue.append(event)
if len(self._event_queue) > self._max_queue_size:
self._event_queue.pop(0)
# Notify subscribers
for callback in list(self._subscribers):
try:
callback(event)
except Exception as e:
logger.error(f"Subscriber callback error: {e}")
def get_recent_events(self, since_timestamp: Optional[str] = None) -> list[dict]:
"""Get events since a timestamp (for SSE polling)."""
with self._lock:
if not since_timestamp:
return list(self._event_queue)
try:
since = datetime.fromisoformat(since_timestamp)
return [
e for e in self._event_queue
if datetime.fromisoformat(e["timestamp"]) > since
]
except (ValueError, TypeError):
return list(self._event_queue)
# Global broadcaster instance
broadcaster = EventBroadcaster()
class DashboardAPIHandler(BaseHTTPRequestHandler):
"""HTTP request handler for dashboard API endpoints."""
# Reference to runner (set during server initialization)
runner: Optional["Runner"] = None
dashboard_api = None
oauth = None
def log_message(self, format, *args):
"""Override to use our logger."""
logger.debug(f"{self.address_string()} - {format % args}")
def send_json(self, data: dict, status: int = 200):
"""Send JSON response."""
body = json.dumps(data).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", len(body))
self.end_headers()
self.wfile.write(body)
def send_error_json(self, status: int, code: str, message: str):
"""Send JSON error response."""
self.send_json({
"error": True,
"code": code,
"message": message,
}, status)
def get_json_body(self) -> Optional[dict]:
"""Parse JSON request body."""
content_length = int(self.headers.get("Content-Length", 0))
if content_length == 0:
return {}
try:
body = self.rfile.read(content_length)
return json.loads(body)
except (json.JSONDecodeError, UnicodeDecodeError) as e:
logger.warning(f"Invalid JSON body: {e}")
return None
def require_auth(self) -> bool:
"""
Check if request is authenticated.
Returns True if authenticated, False otherwise (and sends 401).
"""
if not self.oauth:
# No OAuth configured - allow all requests
return True
cookie_header = self.headers.get("Cookie", "")
session = self.oauth.get_session_from_cookie(cookie_header)
if not session:
self.send_error_json(401, "UNAUTHORIZED", "Authentication required")
return False
# Store user in request for later use
self.current_user = session.user
return True
def do_GET(self):
"""Handle GET requests."""
parsed = urlparse(self.path)
path = parsed.path
query = parse_qs(parsed.query)
# Health check (no auth required)
if path == "/health":
self.send_json({"status": "healthy"})
return
# OAuth endpoints (no auth required)
if path.startswith("/oauth/"):
return self.handle_oauth(path, query)
# API endpoints (auth required)
if path.startswith("/api/"):
if not self.require_auth():
return
return self.handle_api_get(path, query)
# SSE events endpoint
if path == "/events":
if not self.require_auth():
return
return self.handle_sse(query)
# Static files (auth required for dashboard)
return self.serve_static(path)
def do_POST(self):
"""Handle POST requests."""
parsed = urlparse(self.path)
path = parsed.path
# Webhook endpoint (no auth - has its own verification)
if path == "/webhook/youtrack":
# Delegate to webhook handler in webhook_server.py
return self.handle_webhook()
# API endpoints (auth required)
if path.startswith("/api/"):
if not self.require_auth():
return
return self.handle_api_post(path)
self.send_error_json(404, "NOT_FOUND", f"Unknown endpoint: {path}")
def do_PUT(self):
"""Handle PUT requests."""
parsed = urlparse(self.path)
path = parsed.path
if path.startswith("/api/"):
if not self.require_auth():
return
return self.handle_api_put(path)
self.send_error_json(404, "NOT_FOUND", f"Unknown endpoint: {path}")
def handle_oauth(self, path: str, query: dict):
"""Handle OAuth endpoints."""
if not self.oauth:
self.send_error_json(501, "NOT_CONFIGURED", "OAuth not configured")
return
if path == "/oauth/login":
# Redirect to Gitea authorize URL
authorize_url, state = self.oauth.get_authorize_url()
self.send_response(302)
self.send_header("Location", authorize_url)
self.end_headers()
elif path == "/oauth/callback":
# Handle OAuth callback
code = query.get("code", [None])[0]
state = query.get("state", [None])[0]
if not code or not state:
self.send_error_json(400, "INVALID_CALLBACK", "Missing code or state")
return
session = self.oauth.handle_callback(code, state)
if not session:
self.send_error_json(401, "AUTH_FAILED", "Authentication failed")
return
# Set session cookie and redirect to dashboard
self.send_response(302)
self.send_header("Set-Cookie", self.oauth.create_session_cookie(session))
self.send_header("Location", "/")
self.end_headers()
elif path == "/oauth/logout":
# Clear session
cookie_header = self.headers.get("Cookie", "")
session = self.oauth.get_session_from_cookie(cookie_header)
if session:
self.oauth.logout(session.session_id)
self.send_response(302)
self.send_header("Set-Cookie", self.oauth.create_logout_cookie())
self.send_header("Location", "/oauth/login")
self.end_headers()
else:
self.send_error_json(404, "NOT_FOUND", f"Unknown OAuth endpoint: {path}")
def handle_api_get(self, path: str, query: dict):
"""Handle API GET requests."""
if not self.dashboard_api:
self.send_error_json(503, "NOT_READY", "Dashboard API not initialized")
return
# GET /api/user - Current user info
if path == "/api/user":
if hasattr(self, 'current_user') and self.current_user:
self.send_json(self.current_user.to_dict())
else:
self.send_json({"login": "anonymous", "is_admin": False})
# GET /api/health - Service health
elif path == "/api/health":
self.send_json(self.dashboard_api.get_health_status())
# GET /api/status - Full dashboard status
elif path == "/api/status":
self.send_json(self.dashboard_api.get_dashboard_status())
# GET /api/agents - Agent pool status
elif path == "/api/agents":
self.send_json(self.dashboard_api.get_pool_status())
# GET /api/config - Configuration
elif path == "/api/config":
self.send_json(self.dashboard_api.get_config())
# GET /api/issues - Issue list (proxy to YouTrack)
elif path == "/api/issues":
states = query.get("state", ["Ready,Verify,Document"])[0].split(",")
limit = int(query.get("limit", [50])[0])
self.send_json(self._get_issues(states, limit))
# GET /api/issues/{id} - Single issue
elif path.startswith("/api/issues/"):
issue_id = path.split("/")[-1]
self.send_json(self._get_issue(issue_id))
# GET /api/builds - Build list (proxy to Woodpecker)
elif path == "/api/builds":
repo = query.get("repo", [None])[0]
status = query.get("status", [None])[0]
limit = int(query.get("limit", [20])[0])
self.send_json(self._get_builds(repo, status, limit))
else:
self.send_error_json(404, "NOT_FOUND", f"Unknown API endpoint: {path}")
def handle_api_post(self, path: str):
"""Handle API POST requests."""
if not self.dashboard_api:
self.send_error_json(503, "NOT_READY", "Dashboard API not initialized")
return
body = self.get_json_body()
if body is None:
self.send_error_json(400, "INVALID_JSON", "Invalid JSON body")
return
# POST /api/agents/{task_id}/kill - Kill an agent
if path.startswith("/api/agents/") and path.endswith("/kill"):
parts = path.split("/")
task_id = parts[3] if len(parts) > 3 else None
if task_id:
result = self.dashboard_api.kill_agent(task_id)
self.send_json(result, 200 if result["success"] else 404)
else:
self.send_error_json(400, "MISSING_TASK_ID", "Task ID required")
# POST /api/agents/retry - Retry a task
elif path == "/api/agents/retry":
issue_id = body.get("issue_id")
task_type = body.get("task_type", "remediation")
if not issue_id:
self.send_error_json(400, "MISSING_ISSUE_ID", "Issue ID required")
return
# TODO: Implement retry logic
self.send_json({"success": False, "message": "Retry not yet implemented"})
# POST /api/issues/{id}/transition - Transition issue state
elif path.startswith("/api/issues/") and path.endswith("/transition"):
parts = path.split("/")
issue_id = parts[3] if len(parts) > 4 else None
new_state = body.get("state")
if issue_id and new_state:
result = self._transition_issue(issue_id, new_state)
self.send_json(result, 200 if result.get("success") else 400)
else:
self.send_error_json(400, "MISSING_PARAMS", "Issue ID and state required")
# POST /api/issues - Create new issue
elif path == "/api/issues":
result = self._create_issue(body)
self.send_json(result, 201 if result.get("success") else 400)
# POST /api/builds/{repo}/{id}/retry - Retry a build
elif "/builds/" in path and path.endswith("/retry"):
# TODO: Implement build retry
self.send_json({"success": False, "message": "Build retry not yet implemented"})
else:
self.send_error_json(404, "NOT_FOUND", f"Unknown API endpoint: {path}")
def handle_api_put(self, path: str):
"""Handle API PUT requests."""
if not self.dashboard_api:
self.send_error_json(503, "NOT_READY", "Dashboard API not initialized")
return
body = self.get_json_body()
if body is None:
self.send_error_json(400, "INVALID_JSON", "Invalid JSON body")
return
# PUT /api/agents/config - Update agent pool config
if path == "/api/agents/config":
result = self.dashboard_api.update_config(body)
self.send_json(result)
# PUT /api/config - Update general config
elif path == "/api/config":
result = self.dashboard_api.update_config(body)
self.send_json(result)
else:
self.send_error_json(404, "NOT_FOUND", f"Unknown API endpoint: {path}")
def handle_sse(self, query: dict):
"""Handle Server-Sent Events endpoint for real-time updates."""
since = query.get("since", [None])[0]
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.end_headers()
# Send recent events
events = broadcaster.get_recent_events(since)
for event in events:
self._send_sse_event(event)
# Note: For true SSE, we'd keep this connection open and stream events.
# Since we're using a simple HTTP server, clients should poll this endpoint.
def _send_sse_event(self, event: dict):
"""Send a single SSE event."""
try:
data = json.dumps(event)
self.wfile.write(f"data: {data}\n\n".encode())
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
pass
def serve_static(self, path: str):
"""Serve static files from the dashboard build."""
# Require auth for dashboard pages (but not for OAuth redirect)
if self.oauth:
cookie_header = self.headers.get("Cookie", "")
session = self.oauth.get_session_from_cookie(cookie_header)
if not session:
# Redirect to login
self.send_response(302)
self.send_header("Location", "/oauth/login")
self.end_headers()
return
# Normalize path
if path == "/" or path == "":
path = "/index.html"
# Resolve file path
file_path = STATIC_DIR / path.lstrip("/")
# SPA fallback - serve index.html for routes without file extensions
if not file_path.exists():
last_segment = path.split("/")[-1]
if "." not in last_segment:
file_path = STATIC_DIR / "index.html"
if not file_path.exists():
# If no static files exist yet, show a placeholder
if not STATIC_DIR.exists():
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(b"""
<!DOCTYPE html>
<html>
<head><title>Agent Dashboard</title></head>
<body>
<h1>Agent Dashboard</h1>
<p>Frontend not built yet. Run:</p>
<pre>cd /opt/agent_runner/dashboard && npm run build</pre>
<p><a href="/api/status">View API Status</a></p>
</body>
</html>
""")
return
self.send_error(404, f"File not found: {path}")
return
# Security: prevent path traversal
try:
file_path = file_path.resolve()
if not str(file_path).startswith(str(STATIC_DIR.resolve())):
self.send_error(403, "Forbidden")
return
except (ValueError, OSError):
self.send_error(403, "Forbidden")
return
# Determine content type
content_type, _ = mimetypes.guess_type(str(file_path))
if content_type is None:
content_type = "application/octet-stream"
# Send file
try:
content = file_path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", len(content))
# Cache static assets
if any(file_path.suffix in ext for ext in [".js", ".css", ".woff2", ".svg"]):
self.send_header("Cache-Control", "public, max-age=31536000")
self.end_headers()
self.wfile.write(content)
except IOError as e:
self.send_error(500, f"Error reading file: {e}")
def handle_webhook(self):
"""Placeholder for webhook handling - actual implementation in webhook_server.py."""
self.send_error_json(501, "NOT_IMPLEMENTED", "Webhook handling delegated to webhook_server")
# Helper methods for external service calls
def _get_issues(self, states: list[str], limit: int) -> dict:
"""Fetch issues from YouTrack."""
if not self.runner:
return {"issues": [], "counts": {}}
try:
issues = []
counts = {}
for state in states:
state_issues = self.runner.youtrack.get_issues_by_state(state, limit=limit)
counts[state] = len(state_issues)
for issue in state_issues:
issues.append({
"id": issue.id,
"summary": issue.summary,
"state": state,
"priority": issue.priority,
"repository": getattr(issue, 'repository', None),
"created": getattr(issue, 'created', None),
"updated": getattr(issue, 'updated', None),
})
return {"issues": issues, "counts": counts}
except Exception as e:
logger.error(f"Failed to fetch issues: {e}")
return {"issues": [], "counts": {}, "error": str(e)}
def _get_issue(self, issue_id: str) -> dict:
"""Fetch single issue from YouTrack."""
if not self.runner:
return {"error": "Runner not available"}
try:
issue = self.runner.youtrack.get_issue(issue_id)
if not issue:
return {"error": f"Issue {issue_id} not found"}
comments = self.runner.youtrack.get_issue_comments(issue_id)
return {
"id": issue.id,
"summary": issue.summary,
"description": getattr(issue, 'description', ''),
"state": getattr(issue, 'state', ''),
"priority": issue.priority,
"repository": getattr(issue, 'repository', None),
"comments": [
{
"author": c.get("author", {}).get("login", "unknown"),
"body": c.get("body", ""),
"created": c.get("createdAt", ""),
}
for c in comments
],
}
except Exception as e:
logger.error(f"Failed to fetch issue {issue_id}: {e}")
return {"error": str(e)}
def _transition_issue(self, issue_id: str, new_state: str) -> dict:
"""Transition issue to new state in YouTrack."""
if not self.runner:
return {"success": False, "message": "Runner not available"}
try:
self.runner.youtrack.update_issue_state(issue_id, new_state)
# Broadcast event
broadcaster.broadcast("issue.state_changed", {
"issue_id": issue_id,
"new_state": new_state,
})
return {"success": True, "issue_id": issue_id, "new_state": new_state}
except Exception as e:
logger.error(f"Failed to transition issue {issue_id}: {e}")
return {"success": False, "message": str(e)}
def _create_issue(self, data: dict) -> dict:
"""Create new issue in YouTrack."""
# TODO: Implement issue creation
return {"success": False, "message": "Issue creation not yet implemented"}
def _get_builds(self, repo: Optional[str], status: Optional[str], limit: int) -> dict:
"""Fetch builds from Woodpecker."""
if not self.runner:
return {"builds": []}
try:
builds = []
repos = [repo] if repo else list(self.runner.config.get("repos", {}).keys())
for repo_name in repos[:3]: # Limit repos to avoid too many API calls
repo_config = self.runner.config.get("repos", {}).get(repo_name, {})
woodpecker_repo = repo_config.get("name")
if not woodpecker_repo:
continue
try:
repo_builds = self.runner.woodpecker.get_builds(
woodpecker_repo,
limit=limit
)
for build in repo_builds:
if status and build.status.lower() != status.lower():
continue
builds.append({
"build_id": build.id,
"repo": repo_name,
"branch": build.branch,
"status": build.status,
"commit": build.commit[:8] if build.commit else "",
"started": build.started,
"web_url": build.web_url,
})
except Exception as e:
logger.warning(f"Failed to fetch builds for {repo_name}: {e}")
return {"builds": builds[:limit]}
except Exception as e:
logger.error(f"Failed to fetch builds: {e}")
return {"builds": [], "error": str(e)}
def setup_api_handler(runner: "Runner", oauth=None):
"""
Configure the API handler with runner reference.
Call this before starting the HTTP server.
"""
from dashboard_api import DashboardAPI
DashboardAPIHandler.runner = runner
DashboardAPIHandler.dashboard_api = DashboardAPI(runner)
DashboardAPIHandler.oauth = oauth
logger.info("Dashboard API handler configured")

23
dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
dashboard/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

38
dashboard/README.md Normal file
View File

@@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

3582
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
dashboard/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "agent-runner-dashboard",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@smui/button": "^8.0.0",
"@smui/card": "^8.0.0",
"@smui/chips": "^8.0.0",
"@smui/circular-progress": "^8.0.0",
"@smui/data-table": "^8.0.0",
"@smui/dialog": "^8.0.0",
"@smui/drawer": "^8.0.0",
"@smui/fab": "^8.0.0",
"@smui/icon-button": "^8.0.0",
"@smui/linear-progress": "^8.0.0",
"@smui/list": "^8.0.0",
"@smui/menu": "^8.0.0",
"@smui/paper": "^8.0.0",
"@smui/segmented-button": "^8.0.0",
"@smui/select": "^8.0.0",
"@smui/slider": "^8.0.0",
"@smui/snackbar": "^8.0.0",
"@smui/switch": "^8.0.0",
"@smui/tab": "^8.0.0",
"@smui/tab-bar": "^8.0.0",
"@smui/textfield": "^8.0.0",
"@smui/top-app-bar": "^8.0.0",
"@smui/tooltip": "^8.0.0",
"smui-theme": "^8.0.0",
"svelte": "^5.43.8",
"svelte-check": "^4.3.4",
"typescript": "^5.9.3",
"vite": "^7.2.2"
}
}

212
dashboard/src/app.css Normal file
View File

@@ -0,0 +1,212 @@
/* Global styles for Agent Runner Dashboard */
:root {
--mdc-theme-primary: #1976d2;
--mdc-theme-secondary: #03dac6;
--mdc-theme-background: #fafafa;
--mdc-theme-surface: #ffffff;
--mdc-theme-error: #b00020;
--mdc-theme-on-primary: #ffffff;
--mdc-theme-on-secondary: #000000;
--mdc-theme-on-surface: #000000;
--mdc-theme-on-error: #ffffff;
/* Custom colors */
--status-healthy: #4caf50;
--status-warning: #ff9800;
--status-error: #f44336;
--status-pending: #9e9e9e;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
font-family: 'Roboto', sans-serif;
background-color: var(--mdc-theme-background);
color: var(--mdc-theme-on-surface);
}
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
}
.main-content {
display: flex;
flex: 1;
overflow: hidden;
}
.sidebar {
width: 250px;
background-color: var(--mdc-theme-surface);
border-right: 1px solid rgba(0, 0, 0, 0.12);
flex-shrink: 0;
}
.page-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* Status indicators */
.status-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.status-dot.healthy {
background-color: var(--status-healthy);
}
.status-dot.warning {
background-color: var(--status-warning);
}
.status-dot.error {
background-color: var(--status-error);
}
.status-dot.pending {
background-color: var(--status-pending);
}
/* Card grid */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
/* Section headers */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
font-size: 1.25rem;
font-weight: 500;
color: rgba(0, 0, 0, 0.87);
}
/* Loading states */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
gap: 16px;
}
/* Login page */
.login-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 24px;
}
.login-card {
padding: 32px;
text-align: center;
max-width: 400px;
}
/* Agent cards */
.agent-card {
position: relative;
}
.agent-card .task-type {
text-transform: uppercase;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.5px;
color: var(--mdc-theme-primary);
}
.agent-card .elapsed-time {
font-family: 'Roboto Mono', monospace;
font-size: 0.875rem;
}
/* Issue state chips */
.state-chip {
font-size: 0.75rem;
padding: 4px 8px;
border-radius: 12px;
text-transform: uppercase;
font-weight: 500;
}
.state-chip.ready {
background-color: #e3f2fd;
color: #1565c0;
}
.state-chip.in-progress {
background-color: #fff3e0;
color: #ef6c00;
}
.state-chip.build {
background-color: #f3e5f5;
color: #7b1fa2;
}
.state-chip.verify {
background-color: #e8f5e9;
color: #2e7d32;
}
.state-chip.done {
background-color: #e8f5e9;
color: #1b5e20;
}
/* Connection status bar */
.connection-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px;
font-size: 0.875rem;
}
.connection-bar.connected {
background-color: #e8f5e9;
color: #2e7d32;
}
.connection-bar.disconnected {
background-color: #ffebee;
color: #c62828;
}
.connection-bar.reconnecting {
background-color: #fff3e0;
color: #ef6c00;
}

13
dashboard/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

20
dashboard/src/app.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agent Runner Dashboard</title>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,138 @@
// API Client for Agent Runner Dashboard
import type { User, DashboardStatus, Issue, Build, Config } from './types';
class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string
) {
super(message);
this.name = 'ApiError';
}
}
async function handleResponse<T>(response: Response): Promise<T> {
// Check content type to ensure we're getting JSON
const contentType = response.headers.get('content-type') || '';
if (response.status === 401) {
// Try to get error details if JSON response
if (contentType.includes('application/json')) {
try {
const data = await response.json();
throw new ApiError(401, data.code || 'UNAUTHORIZED', data.message || 'Authentication required');
} catch (e) {
if (e instanceof ApiError) throw e;
}
}
throw new ApiError(401, 'UNAUTHORIZED', 'Authentication required');
}
// Ensure response is JSON
if (!contentType.includes('application/json')) {
const text = await response.text();
console.error('Expected JSON response, got:', contentType, text.substring(0, 200));
throw new ApiError(response.status, 'INVALID_RESPONSE', 'Server returned non-JSON response');
}
const data = await response.json();
if (!response.ok) {
throw new ApiError(response.status, data.code || 'ERROR', data.message || 'Request failed');
}
return data as T;
}
export const api = {
// Auth
async getUser(): Promise<User> {
const response = await fetch('/api/user', { credentials: 'include' });
return handleResponse<User>(response);
},
async logout(): Promise<void> {
window.location.href = '/oauth/logout';
},
// Dashboard Status
async getStatus(): Promise<DashboardStatus> {
const response = await fetch('/api/status', { credentials: 'include' });
return handleResponse<DashboardStatus>(response);
},
async getHealth(): Promise<{ healthy: boolean; services: Record<string, boolean> }> {
const response = await fetch('/api/health', { credentials: 'include' });
return handleResponse<{ healthy: boolean; services: Record<string, boolean> }>(response);
},
// Agents
async killAgent(taskId: string, reason: string = 'manual'): Promise<{ success: boolean }> {
const response = await fetch(`/api/agents/${taskId}/kill`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ reason })
});
return handleResponse<{ success: boolean }>(response);
},
// Issues
async getIssues(states?: string[]): Promise<Issue[]> {
const params = states ? `?state=${states.join(',')}` : '';
const response = await fetch(`/api/issues${params}`, { credentials: 'include' });
return handleResponse<Issue[]>(response);
},
async getIssue(id: string): Promise<Issue> {
const response = await fetch(`/api/issues/${id}`, { credentials: 'include' });
return handleResponse<Issue>(response);
},
async transitionIssue(id: string, newState: string): Promise<{ success: boolean }> {
const response = await fetch(`/api/issues/${id}/transition`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ state: newState })
});
return handleResponse<{ success: boolean }>(response);
},
// Builds
async getBuilds(repo?: string, status?: string): Promise<Build[]> {
const params = new URLSearchParams();
if (repo) params.set('repo', repo);
if (status) params.set('status', status);
const query = params.toString() ? `?${params}` : '';
const response = await fetch(`/api/builds${query}`, { credentials: 'include' });
return handleResponse<Build[]>(response);
},
async retryBuild(repo: string, buildId: number): Promise<{ success: boolean; build_id: number }> {
const response = await fetch(`/api/builds/${repo}/${buildId}/retry`, {
method: 'POST',
credentials: 'include'
});
return handleResponse<{ success: boolean; build_id: number }>(response);
},
// Config
async getConfig(): Promise<Config> {
const response = await fetch('/api/config', { credentials: 'include' });
return handleResponse<Config>(response);
},
async updateConfig(updates: Partial<Config>): Promise<{ success: boolean }> {
const response = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(updates)
});
return handleResponse<{ success: boolean }>(response);
}
};
export { ApiError };

View File

@@ -0,0 +1,112 @@
// API Response Types
export interface User {
id: number;
username: string;
email: string;
avatar_url: string;
}
export interface ServiceHealth {
youtrack: boolean;
gitea: boolean;
woodpecker: boolean;
}
export interface AgentTask {
task_id: string;
issue_id: string;
repo: string;
task_type: string;
platform: string;
start_time: string;
elapsed_seconds: number;
}
export interface PoolStatus {
active_count: number;
max_agents: number;
active_tasks: AgentTask[];
}
export interface IssueCountsByState {
[state: string]: number;
}
export interface DashboardStatus {
health: ServiceHealth;
pool: PoolStatus;
last_poll: string | null;
poll_interval: number;
issue_counts: IssueCountsByState;
}
export interface Issue {
id: string;
summary: string;
state: string;
type?: string;
priority?: string;
created: string;
updated: string;
reporter?: string;
assignee?: string;
description?: string;
}
export interface Build {
id: number;
number: number;
status: string;
event: string;
branch: string;
message: string;
author: string;
started?: number;
finished?: number;
created: number;
}
export interface Config {
poll_interval_seconds: number;
max_parallel_agents: number;
agent_timeout_seconds: number;
auto_push: boolean;
repos: {
[key: string]: {
name: string;
path: string;
platform: string;
};
};
project: {
name: string;
states: { [key: string]: string };
};
}
// SSE Event Types
export interface SSEEvent {
type: string;
data: unknown;
timestamp: string;
}
export interface AgentStartedEvent {
task_id: string;
issue_id: string;
repo: string;
task_type: string;
}
export interface AgentCompletedEvent {
task_id: string;
returncode: number;
duration_seconds: number;
timed_out: boolean;
}
export interface AgentKilledEvent {
task_id: string;
reason: string;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,45 @@
// Auth store for user session
import { writable, derived } from 'svelte/store';
import type { User } from '$lib/api/types';
import { api } from '$lib/api/client';
interface AuthState {
user: User | null;
loading: boolean;
error: string | null;
}
function createAuthStore() {
const { subscribe, set, update } = writable<AuthState>({
user: null,
loading: true,
error: null
});
return {
subscribe,
async init() {
update((s) => ({ ...s, loading: true, error: null }));
try {
const user = await api.getUser();
set({ user, loading: false, error: null });
} catch (err) {
// Not authenticated - this is expected if not logged in
set({ user: null, loading: false, error: null });
}
},
logout() {
api.logout();
},
clear() {
set({ user: null, loading: false, error: null });
}
};
}
export const auth = createAuthStore();
export const isAuthenticated = derived(auth, ($auth) => $auth.user !== null);
export const currentUser = derived(auth, ($auth) => $auth.user);

View File

@@ -0,0 +1,106 @@
// Dashboard store for status and agents
import { writable, derived } from 'svelte/store';
import type { DashboardStatus, AgentTask, ServiceHealth, IssueCountsByState } from '$lib/api/types';
import { api } from '$lib/api/client';
interface DashboardState {
status: DashboardStatus | null;
loading: boolean;
error: string | null;
lastUpdate: Date | null;
}
function createDashboardStore() {
const { subscribe, set, update } = writable<DashboardState>({
status: null,
loading: false,
error: null,
lastUpdate: null
});
let refreshInterval: ReturnType<typeof setInterval> | null = null;
return {
subscribe,
async refresh() {
update((s) => ({ ...s, loading: true, error: null }));
try {
const status = await api.getStatus();
set({
status,
loading: false,
error: null,
lastUpdate: new Date()
});
} catch (err) {
update((s) => ({
...s,
loading: false,
error: err instanceof Error ? err.message : 'Failed to fetch status'
}));
}
},
startPolling(intervalMs: number = 5000) {
this.stopPolling();
this.refresh();
refreshInterval = setInterval(() => this.refresh(), intervalMs);
},
stopPolling() {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
},
updateFromEvent(event: { type: string; data: unknown }) {
update((s) => {
if (!s.status) return s;
const newStatus = { ...s.status };
switch (event.type) {
case 'agent.started': {
const data = event.data as AgentTask;
newStatus.pool = {
...newStatus.pool,
active_count: newStatus.pool.active_count + 1,
active_tasks: [...newStatus.pool.active_tasks, data]
};
break;
}
case 'agent.completed':
case 'agent.killed': {
const { task_id } = event.data as { task_id: string };
newStatus.pool = {
...newStatus.pool,
active_count: Math.max(0, newStatus.pool.active_count - 1),
active_tasks: newStatus.pool.active_tasks.filter((t) => t.task_id !== task_id)
};
break;
}
case 'health.changed': {
const { service, healthy } = event.data as { service: string; healthy: boolean };
newStatus.health = {
...newStatus.health,
[service]: healthy
};
break;
}
}
return { ...s, status: newStatus, lastUpdate: new Date() };
});
}
};
}
export const dashboard = createDashboardStore();
// Derived stores for specific data
export const health = derived(dashboard, ($d) => $d.status?.health ?? null);
export const pool = derived(dashboard, ($d) => $d.status?.pool ?? null);
export const activeAgents = derived(dashboard, ($d) => $d.status?.pool.active_tasks ?? []);
export const issueCounts = derived(dashboard, ($d) => $d.status?.issue_counts ?? {});

View File

@@ -0,0 +1,38 @@
// Server-Sent Events (SSE) connection manager
// Note: Currently disabled as the backend uses polling instead of persistent SSE
import { writable } from 'svelte/store';
interface EventsState {
connected: boolean;
reconnecting: boolean;
error: string | null;
}
function createEventsStore() {
const { subscribe, set } = writable<EventsState>({
connected: false,
reconnecting: false,
error: null
});
return {
subscribe,
// SSE is disabled - dashboard uses polling instead
// The backend's simple HTTP server doesn't support persistent SSE connections
connect() {
// Mark as "connected" since we're using polling successfully
set({ connected: true, reconnecting: false, error: null });
},
disconnect() {
set({ connected: false, reconnecting: false, error: null });
},
reconnect() {
set({ connected: true, reconnecting: false, error: null });
}
};
}
export const events = createEventsStore();

View File

@@ -0,0 +1,4 @@
// Re-export all stores
export { auth, isAuthenticated, currentUser } from './auth';
export { dashboard, health, pool, activeAgents, issueCounts } from './dashboard';
export { events } from './events';

View File

@@ -0,0 +1,283 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { auth, isAuthenticated, currentUser, dashboard, events } from '$lib/stores';
import '../app.css';
let { children } = $props();
// Navigation items
const navItems = [
{ path: '/', label: 'Dashboard', icon: 'dashboard' },
{ path: '/agents', label: 'Agents', icon: 'smart_toy' },
{ path: '/issues', label: 'Issues', icon: 'assignment' },
{ path: '/builds', label: 'Builds', icon: 'build' },
{ path: '/config', label: 'Config', icon: 'settings' }
];
let initialized = $state(false);
onMount(async () => {
// Initialize auth
await auth.init();
initialized = true;
});
// React to authentication changes
$effect(() => {
if (initialized && $isAuthenticated) {
dashboard.startPolling(5000);
events.connect();
} else {
dashboard.stopPolling();
events.disconnect();
}
});
onDestroy(() => {
dashboard.stopPolling();
events.disconnect();
});
function handleLogin() {
window.location.href = '/oauth/login';
}
function handleLogout() {
auth.logout();
}
function isActive(path: string): boolean {
if (path === '/') {
return $page.url.pathname === '/';
}
return $page.url.pathname.startsWith(path);
}
</script>
{#if $auth.loading}
<div class="login-container">
<div class="loading-container">
<span class="material-icons" style="font-size: 48px; color: var(--mdc-theme-primary);">hourglass_empty</span>
<p>Loading...</p>
</div>
</div>
{:else if !$isAuthenticated}
<div class="login-container">
<div class="login-card">
<span class="material-icons" style="font-size: 64px; color: var(--mdc-theme-primary); margin-bottom: 16px;">smart_toy</span>
<h1 style="margin-bottom: 8px;">Agent Runner</h1>
<p style="color: rgba(0,0,0,0.6); margin-bottom: 24px;">Sign in with Gitea to access the dashboard</p>
<button class="login-button" onclick={handleLogin}>
<span class="material-icons">login</span>
Sign in with Gitea
</button>
</div>
</div>
{:else}
<div class="app-container">
<!-- Top App Bar -->
<header class="top-bar">
<div class="top-bar-left">
<span class="material-icons">smart_toy</span>
<span class="app-title">Agent Runner</span>
</div>
<div class="top-bar-right">
{#if $events.connected}
<span class="connection-status connected">
<span class="material-icons">cloud_done</span>
Connected
</span>
{:else if $events.reconnecting}
<span class="connection-status reconnecting">
<span class="material-icons">cloud_sync</span>
Reconnecting...
</span>
{:else}
<span class="connection-status disconnected">
<span class="material-icons">cloud_off</span>
Disconnected
</span>
{/if}
{#if $currentUser}
<div class="user-info">
{#if $currentUser.avatar_url}
<img src={$currentUser.avatar_url} alt={$currentUser.username} class="avatar" />
{/if}
<span>{$currentUser.username}</span>
</div>
{/if}
<button class="icon-button" onclick={handleLogout} title="Logout">
<span class="material-icons">logout</span>
</button>
</div>
</header>
<div class="main-content">
<!-- Sidebar Navigation -->
<nav class="sidebar">
<ul class="nav-list">
{#each navItems as item}
<li>
<a href={item.path} class="nav-item" class:active={isActive(item.path)}>
<span class="material-icons">{item.icon}</span>
<span>{item.label}</span>
</a>
</li>
{/each}
</ul>
</nav>
<!-- Page Content -->
<main class="page-content">
{@render children()}
</main>
</div>
</div>
{/if}
<style>
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
padding: 0 16px;
background-color: var(--mdc-theme-primary);
color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.top-bar-left {
display: flex;
align-items: center;
gap: 12px;
}
.app-title {
font-size: 1.25rem;
font-weight: 500;
}
.top-bar-right {
display: flex;
align-items: center;
gap: 16px;
}
.connection-status {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.875rem;
padding: 4px 8px;
border-radius: 4px;
}
.connection-status .material-icons {
font-size: 18px;
}
.connection-status.connected {
background-color: rgba(76, 175, 80, 0.2);
}
.connection-status.reconnecting {
background-color: rgba(255, 152, 0, 0.2);
}
.connection-status.disconnected {
background-color: rgba(244, 67, 54, 0.2);
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
}
.icon-button {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: transparent;
color: white;
cursor: pointer;
transition: background-color 0.2s;
}
.icon-button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.sidebar {
width: 250px;
background-color: white;
border-right: 1px solid rgba(0, 0, 0, 0.12);
}
.nav-list {
list-style: none;
padding: 8px 0;
}
.nav-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 24px;
color: rgba(0, 0, 0, 0.87);
text-decoration: none;
transition: background-color 0.2s;
}
.nav-item:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.nav-item.active {
background-color: rgba(25, 118, 210, 0.1);
color: var(--mdc-theme-primary);
}
.nav-item .material-icons {
font-size: 24px;
}
.login-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border: none;
border-radius: 4px;
background-color: var(--mdc-theme-primary);
color: white;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.login-button:hover {
background-color: #1565c0;
}
.login-card {
background: white;
padding: 48px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
text-align: center;
}
</style>

View File

@@ -0,0 +1,3 @@
// Enable SPA mode - disable SSR
export const ssr = false;
export const prerender = true;

View File

@@ -0,0 +1,389 @@
<script lang="ts">
import { dashboard, health, pool, activeAgents, issueCounts } from '$lib/stores';
import { api } from '$lib/api/client';
function formatElapsed(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function getTaskTypeColor(taskType: string): string {
const colors: Record<string, string> = {
developer: '#1976d2',
qa: '#7b1fa2',
librarian: '#00796b',
build: '#ef6c00'
};
return colors[taskType] || '#757575';
}
async function handleKillAgent(taskId: string) {
if (confirm('Are you sure you want to terminate this agent?')) {
try {
await api.killAgent(taskId, 'manual');
dashboard.refresh();
} catch (err) {
alert('Failed to terminate agent: ' + (err instanceof Error ? err.message : 'Unknown error'));
}
}
}
</script>
<div class="dashboard">
<h1 class="page-title">Dashboard</h1>
{#if $dashboard.loading && !$dashboard.status}
<div class="loading-container">
<span class="material-icons spinning">sync</span>
<p>Loading dashboard...</p>
</div>
{:else if $dashboard.error}
<div class="error-card">
<span class="material-icons">error</span>
<p>{$dashboard.error}</p>
<button onclick={() => dashboard.refresh()}>Retry</button>
</div>
{:else}
<!-- Health Status -->
<section class="section">
<h2 class="section-title">Service Health</h2>
<div class="health-grid">
{#if $health}
<div class="health-card" class:healthy={$health.youtrack}>
<span class="material-icons">{$health.youtrack ? 'check_circle' : 'error'}</span>
<span class="service-name">YouTrack</span>
</div>
<div class="health-card" class:healthy={$health.gitea}>
<span class="material-icons">{$health.gitea ? 'check_circle' : 'error'}</span>
<span class="service-name">Gitea</span>
</div>
<div class="health-card" class:healthy={$health.woodpecker}>
<span class="material-icons">{$health.woodpecker ? 'check_circle' : 'error'}</span>
<span class="service-name">Woodpecker CI</span>
</div>
{/if}
</div>
</section>
<!-- Agent Pool -->
<section class="section">
<div class="section-header">
<h2 class="section-title">Agent Pool</h2>
{#if $pool}
<span class="pool-counter">{$pool.active_count} / {$pool.max_agents} active</span>
{/if}
</div>
{#if $activeAgents.length === 0}
<div class="empty-state">
<span class="material-icons">hourglass_empty</span>
<p>No agents currently running</p>
</div>
{:else}
<div class="agent-grid">
{#each $activeAgents as agent}
<div class="agent-card">
<div class="agent-header">
<span class="task-type" style="color: {getTaskTypeColor(agent.task_type)}">{agent.task_type}</span>
<button class="kill-button" onclick={() => handleKillAgent(agent.task_id)} title="Terminate agent">
<span class="material-icons">close</span>
</button>
</div>
<div class="agent-issue">{agent.issue_id}</div>
<div class="agent-meta">
<span class="repo">
<span class="material-icons">folder</span>
{agent.repo}
</span>
<span class="elapsed">
<span class="material-icons">timer</span>
{formatElapsed(agent.elapsed_seconds)}
</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {Math.min((agent.elapsed_seconds / 1800) * 100, 100)}%"></div>
</div>
</div>
{/each}
</div>
{/if}
</section>
<!-- Issue Counts -->
<section class="section">
<h2 class="section-title">Issue Queue</h2>
<div class="issue-counts">
{#each Object.entries($issueCounts) as [state, count]}
<a href="/issues?state={state}" class="count-card">
<span class="count-value">{count}</span>
<span class="count-label">{state}</span>
</a>
{/each}
</div>
</section>
<!-- Last Update -->
{#if $dashboard.lastUpdate}
<div class="last-update">
Last updated: {$dashboard.lastUpdate.toLocaleTimeString()}
</div>
{/if}
{/if}
</div>
<style>
.dashboard {
max-width: 1200px;
margin: 0 auto;
}
.page-title {
font-size: 1.75rem;
font-weight: 400;
margin-bottom: 24px;
}
.section {
margin-bottom: 32px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
font-size: 1.25rem;
font-weight: 500;
color: rgba(0, 0, 0, 0.87);
}
.pool-counter {
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.6);
background-color: rgba(0, 0, 0, 0.05);
padding: 4px 12px;
border-radius: 12px;
}
/* Health grid */
.health-grid {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.health-card {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background-color: #ffebee;
border-radius: 8px;
color: #c62828;
}
.health-card.healthy {
background-color: #e8f5e9;
color: #2e7d32;
}
.service-name {
font-weight: 500;
}
/* Agent grid */
.agent-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.agent-card {
background: white;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.agent-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.task-type {
text-transform: uppercase;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.5px;
}
.kill-button {
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: transparent;
color: rgba(0, 0, 0, 0.4);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.kill-button:hover {
background-color: #ffebee;
color: #c62828;
}
.kill-button .material-icons {
font-size: 18px;
}
.agent-issue {
font-size: 1.125rem;
font-weight: 500;
margin-bottom: 12px;
}
.agent-meta {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.6);
margin-bottom: 12px;
}
.agent-meta span {
display: flex;
align-items: center;
gap: 4px;
}
.agent-meta .material-icons {
font-size: 16px;
}
.progress-bar {
height: 4px;
background-color: rgba(0, 0, 0, 0.08);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: var(--mdc-theme-primary);
transition: width 1s linear;
}
/* Issue counts */
.issue-counts {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.count-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 24px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
text-decoration: none;
min-width: 100px;
transition: box-shadow 0.2s;
}
.count-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.count-value {
font-size: 2rem;
font-weight: 500;
color: var(--mdc-theme-primary);
}
.count-label {
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.6);
text-transform: capitalize;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px;
color: rgba(0, 0, 0, 0.4);
}
.empty-state .material-icons {
font-size: 48px;
margin-bottom: 8px;
}
/* Error */
.error-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 32px;
background-color: #ffebee;
border-radius: 8px;
color: #c62828;
}
.error-card .material-icons {
font-size: 48px;
}
.error-card button {
padding: 8px 16px;
border: 1px solid #c62828;
border-radius: 4px;
background: transparent;
color: #c62828;
cursor: pointer;
}
/* Loading */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 64px;
color: rgba(0, 0, 0, 0.6);
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Last update */
.last-update {
text-align: center;
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.4);
margin-top: 24px;
}
</style>

View File

@@ -0,0 +1,246 @@
<script lang="ts">
import { activeAgents, pool, dashboard } from '$lib/stores';
import { api } from '$lib/api/client';
function formatElapsed(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function formatStartTime(isoTime: string): string {
return new Date(isoTime).toLocaleTimeString();
}
async function handleKillAgent(taskId: string) {
if (confirm('Are you sure you want to terminate this agent?')) {
try {
await api.killAgent(taskId, 'manual');
dashboard.refresh();
} catch (err) {
alert('Failed to terminate agent: ' + (err instanceof Error ? err.message : 'Unknown error'));
}
}
}
</script>
<div class="agents-page">
<div class="page-header">
<h1 class="page-title">Agents</h1>
{#if $pool}
<span class="pool-status">{$pool.active_count} / {$pool.max_agents} active</span>
{/if}
</div>
{#if $activeAgents.length === 0}
<div class="empty-state">
<span class="material-icons">smart_toy</span>
<h2>No Active Agents</h2>
<p>Agents will appear here when processing issues</p>
</div>
{:else}
<div class="agent-table">
<div class="table-header">
<span class="col-issue">Issue</span>
<span class="col-type">Type</span>
<span class="col-repo">Repository</span>
<span class="col-started">Started</span>
<span class="col-elapsed">Elapsed</span>
<span class="col-actions">Actions</span>
</div>
{#each $activeAgents as agent}
<div class="table-row">
<span class="col-issue">
<a href="https://track.cleargrow.io/issue/{agent.issue_id}" target="_blank" rel="noopener">
{agent.issue_id}
</a>
</span>
<span class="col-type">
<span class="type-badge {agent.task_type}">{agent.task_type}</span>
</span>
<span class="col-repo">{agent.repo}</span>
<span class="col-started">{formatStartTime(agent.start_time)}</span>
<span class="col-elapsed">
<span class="elapsed-badge" class:warning={agent.elapsed_seconds > 900} class:danger={agent.elapsed_seconds > 1500}>
{formatElapsed(agent.elapsed_seconds)}
</span>
</span>
<span class="col-actions">
<button class="action-button danger" onclick={() => handleKillAgent(agent.task_id)} title="Terminate agent">
<span class="material-icons">stop</span>
</button>
</span>
</div>
{/each}
</div>
{/if}
</div>
<style>
.agents-page {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-title {
font-size: 1.75rem;
font-weight: 400;
}
.pool-status {
background-color: var(--mdc-theme-primary);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 0.875rem;
font-weight: 500;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 64px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.empty-state .material-icons {
font-size: 64px;
color: rgba(0, 0, 0, 0.2);
margin-bottom: 16px;
}
.empty-state h2 {
font-size: 1.25rem;
font-weight: 500;
margin-bottom: 8px;
}
.empty-state p {
color: rgba(0, 0, 0, 0.6);
}
.agent-table {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.table-header {
display: grid;
grid-template-columns: 1fr 100px 120px 100px 100px 80px;
gap: 16px;
padding: 16px;
background-color: #fafafa;
font-weight: 500;
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.6);
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
}
.table-row {
display: grid;
grid-template-columns: 1fr 100px 120px 100px 100px 80px;
gap: 16px;
padding: 16px;
align-items: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.table-row:last-child {
border-bottom: none;
}
.table-row:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.col-issue a {
color: var(--mdc-theme-primary);
text-decoration: none;
font-weight: 500;
}
.col-issue a:hover {
text-decoration: underline;
}
.type-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.type-badge.developer {
background-color: #e3f2fd;
color: #1565c0;
}
.type-badge.qa {
background-color: #f3e5f5;
color: #7b1fa2;
}
.type-badge.librarian {
background-color: #e0f2f1;
color: #00796b;
}
.type-badge.build {
background-color: #fff3e0;
color: #ef6c00;
}
.elapsed-badge {
font-family: 'Roboto Mono', monospace;
padding: 4px 8px;
border-radius: 4px;
background-color: #e8f5e9;
color: #2e7d32;
}
.elapsed-badge.warning {
background-color: #fff3e0;
color: #ef6c00;
}
.elapsed-badge.danger {
background-color: #ffebee;
color: #c62828;
}
.action-button {
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.action-button.danger:hover {
background-color: #ffebee;
color: #c62828;
}
.action-button .material-icons {
font-size: 20px;
}
</style>

View File

@@ -0,0 +1,327 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api/client';
import type { Build } from '$lib/api/types';
let builds: Build[] = $state([]);
let loading = $state(true);
let error = $state<string | null>(null);
let selectedRepo = $state('all');
const repos = ['all', 'controller', 'probe', 'docs'];
onMount(() => {
loadBuilds();
});
async function loadBuilds() {
loading = true;
error = null;
try {
const repo = selectedRepo === 'all' ? undefined : selectedRepo;
builds = await api.getBuilds(repo);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load builds';
} finally {
loading = false;
}
}
async function handleRetry(repo: string, buildId: number) {
try {
await api.retryBuild(repo, buildId);
loadBuilds();
} catch (err) {
alert('Failed to retry build: ' + (err instanceof Error ? err.message : 'Unknown error'));
}
}
function formatDuration(started?: number, finished?: number): string {
if (!started) return '-';
const end = finished || Date.now() / 1000;
const duration = Math.floor(end - started);
const mins = Math.floor(duration / 60);
const secs = duration % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function formatTime(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleString();
}
function getStatusIcon(status: string): string {
const icons: Record<string, string> = {
success: 'check_circle',
failure: 'cancel',
running: 'sync',
pending: 'schedule',
killed: 'block'
};
return icons[status] || 'help';
}
</script>
<div class="builds-page">
<h1 class="page-title">Builds</h1>
<!-- Repo Filter -->
<div class="repo-filter">
{#each repos as repo}
<button
class="repo-tab"
class:active={selectedRepo === repo}
onclick={() => { selectedRepo = repo; loadBuilds(); }}
>
{repo === 'all' ? 'All' : repo}
</button>
{/each}
</div>
{#if loading}
<div class="loading-state">
<span class="material-icons spinning">sync</span>
<p>Loading builds...</p>
</div>
{:else if error}
<div class="error-state">
<span class="material-icons">error</span>
<p>{error}</p>
<button onclick={loadBuilds}>Retry</button>
</div>
{:else if builds.length === 0}
<div class="empty-state">
<span class="material-icons">build</span>
<h2>No Builds Found</h2>
<p>No builds match the selected filter</p>
</div>
{:else}
<div class="builds-table">
<div class="table-header">
<span class="col-status">Status</span>
<span class="col-number">#</span>
<span class="col-branch">Branch</span>
<span class="col-message">Message</span>
<span class="col-author">Author</span>
<span class="col-duration">Duration</span>
<span class="col-time">Started</span>
<span class="col-actions">Actions</span>
</div>
{#each builds as build}
<div class="table-row status-{build.status}">
<span class="col-status">
<span class="status-icon status-{build.status}">
<span class="material-icons" class:spinning={build.status === 'running'}>
{getStatusIcon(build.status)}
</span>
</span>
</span>
<span class="col-number">
<a href="https://ci.cleargrow.io/repos/cleargrow/{selectedRepo === 'all' ? 'controller' : selectedRepo}/pipeline/{build.number}" target="_blank" rel="noopener">
{build.number}
</a>
</span>
<span class="col-branch">
<span class="branch-badge">{build.branch}</span>
</span>
<span class="col-message" title={build.message}>
{build.message.length > 50 ? build.message.slice(0, 50) + '...' : build.message}
</span>
<span class="col-author">{build.author}</span>
<span class="col-duration">{formatDuration(build.started, build.finished)}</span>
<span class="col-time">{formatTime(build.created)}</span>
<span class="col-actions">
{#if build.status === 'failure'}
<button class="action-button" onclick={() => handleRetry(selectedRepo === 'all' ? 'controller' : selectedRepo, build.number)} title="Retry build">
<span class="material-icons">replay</span>
</button>
{/if}
</span>
</div>
{/each}
</div>
{/if}
</div>
<style>
.builds-page {
max-width: 1400px;
margin: 0 auto;
}
.page-title {
font-size: 1.75rem;
font-weight: 400;
margin-bottom: 24px;
}
.repo-filter {
display: flex;
gap: 4px;
margin-bottom: 24px;
background-color: rgba(0, 0, 0, 0.04);
padding: 4px;
border-radius: 8px;
}
.repo-tab {
padding: 10px 20px;
border: none;
border-radius: 6px;
background: transparent;
font-size: 0.875rem;
font-weight: 500;
color: rgba(0, 0, 0, 0.6);
cursor: pointer;
text-transform: capitalize;
transition: all 0.2s;
}
.repo-tab:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.repo-tab.active {
background-color: white;
color: var(--mdc-theme-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.builds-table {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow-x: auto;
}
.table-header {
display: grid;
grid-template-columns: 60px 60px 120px 1fr 100px 80px 140px 60px;
gap: 16px;
padding: 16px;
background-color: #fafafa;
font-weight: 500;
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.6);
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
}
.table-row {
display: grid;
grid-template-columns: 60px 60px 120px 1fr 100px 80px 140px 60px;
gap: 16px;
padding: 16px;
align-items: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.table-row:last-child {
border-bottom: none;
}
.status-icon {
display: flex;
align-items: center;
justify-content: center;
}
.status-icon.status-success { color: #2e7d32; }
.status-icon.status-failure { color: #c62828; }
.status-icon.status-running { color: #1565c0; }
.status-icon.status-pending { color: #ef6c00; }
.status-icon.status-killed { color: #757575; }
.col-number a {
color: var(--mdc-theme-primary);
text-decoration: none;
font-weight: 500;
}
.col-number a:hover {
text-decoration: underline;
}
.branch-badge {
display: inline-block;
padding: 4px 8px;
background-color: rgba(0, 0, 0, 0.06);
border-radius: 4px;
font-size: 0.75rem;
font-family: 'Roboto Mono', monospace;
}
.col-message {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.col-duration {
font-family: 'Roboto Mono', monospace;
font-size: 0.875rem;
}
.col-time {
font-size: 0.8125rem;
color: rgba(0, 0, 0, 0.6);
}
.action-button {
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.action-button:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.loading-state,
.empty-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 64px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.loading-state .material-icons,
.empty-state .material-icons {
font-size: 48px;
color: rgba(0, 0, 0, 0.2);
margin-bottom: 16px;
}
.error-state .material-icons {
font-size: 48px;
color: #c62828;
margin-bottom: 16px;
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.error-state button {
margin-top: 16px;
padding: 8px 16px;
border: 1px solid #c62828;
border-radius: 4px;
background: transparent;
color: #c62828;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,424 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api/client';
import type { Config } from '$lib/api/types';
let config: Config | null = $state(null);
let loading = $state(true);
let saving = $state(false);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
// Editable values
let pollInterval = $state(10);
let maxAgents = $state(10);
let agentTimeout = $state(1800);
let autoPush = $state(true);
onMount(async () => {
await loadConfig();
});
async function loadConfig() {
loading = true;
error = null;
try {
config = await api.getConfig();
pollInterval = config.poll_interval_seconds;
maxAgents = config.max_parallel_agents;
agentTimeout = config.agent_timeout_seconds;
autoPush = config.auto_push;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load configuration';
} finally {
loading = false;
}
}
async function saveConfig() {
saving = true;
error = null;
successMessage = null;
try {
await api.updateConfig({
poll_interval_seconds: pollInterval,
max_parallel_agents: maxAgents,
agent_timeout_seconds: agentTimeout,
auto_push: autoPush
});
successMessage = 'Configuration saved successfully';
setTimeout(() => successMessage = null, 3000);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save configuration';
} finally {
saving = false;
}
}
function formatTimeout(seconds: number): string {
const mins = Math.floor(seconds / 60);
return `${mins} minutes`;
}
</script>
<div class="config-page">
<h1 class="page-title">Configuration</h1>
{#if loading}
<div class="loading-state">
<span class="material-icons spinning">sync</span>
<p>Loading configuration...</p>
</div>
{:else if error && !config}
<div class="error-state">
<span class="material-icons">error</span>
<p>{error}</p>
<button onclick={loadConfig}>Retry</button>
</div>
{:else if config}
{#if successMessage}
<div class="success-message">
<span class="material-icons">check_circle</span>
{successMessage}
</div>
{/if}
{#if error}
<div class="error-message">
<span class="material-icons">error</span>
{error}
</div>
{/if}
<div class="config-sections">
<!-- Polling Settings -->
<section class="config-section">
<h2 class="section-title">
<span class="material-icons">schedule</span>
Polling Settings
</h2>
<div class="config-grid">
<div class="config-item">
<label for="poll-interval">Poll Interval (seconds)</label>
<input
type="number"
id="poll-interval"
bind:value={pollInterval}
min="5"
max="300"
/>
<span class="config-hint">How often to check for new issues</span>
</div>
</div>
</section>
<!-- Agent Settings -->
<section class="config-section">
<h2 class="section-title">
<span class="material-icons">smart_toy</span>
Agent Settings
</h2>
<div class="config-grid">
<div class="config-item">
<label for="max-agents">Max Parallel Agents</label>
<input
type="number"
id="max-agents"
bind:value={maxAgents}
min="1"
max="20"
/>
<span class="config-hint">Maximum concurrent agents</span>
</div>
<div class="config-item">
<label for="agent-timeout">Agent Timeout (seconds)</label>
<input
type="number"
id="agent-timeout"
bind:value={agentTimeout}
min="300"
max="7200"
/>
<span class="config-hint">{formatTimeout(agentTimeout)}</span>
</div>
<div class="config-item checkbox">
<label>
<input
type="checkbox"
bind:checked={autoPush}
/>
Auto Push Changes
</label>
<span class="config-hint">Automatically push agent commits to remote</span>
</div>
</div>
</section>
<!-- Repository Info (Read-only) -->
<section class="config-section">
<h2 class="section-title">
<span class="material-icons">folder</span>
Repository Mapping
</h2>
<div class="repo-list">
{#each Object.entries(config.repos) as [key, repo]}
<div class="repo-item">
<span class="repo-key">{key}</span>
<span class="repo-name">{repo.name}</span>
<span class="repo-path">{repo.path}</span>
<span class="repo-platform">{repo.platform}</span>
</div>
{/each}
</div>
</section>
<!-- Project States (Read-only) -->
<section class="config-section">
<h2 class="section-title">
<span class="material-icons">view_kanban</span>
Workflow States
</h2>
<div class="states-list">
{#each Object.entries(config.project.states) as [key, value]}
<span class="state-badge">{value}</span>
{/each}
</div>
</section>
<!-- Save Button -->
<div class="config-actions">
<button class="save-button" onclick={saveConfig} disabled={saving}>
{#if saving}
<span class="material-icons spinning">sync</span>
Saving...
{:else}
<span class="material-icons">save</span>
Save Changes
{/if}
</button>
</div>
</div>
{/if}
</div>
<style>
.config-page {
max-width: 800px;
margin: 0 auto;
}
.page-title {
font-size: 1.75rem;
font-weight: 400;
margin-bottom: 24px;
}
.config-sections {
display: flex;
flex-direction: column;
gap: 24px;
}
.config-section {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.section-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 1.125rem;
font-weight: 500;
margin-bottom: 20px;
color: rgba(0, 0, 0, 0.87);
}
.section-title .material-icons {
color: var(--mdc-theme-primary);
}
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 24px;
}
.config-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.config-item label {
font-weight: 500;
font-size: 0.875rem;
}
.config-item input[type="number"] {
padding: 12px;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 4px;
font-size: 1rem;
}
.config-item input[type="number"]:focus {
outline: none;
border-color: var(--mdc-theme-primary);
}
.config-item.checkbox label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.config-item.checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
}
.config-hint {
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.6);
}
.repo-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.repo-item {
display: grid;
grid-template-columns: 100px 1fr 1fr 100px;
gap: 16px;
padding: 12px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 4px;
font-size: 0.875rem;
}
.repo-key {
font-weight: 500;
}
.repo-path {
font-family: 'Roboto Mono', monospace;
font-size: 0.8125rem;
color: rgba(0, 0, 0, 0.6);
}
.repo-platform {
color: var(--mdc-theme-primary);
}
.states-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.state-badge {
padding: 8px 16px;
background-color: rgba(0, 0, 0, 0.06);
border-radius: 16px;
font-size: 0.875rem;
}
.config-actions {
display: flex;
justify-content: flex-end;
}
.save-button {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border: none;
border-radius: 4px;
background-color: var(--mdc-theme-primary);
color: white;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.save-button:hover:not(:disabled) {
background-color: #1565c0;
}
.save-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.success-message {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background-color: #e8f5e9;
color: #2e7d32;
border-radius: 4px;
margin-bottom: 16px;
}
.error-message {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background-color: #ffebee;
color: #c62828;
border-radius: 4px;
margin-bottom: 16px;
}
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 64px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.loading-state .material-icons {
font-size: 48px;
color: rgba(0, 0, 0, 0.2);
margin-bottom: 16px;
}
.error-state .material-icons {
font-size: 48px;
color: #c62828;
margin-bottom: 16px;
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.error-state button {
margin-top: 16px;
padding: 8px 16px;
border: 1px solid #c62828;
border-radius: 4px;
background: transparent;
color: #c62828;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,315 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { api } from '$lib/api/client';
import type { Issue } from '$lib/api/types';
let issues: Issue[] = $state([]);
let loading = $state(true);
let error = $state<string | null>(null);
let selectedState = $state('Ready');
const states = ['Ready', 'In Progress', 'Build', 'Verify', 'Document', 'Review'];
onMount(() => {
// Check URL for state filter
const urlState = $page.url.searchParams.get('state');
if (urlState) {
selectedState = urlState;
}
loadIssues();
});
async function loadIssues() {
loading = true;
error = null;
try {
issues = await api.getIssues([selectedState]);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load issues';
} finally {
loading = false;
}
}
async function handleTransition(issueId: string, newState: string) {
try {
await api.transitionIssue(issueId, newState);
loadIssues();
} catch (err) {
alert('Failed to transition issue: ' + (err instanceof Error ? err.message : 'Unknown error'));
}
}
function handleStateChange(state: string) {
selectedState = state;
loadIssues();
}
</script>
<div class="issues-page">
<h1 class="page-title">Issue Queue</h1>
<!-- State Tabs -->
<div class="state-tabs">
{#each states as state}
<button
class="state-tab"
class:active={selectedState === state}
onclick={() => handleStateChange(state)}
>
{state}
</button>
{/each}
</div>
{#if loading}
<div class="loading-state">
<span class="material-icons spinning">sync</span>
<p>Loading issues...</p>
</div>
{:else if error}
<div class="error-state">
<span class="material-icons">error</span>
<p>{error}</p>
<button onclick={loadIssues}>Retry</button>
</div>
{:else if issues.length === 0}
<div class="empty-state">
<span class="material-icons">check_circle</span>
<h2>No Issues in {selectedState}</h2>
<p>All issues have been processed or moved to another state</p>
</div>
{:else}
<div class="issues-list">
{#each issues as issue}
<div class="issue-card">
<div class="issue-header">
<a href="https://track.cleargrow.io/issue/{issue.id}" target="_blank" rel="noopener" class="issue-id">
{issue.id}
</a>
<span class="issue-state state-{issue.state.toLowerCase().replace(' ', '-')}">{issue.state}</span>
</div>
<h3 class="issue-summary">{issue.summary}</h3>
<div class="issue-meta">
{#if issue.type}
<span class="meta-item">
<span class="material-icons">label</span>
{issue.type}
</span>
{/if}
{#if issue.priority}
<span class="meta-item">
<span class="material-icons">flag</span>
{issue.priority}
</span>
{/if}
<span class="meta-item">
<span class="material-icons">schedule</span>
{new Date(issue.updated).toLocaleDateString()}
</span>
</div>
<div class="issue-actions">
<select onchange={(e) => handleTransition(issue.id, (e.target as HTMLSelectElement).value)}>
<option value="">Move to...</option>
{#each states.filter(s => s !== issue.state) as state}
<option value={state}>{state}</option>
{/each}
</select>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.issues-page {
max-width: 1200px;
margin: 0 auto;
}
.page-title {
font-size: 1.75rem;
font-weight: 400;
margin-bottom: 24px;
}
.state-tabs {
display: flex;
gap: 4px;
margin-bottom: 24px;
background-color: rgba(0, 0, 0, 0.04);
padding: 4px;
border-radius: 8px;
overflow-x: auto;
}
.state-tab {
padding: 10px 20px;
border: none;
border-radius: 6px;
background: transparent;
font-size: 0.875rem;
font-weight: 500;
color: rgba(0, 0, 0, 0.6);
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.state-tab:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.state-tab.active {
background-color: white;
color: var(--mdc-theme-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.issues-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.issue-card {
background: white;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.issue-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.issue-id {
color: var(--mdc-theme-primary);
font-weight: 500;
text-decoration: none;
}
.issue-id:hover {
text-decoration: underline;
}
.issue-state {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.issue-state.state-ready {
background-color: #e3f2fd;
color: #1565c0;
}
.issue-state.state-in-progress {
background-color: #fff3e0;
color: #ef6c00;
}
.issue-state.state-build {
background-color: #f3e5f5;
color: #7b1fa2;
}
.issue-state.state-verify {
background-color: #e0f2f1;
color: #00796b;
}
.issue-state.state-document {
background-color: #fce4ec;
color: #c2185b;
}
.issue-state.state-review {
background-color: #e8f5e9;
color: #2e7d32;
}
.issue-summary {
font-size: 1rem;
font-weight: 500;
margin-bottom: 12px;
line-height: 1.4;
}
.issue-meta {
display: flex;
gap: 16px;
margin-bottom: 12px;
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.6);
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.meta-item .material-icons {
font-size: 16px;
}
.issue-actions select {
padding: 8px 12px;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 4px;
font-size: 0.875rem;
cursor: pointer;
}
.loading-state,
.empty-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 64px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.loading-state .material-icons,
.empty-state .material-icons {
font-size: 48px;
color: rgba(0, 0, 0, 0.2);
margin-bottom: 16px;
}
.error-state .material-icons {
font-size: 48px;
color: #c62828;
margin-bottom: 16px;
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.error-state button {
margin-top: 16px;
padding: 8px 16px;
border: 1px solid #c62828;
border-radius: 4px;
background: transparent;
color: #c62828;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

View File

@@ -0,0 +1,23 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
// Static adapter for SPA mode - output to ../static/dashboard
adapter: adapter({
pages: '../static/dashboard',
assets: '../static/dashboard',
fallback: 'index.html',
precompress: false,
strict: true
}),
paths: {
base: ''
}
}
};
export default config;

20
dashboard/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

24
dashboard/vite.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 5173,
proxy: {
// Proxy API calls to backend during development
'/api': {
target: 'http://localhost:8765',
changeOrigin: true
},
'/oauth': {
target: 'http://localhost:8765',
changeOrigin: true
},
'/events': {
target: 'http://localhost:8765',
changeOrigin: true
}
}
}
});

369
dashboard_api.py Normal file
View File

@@ -0,0 +1,369 @@
"""
Dashboard API data aggregation layer.
Provides thread-safe access to runner state for the dashboard API.
"""
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from runner import Runner
from agent import AgentPool
logger = logging.getLogger(__name__)
@dataclass
class ServiceHealth:
"""Health status for a single service."""
name: str
healthy: bool
url: str
last_check: Optional[datetime] = None
error: Optional[str] = None
def to_dict(self) -> dict:
return {
"name": self.name,
"healthy": self.healthy,
"url": self.url,
"last_check": self.last_check.isoformat() if self.last_check else None,
"error": self.error,
}
@dataclass
class AgentTaskInfo:
"""Information about a running agent task."""
task_id: str
issue_id: str
repo: str
platform: str
task_type: str
started_at: Optional[datetime] = None
elapsed_seconds: float = 0
def to_dict(self) -> dict:
return {
"task_id": self.task_id,
"issue_id": self.issue_id,
"repo": self.repo,
"platform": self.platform,
"task_type": self.task_type,
"started_at": self.started_at.isoformat() if self.started_at else None,
"elapsed_seconds": round(self.elapsed_seconds, 1),
}
@dataclass
class PoolStatus:
"""Agent pool status."""
max_agents: int
active: int
available: int
timeout_seconds: int
tasks: list[AgentTaskInfo] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"max_agents": self.max_agents,
"active": self.active,
"available": self.available,
"timeout_seconds": self.timeout_seconds,
"tasks": [t.to_dict() for t in self.tasks],
}
class DashboardAPI:
"""
Aggregates data from runner components for dashboard display.
Thread-safe: all methods can be called from API handler threads.
"""
def __init__(self, runner: "Runner"):
self._runner = runner
self._start_time = datetime.now()
@property
def uptime_seconds(self) -> float:
"""Get runner uptime in seconds."""
return (datetime.now() - self._start_time).total_seconds()
def get_health_status(self) -> dict:
"""
Get health status for all services.
Returns:
{
"status": "ok" | "degraded" | "error",
"services": {
"youtrack": {...},
"gitea": {...},
"woodpecker": {...}
},
"uptime_seconds": 12345
}
"""
health = self._runner.health
# Get current status dict from SystemHealth
status_dict = health._status
services = {}
all_healthy = True
any_healthy = False
# YouTrack
yt_healthy = status_dict.get("youtrack", False)
services["youtrack"] = ServiceHealth(
name="youtrack",
healthy=yt_healthy,
url=self._runner.config.get("youtrack", {}).get("base_url", ""),
last_check=datetime.now(),
).to_dict()
if yt_healthy:
any_healthy = True
else:
all_healthy = False
# Gitea
gitea_healthy = status_dict.get("gitea", False)
services["gitea"] = ServiceHealth(
name="gitea",
healthy=gitea_healthy,
url=self._runner.config.get("gitea", {}).get("base_url", ""),
last_check=datetime.now(),
).to_dict()
if gitea_healthy:
any_healthy = True
else:
all_healthy = False
# Woodpecker
wp_healthy = status_dict.get("woodpecker", False)
services["woodpecker"] = ServiceHealth(
name="woodpecker",
healthy=wp_healthy,
url=self._runner.config.get("woodpecker", {}).get("base_url", ""),
last_check=datetime.now(),
).to_dict()
if wp_healthy:
any_healthy = True
else:
all_healthy = False
# Determine overall status
if all_healthy:
status = "ok"
elif any_healthy:
status = "degraded"
else:
status = "error"
return {
"status": status,
"services": services,
"uptime_seconds": round(self.uptime_seconds),
# Simple boolean format for frontend compatibility
"youtrack": yt_healthy,
"gitea": gitea_healthy,
"woodpecker": wp_healthy,
}
def get_pool_status(self) -> dict:
"""
Get agent pool status.
Thread-safe: uses AgentPool.get_status() which acquires lock.
Returns:
{
"max_agents": 10,
"active": 3,
"available": 7,
"timeout_seconds": 1800,
"tasks": [...]
}
"""
pool = self._runner.agent_pool
now = datetime.now()
# get_status() is thread-safe
raw_status = pool.get_status()
tasks = []
for task_info in raw_status.get("tasks", []):
started_at = None
elapsed = 0
if task_info.get("started"):
try:
started_at = datetime.fromisoformat(task_info["started"])
elapsed = (now - started_at).total_seconds()
except (ValueError, TypeError):
pass
tasks.append(AgentTaskInfo(
task_id=task_info.get("task_id", ""),
issue_id=task_info.get("issue_id", ""),
repo=task_info.get("repo", ""),
platform=task_info.get("platform", ""),
task_type=task_info.get("task_type", "remediation"),
started_at=started_at,
elapsed_seconds=elapsed,
))
return PoolStatus(
max_agents=raw_status.get("max_agents", 0),
active=raw_status.get("active", 0),
available=raw_status.get("available", 0),
timeout_seconds=pool.timeout_seconds,
tasks=tasks,
).to_dict()
def get_dashboard_status(self) -> dict:
"""
Get combined status for dashboard overview.
Returns:
{
"health": {...},
"pool": {...},
"issue_counts": {...},
"last_poll": "...",
"poll_interval": 10
}
"""
return {
"health": self.get_health_status(),
"pool": self.get_pool_status(),
"issue_counts": self._get_issue_counts(),
"last_poll": self._runner.last_poll_time.isoformat() if hasattr(self._runner, 'last_poll_time') and self._runner.last_poll_time else None,
"poll_interval": self._runner.config.get("poll_interval_seconds", 10),
}
def _get_issue_counts(self) -> dict:
"""
Get issue counts by state.
Note: This makes API calls to YouTrack, so use sparingly.
Consider caching in production.
"""
states = self._runner.config.get("project", {}).get("states", {})
counts = {}
for state_key, state_name in states.items():
# Skip certain states we don't track
if state_key in ("triage", "backlog", "done"):
continue
counts[state_name] = 0 # Default to 0, actual counts fetched on demand
return counts
def get_config(self) -> dict:
"""
Get current configuration (safe subset).
Returns config without sensitive tokens.
"""
config = self._runner.config
return {
"poll_interval_seconds": config.get("poll_interval_seconds", 10),
"agent_timeout_seconds": config.get("agent_timeout_seconds", 1800),
"max_parallel_agents": config.get("max_parallel_agents", 10),
"auto_push": config.get("auto_push", True),
"health_check_interval": config.get("health_check_interval", 300),
"repos": {
name: {
"name": repo.get("name"),
"path": repo.get("path"),
"platform": repo.get("platform"),
}
for name, repo in config.get("repos", {}).items()
},
"project": config.get("project", {}),
}
def update_config(self, updates: dict) -> dict:
"""
Update configuration values.
Only allows updating safe, runtime-modifiable settings.
Args:
updates: Dict of config keys to update
Returns:
{"success": True, "updated": [...], "requires_restart": False}
"""
allowed_keys = {
"poll_interval_seconds",
"agent_timeout_seconds",
"max_parallel_agents",
"auto_push",
}
updated = []
requires_restart = False
for key, value in updates.items():
if key not in allowed_keys:
continue
# Validate types
if key in ("poll_interval_seconds", "agent_timeout_seconds", "max_parallel_agents"):
if not isinstance(value, int) or value < 1:
continue
# Update config
self._runner.config[key] = value
updated.append(key)
# Update pool if relevant
if key == "max_parallel_agents":
self._runner.agent_pool.max_agents = value
elif key == "agent_timeout_seconds":
self._runner.agent_pool.timeout_seconds = value
return {
"success": True,
"updated": updated,
"requires_restart": requires_restart,
}
def kill_agent(self, task_id: str) -> dict:
"""
Kill a running agent task.
Args:
task_id: The task ID to kill
Returns:
{"success": True/False, "message": "..."}
"""
pool = self._runner.agent_pool
# Check if task exists
if not pool.is_task_running(task_id):
return {
"success": False,
"message": f"Task {task_id} not found or already completed",
}
# Kill the task (method added in agent.py modifications)
if hasattr(pool, 'kill_task'):
result = pool.kill_task(task_id)
if result:
return {
"success": True,
"message": f"Task {task_id} terminated",
}
return {
"success": False,
"message": f"Failed to terminate task {task_id}",
}

417
oauth.py Normal file
View File

@@ -0,0 +1,417 @@
"""
Gitea OAuth2 authentication for Agent Runner Dashboard.
Implements the same OAuth2 flow pattern as Woodpecker CI.
"""
import hashlib
import hmac
import json
import logging
import secrets
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from http.cookies import SimpleCookie
from typing import Optional
from urllib.parse import urlencode, parse_qs, urlparse
import requests
logger = logging.getLogger(__name__)
@dataclass
class User:
"""Authenticated user from Gitea."""
id: int
login: str
full_name: str
email: str
avatar_url: str
is_admin: bool = False
def to_dict(self) -> dict:
return {
"id": self.id,
"login": self.login,
"full_name": self.full_name,
"email": self.email,
"avatar_url": self.avatar_url,
"is_admin": self.is_admin,
}
@dataclass
class Session:
"""User session with OAuth tokens."""
session_id: str
user: User
access_token: str
refresh_token: Optional[str] = None
token_expiry: Optional[datetime] = None
created_at: datetime = field(default_factory=datetime.now)
last_accessed: datetime = field(default_factory=datetime.now)
def is_expired(self, max_age_hours: int = 24) -> bool:
"""Check if session has expired."""
age = datetime.now() - self.created_at
return age > timedelta(hours=max_age_hours)
def touch(self):
"""Update last accessed time."""
self.last_accessed = datetime.now()
class SessionStore:
"""In-memory session storage with cleanup."""
def __init__(self, max_age_hours: int = 24, cleanup_interval: int = 3600):
self._sessions: dict[str, Session] = {}
self._max_age_hours = max_age_hours
self._last_cleanup = time.time()
self._cleanup_interval = cleanup_interval
def create(self, user: User, access_token: str,
refresh_token: Optional[str] = None,
token_expiry: Optional[datetime] = None) -> Session:
"""Create a new session for a user."""
self._maybe_cleanup()
session_id = secrets.token_urlsafe(32)
session = Session(
session_id=session_id,
user=user,
access_token=access_token,
refresh_token=refresh_token,
token_expiry=token_expiry,
)
self._sessions[session_id] = session
logger.info(f"Created session for user {user.login}")
return session
def get(self, session_id: str) -> Optional[Session]:
"""Get a session by ID, returns None if expired or not found."""
self._maybe_cleanup()
session = self._sessions.get(session_id)
if session is None:
return None
if session.is_expired(self._max_age_hours):
self.delete(session_id)
return None
session.touch()
return session
def delete(self, session_id: str) -> bool:
"""Delete a session."""
if session_id in self._sessions:
user = self._sessions[session_id].user.login
del self._sessions[session_id]
logger.info(f"Deleted session for user {user}")
return True
return False
def _maybe_cleanup(self):
"""Periodically clean up expired sessions."""
now = time.time()
if now - self._last_cleanup < self._cleanup_interval:
return
self._last_cleanup = now
expired = [
sid for sid, session in self._sessions.items()
if session.is_expired(self._max_age_hours)
]
for sid in expired:
del self._sessions[sid]
if expired:
logger.info(f"Cleaned up {len(expired)} expired sessions")
@property
def active_count(self) -> int:
return len(self._sessions)
class GiteaOAuth:
"""Gitea OAuth2 client."""
def __init__(
self,
gitea_url: str,
client_id: str,
client_secret: str,
redirect_uri: str,
allowed_users: Optional[list[str]] = None,
allowed_orgs: Optional[list[str]] = None,
session_max_age_hours: int = 24,
):
self.gitea_url = gitea_url.rstrip("/")
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.allowed_users = allowed_users
self.allowed_orgs = allowed_orgs
# OAuth endpoints
self.authorize_url = f"{self.gitea_url}/login/oauth/authorize"
self.token_url = f"{self.gitea_url}/login/oauth/access_token"
self.user_api_url = f"{self.gitea_url}/api/v1/user"
self.orgs_api_url = f"{self.gitea_url}/api/v1/user/orgs"
# Session management
self.sessions = SessionStore(max_age_hours=session_max_age_hours)
# CSRF state tokens (short-lived)
self._pending_states: dict[str, float] = {} # state -> timestamp
self._state_max_age = 600 # 10 minutes
def get_authorize_url(self, next_url: Optional[str] = None) -> tuple[str, str]:
"""
Generate OAuth2 authorization URL.
Returns:
Tuple of (authorize_url, state_token)
"""
state = secrets.token_urlsafe(32)
self._pending_states[state] = time.time()
self._cleanup_states()
params = {
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"response_type": "code",
"state": state,
}
url = f"{self.authorize_url}?{urlencode(params)}"
return url, state
def handle_callback(self, code: str, state: str) -> Optional[Session]:
"""
Handle OAuth2 callback.
Args:
code: Authorization code from Gitea
state: State token for CSRF verification
Returns:
Session if successful, None otherwise
"""
# Verify state
if not self._verify_state(state):
logger.warning("Invalid or expired OAuth state token")
return None
# Exchange code for token
token_data = self._exchange_code(code)
if not token_data:
return None
access_token = token_data.get("access_token")
refresh_token = token_data.get("refresh_token")
expires_in = token_data.get("expires_in")
token_expiry = None
if expires_in:
token_expiry = datetime.now() + timedelta(seconds=int(expires_in))
# Fetch user info
user = self._fetch_user(access_token)
if not user:
return None
# Check authorization
if not self._is_authorized(user, access_token):
logger.warning(f"User {user.login} is not authorized")
return None
# Create session
return self.sessions.create(
user=user,
access_token=access_token,
refresh_token=refresh_token,
token_expiry=token_expiry,
)
def get_session_from_cookie(self, cookie_header: str) -> Optional[Session]:
"""Extract and validate session from cookie header."""
if not cookie_header:
return None
cookie = SimpleCookie()
try:
cookie.load(cookie_header)
except Exception:
return None
session_cookie = cookie.get("agent_session")
if not session_cookie:
return None
return self.sessions.get(session_cookie.value)
def create_session_cookie(self, session: Session, secure: bool = True) -> str:
"""Create Set-Cookie header for session."""
parts = [
f"agent_session={session.session_id}",
"HttpOnly",
"Path=/",
"SameSite=Lax",
]
if secure:
parts.append("Secure")
# Set expiry
max_age = 24 * 60 * 60 # 24 hours
parts.append(f"Max-Age={max_age}")
return "; ".join(parts)
def create_logout_cookie(self) -> str:
"""Create Set-Cookie header to clear session."""
return "agent_session=; HttpOnly; Path=/; Max-Age=0"
def logout(self, session_id: str) -> bool:
"""Log out a session."""
return self.sessions.delete(session_id)
def _verify_state(self, state: str) -> bool:
"""Verify and consume a state token."""
self._cleanup_states()
timestamp = self._pending_states.pop(state, None)
if timestamp is None:
return False
age = time.time() - timestamp
return age < self._state_max_age
def _cleanup_states(self):
"""Remove expired state tokens."""
now = time.time()
expired = [
s for s, t in self._pending_states.items()
if now - t > self._state_max_age
]
for s in expired:
del self._pending_states[s]
def _exchange_code(self, code: str) -> Optional[dict]:
"""Exchange authorization code for access token."""
try:
response = requests.post(
self.token_url,
data={
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": self.redirect_uri,
},
headers={"Accept": "application/json"},
timeout=30,
)
response.raise_for_status()
return response.json()
except requests.RequestException as e:
logger.error(f"Failed to exchange OAuth code: {e}")
return None
def _fetch_user(self, access_token: str) -> Optional[User]:
"""Fetch user info from Gitea API."""
try:
response = requests.get(
self.user_api_url,
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
timeout=30,
)
response.raise_for_status()
data = response.json()
return User(
id=data["id"],
login=data["login"],
full_name=data.get("full_name", ""),
email=data.get("email", ""),
avatar_url=data.get("avatar_url", ""),
is_admin=data.get("is_admin", False),
)
except requests.RequestException as e:
logger.error(f"Failed to fetch user info: {e}")
return None
def _is_authorized(self, user: User, access_token: str) -> bool:
"""Check if user is authorized to access the dashboard."""
# No restrictions configured - allow all authenticated users
if not self.allowed_users and not self.allowed_orgs:
return True
# Check username whitelist
if self.allowed_users and user.login in self.allowed_users:
return True
# Check organization membership
if self.allowed_orgs:
try:
response = requests.get(
self.orgs_api_url,
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
timeout=30,
)
response.raise_for_status()
orgs = response.json()
user_orgs = {org["username"] for org in orgs}
if user_orgs & set(self.allowed_orgs):
return True
except requests.RequestException as e:
logger.error(f"Failed to fetch user orgs: {e}")
return False
def create_oauth_from_config(config: dict) -> Optional[GiteaOAuth]:
"""Create GiteaOAuth instance from config.yaml settings."""
oauth_config = config.get("oauth")
if not oauth_config:
logger.warning("No OAuth configuration found - authentication disabled")
return None
gitea_config = config.get("gitea", {})
gitea_url = gitea_config.get("base_url")
if not gitea_url:
logger.error("Gitea base_url required for OAuth")
return None
client_id = oauth_config.get("client_id")
client_secret = oauth_config.get("client_secret")
if not client_id or not client_secret:
logger.error("OAuth client_id and client_secret required")
return None
redirect_uri = oauth_config.get(
"redirect_uri",
"https://agent.cleargrow.io/oauth/callback"
)
return GiteaOAuth(
gitea_url=gitea_url,
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
allowed_users=oauth_config.get("allowed_users"),
allowed_orgs=oauth_config.get("allowed_orgs"),
session_max_age_hours=oauth_config.get("session_max_age_hours", 24),
)

View File

@@ -172,6 +172,7 @@ class Runner:
self._validate_config()
self.youtrack: Optional[YouTrackClient] = None
self.youtrack_build: Optional[YouTrackClient] = None # For build agent comments
self.youtrack_qa: Optional[YouTrackClient] = None # For QA agent comments
self.gitea: Optional[GiteaClient] = None
self.woodpecker: Optional[WoodpeckerClient] = None
self.agent_pool: Optional[AgentPool] = None
@@ -292,8 +293,32 @@ class Runner:
level = self.config.get("log_level", "INFO")
logging.getLogger().setLevel(getattr(logging, level))
def _broadcast_event(self, event_type: str, data: dict):
"""Broadcast an event to dashboard clients."""
try:
from api_server import broadcaster
broadcaster.broadcast(event_type, data)
except ImportError:
pass # Dashboard API not available
except Exception as e:
logger.debug(f"Failed to broadcast event: {e}")
def _on_agent_complete(self, task: AgentTask):
"""Called when an agent finishes."""
# Broadcast completion event to dashboard
duration = 0
if task.started_at and task.completed_at:
duration = (task.completed_at - task.started_at).total_seconds()
self._broadcast_event("agent.completed", {
"task_id": task.task_id,
"issue_id": task.issue_id,
"task_type": task.task_type,
"returncode": task.returncode,
"timed_out": task.timed_out,
"duration_seconds": round(duration, 1),
})
states = self.config["project"].get("states", {})
triage_state = states.get("triage", "Triage")
build_state = states.get("build", "Build")
@@ -504,8 +529,18 @@ class Runner:
# Submit to pool
if self.agent_pool.submit(task):
self._seen_items.add(task_id)
# Broadcast agent started event
self._broadcast_event("agent.started", {
"task_id": task_id,
"issue_id": issue.id,
"repo": repo_name,
"platform": platform,
"task_type": task_type,
})
return True
return False
def _get_build_type_for_repo(self, repo_name: str) -> Optional[str]:
@@ -526,7 +561,7 @@ class Runner:
if success:
logger.info(f"Merged {feature_branch} to main for {task.issue_id}")
self.youtrack.add_issue_comment(
self.youtrack_qa.add_issue_comment(
task.issue_id,
f"## Branch Merged\n\n"
f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M')}\n"
@@ -536,7 +571,7 @@ class Runner:
)
else:
logger.error(f"Failed to merge {feature_branch} for {task.issue_id}: {message}")
self.youtrack.add_issue_comment(
self.youtrack_qa.add_issue_comment(
task.issue_id,
f"## Merge Failed\n\n"
f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M')}\n"
@@ -837,6 +872,21 @@ class Runner:
logger.info("No build agent token configured, using admin token for build comments")
self.youtrack_build = self.youtrack
# Initialize YouTrack QA agent client (for QA/verification-related comments)
qa_token = agent_tokens.get("qa")
if qa_token:
yt_config = self.config.get("youtrack", {})
self.youtrack_qa = YouTrackClient(yt_config.get("base_url"), qa_token)
qa_status = self.youtrack_qa.test_connection()
if qa_status.get("status") == "ok":
logger.info(f"YouTrack QA agent connected as: {qa_status.get('user', {}).get('login', 'qa')}")
else:
logger.warning("YouTrack QA agent connection failed, using admin token for QA comments")
self.youtrack_qa = self.youtrack
else:
logger.info("No QA agent token configured, using admin token for QA comments")
self.youtrack_qa = self.youtrack
# Initialize Gitea client (optional - for comments)
self.gitea = load_gitea_config(self.config)
if self.gitea:
@@ -885,7 +935,19 @@ class Runner:
logger.info(f"Agent pool started (max={self.agent_pool.max_agents}, timeout={timeout}s)")
# Initialize webhook server (optional)
# Initialize OAuth if configured
oauth = None
oauth_config = self.config.get("oauth")
if oauth_config and oauth_config.get("client_id"):
try:
from oauth import create_oauth_from_config
oauth = create_oauth_from_config(self.config)
if oauth:
logger.info("OAuth authentication enabled")
except ImportError as e:
logger.warning(f"OAuth module not available: {e}")
# Initialize webhook server with dashboard API (optional)
webhook_config = self.config.get("webhook", {})
if webhook_config.get("enabled", False):
self.webhook_server = WebhookServer(
@@ -893,9 +955,13 @@ class Runner:
port=webhook_config.get("port", 8765),
secret=webhook_config.get("secret"),
on_event=self._on_webhook_event,
runner=self, # Pass runner for dashboard API
oauth=oauth, # Pass OAuth handler
)
self.webhook_server.start()
logger.info(f"Webhook server started on {webhook_config.get('host', '0.0.0.0')}:{webhook_config.get('port', 8765)}")
if oauth:
logger.info("Dashboard API available with Gitea OAuth authentication")
poll_interval = self.config.get("poll_interval_seconds", DEFAULT_POLL_INTERVAL)
backoff_interval = poll_interval

View File

@@ -0,0 +1 @@
export const env={}

View File

@@ -0,0 +1 @@
:root{--mdc-theme-primary: #1976d2;--mdc-theme-secondary: #03dac6;--mdc-theme-background: #fafafa;--mdc-theme-surface: #ffffff;--mdc-theme-error: #b00020;--mdc-theme-on-primary: #ffffff;--mdc-theme-on-secondary: #000000;--mdc-theme-on-surface: #000000;--mdc-theme-on-error: #ffffff;--status-healthy: #4caf50;--status-warning: #ff9800;--status-error: #f44336;--status-pending: #9e9e9e}*{box-sizing:border-box;margin:0;padding:0}html,body{height:100%;font-family:Roboto,sans-serif;background-color:var(--mdc-theme-background);color:var(--mdc-theme-on-surface)}.app-container{display:flex;flex-direction:column;height:100vh}.main-content{display:flex;flex:1;overflow:hidden}.sidebar{width:250px;background-color:var(--mdc-theme-surface);border-right:1px solid rgba(0,0,0,.12);flex-shrink:0}.page-content{flex:1;overflow-y:auto;padding:24px}.status-indicator{display:inline-flex;align-items:center;gap:6px}.status-dot{width:10px;height:10px;border-radius:50%}.status-dot.healthy{background-color:var(--status-healthy)}.status-dot.warning{background-color:var(--status-warning)}.status-dot.error{background-color:var(--status-error)}.status-dot.pending{background-color:var(--status-pending)}.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;margin-bottom:24px}.section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.section-title{font-size:1.25rem;font-weight:500;color:#000000de}.loading-container{display:flex;flex-direction:column;align-items:center;justify-content:center;height:200px;gap:16px}.login-container{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;gap:24px}.login-card{padding:32px;text-align:center;max-width:400px}.agent-card{position:relative}.agent-card .task-type{text-transform:uppercase;font-size:.75rem;font-weight:500;letter-spacing:.5px;color:var(--mdc-theme-primary)}.agent-card .elapsed-time{font-family:Roboto Mono,monospace;font-size:.875rem}.state-chip{font-size:.75rem;padding:4px 8px;border-radius:12px;text-transform:uppercase;font-weight:500}.state-chip.ready{background-color:#e3f2fd;color:#1565c0}.state-chip.in-progress{background-color:#fff3e0;color:#ef6c00}.state-chip.build{background-color:#f3e5f5;color:#7b1fa2}.state-chip.verify{background-color:#e8f5e9;color:#2e7d32}.state-chip.done{background-color:#e8f5e9;color:#1b5e20}.connection-bar{display:flex;align-items:center;justify-content:center;gap:8px;padding:8px;font-size:.875rem}.connection-bar.connected{background-color:#e8f5e9;color:#2e7d32}.connection-bar.disconnected{background-color:#ffebee;color:#c62828}.connection-bar.reconnecting{background-color:#fff3e0;color:#ef6c00}.top-bar.svelte-12qhfyh{display:flex;align-items:center;justify-content:space-between;height:64px;padding:0 16px;background-color:var(--mdc-theme-primary);color:#fff;box-shadow:0 2px 4px #0000001a}.top-bar-left.svelte-12qhfyh{display:flex;align-items:center;gap:12px}.app-title.svelte-12qhfyh{font-size:1.25rem;font-weight:500}.top-bar-right.svelte-12qhfyh{display:flex;align-items:center;gap:16px}.connection-status.svelte-12qhfyh{display:flex;align-items:center;gap:4px;font-size:.875rem;padding:4px 8px;border-radius:4px}.connection-status.svelte-12qhfyh .material-icons:where(.svelte-12qhfyh){font-size:18px}.connection-status.connected.svelte-12qhfyh{background-color:#4caf5033}.connection-status.reconnecting.svelte-12qhfyh{background-color:#ff980033}.connection-status.disconnected.svelte-12qhfyh{background-color:#f4433633}.user-info.svelte-12qhfyh{display:flex;align-items:center;gap:8px}.avatar.svelte-12qhfyh{width:32px;height:32px;border-radius:50%}.icon-button.svelte-12qhfyh{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border:none;border-radius:50%;background:transparent;color:#fff;cursor:pointer;transition:background-color .2s}.icon-button.svelte-12qhfyh:hover{background-color:#ffffff1a}.sidebar.svelte-12qhfyh{width:250px;background-color:#fff;border-right:1px solid rgba(0,0,0,.12)}.nav-list.svelte-12qhfyh{list-style:none;padding:8px 0}.nav-item.svelte-12qhfyh{display:flex;align-items:center;gap:16px;padding:12px 24px;color:#000000de;text-decoration:none;transition:background-color .2s}.nav-item.svelte-12qhfyh:hover{background-color:#0000000a}.nav-item.active.svelte-12qhfyh{background-color:#1976d21a;color:var(--mdc-theme-primary)}.nav-item.svelte-12qhfyh .material-icons:where(.svelte-12qhfyh){font-size:24px}.login-button.svelte-12qhfyh{display:inline-flex;align-items:center;gap:8px;padding:12px 24px;border:none;border-radius:4px;background-color:var(--mdc-theme-primary);color:#fff;font-size:1rem;font-weight:500;cursor:pointer;transition:background-color .2s}.login-button.svelte-12qhfyh:hover{background-color:#1565c0}.login-card.svelte-12qhfyh{background:#fff;padding:48px;border-radius:8px;box-shadow:0 2px 8px #0000001a;text-align:center}

View File

@@ -0,0 +1 @@
.dashboard.svelte-1uha8ag{max-width:1200px;margin:0 auto}.page-title.svelte-1uha8ag{font-size:1.75rem;font-weight:400;margin-bottom:24px}.section.svelte-1uha8ag{margin-bottom:32px}.section-header.svelte-1uha8ag{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.section-title.svelte-1uha8ag{font-size:1.25rem;font-weight:500;color:#000000de}.pool-counter.svelte-1uha8ag{font-size:.875rem;color:#0009;background-color:#0000000d;padding:4px 12px;border-radius:12px}.health-grid.svelte-1uha8ag{display:flex;gap:16px;flex-wrap:wrap}.health-card.svelte-1uha8ag{display:flex;align-items:center;gap:8px;padding:12px 20px;background-color:#ffebee;border-radius:8px;color:#c62828}.health-card.healthy.svelte-1uha8ag{background-color:#e8f5e9;color:#2e7d32}.service-name.svelte-1uha8ag{font-weight:500}.agent-grid.svelte-1uha8ag{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}.agent-card.svelte-1uha8ag{background:#fff;border-radius:8px;padding:16px;box-shadow:0 1px 3px #0000001a}.agent-header.svelte-1uha8ag{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}.task-type.svelte-1uha8ag{text-transform:uppercase;font-size:.75rem;font-weight:600;letter-spacing:.5px}.kill-button.svelte-1uha8ag{width:28px;height:28px;border:none;border-radius:50%;background:transparent;color:#0006;cursor:pointer;display:flex;align-items:center;justify-content:center}.kill-button.svelte-1uha8ag:hover{background-color:#ffebee;color:#c62828}.kill-button.svelte-1uha8ag .material-icons:where(.svelte-1uha8ag){font-size:18px}.agent-issue.svelte-1uha8ag{font-size:1.125rem;font-weight:500;margin-bottom:12px}.agent-meta.svelte-1uha8ag{display:flex;justify-content:space-between;font-size:.875rem;color:#0009;margin-bottom:12px}.agent-meta.svelte-1uha8ag span:where(.svelte-1uha8ag){display:flex;align-items:center;gap:4px}.agent-meta.svelte-1uha8ag .material-icons:where(.svelte-1uha8ag){font-size:16px}.progress-bar.svelte-1uha8ag{height:4px;background-color:#00000014;border-radius:2px;overflow:hidden}.progress-fill.svelte-1uha8ag{height:100%;background-color:var(--mdc-theme-primary);transition:width 1s linear}.issue-counts.svelte-1uha8ag{display:flex;gap:12px;flex-wrap:wrap}.count-card.svelte-1uha8ag{display:flex;flex-direction:column;align-items:center;padding:16px 24px;background:#fff;border-radius:8px;box-shadow:0 1px 3px #0000001a;text-decoration:none;min-width:100px;transition:box-shadow .2s}.count-card.svelte-1uha8ag:hover{box-shadow:0 2px 8px #00000026}.count-value.svelte-1uha8ag{font-size:2rem;font-weight:500;color:var(--mdc-theme-primary)}.count-label.svelte-1uha8ag{font-size:.875rem;color:#0009;text-transform:capitalize}.empty-state.svelte-1uha8ag{display:flex;flex-direction:column;align-items:center;padding:48px;color:#0006}.empty-state.svelte-1uha8ag .material-icons:where(.svelte-1uha8ag){font-size:48px;margin-bottom:8px}.error-card.svelte-1uha8ag{display:flex;flex-direction:column;align-items:center;gap:12px;padding:32px;background-color:#ffebee;border-radius:8px;color:#c62828}.error-card.svelte-1uha8ag .material-icons:where(.svelte-1uha8ag){font-size:48px}.error-card.svelte-1uha8ag button:where(.svelte-1uha8ag){padding:8px 16px;border:1px solid #c62828;border-radius:4px;background:transparent;color:#c62828;cursor:pointer}.loading-container.svelte-1uha8ag{display:flex;flex-direction:column;align-items:center;padding:64px;color:#0009}.spinning.svelte-1uha8ag{animation:svelte-1uha8ag-spin 1s linear infinite}@keyframes svelte-1uha8ag-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.last-update.svelte-1uha8ag{text-align:center;font-size:.75rem;color:#0006;margin-top:24px}

View File

@@ -0,0 +1 @@
.agents-page.svelte-h3sa6j{max-width:1200px;margin:0 auto}.page-header.svelte-h3sa6j{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}.page-title.svelte-h3sa6j{font-size:1.75rem;font-weight:400}.pool-status.svelte-h3sa6j{background-color:var(--mdc-theme-primary);color:#fff;padding:8px 16px;border-radius:20px;font-size:.875rem;font-weight:500}.empty-state.svelte-h3sa6j{display:flex;flex-direction:column;align-items:center;padding:64px;background:#fff;border-radius:8px;box-shadow:0 1px 3px #0000001a}.empty-state.svelte-h3sa6j .material-icons:where(.svelte-h3sa6j){font-size:64px;color:#0003;margin-bottom:16px}.empty-state.svelte-h3sa6j h2:where(.svelte-h3sa6j){font-size:1.25rem;font-weight:500;margin-bottom:8px}.empty-state.svelte-h3sa6j p:where(.svelte-h3sa6j){color:#0009}.agent-table.svelte-h3sa6j{background:#fff;border-radius:8px;box-shadow:0 1px 3px #0000001a;overflow:hidden}.table-header.svelte-h3sa6j{display:grid;grid-template-columns:1fr 100px 120px 100px 100px 80px;gap:16px;padding:16px;background-color:#fafafa;font-weight:500;font-size:.875rem;color:#0009;border-bottom:1px solid rgba(0,0,0,.12)}.table-row.svelte-h3sa6j{display:grid;grid-template-columns:1fr 100px 120px 100px 100px 80px;gap:16px;padding:16px;align-items:center;border-bottom:1px solid rgba(0,0,0,.08)}.table-row.svelte-h3sa6j:last-child{border-bottom:none}.table-row.svelte-h3sa6j:hover{background-color:#00000005}.col-issue.svelte-h3sa6j a:where(.svelte-h3sa6j){color:var(--mdc-theme-primary);text-decoration:none;font-weight:500}.col-issue.svelte-h3sa6j a:where(.svelte-h3sa6j):hover{text-decoration:underline}.type-badge.svelte-h3sa6j{display:inline-block;padding:4px 8px;border-radius:4px;font-size:.75rem;font-weight:500;text-transform:uppercase}.type-badge.developer.svelte-h3sa6j{background-color:#e3f2fd;color:#1565c0}.type-badge.qa.svelte-h3sa6j{background-color:#f3e5f5;color:#7b1fa2}.type-badge.librarian.svelte-h3sa6j{background-color:#e0f2f1;color:#00796b}.type-badge.build.svelte-h3sa6j{background-color:#fff3e0;color:#ef6c00}.elapsed-badge.svelte-h3sa6j{font-family:Roboto Mono,monospace;padding:4px 8px;border-radius:4px;background-color:#e8f5e9;color:#2e7d32}.elapsed-badge.warning.svelte-h3sa6j{background-color:#fff3e0;color:#ef6c00}.elapsed-badge.danger.svelte-h3sa6j{background-color:#ffebee;color:#c62828}.action-button.svelte-h3sa6j{width:36px;height:36px;border:none;border-radius:50%;background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background-color .2s}.action-button.danger.svelte-h3sa6j:hover{background-color:#ffebee;color:#c62828}.action-button.svelte-h3sa6j .material-icons:where(.svelte-h3sa6j){font-size:20px}

View File

@@ -0,0 +1 @@
.builds-page.svelte-1itx8h6{max-width:1400px;margin:0 auto}.page-title.svelte-1itx8h6{font-size:1.75rem;font-weight:400;margin-bottom:24px}.repo-filter.svelte-1itx8h6{display:flex;gap:4px;margin-bottom:24px;background-color:#0000000a;padding:4px;border-radius:8px}.repo-tab.svelte-1itx8h6{padding:10px 20px;border:none;border-radius:6px;background:transparent;font-size:.875rem;font-weight:500;color:#0009;cursor:pointer;text-transform:capitalize;transition:all .2s}.repo-tab.svelte-1itx8h6:hover{background-color:#0000000a}.repo-tab.active.svelte-1itx8h6{background-color:#fff;color:var(--mdc-theme-primary);box-shadow:0 1px 3px #0000001a}.builds-table.svelte-1itx8h6{background:#fff;border-radius:8px;box-shadow:0 1px 3px #0000001a;overflow-x:auto}.table-header.svelte-1itx8h6{display:grid;grid-template-columns:60px 60px 120px 1fr 100px 80px 140px 60px;gap:16px;padding:16px;background-color:#fafafa;font-weight:500;font-size:.875rem;color:#0009;border-bottom:1px solid rgba(0,0,0,.12)}.table-row.svelte-1itx8h6{display:grid;grid-template-columns:60px 60px 120px 1fr 100px 80px 140px 60px;gap:16px;padding:16px;align-items:center;border-bottom:1px solid rgba(0,0,0,.08)}.table-row.svelte-1itx8h6:last-child{border-bottom:none}.status-icon.svelte-1itx8h6{display:flex;align-items:center;justify-content:center}.status-icon.status-success.svelte-1itx8h6{color:#2e7d32}.status-icon.status-failure.svelte-1itx8h6{color:#c62828}.status-icon.status-running.svelte-1itx8h6{color:#1565c0}.status-icon.status-pending.svelte-1itx8h6{color:#ef6c00}.status-icon.status-killed.svelte-1itx8h6{color:#757575}.col-number.svelte-1itx8h6 a:where(.svelte-1itx8h6){color:var(--mdc-theme-primary);text-decoration:none;font-weight:500}.col-number.svelte-1itx8h6 a:where(.svelte-1itx8h6):hover{text-decoration:underline}.branch-badge.svelte-1itx8h6{display:inline-block;padding:4px 8px;background-color:#0000000f;border-radius:4px;font-size:.75rem;font-family:Roboto Mono,monospace}.col-message.svelte-1itx8h6{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.col-duration.svelte-1itx8h6{font-family:Roboto Mono,monospace;font-size:.875rem}.col-time.svelte-1itx8h6{font-size:.8125rem;color:#0009}.action-button.svelte-1itx8h6{width:36px;height:36px;border:none;border-radius:50%;background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center}.action-button.svelte-1itx8h6:hover{background-color:#0000000a}.loading-state.svelte-1itx8h6,.empty-state.svelte-1itx8h6,.error-state.svelte-1itx8h6{display:flex;flex-direction:column;align-items:center;padding:64px;background:#fff;border-radius:8px;box-shadow:0 1px 3px #0000001a}.loading-state.svelte-1itx8h6 .material-icons:where(.svelte-1itx8h6),.empty-state.svelte-1itx8h6 .material-icons:where(.svelte-1itx8h6){font-size:48px;color:#0003;margin-bottom:16px}.error-state.svelte-1itx8h6 .material-icons:where(.svelte-1itx8h6){font-size:48px;color:#c62828;margin-bottom:16px}.spinning.svelte-1itx8h6{animation:svelte-1itx8h6-spin 1s linear infinite}@keyframes svelte-1itx8h6-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.error-state.svelte-1itx8h6 button:where(.svelte-1itx8h6){margin-top:16px;padding:8px 16px;border:1px solid #c62828;border-radius:4px;background:transparent;color:#c62828;cursor:pointer}

View File

@@ -0,0 +1 @@
.config-page.svelte-1gp6n77{max-width:800px;margin:0 auto}.page-title.svelte-1gp6n77{font-size:1.75rem;font-weight:400;margin-bottom:24px}.config-sections.svelte-1gp6n77{display:flex;flex-direction:column;gap:24px}.config-section.svelte-1gp6n77{background:#fff;border-radius:8px;padding:24px;box-shadow:0 1px 3px #0000001a}.section-title.svelte-1gp6n77{display:flex;align-items:center;gap:12px;font-size:1.125rem;font-weight:500;margin-bottom:20px;color:#000000de}.section-title.svelte-1gp6n77 .material-icons:where(.svelte-1gp6n77){color:var(--mdc-theme-primary)}.config-grid.svelte-1gp6n77{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:24px}.config-item.svelte-1gp6n77{display:flex;flex-direction:column;gap:8px}.config-item.svelte-1gp6n77 label:where(.svelte-1gp6n77){font-weight:500;font-size:.875rem}.config-item.svelte-1gp6n77 input[type=number]:where(.svelte-1gp6n77){padding:12px;border:1px solid rgba(0,0,0,.2);border-radius:4px;font-size:1rem}.config-item.svelte-1gp6n77 input[type=number]:where(.svelte-1gp6n77):focus{outline:none;border-color:var(--mdc-theme-primary)}.config-item.checkbox.svelte-1gp6n77 label:where(.svelte-1gp6n77){display:flex;align-items:center;gap:8px;cursor:pointer}.config-item.checkbox.svelte-1gp6n77 input[type=checkbox]:where(.svelte-1gp6n77){width:18px;height:18px}.config-hint.svelte-1gp6n77{font-size:.75rem;color:#0009}.repo-list.svelte-1gp6n77{display:flex;flex-direction:column;gap:12px}.repo-item.svelte-1gp6n77{display:grid;grid-template-columns:100px 1fr 1fr 100px;gap:16px;padding:12px;background-color:#00000005;border-radius:4px;font-size:.875rem}.repo-key.svelte-1gp6n77{font-weight:500}.repo-path.svelte-1gp6n77{font-family:Roboto Mono,monospace;font-size:.8125rem;color:#0009}.repo-platform.svelte-1gp6n77{color:var(--mdc-theme-primary)}.states-list.svelte-1gp6n77{display:flex;flex-wrap:wrap;gap:8px}.state-badge.svelte-1gp6n77{padding:8px 16px;background-color:#0000000f;border-radius:16px;font-size:.875rem}.config-actions.svelte-1gp6n77{display:flex;justify-content:flex-end}.save-button.svelte-1gp6n77{display:flex;align-items:center;gap:8px;padding:12px 24px;border:none;border-radius:4px;background-color:var(--mdc-theme-primary);color:#fff;font-size:1rem;font-weight:500;cursor:pointer;transition:background-color .2s}.save-button.svelte-1gp6n77:hover:not(:disabled){background-color:#1565c0}.save-button.svelte-1gp6n77:disabled{opacity:.6;cursor:not-allowed}.success-message.svelte-1gp6n77{display:flex;align-items:center;gap:8px;padding:12px 16px;background-color:#e8f5e9;color:#2e7d32;border-radius:4px;margin-bottom:16px}.error-message.svelte-1gp6n77{display:flex;align-items:center;gap:8px;padding:12px 16px;background-color:#ffebee;color:#c62828;border-radius:4px;margin-bottom:16px}.loading-state.svelte-1gp6n77,.error-state.svelte-1gp6n77{display:flex;flex-direction:column;align-items:center;padding:64px;background:#fff;border-radius:8px;box-shadow:0 1px 3px #0000001a}.loading-state.svelte-1gp6n77 .material-icons:where(.svelte-1gp6n77){font-size:48px;color:#0003;margin-bottom:16px}.error-state.svelte-1gp6n77 .material-icons:where(.svelte-1gp6n77){font-size:48px;color:#c62828;margin-bottom:16px}.spinning.svelte-1gp6n77{animation:svelte-1gp6n77-spin 1s linear infinite}@keyframes svelte-1gp6n77-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.error-state.svelte-1gp6n77 button:where(.svelte-1gp6n77){margin-top:16px;padding:8px 16px;border:1px solid #c62828;border-radius:4px;background:transparent;color:#c62828;cursor:pointer}

View File

@@ -0,0 +1 @@
.issues-page.svelte-1k9wk9x{max-width:1200px;margin:0 auto}.page-title.svelte-1k9wk9x{font-size:1.75rem;font-weight:400;margin-bottom:24px}.state-tabs.svelte-1k9wk9x{display:flex;gap:4px;margin-bottom:24px;background-color:#0000000a;padding:4px;border-radius:8px;overflow-x:auto}.state-tab.svelte-1k9wk9x{padding:10px 20px;border:none;border-radius:6px;background:transparent;font-size:.875rem;font-weight:500;color:#0009;cursor:pointer;white-space:nowrap;transition:all .2s}.state-tab.svelte-1k9wk9x:hover{background-color:#0000000a}.state-tab.active.svelte-1k9wk9x{background-color:#fff;color:var(--mdc-theme-primary);box-shadow:0 1px 3px #0000001a}.issues-list.svelte-1k9wk9x{display:flex;flex-direction:column;gap:12px}.issue-card.svelte-1k9wk9x{background:#fff;border-radius:8px;padding:16px;box-shadow:0 1px 3px #0000001a}.issue-header.svelte-1k9wk9x{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}.issue-id.svelte-1k9wk9x{color:var(--mdc-theme-primary);font-weight:500;text-decoration:none}.issue-id.svelte-1k9wk9x:hover{text-decoration:underline}.issue-state.svelte-1k9wk9x{padding:4px 12px;border-radius:12px;font-size:.75rem;font-weight:500;text-transform:uppercase}.issue-state.state-ready.svelte-1k9wk9x{background-color:#e3f2fd;color:#1565c0}.issue-state.state-in-progress.svelte-1k9wk9x{background-color:#fff3e0;color:#ef6c00}.issue-state.state-build.svelte-1k9wk9x{background-color:#f3e5f5;color:#7b1fa2}.issue-state.state-verify.svelte-1k9wk9x{background-color:#e0f2f1;color:#00796b}.issue-state.state-document.svelte-1k9wk9x{background-color:#fce4ec;color:#c2185b}.issue-state.state-review.svelte-1k9wk9x{background-color:#e8f5e9;color:#2e7d32}.issue-summary.svelte-1k9wk9x{font-size:1rem;font-weight:500;margin-bottom:12px;line-height:1.4}.issue-meta.svelte-1k9wk9x{display:flex;gap:16px;margin-bottom:12px;font-size:.875rem;color:#0009}.meta-item.svelte-1k9wk9x{display:flex;align-items:center;gap:4px}.meta-item.svelte-1k9wk9x .material-icons:where(.svelte-1k9wk9x){font-size:16px}.issue-actions.svelte-1k9wk9x select:where(.svelte-1k9wk9x){padding:8px 12px;border:1px solid rgba(0,0,0,.2);border-radius:4px;font-size:.875rem;cursor:pointer}.loading-state.svelte-1k9wk9x,.empty-state.svelte-1k9wk9x,.error-state.svelte-1k9wk9x{display:flex;flex-direction:column;align-items:center;padding:64px;background:#fff;border-radius:8px;box-shadow:0 1px 3px #0000001a}.loading-state.svelte-1k9wk9x .material-icons:where(.svelte-1k9wk9x),.empty-state.svelte-1k9wk9x .material-icons:where(.svelte-1k9wk9x){font-size:48px;color:#0003;margin-bottom:16px}.error-state.svelte-1k9wk9x .material-icons:where(.svelte-1k9wk9x){font-size:48px;color:#c62828;margin-bottom:16px}.spinning.svelte-1k9wk9x{animation:svelte-1k9wk9x-spin 1s linear infinite}@keyframes svelte-1k9wk9x-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.error-state.svelte-1k9wk9x button:where(.svelte-1k9wk9x){margin-top:16px;padding:8px 16px;border:1px solid #c62828;border-radius:4px;background:transparent;color:#c62828;cursor:pointer}

View File

@@ -0,0 +1 @@
import{s as f,g as c}from"./Ct5qbkUR.js";import{n as a,m as l,g as b,t as _,s as d,d as p}from"./CYdnMBah.js";let u=!1,t=Symbol();function v(e,r,n){const s=n[r]??={store:null,source:l(void 0),unsubscribe:a};if(s.store!==e&&!(t in n))if(s.unsubscribe(),s.store=e??null,e==null)s.source.v=void 0,s.unsubscribe=a;else{var i=!0;s.unsubscribe=f(e,o=>{i?s.source.v=o:d(s.source,o)}),i=!1}return e&&t in n?c(e):b(s.source)}function y(){const e={};function r(){_(()=>{for(var n in e)e[n].unsubscribe();p(e,t,{enumerable:!1,value:!0})})}return[e,r]}function N(e){var r=u;try{return u=!1,[e(),u]}finally{u=r}}export{v as a,N as c,y as s};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{D as l,E as v,F as o,G as m,H as u,I as _,C as d,J as b,K as k,L as g,M as y,N as T,O as A,P as E,Q as w,R as F,S as M,T as p}from"./CYdnMBah.js";class R{anchor;#s=new Map;#t=new Map;#e=new Map;#a=new Set;#i=!0;constructor(s,a=!0){this.anchor=s,this.#i=a}#r=()=>{var s=l;if(this.#s.has(s)){var a=this.#s.get(s),e=this.#t.get(a);if(e)v(e),this.#a.delete(a);else{var f=this.#e.get(a);f&&(this.#t.set(a,f.effect),this.#e.delete(a),f.fragment.lastChild.remove(),this.anchor.before(f.fragment),e=f.effect)}for(const[i,t]of this.#s){if(this.#s.delete(i),i===s)break;const r=this.#e.get(t);r&&(o(r.effect),this.#e.delete(t))}for(const[i,t]of this.#t){if(i===a||this.#a.has(i))continue;const r=()=>{if(Array.from(this.#s.values()).includes(i)){var c=document.createDocumentFragment();k(t,c),c.append(u()),this.#e.set(i,{effect:t,fragment:c})}else o(t);this.#a.delete(i),this.#t.delete(i)};this.#i||!e?(this.#a.add(i),m(t,r,!1)):r()}}};#f=s=>{this.#s.delete(s);const a=Array.from(this.#s.values());for(const[e,f]of this.#e)a.includes(e)||(o(f.effect),this.#e.delete(e))};ensure(s,a){var e=l,f=g();if(a&&!this.#t.has(s)&&!this.#e.has(s))if(f){var i=document.createDocumentFragment(),t=u();i.append(t),this.#e.set(s,{effect:_(()=>a(t)),fragment:i})}else this.#t.set(s,_(()=>a(this.anchor)));if(this.#s.set(e,s),f){for(const[r,h]of this.#t)r===s?e.skipped_effects.delete(h):e.skipped_effects.add(h);for(const[r,h]of this.#e)r===s?e.skipped_effects.delete(h.effect):e.skipped_effects.add(h.effect);e.oncommit(this.#r),e.ondiscard(this.#f)}else d&&(this.anchor=b),this.#r()}}function D(n,s,a=!1){d&&T();var e=new R(n),f=a?A:0;function i(t,r){if(d){const c=E(n)===w;if(t===c){var h=F();M(h),e.anchor=h,p(!1),e.ensure(t,r),p(!0);return}}e.ensure(t,r)}y(()=>{var t=!1;s((r,h=!0)=>{t=!0,i(h,r)}),t||i(!1,null)},f)}export{R as B,D as i};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{s as e}from"./BdaDkaeS.js";const r=()=>{const s=e;return{page:{subscribe:s.page.subscribe},navigating:{subscribe:s.navigating.subscribe},updated:s.updated}},b={subscribe(s){return r().page.subscribe(s)}};export{b as p};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{c as g,j as d,u as l,b,k as i,o as m,g as p,q as v,v as k,w as h}from"./CYdnMBah.js";function x(n=!1){const s=g,e=s.l.u;if(!e)return;let f=()=>v(s.s);if(n){let o=0,t={};const _=k(()=>{let c=!1;const r=s.s;for(const a in r)r[a]!==t[a]&&(t[a]=r[a],c=!0);return c&&o++,o});f=()=>p(_)}e.b.length&&d(()=>{u(s,f),i(e.b)}),l(()=>{const o=b(()=>e.m.map(m));return()=>{for(const t of o)typeof t=="function"&&t()}}),e.a.length&&l(()=>{u(s,f),i(e.a)})}function u(n,s){if(n.l.s)for(const e of n.l.s)p(e);s()}h();export{x as i};

View File

@@ -0,0 +1 @@
import{l as e,u as l,c as t,a as u,b as a}from"./CYdnMBah.js";function c(n){t===null&&e(),u&&t.l!==null?s(t).m.push(n):l(()=>{const o=a(n);if(typeof o=="function")return o})}function i(n){t===null&&e(),c(()=>()=>a(n))}function s(n){var o=n.l;return o.u??={a:[],b:[],m:[]}}export{i as a,c as o};

View File

@@ -0,0 +1 @@
import{n as b,b as m,B as q,k}from"./CYdnMBah.js";function _(e,t,n){if(e==null)return t(void 0),n&&n(void 0),b;const r=m(()=>e.subscribe(t,n));return r.unsubscribe?()=>r.unsubscribe():r}const f=[];function x(e,t){return{subscribe:z(e,t).subscribe}}function z(e,t=b){let n=null;const r=new Set;function i(u){if(q(e,u)&&(e=u,n)){const o=!f.length;for(const s of r)s[1](),f.push(s,e);if(o){for(let s=0;s<f.length;s+=2)f[s][0](f[s+1]);f.length=0}}}function l(u){i(u(e))}function a(u,o=b){const s=[u,o];return r.add(s),r.size===1&&(n=t(i,l)||b),u(e),()=>{r.delete(s),r.size===0&&n&&(n(),n=null)}}return{set:i,update:l,subscribe:a}}function B(e,t,n){const r=!Array.isArray(e),i=r?[e]:e;if(!i.every(Boolean))throw new Error("derived() expects stores as input, got a falsy value");const l=t.length<2;return x(n,(a,u)=>{let o=!1;const s=[];let d=0,p=b;const y=()=>{if(d)return;p();const c=t(r?s[0]:s,a,u);l?a(c):p=typeof c=="function"?c:b},h=i.map((c,g)=>_(c,w=>{s[g]=w,d&=~(1<<g),o&&y()},()=>{d|=1<<g}));return o=!0,y(),function(){k(h),p(),o=!1}})}function E(e){let t;return _(e,n=>t=n)(),t}export{B as d,E as g,_ as s,z as w};

View File

@@ -0,0 +1 @@
import{d as o,w as h}from"./Ct5qbkUR.js";import{a as d}from"./BDKQDa-A.js";function g(){const{subscribe:t,set:s,update:l}=h({user:null,loading:!0,error:null});return{subscribe:t,async init(){l(r=>({...r,loading:!0,error:null}));try{const r=await d.getUser();s({user:r,loading:!1,error:null})}catch{s({user:null,loading:!1,error:null})}},logout(){d.logout()},clear(){s({user:null,loading:!1,error:null})}}}const f=g(),k=o(f,t=>t.user!==null),m=o(f,t=>t.user);function p(){const{subscribe:t,set:s,update:l}=h({status:null,loading:!1,error:null,lastUpdate:null});let r=null;return{subscribe:t,async refresh(){l(e=>({...e,loading:!0,error:null}));try{const e=await d.getStatus();s({status:e,loading:!1,error:null,lastUpdate:new Date})}catch(e){l(n=>({...n,loading:!1,error:e instanceof Error?e.message:"Failed to fetch status"}))}},startPolling(e=5e3){this.stopPolling(),this.refresh(),r=setInterval(()=>this.refresh(),e)},stopPolling(){r&&(clearInterval(r),r=null)},updateFromEvent(e){l(n=>{if(!n.status)return n;const a={...n.status};switch(e.type){case"agent.started":{const c=e.data;a.pool={...a.pool,active_count:a.pool.active_count+1,active_tasks:[...a.pool.active_tasks,c]};break}case"agent.completed":case"agent.killed":{const{task_id:c}=e.data;a.pool={...a.pool,active_count:Math.max(0,a.pool.active_count-1),active_tasks:a.pool.active_tasks.filter(i=>i.task_id!==c)};break}case"health.changed":{const{service:c,healthy:i}=e.data;a.health={...a.health,[c]:i};break}}return{...n,status:a,lastUpdate:new Date}})}}}const u=p(),w=o(u,t=>t.status?.health??null),y=o(u,t=>t.status?.pool??null),S=o(u,t=>t.status?.pool.active_tasks??[]),U=o(u,t=>t.status?.issue_counts??{});function v(){const{subscribe:t,set:s}=h({connected:!1,reconnecting:!1,error:null});return{subscribe:t,connect(){s({connected:!0,reconnecting:!1,error:null})},disconnect(){s({connected:!1,reconnecting:!1,error:null})},reconnect(){s({connected:!0,reconnecting:!1,error:null})}}}const A=v();export{S as a,k as b,f as c,u as d,A as e,m as f,w as h,U as i,y as p};

View File

@@ -0,0 +1,2 @@
import{C as c}from"./CYdnMBah.js";const a=[...`
\r\f \v\uFEFF`];function e(r,g,u){var i=r==null?"":""+r;if(g&&(i=i?i+" "+g:g),u){for(var f in u)if(u[f])i=i?i+" "+f:f;else if(i.length)for(var l=f.length,t=0;(t=i.indexOf(f,t))>=0;){var n=t+l;(t===0||a.includes(i[t-1]))&&(n===i.length||a.includes(i[n]))?i=(t===0?"":i.substring(0,t))+i.substring(n+1):t=n}}return i===""?null:i}function v(r,g){return r==null?null:String(r)}function N(r,g,u,i,f,l){var t=r.__className;if(c||t!==u||t===void 0){var n=e(u,i,l);(!c||n!==r.getAttribute("class"))&&(n==null?r.removeAttribute("class"):r.className=n),r.__className=u}else if(l&&f!==l)for(var s in l){var o=!!l[s];(f==null||o!==!!f[s])&&r.classList.toggle(s,o)}return l}export{N as s,v as t};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{l as o,a as r}from"../chunks/BdaDkaeS.js";export{o as load_css,r as start};

View File

@@ -0,0 +1 @@
import{d as va,c as M,a as o,f as l,s as T}from"../chunks/Bvwd5tzy.js";import{o as pa,a as ha}from"../chunks/Cs7Bpczn.js";import{M as da,O as ua,p as fa,u as ma,g as q,z as B,e as _a,s as ga,x as ba,f as s,h as p,r as a,i as C}from"../chunks/CYdnMBah.js";import{B as ya,i as _}from"../chunks/BjMiwTv6.js";import{e as qa,s as D,i as xa}from"../chunks/BDKQDa-A.js";import{s as Aa}from"../chunks/FlejbVmh.js";import{a as x,s as wa}from"../chunks/B-HjBn8c.js";import{p as ka}from"../chunks/CP2ZW1Li.js";import{d as E,e as S,b as za,c as O,f as Pa}from"../chunks/DiAOlpg7.js";function Ra($,A,...w){var k=new ya($);da(()=>{const g=A()??null;k.ensure(g,g&&(z=>g(z,...w)))},ua)}const Sa=!1,$a=!0,Qa=Object.freeze(Object.defineProperty({__proto__:null,prerender:$a,ssr:Sa},Symbol.toStringTag,{value:"Module"}));var La=l('<div class="login-container"><div class="loading-container"><span class="material-icons" style="font-size: 48px; color: var(--mdc-theme-primary);">hourglass_empty</span> <p>Loading...</p></div></div>'),Ma=l('<div class="login-container"><div class="login-card svelte-12qhfyh"><span class="material-icons" style="font-size: 64px; color: var(--mdc-theme-primary); margin-bottom: 16px;">smart_toy</span> <h1 style="margin-bottom: 8px;">Agent Runner</h1> <p style="color: rgba(0,0,0,0.6); margin-bottom: 24px;">Sign in with Gitea to access the dashboard</p> <button class="login-button svelte-12qhfyh"><span class="material-icons">login</span> Sign in with Gitea</button></div></div>'),Ta=l('<span class="connection-status connected svelte-12qhfyh"><span class="material-icons svelte-12qhfyh">cloud_done</span> Connected</span>'),Ba=l('<span class="connection-status reconnecting svelte-12qhfyh"><span class="material-icons svelte-12qhfyh">cloud_sync</span> Reconnecting...</span>'),Ca=l('<span class="connection-status disconnected svelte-12qhfyh"><span class="material-icons svelte-12qhfyh">cloud_off</span> Disconnected</span>'),Da=l('<img class="avatar svelte-12qhfyh"/>'),Ea=l('<div class="user-info svelte-12qhfyh"><!> <span> </span></div>'),Oa=l('<li><a><span class="material-icons svelte-12qhfyh"> </span> <span> </span></a></li>'),Ua=l('<div class="app-container"><header class="top-bar svelte-12qhfyh"><div class="top-bar-left svelte-12qhfyh"><span class="material-icons">smart_toy</span> <span class="app-title svelte-12qhfyh">Agent Runner</span></div> <div class="top-bar-right svelte-12qhfyh"><!> <!> <button class="icon-button svelte-12qhfyh" title="Logout"><span class="material-icons">logout</span></button></div></header> <div class="main-content"><nav class="sidebar svelte-12qhfyh"><ul class="nav-list svelte-12qhfyh"></ul></nav> <main class="page-content"><!></main></div></div>');function Va($,A){fa(A,!0);const w=()=>x(za,"$isAuthenticated",y),k=()=>x(ka,"$page",y),g=()=>x(O,"$auth",y),z=()=>x(S,"$events",y),b=()=>x(Pa,"$currentUser",y),[y,H]=wa(),J=[{path:"/",label:"Dashboard",icon:"dashboard"},{path:"/agents",label:"Agents",icon:"smart_toy"},{path:"/issues",label:"Issues",icon:"assignment"},{path:"/builds",label:"Builds",icon:"build"},{path:"/config",label:"Config",icon:"settings"}];let U=ba(!1);pa(async()=>{await O.init(),ga(U,!0)}),ma(()=>{q(U)&&w()?(E.startPolling(5e3),S.connect()):(E.stopPolling(),S.disconnect())}),ha(()=>{E.stopPolling(),S.disconnect()});function K(){window.location.href="/oauth/login"}function Q(){O.logout()}function V(r){return r==="/"?k().url.pathname==="/":k().url.pathname.startsWith(r)}var j=M(),X=B(j);{var Y=r=>{var P=La();o(r,P)},Z=r=>{var P=M(),aa=B(P);{var sa=u=>{var h=Ma(),f=s(h),R=p(s(f),6);R.__click=K,a(f),a(h),o(u,h)},ta=u=>{var h=Ua(),f=s(h),R=p(s(f),2),F=s(R);{var ea=e=>{var t=Ta();o(e,t)},na=e=>{var t=M(),c=B(t);{var v=n=>{var i=Ba();o(n,i)},d=n=>{var i=Ca();o(n,i)};_(c,n=>{z().reconnecting?n(v):n(d,!1)},!0)}o(e,t)};_(F,e=>{z().connected?e(ea):e(na,!1)})}var G=p(F,2);{var oa=e=>{var t=Ea(),c=s(t);{var v=i=>{var m=Da();C(()=>{D(m,"src",b().avatar_url),D(m,"alt",b().username)}),o(i,m)};_(c,i=>{b().avatar_url&&i(v)})}var d=p(c,2),n=s(d,!0);a(d),a(t),C(()=>T(n,b().username)),o(e,t)};_(G,e=>{b()&&e(oa)})}var ra=p(G,2);ra.__click=Q,a(R),a(f);var I=p(f,2),L=s(I),N=s(L);qa(N,21,()=>J,xa,(e,t)=>{var c=Oa(),v=s(c);let d;var n=s(v),i=s(n,!0);a(n);var m=p(n,2),la=s(m,!0);a(m),a(v),a(c),C(ca=>{D(v,"href",q(t).path),d=Aa(v,1,"nav-item svelte-12qhfyh",null,d,ca),T(i,q(t).icon),T(la,q(t).label)},[()=>({active:V(q(t).path)})]),o(e,c)}),a(N),a(L);var W=p(L,2),ia=s(W);Ra(ia,()=>A.children),a(W),a(I),a(h),o(u,h)};_(aa,u=>{w()?u(ta,!1):u(sa)},!0)}o(r,P)};_(X,r=>{g().loading?r(Y):r(Z,!1)})}o($,j),_a(),H()}va(["click"]);export{Va as component,Qa as universal};

View File

@@ -0,0 +1 @@
import{f as h,a as u,s}from"../chunks/Bvwd5tzy.js";import{i as g}from"../chunks/CiwwMhIj.js";import{p as l,z as v,i as d,e as _,f as a,r as o,h as x}from"../chunks/CYdnMBah.js";import{s as $,p}from"../chunks/BdaDkaeS.js";const k={get error(){return p.error},get status(){return p.status}};$.updated.check;const i=k;var b=h("<h1> </h1> <p> </p>",1);function w(m,f){l(f,!1),g();var t=b(),r=v(t),n=a(r,!0);o(r);var e=x(r,2),c=a(e,!0);o(e),d(()=>{s(n,i.status),s(c,i.error?.message)}),u(m,t),_()}export{w as component};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{d as X,f as c,a as i,s as l}from"../chunks/Bvwd5tzy.js";import{i as Y}from"../chunks/CiwwMhIj.js";import{p as Z,e as ss,f as e,h as o,r as t,i as S,g as n}from"../chunks/CYdnMBah.js";import{i as T}from"../chunks/BjMiwTv6.js";import{e as as,s as es,i as ts,a as rs}from"../chunks/BDKQDa-A.js";import{s as E}from"../chunks/FlejbVmh.js";import{s as ns,a as M}from"../chunks/B-HjBn8c.js";import{p as os,a as ls,d as ps}from"../chunks/DiAOlpg7.js";var is=c('<span class="pool-status svelte-h3sa6j"> </span>'),cs=c('<div class="empty-state svelte-h3sa6j"><span class="material-icons svelte-h3sa6j">smart_toy</span> <h2 class="svelte-h3sa6j">No Active Agents</h2> <p class="svelte-h3sa6j">Agents will appear here when processing issues</p></div>'),vs=c('<div class="table-row svelte-h3sa6j"><span class="col-issue svelte-h3sa6j"><a target="_blank" rel="noopener" class="svelte-h3sa6j"> </a></span> <span class="col-type"><span> </span></span> <span class="col-repo"> </span> <span class="col-started"> </span> <span class="col-elapsed"><span> </span></span> <span class="col-actions"><button class="action-button danger svelte-h3sa6j" title="Terminate agent"><span class="material-icons svelte-h3sa6j">stop</span></button></span></div>'),ds=c('<div class="agent-table svelte-h3sa6j"><div class="table-header svelte-h3sa6j"><span class="col-issue">Issue</span> <span class="col-type">Type</span> <span class="col-repo">Repository</span> <span class="col-started">Started</span> <span class="col-elapsed">Elapsed</span> <span class="col-actions">Actions</span></div> <!></div>'),_s=c('<div class="agents-page svelte-h3sa6j"><div class="page-header svelte-h3sa6j"><h1 class="page-title svelte-h3sa6j">Agents</h1> <!></div> <!></div>');function $s(D,F){Z(F,!1);const v=()=>M(os,"$pool",k),A=()=>M(ls,"$activeAgents",k),[k,I]=ns();function K(s){const a=Math.floor(s/60),p=Math.floor(s%60);return`${a}:${p.toString().padStart(2,"0")}`}function L(s){return new Date(s).toLocaleTimeString()}async function N(s){if(confirm("Are you sure you want to terminate this agent?"))try{await rs.killAgent(s,"manual"),ps.refresh()}catch(a){alert("Failed to terminate agent: "+(a instanceof Error?a.message:"Unknown error"))}}Y();var d=_s(),_=e(d),R=o(e(_),2);{var U=s=>{var a=is(),p=e(a);t(a),S(()=>l(p,`${v().active_count??""} / ${v().max_agents??""} active`)),i(s,a)};T(R,s=>{v()&&s(U)})}t(_);var q=o(_,2);{var z=s=>{var a=cs();i(s,a)},B=s=>{var a=ds(),p=o(e(a),2);as(p,1,A,ts,(C,r)=>{var h=vs(),m=e(h),u=e(m),G=e(u,!0);t(u),t(m);var f=o(m,2),g=e(f),H=e(g,!0);t(g),t(f);var j=o(f,2),J=e(j,!0);t(j);var b=o(j,2),O=e(b,!0);t(b);var y=o(b,2),$=e(y);let x;var P=e($,!0);t($),t(y);var w=o(y,2),Q=e(w);Q.__click=()=>N(n(r).task_id),t(w),t(h),S((V,W)=>{es(u,"href",`https://track.cleargrow.io/issue/${n(r).issue_id??""}`),l(G,n(r).issue_id),E(g,1,`type-badge ${n(r).task_type??""}`,"svelte-h3sa6j"),l(H,n(r).task_type),l(J,n(r).repo),l(O,V),x=E($,1,"elapsed-badge svelte-h3sa6j",null,x,{warning:n(r).elapsed_seconds>900,danger:n(r).elapsed_seconds>1500}),l(P,W)},[()=>L(n(r).start_time),()=>K(n(r).elapsed_seconds)]),i(C,h)}),t(a),i(s,a)};T(q,s=>{A().length===0?s(z):s(B,!1)})}t(d),i(D,d),ss(),I()}X(["click"]);export{$s as component};

View File

@@ -0,0 +1 @@
import{d as wa,f as u,a as i,s as c,c as V}from"../chunks/Bvwd5tzy.js";import{o as Ba}from"../chunks/Cs7Bpczn.js";import{p as Sa,x as D,y as Da,e as Ma,s as m,g as a,h as o,f as e,r as t,i as K,z as W}from"../chunks/CYdnMBah.js";import{i as M}from"../chunks/BjMiwTv6.js";import{e as X,a as Y,i as Z,s as $}from"../chunks/BDKQDa-A.js";import{s as R}from"../chunks/FlejbVmh.js";var Ra=u("<button> </button>"),Aa=u('<div class="loading-state svelte-1itx8h6"><span class="material-icons spinning svelte-1itx8h6">sync</span> <p>Loading builds...</p></div>'),Fa=u('<div class="error-state svelte-1itx8h6"><span class="material-icons svelte-1itx8h6">error</span> <p> </p> <button class="svelte-1itx8h6">Retry</button></div>'),Ea=u('<div class="empty-state svelte-1itx8h6"><span class="material-icons svelte-1itx8h6">build</span> <h2>No Builds Found</h2> <p>No builds match the selected filter</p></div>'),La=u('<button class="action-button svelte-1itx8h6" title="Retry build"><span class="material-icons svelte-1itx8h6">replay</span></button>'),Na=u('<div><span class="col-status"><span><span> </span></span></span> <span class="col-number svelte-1itx8h6"><a target="_blank" rel="noopener" class="svelte-1itx8h6"> </a></span> <span class="col-branch"><span class="branch-badge svelte-1itx8h6"> </span></span> <span class="col-message svelte-1itx8h6"> </span> <span class="col-author"> </span> <span class="col-duration svelte-1itx8h6"> </span> <span class="col-time svelte-1itx8h6"> </span> <span class="col-actions"><!></span></div>'),za=u('<div class="builds-table svelte-1itx8h6"><div class="table-header svelte-1itx8h6"><span class="col-status">Status</span> <span class="col-number">#</span> <span class="col-branch">Branch</span> <span class="col-message svelte-1itx8h6">Message</span> <span class="col-author">Author</span> <span class="col-duration svelte-1itx8h6">Duration</span> <span class="col-time svelte-1itx8h6">Started</span> <span class="col-actions">Actions</span></div> <!></div>'),Ia=u('<div class="builds-page svelte-1itx8h6"><h1 class="page-title svelte-1itx8h6">Builds</h1> <div class="repo-filter svelte-1itx8h6"></div> <!></div>');function Ha(aa,sa){Sa(sa,!0);let A=D(Da([])),F=D(!0),y=D(null),v=D("all");const ta=["all","controller","probe","docs"];Ba(()=>{k()});async function k(){m(F,!0),m(y,null);try{const s=a(v)==="all"?void 0:a(v);m(A,await Y.getBuilds(s),!0)}catch(s){m(y,s instanceof Error?s.message:"Failed to load builds",!0)}finally{m(F,!1)}}async function ea(s,n){try{await Y.retryBuild(s,n),k()}catch(l){alert("Failed to retry build: "+(l instanceof Error?l.message:"Unknown error"))}}function ra(s,n){if(!s)return"-";const l=n||Date.now()/1e3,h=Math.floor(l-s),f=Math.floor(h/60),p=h%60;return`${f}:${p.toString().padStart(2,"0")}`}function na(s){return new Date(s*1e3).toLocaleString()}function la(s){return{success:"check_circle",failure:"cancel",running:"sync",pending:"schedule",killed:"block"}[s]||"help"}var E=Ia(),L=o(e(E),2);X(L,21,()=>ta,Z,(s,n)=>{var l=Ra();let h;l.__click=()=>{m(v,a(n),!0),k()};var f=e(l,!0);t(l),K(()=>{h=R(l,1,"repo-tab svelte-1itx8h6",null,h,{active:a(v)===a(n)}),c(f,a(n)==="all"?"All":a(n))}),i(s,l)}),t(L);var oa=o(L,2);{var ia=s=>{var n=Aa();i(s,n)},ca=s=>{var n=V(),l=W(n);{var h=p=>{var d=Fa(),x=o(e(d),2),N=e(x,!0);t(x);var z=o(x,2);z.__click=k,t(d),K(()=>c(N,a(y))),i(p,d)},f=p=>{var d=V(),x=W(d);{var N=_=>{var g=Ea();i(_,g)},z=_=>{var g=za(),va=o(e(g),2);X(va,17,()=>a(A),Z,(pa,r)=>{var w=Na(),I=e(w),T=e(I),U=e(T);let O;var ua=e(U,!0);t(U),t(T),t(I);var j=o(I,2),q=e(j),ha=e(q,!0);t(q),t(j);var C=o(j,2),P=e(C),da=e(P,!0);t(P),t(C);var B=o(C,2),_a=e(B,!0);t(B);var G=o(B,2),ma=e(G,!0);t(G);var H=o(G,2),fa=e(H,!0);t(H);var J=o(H,2),xa=e(J,!0);t(J);var Q=o(J,2),ga=e(Q);{var ba=b=>{var S=La();S.__click=()=>ea(a(v)==="all"?"controller":a(v),a(r).number),i(b,S)};M(ga,b=>{a(r).status==="failure"&&b(ba)})}t(Q),t(w),K((b,S,ya,ka)=>{R(w,1,`table-row status-${a(r).status??""}`,"svelte-1itx8h6"),R(T,1,`status-icon status-${a(r).status??""}`,"svelte-1itx8h6"),O=R(U,1,"material-icons svelte-1itx8h6",null,O,{spinning:a(r).status==="running"}),c(ua,b),$(q,"href",`https://ci.cleargrow.io/repos/cleargrow/${(a(v)==="all"?"controller":a(v))??""}/pipeline/${a(r).number??""}`),c(ha,a(r).number),c(da,a(r).branch),$(B,"title",a(r).message),c(_a,S),c(ma,a(r).author),c(fa,ya),c(xa,ka)},[()=>la(a(r).status),()=>a(r).message.length>50?a(r).message.slice(0,50)+"...":a(r).message,()=>ra(a(r).started,a(r).finished),()=>na(a(r).created)]),i(pa,w)}),t(g),i(_,g)};M(x,_=>{a(A).length===0?_(N):_(z,!1)},!0)}i(p,d)};M(l,p=>{a(y)?p(h):p(f,!1)},!0)}i(s,n)};M(oa,s=>{a(F)?s(ia):s(ca,!1)})}t(E),i(aa,E),Ma()}wa(["click"]);export{Ha as component};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{d as wa,f as d,a as v,s as p,c as X}from"../chunks/Bvwd5tzy.js";import{o as ga}from"../chunks/Cs7Bpczn.js";import{p as ha,x as D,y as ya,e as ba,h as o,s as u,g as a,f as t,r as e,i as m,z as Y,A as Ia}from"../chunks/CYdnMBah.js";import{i as y}from"../chunks/BjMiwTv6.js";import{e as j,a as Z,i as q,s as $a}from"../chunks/BDKQDa-A.js";import{s as aa}from"../chunks/FlejbVmh.js";import{s as Ra,a as Sa}from"../chunks/B-HjBn8c.js";import{p as Da}from"../chunks/CP2ZW1Li.js";var La=d("<button> </button>"),Aa=d('<div class="loading-state svelte-1k9wk9x"><span class="material-icons spinning svelte-1k9wk9x">sync</span> <p>Loading issues...</p></div>'),Ca=d('<div class="error-state svelte-1k9wk9x"><span class="material-icons svelte-1k9wk9x">error</span> <p> </p> <button class="svelte-1k9wk9x">Retry</button></div>'),Ea=d('<div class="empty-state svelte-1k9wk9x"><span class="material-icons svelte-1k9wk9x">check_circle</span> <h2> </h2> <p>All issues have been processed or moved to another state</p></div>'),Fa=d('<span class="meta-item svelte-1k9wk9x"><span class="material-icons svelte-1k9wk9x">label</span> </span>'),Ma=d('<span class="meta-item svelte-1k9wk9x"><span class="material-icons svelte-1k9wk9x">flag</span> </span>'),Pa=d("<option> </option>"),za=d('<div class="issue-card svelte-1k9wk9x"><div class="issue-header svelte-1k9wk9x"><a target="_blank" rel="noopener" class="issue-id svelte-1k9wk9x"> </a> <span> </span></div> <h3 class="issue-summary svelte-1k9wk9x"> </h3> <div class="issue-meta svelte-1k9wk9x"><!> <!> <span class="meta-item svelte-1k9wk9x"><span class="material-icons svelte-1k9wk9x">schedule</span> </span></div> <div class="issue-actions svelte-1k9wk9x"><select class="svelte-1k9wk9x"><option>Move to...</option><!></select></div></div>'),Ba=d('<div class="issues-list svelte-1k9wk9x"></div>'),Na=d('<div class="issues-page svelte-1k9wk9x"><h1 class="page-title svelte-1k9wk9x">Issue Queue</h1> <div class="state-tabs svelte-1k9wk9x"></div> <!></div>');function Ja(ea,sa){ha(sa,!0);const ta=()=>Sa(Da,"$page",ra),[ra,oa]=Ra();let L=D(ya([])),A=D(!0),b=D(null),g=D("Ready");const G=["Ready","In Progress","Build","Verify","Document","Review"];ga(()=>{const s=ta().url.searchParams.get("state");s&&u(g,s,!0),I()});async function I(){u(A,!0),u(b,null);try{u(L,await Z.getIssues([a(g)]),!0)}catch(s){u(b,s instanceof Error?s.message:"Failed to load issues",!0)}finally{u(A,!1)}}async function ia(s,n){try{await Z.transitionIssue(s,n),I()}catch(c){alert("Failed to transition issue: "+(c instanceof Error?c.message:"Unknown error"))}}function la(s){u(g,s,!0),I()}var C=Na(),E=o(t(C),2);j(E,21,()=>G,q,(s,n)=>{var c=La();let $;c.__click=()=>la(a(n));var F=t(c,!0);e(c),m(()=>{$=aa(c,1,"state-tab svelte-1k9wk9x",null,$,{active:a(g)===a(n)}),p(F,a(n))}),v(s,c)}),e(E);var va=o(E,2);{var na=s=>{var n=Aa();v(s,n)},ca=s=>{var n=X(),c=Y(n);{var $=x=>{var f=Ca(),h=o(t(f),2),M=t(h,!0);e(h);var P=o(h,2);P.__click=I,e(f),m(()=>p(M,a(b))),v(x,f)},F=x=>{var f=X(),h=Y(f);{var M=w=>{var k=Ea(),R=o(t(k),2),i=t(R);e(R),Ia(2),e(k),m(()=>p(i,`No Issues in ${a(g)??""}`)),v(w,k)},P=w=>{var k=Ba();j(k,21,()=>a(L),q,(R,i)=>{var z=za(),B=t(z),S=t(B),pa=t(S,!0);e(S);var N=o(S,2),da=t(N,!0);e(N),e(B);var Q=o(B,2),_a=t(Q,!0);e(Q);var T=o(Q,2),H=t(T);{var ka=r=>{var l=Fa(),_=o(t(l));e(l),m(()=>p(_,` ${a(i).type??""}`)),v(r,l)};y(H,r=>{a(i).type&&r(ka)})}var J=o(H,2);{var ua=r=>{var l=Ma(),_=o(t(l));e(l),m(()=>p(_,` ${a(i).priority??""}`)),v(r,l)};y(J,r=>{a(i).priority&&r(ua)})}var K=o(J,2),ma=o(t(K));e(K),e(T);var O=o(T,2),U=t(O);U.__change=r=>ia(a(i).id,r.target.value);var V=t(U);V.value=V.__value="";var xa=o(V);j(xa,17,()=>G.filter(r=>r!==a(i).state),q,(r,l)=>{var _=Pa(),fa=t(_,!0);e(_);var W={};m(()=>{p(fa,a(l)),W!==(W=a(l))&&(_.value=(_.__value=a(l))??"")}),v(r,_)}),e(U),e(O),e(z),m((r,l)=>{$a(S,"href",`https://track.cleargrow.io/issue/${a(i).id??""}`),p(pa,a(i).id),aa(N,1,`issue-state state-${r??""}`,"svelte-1k9wk9x"),p(da,a(i).state),p(_a,a(i).summary),p(ma,` ${l??""}`)},[()=>a(i).state.toLowerCase().replace(" ","-"),()=>new Date(a(i).updated).toLocaleDateString()]),v(R,z)}),e(k),v(w,k)};y(h,w=>{a(L).length===0?w(M):w(P,!1)},!0)}v(x,f)};y(c,x=>{a(b)?x($):x(F,!1)},!0)}v(s,n)};y(va,s=>{a(A)?s(na):s(ca,!1)})}e(C),v(ea,C),ba(),oa()}wa(["click","change"]);export{Ja as component};

View File

@@ -0,0 +1 @@
{"version":"1765462202287"}

View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agent Runner Dashboard</title>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
/>
<link rel="modulepreload" href="./_app/immutable/entry/start.CLXRBEIA.js">
<link rel="modulepreload" href="./_app/immutable/chunks/BdaDkaeS.js">
<link rel="modulepreload" href="./_app/immutable/chunks/CYdnMBah.js">
<link rel="modulepreload" href="./_app/immutable/chunks/Ct5qbkUR.js">
<link rel="modulepreload" href="./_app/immutable/chunks/Cs7Bpczn.js">
<link rel="modulepreload" href="./_app/immutable/entry/app.dPnJ_Lf1.js">
<link rel="modulepreload" href="./_app/immutable/chunks/Bvwd5tzy.js">
<link rel="modulepreload" href="./_app/immutable/chunks/BjMiwTv6.js">
<link rel="modulepreload" href="./_app/immutable/chunks/B-HjBn8c.js">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_181hkpp = {
base: new URL(".", location).pathname.slice(0, -1)
};
const element = document.currentScript.parentElement;
Promise.all([
import("./_app/immutable/entry/start.CLXRBEIA.js"),
import("./_app/immutable/entry/app.dPnJ_Lf1.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
}
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agent Runner Dashboard</title>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
/>
<link rel="modulepreload" href="./_app/immutable/entry/start.CLXRBEIA.js">
<link rel="modulepreload" href="./_app/immutable/chunks/BdaDkaeS.js">
<link rel="modulepreload" href="./_app/immutable/chunks/CYdnMBah.js">
<link rel="modulepreload" href="./_app/immutable/chunks/Ct5qbkUR.js">
<link rel="modulepreload" href="./_app/immutable/chunks/Cs7Bpczn.js">
<link rel="modulepreload" href="./_app/immutable/entry/app.dPnJ_Lf1.js">
<link rel="modulepreload" href="./_app/immutable/chunks/Bvwd5tzy.js">
<link rel="modulepreload" href="./_app/immutable/chunks/BjMiwTv6.js">
<link rel="modulepreload" href="./_app/immutable/chunks/B-HjBn8c.js">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_181hkpp = {
base: new URL(".", location).pathname.slice(0, -1)
};
const element = document.currentScript.parentElement;
Promise.all([
import("./_app/immutable/entry/start.CLXRBEIA.js"),
import("./_app/immutable/entry/app.dPnJ_Lf1.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
}
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agent Runner Dashboard</title>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
/>
<link rel="modulepreload" href="./_app/immutable/entry/start.CLXRBEIA.js">
<link rel="modulepreload" href="./_app/immutable/chunks/BdaDkaeS.js">
<link rel="modulepreload" href="./_app/immutable/chunks/CYdnMBah.js">
<link rel="modulepreload" href="./_app/immutable/chunks/Ct5qbkUR.js">
<link rel="modulepreload" href="./_app/immutable/chunks/Cs7Bpczn.js">
<link rel="modulepreload" href="./_app/immutable/entry/app.dPnJ_Lf1.js">
<link rel="modulepreload" href="./_app/immutable/chunks/Bvwd5tzy.js">
<link rel="modulepreload" href="./_app/immutable/chunks/BjMiwTv6.js">
<link rel="modulepreload" href="./_app/immutable/chunks/B-HjBn8c.js">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_181hkpp = {
base: new URL(".", location).pathname.slice(0, -1)
};
const element = document.currentScript.parentElement;
Promise.all([
import("./_app/immutable/entry/start.CLXRBEIA.js"),
import("./_app/immutable/entry/app.dPnJ_Lf1.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
}
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agent Runner Dashboard</title>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
/>
<link rel="modulepreload" href="/_app/immutable/entry/start.CLXRBEIA.js">
<link rel="modulepreload" href="/_app/immutable/chunks/BdaDkaeS.js">
<link rel="modulepreload" href="/_app/immutable/chunks/CYdnMBah.js">
<link rel="modulepreload" href="/_app/immutable/chunks/Ct5qbkUR.js">
<link rel="modulepreload" href="/_app/immutable/chunks/Cs7Bpczn.js">
<link rel="modulepreload" href="/_app/immutable/entry/app.dPnJ_Lf1.js">
<link rel="modulepreload" href="/_app/immutable/chunks/Bvwd5tzy.js">
<link rel="modulepreload" href="/_app/immutable/chunks/BjMiwTv6.js">
<link rel="modulepreload" href="/_app/immutable/chunks/B-HjBn8c.js">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_181hkpp = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.CLXRBEIA.js"),
import("/_app/immutable/entry/app.dPnJ_Lf1.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
}
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agent Runner Dashboard</title>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
/>
<link rel="modulepreload" href="./_app/immutable/entry/start.CLXRBEIA.js">
<link rel="modulepreload" href="./_app/immutable/chunks/BdaDkaeS.js">
<link rel="modulepreload" href="./_app/immutable/chunks/CYdnMBah.js">
<link rel="modulepreload" href="./_app/immutable/chunks/Ct5qbkUR.js">
<link rel="modulepreload" href="./_app/immutable/chunks/Cs7Bpczn.js">
<link rel="modulepreload" href="./_app/immutable/entry/app.dPnJ_Lf1.js">
<link rel="modulepreload" href="./_app/immutable/chunks/Bvwd5tzy.js">
<link rel="modulepreload" href="./_app/immutable/chunks/BjMiwTv6.js">
<link rel="modulepreload" href="./_app/immutable/chunks/B-HjBn8c.js">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_181hkpp = {
base: new URL(".", location).pathname.slice(0, -1)
};
const element = document.currentScript.parentElement;
Promise.all([
import("./_app/immutable/entry/start.CLXRBEIA.js"),
import("./_app/immutable/entry/app.dPnJ_Lf1.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
}
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

View File

@@ -193,7 +193,7 @@ class WebhookHandler(BaseHTTPRequestHandler):
class WebhookServer:
"""
HTTP server for receiving YouTrack webhooks.
HTTP server for receiving YouTrack webhooks and serving dashboard API.
Usage:
server = WebhookServer(
@@ -216,27 +216,47 @@ class WebhookServer:
port: int = 8765,
secret: Optional[str] = None,
on_event: Optional[Callable[[WebhookEvent], None]] = None,
runner=None, # Optional runner reference for dashboard API
oauth=None, # Optional OAuth handler for authentication
):
self.host = host
self.port = port
self.secret = secret
self.on_event = on_event
self.runner = runner
self.oauth = oauth
self._server: Optional[HTTPServer] = None
self._thread: Optional[threading.Thread] = None
self._shutdown_event = threading.Event()
self._handler_class = None
def start(self):
"""Start the webhook server in a background thread."""
# Reset shutdown event
self._shutdown_event.clear()
# Determine which handler to use
if self.runner:
# Use dashboard API handler (includes webhook support)
try:
from api_server import DashboardAPIHandler, setup_api_handler
setup_api_handler(self.runner, self.oauth)
self._handler_class = self._create_combined_handler(DashboardAPIHandler)
logger.info("Dashboard API enabled")
except ImportError as e:
logger.warning(f"Dashboard API not available: {e}")
self._handler_class = WebhookHandler
else:
# Use basic webhook handler
self._handler_class = WebhookHandler
# Configure handler class
WebhookHandler.secret = self.secret
WebhookHandler.on_event = self.on_event
self._handler_class.secret = self.secret
self._handler_class.on_event = self.on_event
# Create server
self._server = HTTPServer((self.host, self.port), WebhookHandler)
self._server = HTTPServer((self.host, self.port), self._handler_class)
# Start in thread (non-daemon so it can be properly joined)
self._thread = threading.Thread(
@@ -248,6 +268,128 @@ class WebhookServer:
logger.info(f"Webhook server started on {self.host}:{self.port}")
def _create_combined_handler(self, api_handler_class):
"""Create a handler that combines webhook and API functionality."""
webhook_secret = self.secret
webhook_callback = self.on_event
class CombinedHandler(api_handler_class):
"""Combined webhook + dashboard API handler."""
secret = webhook_secret
on_event = webhook_callback
def handle_webhook(self):
"""Handle YouTrack webhook POST."""
# Read body
content_length = int(self.headers.get("Content-Length", 0))
if content_length == 0:
self.send_error(400, "Empty body")
return
body = self.rfile.read(content_length)
# Verify signature if secret configured
if self.secret:
signature = self.headers.get("X-Hub-Signature-256", "")
if not self._verify_signature(body, signature):
logger.warning("Webhook signature verification failed")
self.send_error(403, "Invalid signature")
return
# Parse JSON
try:
payload = json.loads(body.decode("utf-8"))
except json.JSONDecodeError as e:
logger.warning(f"Invalid JSON in webhook: {e}")
self.send_error(400, "Invalid JSON")
return
# Parse event
event = self._parse_webhook_event(payload)
if not event:
logger.debug("Ignoring unrecognized webhook event")
self.send_response(200)
self.end_headers()
return
logger.info(f"Webhook received: {event.event_type} for {event.issue_id}")
# Dispatch event
if self.on_event:
try:
threading.Thread(
target=self.on_event,
args=(event,),
daemon=True
).start()
except Exception as e:
logger.error(f"Error dispatching webhook event: {e}")
# Respond quickly
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"status": "ok"}')
def _verify_signature(self, body: bytes, signature: str) -> bool:
"""Verify HMAC-SHA256 signature."""
if not signature.startswith("sha256="):
return False
expected = hmac.new(
self.secret.encode("utf-8"),
body,
hashlib.sha256
).hexdigest()
provided = signature[7:]
return hmac.compare_digest(expected, provided)
def _parse_webhook_event(self, payload: dict) -> Optional[WebhookEvent]:
"""Parse YouTrack webhook payload."""
issue = payload.get("issue", {})
if not issue:
if "idReadable" in payload:
issue = payload
else:
return None
issue_id = issue.get("idReadable", "")
if not issue_id:
return None
project = issue_id.split("-")[0] if "-" in issue_id else ""
event_type = payload.get("type", "issue_updated")
old_state = None
new_state = None
changes = payload.get("fieldChanges", [])
for change in changes:
if change.get("name") == "State":
old_state = change.get("oldValue", {}).get("name")
new_state = change.get("newValue", {}).get("name")
break
if not new_state:
fields = issue.get("customFields", [])
for field in fields:
if field.get("name") == "State":
value = field.get("value", {})
new_state = value.get("name") if isinstance(value, dict) else value
break
return WebhookEvent(
event_type=event_type,
issue_id=issue_id,
project=project,
old_state=old_state,
new_state=new_state,
raw_payload=payload,
)
return CombinedHandler
def _serve_loop(self):
"""Server loop that checks for shutdown signal."""
if self._server:
@@ -289,17 +431,19 @@ class WebhookServer:
return self._thread is not None and self._thread.is_alive()
def load_webhook_config(config: dict) -> Optional[WebhookServer]:
def load_webhook_config(config: dict, runner=None, oauth=None) -> Optional[WebhookServer]:
"""Create WebhookServer from config dict."""
webhook_config = config.get("webhook", {})
if not webhook_config.get("enabled", False):
return None
return WebhookServer(
host=webhook_config.get("host", "0.0.0.0"),
port=webhook_config.get("port", 8765),
secret=webhook_config.get("secret"),
runner=runner,
oauth=oauth,
)