feat: add standalone web UI — tickets page with same style as homelab-dashboard

- corvid/templates/base.html: simplified nav (Corvid logo, Tickets link only),
  no user footer, no SSO — identical dark theme and component styles
- corvid/templates/tickets.html: copied verbatim from homelab-dashboard
  (all API paths are relative, no user-specific references)
- corvid/main.py: /tickets HTML route + / redirect; templates loaded from
  package dir so they work whether installed or run from source
- corvid/router.py: allow open access when TICKETS_API_KEY is not set
  (local/dev mode) — set the key to require auth for external access
- pyproject.toml: add jinja2>=3.1 dependency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
caoimhinr 2026-05-25 19:30:43 +02:00
parent ff7620d5ed
commit dd799cea8e
6 changed files with 1420 additions and 1 deletions

View file

@ -8,6 +8,7 @@ COPY alembic.ini .
COPY alembic/ alembic/
COPY corvid/ corvid/
COPY entrypoint.sh .
# templates are bundled inside corvid/ package dir — no extra COPY needed
RUN chmod +x entrypoint.sh
EXPOSE 8080

View file

@ -1,11 +1,17 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .config import TICKETS_API_KEY
from .database import get_db
from .router import make_router
_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
templates = Jinja2Templates(directory=_TEMPLATE_DIR)
@asynccontextmanager
async def lifespan(app: FastAPI):
@ -17,6 +23,18 @@ app = FastAPI(title="Corvid", lifespan=lifespan)
app.include_router(make_router(api_key=TICKETS_API_KEY, get_db=get_db))
@app.get("/", include_in_schema=False)
async def root():
return RedirectResponse("/tickets")
@app.get("/tickets", response_class=HTMLResponse, include_in_schema=False)
async def tickets_page(request: Request):
return templates.TemplateResponse(
"tickets.html", {"request": request, "active_page": "tickets"}
)
@app.get("/health")
async def health():
return {"status": "ok"}

View file

@ -111,6 +111,9 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter:
return {"username": "claude", "email": "claude@internal"}
if require_user is not None:
return require_user(request)
if not api_key:
# No key configured — open / local mode
return {"username": "local", "email": "local@localhost"}
raise HTTPException(401, "unauthorized")
# ── Ticket endpoints ──────────────────────────────────────────────────────

395
corvid/templates/base.html Normal file
View file

@ -0,0 +1,395 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Corvid{% endblock %} — Corvid</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--raised: #22263a;
--border: #2a2d3a;
--divider: #1e2130;
--fg: #e2e4ed;
--fg-sec: #6b7080;
--primary: #4f8ef7;
--green: #22c55e;
--red: #ef4444;
--yellow: #f59e0b;
--orange: #f97316;
--purple: #a78bfa;
--radius: 6px;
--sidebar: 200px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--fg);
font: 14px/1.5 "Inter", "Segoe UI", system-ui, sans-serif;
display: flex;
min-height: 100vh;
}
/* ── Sidebar ── */
nav {
width: var(--sidebar);
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 10;
transition: transform .25s ease;
}
.nav-logo {
padding: 20px 18px 16px;
font-size: 15px;
font-weight: 700;
letter-spacing: .01em;
border-bottom: 1px solid var(--border);
}
.nav-logo span { color: var(--primary); }
.nav-links { flex: 1; padding: 10px 0; }
.nav-link {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 18px;
color: var(--fg-sec);
text-decoration: none;
font-size: 13px;
font-weight: 500;
border-left: 3px solid transparent;
transition: color .15s, background .15s;
}
.nav-link:hover { color: var(--fg); background: var(--raised); }
.nav-link.active { color: var(--primary); border-left-color: var(--primary); background: #4f8ef710; }
.nav-icon { width: 16px; text-align: center; font-size: 15px; }
.nav-section {
padding: 18px 18px 6px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--fg-sec);
}
/* ── Main content ── */
.main-wrap {
margin-left: var(--sidebar);
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
transition: margin-left .25s ease;
}
nav.nav-collapsed { transform: translateX(-100%); }
.main-wrap.nav-collapsed { margin-left: 0; }
.topbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 28px;
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
}
.topbar-title { font-size: 14px; font-weight: 600; }
.topbar-meta { font-size: 12px; color: var(--fg-sec); }
main { padding: 28px; display: flex; flex-direction: column; gap: 24px; }
/* ── Cards / stats ── */
.cards { display: flex; gap: 14px; flex-wrap: wrap; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 20px;
min-width: 130px;
}
.card-label { font-size: 11px; color: var(--fg-sec); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 6px; }
.card-value { font-size: 24px; font-weight: 700; }
.card-value.green { color: var(--green); }
.card-value.red { color: var(--red); }
.card-value.blue { color: var(--primary);}
.card-value.purple { color: var(--purple); }
/* ── Section blocks ── */
.section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.section-header {
padding: 13px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.section-header h2 {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .07em;
color: var(--fg-sec);
}
.section-header .count {
background: var(--raised);
color: var(--fg-sec);
font-size: 11px;
padding: 1px 7px;
border-radius: 10px;
}
.section-header .spacer { flex: 1; }
/* ── Tables ── */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th {
text-align: left;
padding: 10px 20px;
font-size: 11px;
font-weight: 600;
color: var(--fg-sec);
text-transform: uppercase;
letter-spacing: .05em;
border-bottom: 1px solid var(--divider);
}
td { padding: 11px 20px; border-bottom: 1px solid var(--divider); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--raised); }
/* ── Badges ── */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.badge-success { background: #22c55e18; color: var(--green); }
.badge-error { background: #ef444418; color: var(--red); }
.badge-pending { background: #f59e0b18; color: var(--yellow); }
.badge-running { background: #4f8ef718; color: var(--primary); }
/* ── Misc ── */
.mono { font-family: monospace; font-size: 12px; }
.muted { color: var(--fg-sec); }
.ts { color: var(--fg-sec); font-size: 12px; }
.empty { padding: 24px; color: var(--fg-sec); font-size: 13px; text-align: center; }
/* ── Buttons ── */
button, .btn {
padding: 6px 14px;
border-radius: 4px;
border: none;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: opacity .15s;
}
button:hover { opacity: .85; }
button:disabled { opacity: .4; cursor: default; }
.btn-primary { background: var(--primary); color: #000; }
.btn-danger { background: var(--red); color: #fff; }
.btn-subtle { background: var(--raised); color: var(--fg); border: 1px solid var(--border); }
.btn-sm { padding: 4px 10px; font-size: 11px; }
/* ── Fields / forms ── */
.field { display: flex; flex-direction: column; gap: 4px; }
.field label {
font-size: 11px; color: var(--fg-sec);
font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
}
.field input, .field select, .field textarea {
background: var(--bg); border: 1px solid var(--border);
border-radius: 4px; color: var(--fg);
padding: 6px 10px; font-size: 13px; outline: none;
}
.field input:focus, .field select:focus { border-color: var(--primary); }
/* ── Modal ── */
.overlay {
display: none; position: fixed; inset: 0;
background: #000a; z-index: 100;
align-items: center; justify-content: center;
}
.overlay.active { display: flex; }
.modal {
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; padding: 24px;
min-width: 420px; max-width: 560px;
}
.modal h3 { font-size: 15px; margin-bottom: 16px; }
.modal .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
/* ── Spinner / toast ── */
.spinner {
display: inline-block; width: 12px; height: 12px;
border: 2px solid var(--primary); border-top-color: transparent;
border-radius: 50%; animation: spin .6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.toast {
position: fixed; bottom: 24px; right: 24px;
background: var(--raised); border: 1px solid var(--border);
border-radius: 6px; padding: 12px 18px;
color: var(--fg); font-size: 13px;
transform: translateY(80px); opacity: 0;
transition: all .25s; z-index: 200;
}
.toast.show { transform: translateY(0); opacity: 1; }
.toast.error { border-color: var(--red); color: var(--red); }
/* ── Hamburger button ── */
.hamburger {
display: flex;
background: none;
border: none;
cursor: pointer;
padding: 4px 6px;
flex-direction: column;
gap: 4px;
align-items: center;
justify-content: center;
}
.hamburger span {
display: block;
width: 18px;
height: 2px;
background: var(--fg);
border-radius: 1px;
transition: all .2s;
}
/* ── Nav backdrop (mobile) ── */
.nav-backdrop {
display: none;
position: fixed;
inset: 0;
background: #000b;
z-index: 9;
}
.nav-backdrop.open { display: block; }
/* ── Mobile responsive ── */
@media (max-width: 768px) {
nav {
transform: translateX(-100%);
z-index: 100;
}
nav.nav-open {
transform: translateX(0);
}
.main-wrap {
margin-left: 0;
}
main {
padding: 16px;
gap: 16px;
}
.topbar {
padding: 0 16px;
}
.modal {
min-width: unset !important;
width: calc(100vw - 32px);
max-width: calc(100vw - 32px);
padding: 18px;
}
}
</style>
{% block head %}{% endblock %}
</head>
<body>
<nav>
<div class="nav-logo">Cor<span>vid</span></div>
<div class="nav-links">
<a href="/tickets" class="nav-link {% if active_page == 'tickets' %}active{% endif %}">
<span class="nav-icon"></span> Tickets
</a>
</div>
</nav>
<div class="nav-backdrop" id="nav-backdrop" onclick="closeNav()"></div>
<div class="main-wrap">
<div class="topbar">
<div style="display:flex;align-items:center;gap:10px;">
<button class="hamburger" onclick="toggleNav()" aria-label="Toggle menu">
<span></span><span></span><span></span>
</button>
<span class="topbar-title">{% block page_title %}{% endblock %}</span>
</div>
<span class="topbar-meta">{% block topbar_meta %}{% endblock %}</span>
</div>
<main>
{% block content %}{% endblock %}
</main>
</div>
<div class="toast" id="toast"></div>
<script>
function toast(msg, err=false) {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast show' + (err ? ' error' : '');
setTimeout(() => el.className = 'toast', 3500);
}
async function api(path, opts={}) {
const r = await fetch(path, opts);
if (!r.ok) { const t = await r.text(); throw new Error(t); }
return r.json();
}
function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function fmt_date(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleDateString('en-IE', {year:'numeric',month:'short',day:'numeric'});
}
</script>
<script>
function toggleNav() {
if (window.innerWidth <= 768) {
document.querySelector('nav').classList.toggle('nav-open');
document.getElementById('nav-backdrop').classList.toggle('open');
} else {
const nav = document.querySelector('nav');
const main = document.querySelector('.main-wrap');
nav.classList.toggle('nav-collapsed');
main.classList.toggle('nav-collapsed');
localStorage.setItem('sidebar', nav.classList.contains('nav-collapsed') ? 'collapsed' : 'open');
}
}
function closeNav() {
document.querySelector('nav').classList.remove('nav-open');
document.getElementById('nav-backdrop').classList.remove('open');
}
document.querySelectorAll('.nav-link').forEach(a => {
a.addEventListener('click', closeNav);
});
if (window.innerWidth > 768 && localStorage.getItem('sidebar') === 'collapsed') {
document.querySelector('nav').classList.add('nav-collapsed');
document.querySelector('.main-wrap').classList.add('nav-collapsed');
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,7 @@ dependencies = [
"uvicorn[standard]>=0.32",
"alembic>=1.14",
"pydantic>=2.0",
"jinja2>=3.1",
]
[project.optional-dependencies]