crates/server: update templates with improved dashboard and styling

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I07f9de61588f61aae003f78c30fd6d326a6a6964
This commit is contained in:
raf 2026-02-02 01:27:05 +03:00
commit b4d3c9d501
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
14 changed files with 1139 additions and 609 deletions

File diff suppressed because it is too large Load diff

View file

@ -51,8 +51,12 @@
</div>
</details>
{% if api_keys.is_empty() %}
<p class="empty">No API keys.</p>
<div class="empty">
<div class="empty-title">No API keys</div>
<div class="empty-hint">Create an API key above to enable API access.</div>
</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr><th>Name</th><th>Role</th><th>Created</th><th>Last Used</th>{% if is_admin %}<th>Actions</th>{% endif %}</tr>
@ -71,6 +75,7 @@
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endif %}
@ -103,8 +108,12 @@
</details>
{% endif %}
{% if builders.is_empty() %}
<p class="empty">No remote builders configured.</p>
<div class="empty">
<div class="empty-title">No remote builders configured</div>
<div class="empty-hint">Remote builders distribute builds across multiple machines.</div>
</div>
{% else %}
<div class="table-wrap">
<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>
@ -127,6 +136,7 @@
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
@ -144,22 +154,29 @@ document.getElementById('create-key-form')?.addEventListener('submit', async (e)
role: document.getElementById('key-role').value,
}),
});
const data = await res.json();
const data = await res.json().catch(() => ({}));
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>';
msg.innerHTML = '<div class="flash-message flash-success">Key created: <code>' + escapeHtml(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>';
throw new Error(data.error || data.message || 'Unknown error');
}
} catch(e) {
msg.innerHTML = '<div class="flash-message flash-error">Request failed</div>';
} catch(err) {
showError(msg, err.message);
}
});
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');
try {
const res = await fetch('/api/v1/api-keys/' + id, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || err.message || 'Failed to delete key');
}
window.location.reload();
} catch(err) {
alert(escapeHtml(err.message));
}
}
document.getElementById('create-builder-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
@ -175,30 +192,43 @@ document.getElementById('create-builder-form')?.addEventListener('submit', async
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>';
if (!res.ok) {
const data = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(data.error || data.message || 'Unknown error');
}
} catch(e) {
msg.innerHTML = '<div class="flash-message flash-error">Request failed</div>';
window.location.reload();
} catch(err) {
showError(msg, err.message);
}
});
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');
try {
const res = await fetch('/api/v1/admin/builders/' + id, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ enabled: enable }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || err.message || 'Failed to update builder');
}
window.location.reload();
} catch(err) {
alert(escapeHtml(err.message));
}
}
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');
try {
const res = await fetch('/api/v1/admin/builders/' + id, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || err.message || 'Failed to delete builder');
}
window.location.reload();
} catch(err) {
alert(escapeHtml(err.message));
}
}
</script>
{% endif %}

View file

@ -4,377 +4,7 @@
<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>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="navbar">
@ -391,13 +21,25 @@ details summary {
{% block auth %}{% endblock %}
</div>
</nav>
<main class="container">
{% block breadcrumbs %}{% endblock %}
{% block content %}{% endblock %}
<main class="page-main">
<div class="container">
{% block breadcrumbs %}{% endblock %}
{% block content %}{% endblock %}
</div>
</main>
<footer class="footer">
<p>FC CI &mdash; Nix-based continuous integration</p>
</footer>
<script>
function escapeHtml(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function showError(el, msg) {
el.innerHTML = '<div class="flash-message flash-error">' + escapeHtml(msg) + '</div>';
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -55,8 +55,9 @@
<h2>Build Steps</h2>
{% if steps.is_empty() %}
<p class="empty">No steps recorded.</p>
<div class="empty">No steps recorded.</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr><th>#</th><th>Command</th><th>Exit</th><th>Started</th><th>Completed</th></tr>
@ -73,12 +74,14 @@
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<h2>Build Products</h2>
{% if products.is_empty() %}
<p class="empty">No products recorded.</p>
<div class="empty">No products recorded.</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr><th>Name</th><th>Path</th><th>Size</th></tr>
@ -93,5 +96,6 @@
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View file

@ -17,8 +17,12 @@
</form>
{% if builds.is_empty() %}
<p class="empty">No builds match filters.</p>
<div class="empty">
<div class="empty-title">No builds match filters</div>
<div class="empty-hint">Try adjusting the filters above or wait for builds to be queued.</div>
</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr><th>Job</th><th>Status</th><th>System</th><th>Created</th></tr>
@ -34,7 +38,8 @@
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% if total_pages > 1 %}
<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>
@ -44,4 +49,6 @@
<a href="/builds?offset={{ next_offset }}&limit={{ limit }}&status={{ filter_status }}&system={{ filter_system }}&job_name={{ filter_job }}">Next &raquo;</a>
{% endif %}
</div>
{% endif %}
{% endif %}
{% endblock %}

View file

@ -3,8 +3,12 @@
{% block content %}
<h1>Channels</h1>
{% if channels.is_empty() %}
<p class="empty">No channels configured.</p>
<div class="empty">
<div class="empty-title">No channels configured</div>
<div class="empty-hint">Channels track successful evaluations for stable release tracking.</div>
</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr><th>Name</th><th>Current Evaluation</th><th>Updated</th></tr>
@ -26,5 +30,6 @@
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View file

@ -40,8 +40,9 @@
<h2>Builds</h2>
{% if builds.is_empty() %}
<p class="empty">No builds for this evaluation.</p>
<div class="empty">No builds for this evaluation.</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr><th>Job</th><th>Status</th><th>System</th><th>Created</th></tr>
@ -57,5 +58,6 @@
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View file

@ -3,8 +3,12 @@
{% block content %}
<h1>Evaluations</h1>
{% if evals.is_empty() %}
<p class="empty">No evaluations yet.</p>
<div class="empty">
<div class="empty-title">No evaluations yet</div>
<div class="empty-hint">Evaluations will appear here once a jobset is evaluated.</div>
</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr><th>Commit</th><th>Project</th><th>Jobset</th><th>Status</th><th>Time</th></tr>
@ -21,7 +25,8 @@
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% if total_pages > 1 %}
<div class="pagination">
{% if has_prev %}
<a href="/evaluations?offset={{ prev_offset }}&limit={{ limit }}">&laquo; Previous</a>
@ -31,4 +36,6 @@
<a href="/evaluations?offset={{ next_offset }}&limit={{ limit }}">Next &raquo;</a>
{% endif %}
</div>
{% endif %}
{% endif %}
{% endblock %}

View file

@ -17,84 +17,132 @@
<div class="stat-label">Total Builds</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ completed_builds }}</div>
<div class="stat-value stat-value-green">{{ completed_builds }}</div>
<div class="stat-label">Completed</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ failed_builds }}</div>
<div class="stat-value stat-value-red">{{ failed_builds }}</div>
<div class="stat-label">Failed</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ running_builds }}</div>
<div class="stat-value stat-value-yellow">{{ 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>
{% if total_builds > 0 %}
<div class="stat-card">
{% let rate = completed_builds * 100 / total_builds %}
<div class="stat-value">
{% if rate >= 80 %}
<span class="success-rate success-rate-high">{{ rate }}%</span>
{% else if rate >= 50 %}
<span class="success-rate success-rate-mid">{{ rate }}%</span>
{% else %}
<span class="success-rate success-rate-low">{{ rate }}%</span>
{% endif %}
</div>
<div class="stat-label">Success Rate</div>
</div>
{% endif %}
</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>
{% if is_admin %}
<div class="quick-actions">
<a href="/projects/new">New Project</a>
<a href="/admin">Admin Panel</a>
<a href="/queue">Build Queue</a>
</div>
{% 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 %}
<div class="dashboard-grid">
<div>
<h2>Recent Builds</h2>
{% if recent_builds.is_empty() %}
<div class="empty">
<div class="empty-title">No builds yet</div>
<div class="empty-hint">Builds will appear here once an evaluation triggers them.</div>
</div>
{% else %}
<div class="table-wrap">
<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>
</div>
{% 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 %}
<h2>Recent Evaluations</h2>
{% if recent_evals.is_empty() %}
<div class="empty">
<div class="empty-title">No evaluations yet</div>
<div class="empty-hint">The evaluator will poll configured jobsets automatically.</div>
</div>
{% else %}
<div class="table-wrap">
<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>
</div>
{% endif %}
</div>
<div>
<h2>Projects</h2>
{% if projects.is_empty() %}
<div class="empty">
<div class="empty-title">No projects yet</div>
{% if is_admin %}
<div class="empty-hint"><a href="/projects/new">Create a project</a> to get started.</div>
{% endif %}
</div>
{% else %}
<div class="table-wrap">
<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>
</div>
{% endif %}
<p class="queue-summary">
<a href="/queue">Queue: {{ pending_builds }} pending, {{ running_builds }} running</a>
</p>
</div>
</div>
{% endblock %}

View file

@ -42,8 +42,12 @@
<h2>Recent Evaluations</h2>
{% if eval_summaries.is_empty() %}
<p class="empty">No evaluations yet.</p>
<div class="empty">
<div class="empty-title">No evaluations yet</div>
<div class="empty-hint">The evaluator will poll this jobset based on the check interval.</div>
</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr><th>Commit</th><th>Status</th><th>Succeeded</th><th>Failed</th><th>Pending</th><th>Time</th></tr>
@ -61,5 +65,6 @@
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View file

@ -1,19 +1,24 @@
{% 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 class="login-container">
<div class="login-card">
<h1>FC CI</h1>
<p class="login-subtitle">Sign in with your API key</p>
{% match error %}
{% when Some with (msg) %}
<div class="flash-message flash-error">{{ msg }}</div>
{% when None %}
{% endmatch %}
<div class="form-card login-form">
<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 autocomplete="current-password">
</div>
<button type="submit" class="btn btn-full">Sign in</button>
</form>
</div>
<button type="submit" class="btn">Login</button>
</form>
</div>
</div>
{% endblock %}

View file

@ -25,7 +25,7 @@
<p><strong>Created:</strong> {{ project.created_at.format("%Y-%m-%d %H:%M") }}</p>
{% if is_admin %}
<div style="margin-top: 0.5rem;">
<div class="action-bar">
<button class="btn btn-danger btn-small" onclick="deleteProject()">Delete Project</button>
</div>
{% endif %}
@ -56,8 +56,14 @@
{% endif %}
{% if jobsets.is_empty() %}
<p class="empty">No jobsets configured.</p>
<div class="empty">
<div class="empty-title">No jobsets configured</div>
{% if is_admin %}
<div class="empty-hint">Add a jobset above to start evaluating this project.</div>
{% endif %}
</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr><th>Name</th><th>Expression</th><th>Flake</th><th>Enabled</th><th>Interval</th></tr>
@ -74,12 +80,14 @@
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<h2>Recent Evaluations</h2>
{% if recent_evals.is_empty() %}
<p class="empty">No evaluations yet.</p>
<div class="empty">No evaluations yet for this project.</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr><th>Commit</th><th>Status</th><th>Time</th></tr>
@ -94,6 +102,7 @@
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
@ -112,21 +121,27 @@ document.getElementById('create-jobset-form')?.addEventListener('submit', async
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>';
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || err.message || 'Unknown error');
}
} catch(e) {
msg.innerHTML = '<div class="flash-message flash-error">Request failed</div>';
window.location.reload();
} catch(err) {
showError(msg, err.message);
}
});
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');
try {
const res = await fetch('/api/v1/projects/{{ project.id }}', { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || err.message || 'Failed to delete project');
}
window.location.href = '/projects';
} catch(err) {
alert(escapeHtml(err.message));
}
}
</script>
{% endif %}

View file

@ -12,8 +12,9 @@
<h1>Projects</h1>
{% if is_admin %}
<p class="action-bar"><a href="/projects/new" class="btn">New Project (Setup Wizard)</a></p>
<details>
<summary>New Project</summary>
<summary>Quick create (no probe)</summary>
<div class="form-card">
<form id="create-project-form">
<div class="form-group">
@ -36,8 +37,16 @@
{% endif %}
{% if projects.is_empty() %}
<p class="empty">No projects yet.</p>
<div class="empty">
<div class="empty-title">No projects yet</div>
{% if is_admin %}
<div class="empty-hint">Create a project using the button above to get started.</div>
{% else %}
<div class="empty-hint">Projects will appear here once an administrator creates them.</div>
{% endif %}
</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr><th>Name</th><th>Repository</th><th>Created</th></tr>
@ -52,7 +61,8 @@
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% if total_pages > 1 %}
<div class="pagination">
{% if has_prev %}
<a href="/projects?offset={{ prev_offset }}&limit={{ limit }}">&laquo; Previous</a>
@ -62,6 +72,8 @@
<a href="/projects?offset={{ next_offset }}&limit={{ limit }}">Next &raquo;</a>
{% endif %}
</div>
{% endif %}
{% endif %}
{% endblock %}
{% block scripts %}
{% if is_admin %}
@ -79,14 +91,13 @@ document.getElementById('create-project-form')?.addEventListener('submit', async
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>';
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || err.message || 'Unknown error');
}
} catch(e) {
msg.innerHTML = '<div class="flash-message flash-error">Request failed</div>';
window.location.reload();
} catch(err) {
showError(msg, err.message);
}
});
</script>

View file

@ -11,8 +11,12 @@
<h2>Running ({{ running_count }})</h2>
{% if running_builds.is_empty() %}
<p class="empty">No builds currently running.</p>
<div class="empty">
<div class="empty-title">No builds currently running</div>
<div class="empty-hint">Running builds will appear here when the queue runner picks them up.</div>
</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr><th>Job</th><th>System</th><th>Started</th></tr>
@ -27,12 +31,17 @@
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<h2>Pending ({{ pending_count }})</h2>
{% if pending_builds.is_empty() %}
<p class="empty">No builds pending.</p>
<div class="empty">
<div class="empty-title">No builds pending</div>
<div class="empty-hint">Pending builds appear after an evaluation discovers new derivations to build.</div>
</div>
{% else %}
<div class="table-wrap">
<table>
<thead>
<tr><th>Job</th><th>System</th><th>Created</th></tr>
@ -47,5 +56,6 @@
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}