crates/server: REST API routes; RBAC auth middleware; cookie sessions; dashboard

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I5298a925bd9c11780e49d8b1c98eebd86a6a6964
This commit is contained in:
raf 2026-02-01 15:13:33 +03:00
commit 235d3d38a6
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
38 changed files with 6275 additions and 7 deletions

View file

@ -0,0 +1,205 @@
{% extends "base.html" %}
{% block title %}Admin - FC CI{% endblock %}
{% block auth %}
{% if !auth_name.is_empty() %}
<span class="auth-user">{{ auth_name }}</span>
<form method="POST" action="/logout"><button type="submit">Logout</button></form>
{% else %}
<a href="/login">Login</a>
{% endif %}
{% endblock %}
{% block content %}
<h1>Administration</h1>
<h2>System Status</h2>
<div class="stats-grid">
<div class="stat-card"><div class="stat-value">{{ status.projects_count }}</div><div class="stat-label">Projects</div></div>
<div class="stat-card"><div class="stat-value">{{ status.jobsets_count }}</div><div class="stat-label">Jobsets</div></div>
<div class="stat-card"><div class="stat-value">{{ status.evaluations_count }}</div><div class="stat-label">Evaluations</div></div>
<div class="stat-card"><div class="stat-value">{{ status.builds_pending }}</div><div class="stat-label">Pending</div></div>
<div class="stat-card"><div class="stat-value">{{ status.builds_running }}</div><div class="stat-label">Running</div></div>
<div class="stat-card"><div class="stat-value">{{ status.builds_completed }}</div><div class="stat-label">Completed</div></div>
<div class="stat-card"><div class="stat-value">{{ status.builds_failed }}</div><div class="stat-label">Failed</div></div>
<div class="stat-card"><div class="stat-value">{{ status.channels_count }}</div><div class="stat-label">Channels</div></div>
</div>
{% if is_admin %}
<h2>API Keys</h2>
<details>
<summary>Create API Key</summary>
<div class="form-card">
<form id="create-key-form">
<div class="form-group">
<label for="key-name">Name</label>
<input type="text" id="key-name" required>
</div>
<div class="form-group">
<label for="key-role">Role</label>
<select id="key-role">
<option value="admin">admin</option>
<option value="read-only" selected>read-only</option>
<option value="create-projects">create-projects</option>
<option value="eval-jobset">eval-jobset</option>
<option value="cancel-build">cancel-build</option>
<option value="restart-jobs">restart-jobs</option>
<option value="bump-to-front">bump-to-front</option>
</select>
</div>
<button type="submit" class="btn">Create Key</button>
</form>
<div id="key-msg"></div>
</div>
</details>
{% if api_keys.is_empty() %}
<p class="empty">No API keys.</p>
{% else %}
<table>
<thead>
<tr><th>Name</th><th>Role</th><th>Created</th><th>Last Used</th>{% if is_admin %}<th>Actions</th>{% endif %}</tr>
</thead>
<tbody>
{% for k in api_keys %}
<tr>
<td>{{ k.name }}</td>
<td><span class="badge badge-pending">{{ k.role }}</span></td>
<td>{{ k.created_at }}</td>
<td>{{ k.last_used_at }}</td>
{% if is_admin %}
<td><button class="btn btn-danger btn-small" onclick="deleteKey('{{ k.id }}')">Delete</button></td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endif %}
<h2>Remote Builders</h2>
{% if is_admin %}
<details>
<summary>Add Remote Builder</summary>
<div class="form-card">
<form id="create-builder-form">
<div class="form-group">
<label for="builder-name">Name</label>
<input type="text" id="builder-name" required>
</div>
<div class="form-group">
<label for="builder-ssh">SSH URI</label>
<input type="text" id="builder-ssh" placeholder="ssh://builder@host" required>
</div>
<div class="form-group">
<label for="builder-systems">Systems (comma-separated)</label>
<input type="text" id="builder-systems" value="x86_64-linux" required>
</div>
<div class="form-group">
<label for="builder-maxjobs">Max Jobs</label>
<input type="number" id="builder-maxjobs" value="4">
</div>
<button type="submit" class="btn">Add Builder</button>
</form>
<div id="builder-msg"></div>
</div>
</details>
{% endif %}
{% if builders.is_empty() %}
<p class="empty">No remote builders configured.</p>
{% else %}
<table>
<thead>
<tr><th>Name</th><th>SSH URI</th><th>Systems</th><th>Max Jobs</th><th>Enabled</th>{% if is_admin %}<th>Actions</th>{% endif %}</tr>
</thead>
<tbody>
{% for b in builders %}
<tr>
<td>{{ b.name }}</td>
<td>{{ b.ssh_uri }}</td>
<td>{{ b.systems.join(", ") }}</td>
<td>{{ b.max_jobs }}</td>
<td>{% if b.enabled %}Yes{% else %}No{% endif %}</td>
{% if is_admin %}
<td>
<button class="btn btn-small" onclick="toggleBuilder('{{ b.id }}', {{ !b.enabled }})">{% if b.enabled %}Disable{% else %}Enable{% endif %}</button>
<button class="btn btn-danger btn-small" onclick="deleteBuilder('{{ b.id }}')">Delete</button>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}
{% block scripts %}
{% if is_admin %}
<script>
document.getElementById('create-key-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = document.getElementById('key-msg');
try {
const res = await fetch('/api/v1/api-keys', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: document.getElementById('key-name').value,
role: document.getElementById('key-role').value,
}),
});
const data = await res.json();
if (res.ok) {
msg.innerHTML = '<div class="flash-message flash-success">Key created: <code>' + data.key + '</code><br>Copy this now, it will not be shown again.</div>';
setTimeout(() => window.location.reload(), 5000);
} else {
msg.innerHTML = '<div class="flash-message flash-error">' + (data.error || 'Error') + '</div>';
}
} catch(e) {
msg.innerHTML = '<div class="flash-message flash-error">Request failed</div>';
}
});
async function deleteKey(id) {
if (!confirm('Delete this API key?')) return;
const res = await fetch('/api/v1/api-keys/' + id, { method: 'DELETE' });
if (res.ok) window.location.reload();
else alert('Failed to delete key');
}
document.getElementById('create-builder-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = document.getElementById('builder-msg');
try {
const res = await fetch('/api/v1/admin/builders', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: document.getElementById('builder-name').value,
ssh_uri: document.getElementById('builder-ssh').value,
systems: document.getElementById('builder-systems').value.split(',').map(s => s.trim()),
max_jobs: parseInt(document.getElementById('builder-maxjobs').value) || 4,
}),
});
if (res.ok) {
window.location.reload();
} else {
const data = await res.json();
msg.innerHTML = '<div class="flash-message flash-error">' + (data.error || 'Error') + '</div>';
}
} catch(e) {
msg.innerHTML = '<div class="flash-message flash-error">Request failed</div>';
}
});
async function toggleBuilder(id, enable) {
const res = await fetch('/api/v1/admin/builders/' + id, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ enabled: enable }),
});
if (res.ok) window.location.reload();
else alert('Failed to update builder');
}
async function deleteBuilder(id) {
if (!confirm('Delete this builder?')) return;
const res = await fetch('/api/v1/admin/builders/' + id, { method: 'DELETE' });
if (res.ok) window.location.reload();
else alert('Failed to delete builder');
}
</script>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,403 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}FC CI{% endblock %}</title>
<style>
/* FC CI Dashboard Styles */
:root {
--bg: #fafafa;
--fg: #1a1a1a;
--border: #ddd;
--accent: #2563eb;
--muted: #6b7280;
--card-bg: #fff;
--green: #16a34a;
--red: #dc2626;
--yellow: #ca8a04;
--gray: #6b7280;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111827;
--fg: #f3f4f6;
--border: #374151;
--accent: #60a5fa;
--muted: #9ca3af;
--card-bg: #1f2937;
--green: #4ade80;
--red: #f87171;
--yellow: #fbbf24;
--gray: #9ca3af;
}
code { background: #374151; }
th { background: #1f2937; }
tbody tr:hover { background: #1f2937; }
.badge-completed { background: #064e3b; color: var(--green); }
.badge-failed { background: #450a0a; color: var(--red); }
.badge-running { background: #422006; color: var(--yellow); }
.badge-pending { background: #1f2937; color: var(--gray); }
.badge-cancelled { background: #1f2937; color: var(--gray); }
.flash-error { background: #450a0a; border-color: var(--red); color: var(--red); }
.flash-success { background: #064e3b; border-color: var(--green); color: var(--green); }
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.6;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code {
background: #f3f4f6;
padding: 0.1em 0.3em;
border-radius: 3px;
font-size: 0.9em;
}
.navbar {
display: flex;
align-items: center;
gap: 2rem;
padding: 0.75rem 1.5rem;
background: var(--card-bg);
border-bottom: 1px solid var(--border);
}
.nav-brand a {
font-weight: 700;
font-size: 1.1rem;
color: var(--fg);
}
.nav-links { display: flex; gap: 1rem; flex: 1; }
.nav-links a { color: var(--muted); font-size: 0.9rem; }
.nav-links a:hover { color: var(--fg); }
.nav-auth { display: flex; gap: 0.75rem; align-items: center; font-size: 0.85rem; }
.nav-auth .auth-user { color: var(--muted); }
.nav-auth a { color: var(--accent); }
.nav-auth form { display: inline; }
.nav-auth button { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 0.85rem; }
.nav-auth button:hover { text-decoration: underline; }
.container {
max-width: 1100px;
margin: 1.5rem auto;
padding: 0 1rem;
}
h1 { margin-bottom: 1rem; font-size: 1.5rem; }
h2 { margin: 1.5rem 0 0.75rem; font-size: 1.2rem; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem;
text-align: center;
}
.stat-value { font-size: 1.75rem; font-weight: 700; }
.stat-label { font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
table {
width: 100%;
border-collapse: collapse;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
th, td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
th {
background: #f9fafb;
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--muted);
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: #f9fafb; }
.badge {
display: inline-block;
padding: 0.15em 0.5em;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
text-transform: capitalize;
}
.badge-completed { background: #dcfce7; color: var(--green); }
.badge-failed { background: #fee2e2; color: var(--red); }
.badge-running { background: #fef9c3; color: var(--yellow); }
.badge-pending { background: #f3f4f6; color: var(--gray); }
.badge-cancelled { background: #f3f4f6; color: var(--gray); }
.empty { color: var(--muted); font-style: italic; }
.pagination {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 1rem;
font-size: 0.9rem;
color: var(--muted);
}
.filter-form {
display: flex;
gap: 1rem;
align-items: end;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filter-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--muted);
}
.filter-form select,
.filter-form input {
padding: 0.35rem 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.9rem;
background: var(--card-bg);
color: var(--fg);
}
.filter-form button {
padding: 0.4rem 1rem;
background: var(--accent);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.filter-form button:hover { opacity: 0.9; }
.breadcrumbs {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--muted);
}
.breadcrumbs a { color: var(--accent); }
.breadcrumbs .sep { color: var(--muted); }
.breadcrumbs .current { color: var(--fg); font-weight: 600; }
.detail-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.35rem 1rem;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.detail-grid dt { font-weight: 600; color: var(--muted); }
.detail-grid dd { color: var(--fg); }
.step-success { color: var(--green); font-weight: 600; }
.step-failure { color: var(--red); font-weight: 600; }
.tab-nav {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border);
margin-bottom: 1rem;
}
.tab-nav a {
padding: 0.5rem 1rem;
color: var(--muted);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
font-size: 0.9rem;
}
.tab-nav a:hover { color: var(--fg); text-decoration: none; }
.tab-nav a.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.queue-summary {
display: inline-flex;
gap: 1rem;
font-size: 0.9rem;
margin-bottom: 1rem;
padding: 0.5rem 1rem;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 6px;
}
/* Form styles */
.form-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1.5rem;
margin-bottom: 1.5rem;
max-width: 500px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--muted);
margin-bottom: 0.25rem;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.9rem;
background: var(--bg);
color: var(--fg);
}
.form-group textarea { min-height: 60px; resize: vertical; }
.btn {
display: inline-block;
padding: 0.5rem 1.25rem;
background: var(--accent);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.btn:hover { opacity: 0.9; text-decoration: none; }
.btn-danger {
background: var(--red);
}
.btn-small {
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
}
.flash-message {
padding: 0.75rem 1rem;
border: 1px solid;
border-radius: 4px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.flash-error {
background: #fef2f2;
border-color: var(--red);
color: var(--red);
}
.flash-success {
background: #f0fdf4;
border-color: var(--green);
color: var(--green);
}
details {
margin-bottom: 1rem;
}
details summary {
cursor: pointer;
font-weight: 600;
color: var(--accent);
font-size: 0.9rem;
padding: 0.5rem 0;
}
.footer {
text-align: center;
padding: 2rem 1rem;
color: var(--muted);
font-size: 0.8rem;
border-top: 1px solid var(--border);
margin-top: 3rem;
}
@media (max-width: 768px) {
.navbar { flex-direction: column; gap: 0.5rem; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.filter-form { flex-direction: column; }
table { font-size: 0.85rem; }
th, td { padding: 0.35rem 0.5rem; }
}
</style>
</head>
<body>
<nav class="navbar">
<div class="nav-brand"><a href="/">FC CI</a></div>
<div class="nav-links">
<a href="/projects">Projects</a>
<a href="/evaluations">Evaluations</a>
<a href="/builds">Builds</a>
<a href="/queue">Queue</a>
<a href="/channels">Channels</a>
<a href="/admin">Admin</a>
</div>
<div class="nav-auth">
{% block auth %}{% endblock %}
</div>
</nav>
<main class="container">
{% block breadcrumbs %}{% endblock %}
{% block content %}{% endblock %}
</main>
<footer class="footer">
<p>FC CI &mdash; Nix-based continuous integration</p>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}Build {{ build.job_name }} - FC CI{% endblock %}
{% block breadcrumbs %}
<nav class="breadcrumbs">
<a href="/">Home</a> <span class="sep">/</span>
<a href="/project/{{ project_id }}">{{ project_name }}</a> <span class="sep">/</span>
<a href="/jobset/{{ jobset_id }}">{{ jobset_name }}</a> <span class="sep">/</span>
<a href="/evaluation/{{ eval_id }}">{{ eval_commit_short }}</a> <span class="sep">/</span>
<span class="current">{{ build.job_name }}</span>
</nav>
{% endblock %}
{% block content %}
<h1>Build: {{ build.job_name }}</h1>
<dl class="detail-grid">
<dt>Status</dt>
<dd><span class="badge badge-{{ build.status_class }}">{{ build.status_text }}</span></dd>
<dt>System</dt>
<dd>{{ build.system }}</dd>
<dt>Derivation</dt>
<dd><code>{{ build.drv_path }}</code></dd>
<dt>Created</dt>
<dd>{{ build.created_at }}</dd>
{% if !build.started_at.is_empty() %}
<dt>Started</dt>
<dd>{{ build.started_at }}</dd>
{% endif %}
{% if !build.completed_at.is_empty() %}
<dt>Completed</dt>
<dd>{{ build.completed_at }}</dd>
{% endif %}
{% if !build.duration.is_empty() %}
<dt>Duration</dt>
<dd>{{ build.duration }}</dd>
{% endif %}
<dt>Priority</dt>
<dd>{{ build.priority }}</dd>
<dt>Signed</dt>
<dd>{% if build.signed %}Yes{% else %}No{% endif %}</dd>
{% if build.is_aggregate %}
<dt>Aggregate</dt>
<dd>Yes</dd>
{% endif %}
</dl>
{% if !build.output_path.is_empty() %}
<p><strong>Output:</strong> <code>{{ build.output_path }}</code></p>
{% endif %}
{% if !build.error_message.is_empty() %}
<p><strong>Error:</strong> {{ build.error_message }}</p>
{% endif %}
{% if !build.log_url.is_empty() %}
<p><a href="{{ build.log_url }}">View log</a></p>
{% endif %}
<h2>Build Steps</h2>
{% if steps.is_empty() %}
<p class="empty">No steps recorded.</p>
{% else %}
<table>
<thead>
<tr><th>#</th><th>Command</th><th>Exit</th><th>Started</th><th>Completed</th></tr>
</thead>
<tbody>
{% for s in steps %}
<tr>
<td>{{ s.step_number }}</td>
<td><code>{{ s.command }}</code></td>
<td>{% match s.exit_code %}{% when Some with (0) %}<span class="step-success">0</span>{% when Some with (code) %}<span class="step-failure">{{ code }}</span>{% when None %}-{% endmatch %}</td>
<td>{{ s.started_at.format("%H:%M:%S") }}</td>
<td>{% match s.completed_at %}{% when Some with (t) %}{{ t.format("%H:%M:%S") }}{% when None %}-{% endmatch %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h2>Build Products</h2>
{% if products.is_empty() %}
<p class="empty">No products recorded.</p>
{% else %}
<table>
<thead>
<tr><th>Name</th><th>Path</th><th>Size</th></tr>
</thead>
<tbody>
{% for p in products %}
<tr>
<td>{{ p.name }}</td>
<td><code>{{ p.path }}</code></td>
<td>{% match p.file_size %}{% when Some with (sz) %}{{ sz }} bytes{% when None %}-{% endmatch %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Builds - FC CI{% endblock %}
{% block content %}
<h1>Builds</h1>
<form method="get" action="/builds" class="filter-form">
<label>Status: <select name="status">
<option value="">All</option>
<option value="pending" {% if filter_status == "pending" %}selected{% endif %}>Pending</option>
<option value="running" {% if filter_status == "running" %}selected{% endif %}>Running</option>
<option value="completed" {% if filter_status == "completed" %}selected{% endif %}>Completed</option>
<option value="failed" {% if filter_status == "failed" %}selected{% endif %}>Failed</option>
</select></label>
<label>System: <input type="text" name="system" value="{{ filter_system }}" placeholder="e.g. x86_64-linux"></label>
<label>Job: <input type="text" name="job_name" value="{{ filter_job }}" placeholder="job name"></label>
<button type="submit">Filter</button>
</form>
{% if builds.is_empty() %}
<p class="empty">No builds match filters.</p>
{% else %}
<table>
<thead>
<tr><th>Job</th><th>Status</th><th>System</th><th>Created</th></tr>
</thead>
<tbody>
{% for b in builds %}
<tr>
<td><a href="/build/{{ b.id }}">{{ b.job_name }}</a></td>
<td><span class="badge badge-{{ b.status_class }}">{{ b.status_text }}</span></td>
<td>{{ b.system }}</td>
<td>{{ b.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<div class="pagination">
{% if has_prev %}
<a href="/builds?offset={{ prev_offset }}&limit={{ limit }}&status={{ filter_status }}&system={{ filter_system }}&job_name={{ filter_job }}">&laquo; Previous</a>
{% endif %}
<span>Page {{ page }} of {{ total_pages }}</span>
{% if has_next %}
<a href="/builds?offset={{ next_offset }}&limit={{ limit }}&status={{ filter_status }}&system={{ filter_system }}&job_name={{ filter_job }}">Next &raquo;</a>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}Channels - FC CI{% endblock %}
{% block content %}
<h1>Channels</h1>
{% if channels.is_empty() %}
<p class="empty">No channels configured.</p>
{% else %}
<table>
<thead>
<tr><th>Name</th><th>Current Evaluation</th><th>Updated</th></tr>
</thead>
<tbody>
{% for c in channels %}
<tr>
<td>{{ c.name }}</td>
<td>
{% match c.current_evaluation_id %}
{% when Some with (eval_id) %}
<a href="/evaluation/{{ eval_id }}">{{ eval_id }}</a>
{% when None %}
-
{% endmatch %}
</td>
<td>{{ c.updated_at.format("%Y-%m-%d %H:%M") }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Evaluation {{ eval.commit_short }} - FC CI{% endblock %}
{% block breadcrumbs %}
<nav class="breadcrumbs">
<a href="/">Home</a> <span class="sep">/</span>
<a href="/project/{{ project_id }}">{{ project_name }}</a> <span class="sep">/</span>
<a href="/jobset/{{ jobset_id }}">{{ jobset_name }}</a> <span class="sep">/</span>
<span class="current">{{ eval.commit_short }}</span>
</nav>
{% endblock %}
{% block content %}
<h1>Evaluation {{ eval.commit_short }}</h1>
<p><strong>Status:</strong> <span class="badge badge-{{ eval.status_class }}">{{ eval.status_text }}</span></p>
<p><strong>Commit:</strong> <code>{{ eval.commit_hash }}</code></p>
<p><strong>Time:</strong> {{ eval.time }}</p>
{% match eval.error_message %}
{% when Some with (err) %}
<p><strong>Error:</strong> {{ err }}</p>
{% when None %}
{% endmatch %}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ succeeded_count }}</div>
<div class="stat-label">Succeeded</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ failed_count }}</div>
<div class="stat-label">Failed</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ running_count }}</div>
<div class="stat-label">Running</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ pending_count }}</div>
<div class="stat-label">Pending</div>
</div>
</div>
<h2>Builds</h2>
{% if builds.is_empty() %}
<p class="empty">No builds for this evaluation.</p>
{% else %}
<table>
<thead>
<tr><th>Job</th><th>Status</th><th>System</th><th>Created</th></tr>
</thead>
<tbody>
{% for b in builds %}
<tr>
<td><a href="/build/{{ b.id }}">{{ b.job_name }}</a></td>
<td><span class="badge badge-{{ b.status_class }}">{{ b.status_text }}</span></td>
<td>{{ b.system }}</td>
<td>{{ b.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Evaluations - FC CI{% endblock %}
{% block content %}
<h1>Evaluations</h1>
{% if evals.is_empty() %}
<p class="empty">No evaluations yet.</p>
{% else %}
<table>
<thead>
<tr><th>Commit</th><th>Project</th><th>Jobset</th><th>Status</th><th>Time</th></tr>
</thead>
<tbody>
{% for e in evals %}
<tr>
<td><a href="/evaluation/{{ e.id }}">{{ e.commit_short }}</a></td>
<td>{{ e.project_name }}</td>
<td>{{ e.jobset_name }}</td>
<td><span class="badge badge-{{ e.status_class }}">{{ e.status_text }}</span></td>
<td>{{ e.time }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<div class="pagination">
{% if has_prev %}
<a href="/evaluations?offset={{ prev_offset }}&limit={{ limit }}">&laquo; Previous</a>
{% endif %}
<span>Page {{ page }} of {{ total_pages }}</span>
{% if has_next %}
<a href="/evaluations?offset={{ next_offset }}&limit={{ limit }}">Next &raquo;</a>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,100 @@
{% extends "base.html" %}
{% block title %}FC CI - Dashboard{% endblock %}
{% block auth %}
{% if !auth_name.is_empty() %}
<span class="auth-user">{{ auth_name }}</span>
<form method="POST" action="/logout"><button type="submit">Logout</button></form>
{% else %}
<a href="/login">Login</a>
{% endif %}
{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ total_builds }}</div>
<div class="stat-label">Total Builds</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ completed_builds }}</div>
<div class="stat-label">Completed</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ failed_builds }}</div>
<div class="stat-label">Failed</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ running_builds }}</div>
<div class="stat-label">Running</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ pending_builds }}</div>
<div class="stat-label">Pending</div>
</div>
</div>
<p class="queue-summary">
<a href="/queue">Queue: {{ pending_builds }} pending, {{ running_builds }} running</a>
</p>
{% if !projects.is_empty() %}
<h2>Projects Overview</h2>
<table>
<thead>
<tr><th>Project</th><th>Jobsets</th><th>Last Eval</th><th>Time</th></tr>
</thead>
<tbody>
{% for p in projects %}
<tr>
<td><a href="/project/{{ p.id }}">{{ p.name }}</a></td>
<td>{{ p.jobset_count }}</td>
<td><span class="badge badge-{{ p.last_eval_class }}">{{ p.last_eval_status }}</span></td>
<td>{{ p.last_eval_time }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h2>Recent Builds</h2>
{% if recent_builds.is_empty() %}
<p class="empty">No builds yet.</p>
{% else %}
<table>
<thead>
<tr><th>Job</th><th>Status</th><th>System</th><th>Created</th></tr>
</thead>
<tbody>
{% for b in recent_builds %}
<tr>
<td><a href="/build/{{ b.id }}">{{ b.job_name }}</a></td>
<td><span class="badge badge-{{ b.status_class }}">{{ b.status_text }}</span></td>
<td>{{ b.system }}</td>
<td>{{ b.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h2>Recent Evaluations</h2>
{% if recent_evals.is_empty() %}
<p class="empty">No evaluations yet.</p>
{% else %}
<table>
<thead>
<tr><th>Commit</th><th>Status</th><th>Time</th></tr>
</thead>
<tbody>
{% for e in recent_evals %}
<tr>
<td><a href="/evaluation/{{ e.id }}">{{ e.commit_short }}</a></td>
<td><span class="badge badge-{{ e.status_class }}">{{ e.status_text }}</span></td>
<td>{{ e.time }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}{{ jobset.name }} - FC CI{% endblock %}
{% block breadcrumbs %}
<nav class="breadcrumbs">
<a href="/">Home</a> <span class="sep">/</span>
<a href="/project/{{ project.id }}">{{ project.name }}</a> <span class="sep">/</span>
<span class="current">{{ jobset.name }}</span>
</nav>
{% endblock %}
{% block content %}
<h1>{{ jobset.name }}</h1>
<dl class="detail-grid">
<dt>Expression</dt>
<dd><code>{{ jobset.nix_expression }}</code></dd>
<dt>Flake mode</dt>
<dd>{% if jobset.flake_mode %}Yes{% else %}No{% endif %}</dd>
<dt>Enabled</dt>
<dd>{% if jobset.enabled %}Yes{% else %}No{% endif %}</dd>
<dt>Check interval</dt>
<dd>{{ jobset.check_interval }}s</dd>
</dl>
{% if !eval_summaries.is_empty() %}
<h2>Latest Evaluation</h2>
{% let latest = eval_summaries[0] %}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ latest.succeeded }}</div>
<div class="stat-label">Succeeded</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ latest.failed }}</div>
<div class="stat-label">Failed</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ latest.pending }}</div>
<div class="stat-label">Pending</div>
</div>
</div>
{% endif %}
<h2>Recent Evaluations</h2>
{% if eval_summaries.is_empty() %}
<p class="empty">No evaluations yet.</p>
{% else %}
<table>
<thead>
<tr><th>Commit</th><th>Status</th><th>Succeeded</th><th>Failed</th><th>Pending</th><th>Time</th></tr>
</thead>
<tbody>
{% for e in eval_summaries %}
<tr>
<td><a href="/evaluation/{{ e.id }}">{{ e.commit_short }}</a></td>
<td><span class="badge badge-{{ e.status_class }}">{{ e.status_text }}</span></td>
<td>{{ e.succeeded }}</td>
<td>{{ e.failed }}</td>
<td>{{ e.pending }}</td>
<td>{{ e.time }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Login - FC CI{% endblock %}
{% block content %}
<h1>Login</h1>
<div class="form-card">
{% match error %}
{% when Some with (msg) %}
<div class="flash-message flash-error">{{ msg }}</div>
{% when None %}
{% endmatch %}
<form method="POST" action="/login">
<div class="form-group">
<label for="api_key">API Key</label>
<input type="password" id="api_key" name="api_key" placeholder="fc_..." required>
</div>
<button type="submit" class="btn">Login</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,133 @@
{% extends "base.html" %}
{% block title %}{{ project.name }} - FC CI{% endblock %}
{% block auth %}
{% if !auth_name.is_empty() %}
<span class="auth-user">{{ auth_name }}</span>
<form method="POST" action="/logout"><button type="submit">Logout</button></form>
{% else %}
<a href="/login">Login</a>
{% endif %}
{% endblock %}
{% block breadcrumbs %}
<nav class="breadcrumbs">
<a href="/">Home</a> <span class="sep">/</span>
<span class="current">{{ project.name }}</span>
</nav>
{% endblock %}
{% block content %}
<h1>{{ project.name }}</h1>
{% match project.description %}
{% when Some with (desc) %}
<p>{{ desc }}</p>
{% when None %}
{% endmatch %}
<p><strong>Repository:</strong> {{ project.repository_url }}</p>
<p><strong>Created:</strong> {{ project.created_at.format("%Y-%m-%d %H:%M") }}</p>
{% if is_admin %}
<div style="margin-top: 0.5rem;">
<button class="btn btn-danger btn-small" onclick="deleteProject()">Delete Project</button>
</div>
{% endif %}
<h2>Jobsets</h2>
{% if is_admin %}
<details>
<summary>Add Jobset</summary>
<div class="form-card">
<form id="create-jobset-form">
<div class="form-group">
<label for="js-name">Name</label>
<input type="text" id="js-name" required>
</div>
<div class="form-group">
<label for="js-expr">Nix Expression</label>
<input type="text" id="js-expr" value="." required>
</div>
<div class="form-group">
<label><input type="checkbox" id="js-flake" checked> Flake mode</label>
</div>
<button type="submit" class="btn">Add Jobset</button>
</form>
<div id="jobset-msg"></div>
</div>
</details>
{% endif %}
{% if jobsets.is_empty() %}
<p class="empty">No jobsets configured.</p>
{% else %}
<table>
<thead>
<tr><th>Name</th><th>Expression</th><th>Flake</th><th>Enabled</th><th>Interval</th></tr>
</thead>
<tbody>
{% for j in jobsets %}
<tr>
<td><a href="/jobset/{{ j.id }}">{{ j.name }}</a></td>
<td><code>{{ j.nix_expression }}</code></td>
<td>{% if j.flake_mode %}Yes{% else %}No{% endif %}</td>
<td>{% if j.enabled %}Yes{% else %}No{% endif %}</td>
<td>{{ j.check_interval }}s</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h2>Recent Evaluations</h2>
{% if recent_evals.is_empty() %}
<p class="empty">No evaluations yet.</p>
{% else %}
<table>
<thead>
<tr><th>Commit</th><th>Status</th><th>Time</th></tr>
</thead>
<tbody>
{% for e in recent_evals %}
<tr>
<td><a href="/evaluation/{{ e.id }}">{{ e.commit_short }}</a></td>
<td><span class="badge badge-{{ e.status_class }}">{{ e.status_text }}</span></td>
<td>{{ e.time }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}
{% block scripts %}
{% if is_admin %}
<script>
document.getElementById('create-jobset-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = document.getElementById('jobset-msg');
try {
const res = await fetch('/api/v1/projects/{{ project.id }}/jobsets', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: document.getElementById('js-name').value,
nix_expression: document.getElementById('js-expr').value,
flake_mode: document.getElementById('js-flake').checked,
}),
});
if (res.ok) {
window.location.reload();
} else {
const err = await res.json();
msg.innerHTML = '<div class="flash-message flash-error">' + (err.error || 'Error') + '</div>';
}
} catch(e) {
msg.innerHTML = '<div class="flash-message flash-error">Request failed</div>';
}
});
async function deleteProject() {
if (!confirm('Delete this project and all its data?')) return;
const res = await fetch('/api/v1/projects/{{ project.id }}', { method: 'DELETE' });
if (res.ok) window.location.href = '/projects';
else alert('Failed to delete project');
}
</script>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}Projects - FC CI{% endblock %}
{% block auth %}
{% if !auth_name.is_empty() %}
<span class="auth-user">{{ auth_name }}</span>
<form method="POST" action="/logout"><button type="submit">Logout</button></form>
{% else %}
<a href="/login">Login</a>
{% endif %}
{% endblock %}
{% block content %}
<h1>Projects</h1>
{% if is_admin %}
<details>
<summary>New Project</summary>
<div class="form-card">
<form id="create-project-form">
<div class="form-group">
<label for="project-name">Name</label>
<input type="text" id="project-name" required>
</div>
<div class="form-group">
<label for="project-repo">Repository URL</label>
<input type="url" id="project-repo" required>
</div>
<div class="form-group">
<label for="project-desc">Description</label>
<textarea id="project-desc"></textarea>
</div>
<button type="submit" class="btn">Create Project</button>
</form>
<div id="project-msg"></div>
</div>
</details>
{% endif %}
{% if projects.is_empty() %}
<p class="empty">No projects yet.</p>
{% else %}
<table>
<thead>
<tr><th>Name</th><th>Repository</th><th>Created</th></tr>
</thead>
<tbody>
{% for p in projects %}
<tr>
<td><a href="/project/{{ p.id }}">{{ p.name }}</a></td>
<td>{{ p.repository_url }}</td>
<td>{{ p.created_at.format("%Y-%m-%d %H:%M") }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<div class="pagination">
{% if has_prev %}
<a href="/projects?offset={{ prev_offset }}&limit={{ limit }}">&laquo; Previous</a>
{% endif %}
<span>Page {{ page }} of {{ total_pages }}</span>
{% if has_next %}
<a href="/projects?offset={{ next_offset }}&limit={{ limit }}">Next &raquo;</a>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
{% if is_admin %}
<script>
document.getElementById('create-project-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = document.getElementById('project-msg');
try {
const res = await fetch('/api/v1/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: document.getElementById('project-name').value,
repository_url: document.getElementById('project-repo').value,
description: document.getElementById('project-desc').value || null,
}),
});
if (res.ok) {
window.location.reload();
} else {
const err = await res.json();
msg.innerHTML = '<div class="flash-message flash-error">' + (err.error || 'Error') + '</div>';
}
} catch(e) {
msg.innerHTML = '<div class="flash-message flash-error">Request failed</div>';
}
});
</script>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Queue - FC CI{% endblock %}
{% block breadcrumbs %}
<nav class="breadcrumbs">
<a href="/">Home</a> <span class="sep">/</span>
<span class="current">Queue</span>
</nav>
{% endblock %}
{% block content %}
<h1>Build Queue</h1>
<h2>Running ({{ running_count }})</h2>
{% if running_builds.is_empty() %}
<p class="empty">No builds currently running.</p>
{% else %}
<table>
<thead>
<tr><th>Job</th><th>System</th><th>Started</th></tr>
</thead>
<tbody>
{% for b in running_builds %}
<tr>
<td><a href="/build/{{ b.id }}">{{ b.job_name }}</a></td>
<td>{{ b.system }}</td>
<td>{{ b.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h2>Pending ({{ pending_count }})</h2>
{% if pending_builds.is_empty() %}
<p class="empty">No builds pending.</p>
{% else %}
<table>
<thead>
<tr><th>Job</th><th>System</th><th>Created</th></tr>
</thead>
<tbody>
{% for b in pending_builds %}
<tr>
<td><a href="/build/{{ b.id }}">{{ b.job_name }}</a></td>
<td>{{ b.system }}</td>
<td>{{ b.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}