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:
61
agent.py
61
agent.py
@@ -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
648
api_server.py
Normal 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
23
dashboard/.gitignore
vendored
Normal 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
1
dashboard/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
38
dashboard/README.md
Normal file
38
dashboard/README.md
Normal 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
3582
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
dashboard/package.json
Normal file
47
dashboard/package.json
Normal 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
212
dashboard/src/app.css
Normal 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
13
dashboard/src/app.d.ts
vendored
Normal 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
20
dashboard/src/app.html
Normal 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>
|
||||
138
dashboard/src/lib/api/client.ts
Normal file
138
dashboard/src/lib/api/client.ts
Normal 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 };
|
||||
112
dashboard/src/lib/api/types.ts
Normal file
112
dashboard/src/lib/api/types.ts
Normal 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;
|
||||
}
|
||||
1
dashboard/src/lib/assets/favicon.svg
Normal file
1
dashboard/src/lib/assets/favicon.svg
Normal 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 |
1
dashboard/src/lib/index.ts
Normal file
1
dashboard/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
45
dashboard/src/lib/stores/auth.ts
Normal file
45
dashboard/src/lib/stores/auth.ts
Normal 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);
|
||||
106
dashboard/src/lib/stores/dashboard.ts
Normal file
106
dashboard/src/lib/stores/dashboard.ts
Normal 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 ?? {});
|
||||
38
dashboard/src/lib/stores/events.ts
Normal file
38
dashboard/src/lib/stores/events.ts
Normal 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();
|
||||
4
dashboard/src/lib/stores/index.ts
Normal file
4
dashboard/src/lib/stores/index.ts
Normal 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';
|
||||
283
dashboard/src/routes/+layout.svelte
Normal file
283
dashboard/src/routes/+layout.svelte
Normal 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>
|
||||
3
dashboard/src/routes/+layout.ts
Normal file
3
dashboard/src/routes/+layout.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Enable SPA mode - disable SSR
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
389
dashboard/src/routes/+page.svelte
Normal file
389
dashboard/src/routes/+page.svelte
Normal 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>
|
||||
246
dashboard/src/routes/agents/+page.svelte
Normal file
246
dashboard/src/routes/agents/+page.svelte
Normal 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>
|
||||
327
dashboard/src/routes/builds/+page.svelte
Normal file
327
dashboard/src/routes/builds/+page.svelte
Normal 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>
|
||||
424
dashboard/src/routes/config/+page.svelte
Normal file
424
dashboard/src/routes/config/+page.svelte
Normal 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>
|
||||
315
dashboard/src/routes/issues/+page.svelte
Normal file
315
dashboard/src/routes/issues/+page.svelte
Normal 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>
|
||||
3
dashboard/static/robots.txt
Normal file
3
dashboard/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
23
dashboard/svelte.config.js
Normal file
23
dashboard/svelte.config.js
Normal 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
20
dashboard/tsconfig.json
Normal 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
24
dashboard/vite.config.ts
Normal 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
369
dashboard_api.py
Normal 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
417
oauth.py
Normal 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),
|
||||
)
|
||||
74
runner.py
74
runner.py
@@ -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
|
||||
|
||||
1
static/dashboard/_app/env.js
Normal file
1
static/dashboard/_app/env.js
Normal file
@@ -0,0 +1 @@
|
||||
export const env={}
|
||||
1
static/dashboard/_app/immutable/assets/0.D74fqcMI.css
Normal file
1
static/dashboard/_app/immutable/assets/0.D74fqcMI.css
Normal 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}
|
||||
1
static/dashboard/_app/immutable/assets/2.wCoH74ND.css
Normal file
1
static/dashboard/_app/immutable/assets/2.wCoH74ND.css
Normal 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}
|
||||
1
static/dashboard/_app/immutable/assets/3.DvPSGLeW.css
Normal file
1
static/dashboard/_app/immutable/assets/3.DvPSGLeW.css
Normal 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}
|
||||
1
static/dashboard/_app/immutable/assets/4.DtMQo2BG.css
Normal file
1
static/dashboard/_app/immutable/assets/4.DtMQo2BG.css
Normal 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}
|
||||
1
static/dashboard/_app/immutable/assets/5.LD-wvsIk.css
Normal file
1
static/dashboard/_app/immutable/assets/5.LD-wvsIk.css
Normal 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}
|
||||
1
static/dashboard/_app/immutable/assets/6.4MAl2ruA.css
Normal file
1
static/dashboard/_app/immutable/assets/6.4MAl2ruA.css
Normal 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}
|
||||
1
static/dashboard/_app/immutable/chunks/B-HjBn8c.js
Normal file
1
static/dashboard/_app/immutable/chunks/B-HjBn8c.js
Normal 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};
|
||||
1
static/dashboard/_app/immutable/chunks/BDKQDa-A.js
Normal file
1
static/dashboard/_app/immutable/chunks/BDKQDa-A.js
Normal file
File diff suppressed because one or more lines are too long
1
static/dashboard/_app/immutable/chunks/BdaDkaeS.js
Normal file
1
static/dashboard/_app/immutable/chunks/BdaDkaeS.js
Normal file
File diff suppressed because one or more lines are too long
1
static/dashboard/_app/immutable/chunks/BjMiwTv6.js
Normal file
1
static/dashboard/_app/immutable/chunks/BjMiwTv6.js
Normal 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};
|
||||
2
static/dashboard/_app/immutable/chunks/Bvwd5tzy.js
Normal file
2
static/dashboard/_app/immutable/chunks/Bvwd5tzy.js
Normal file
File diff suppressed because one or more lines are too long
1
static/dashboard/_app/immutable/chunks/CP2ZW1Li.js
Normal file
1
static/dashboard/_app/immutable/chunks/CP2ZW1Li.js
Normal 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};
|
||||
1
static/dashboard/_app/immutable/chunks/CYdnMBah.js
Normal file
1
static/dashboard/_app/immutable/chunks/CYdnMBah.js
Normal file
File diff suppressed because one or more lines are too long
1
static/dashboard/_app/immutable/chunks/CiwwMhIj.js
Normal file
1
static/dashboard/_app/immutable/chunks/CiwwMhIj.js
Normal 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};
|
||||
1
static/dashboard/_app/immutable/chunks/Cs7Bpczn.js
Normal file
1
static/dashboard/_app/immutable/chunks/Cs7Bpczn.js
Normal 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};
|
||||
1
static/dashboard/_app/immutable/chunks/Ct5qbkUR.js
Normal file
1
static/dashboard/_app/immutable/chunks/Ct5qbkUR.js
Normal 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};
|
||||
1
static/dashboard/_app/immutable/chunks/DiAOlpg7.js
Normal file
1
static/dashboard/_app/immutable/chunks/DiAOlpg7.js
Normal 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};
|
||||
2
static/dashboard/_app/immutable/chunks/FlejbVmh.js
Normal file
2
static/dashboard/_app/immutable/chunks/FlejbVmh.js
Normal 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};
|
||||
2
static/dashboard/_app/immutable/entry/app.dPnJ_Lf1.js
Normal file
2
static/dashboard/_app/immutable/entry/app.dPnJ_Lf1.js
Normal file
File diff suppressed because one or more lines are too long
1
static/dashboard/_app/immutable/entry/start.CLXRBEIA.js
Normal file
1
static/dashboard/_app/immutable/entry/start.CLXRBEIA.js
Normal file
@@ -0,0 +1 @@
|
||||
import{l as o,a as r}from"../chunks/BdaDkaeS.js";export{o as load_css,r as start};
|
||||
1
static/dashboard/_app/immutable/nodes/0.Y2f097_i.js
Normal file
1
static/dashboard/_app/immutable/nodes/0.Y2f097_i.js
Normal 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};
|
||||
1
static/dashboard/_app/immutable/nodes/1.cHq0-eq5.js
Normal file
1
static/dashboard/_app/immutable/nodes/1.cHq0-eq5.js
Normal 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};
|
||||
1
static/dashboard/_app/immutable/nodes/2.D971lFkg.js
Normal file
1
static/dashboard/_app/immutable/nodes/2.D971lFkg.js
Normal file
File diff suppressed because one or more lines are too long
1
static/dashboard/_app/immutable/nodes/3.Ds_9UHWu.js
Normal file
1
static/dashboard/_app/immutable/nodes/3.Ds_9UHWu.js
Normal 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};
|
||||
1
static/dashboard/_app/immutable/nodes/4.DVFdF6lw.js
Normal file
1
static/dashboard/_app/immutable/nodes/4.DVFdF6lw.js
Normal 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};
|
||||
1
static/dashboard/_app/immutable/nodes/5.Dls1yMSd.js
Normal file
1
static/dashboard/_app/immutable/nodes/5.Dls1yMSd.js
Normal file
File diff suppressed because one or more lines are too long
1
static/dashboard/_app/immutable/nodes/6.CXwxBASk.js
Normal file
1
static/dashboard/_app/immutable/nodes/6.CXwxBASk.js
Normal 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};
|
||||
1
static/dashboard/_app/version.json
Normal file
1
static/dashboard/_app/version.json
Normal file
@@ -0,0 +1 @@
|
||||
{"version":"1765462202287"}
|
||||
46
static/dashboard/agents.html
Normal file
46
static/dashboard/agents.html
Normal 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>
|
||||
46
static/dashboard/builds.html
Normal file
46
static/dashboard/builds.html
Normal 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>
|
||||
46
static/dashboard/config.html
Normal file
46
static/dashboard/config.html
Normal 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>
|
||||
46
static/dashboard/index.html
Normal file
46
static/dashboard/index.html
Normal 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>
|
||||
46
static/dashboard/issues.html
Normal file
46
static/dashboard/issues.html
Normal 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>
|
||||
3
static/dashboard/robots.txt
Normal file
3
static/dashboard/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user