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 alembic/ alembic/
COPY corvid/ corvid/ COPY corvid/ corvid/
COPY entrypoint.sh . COPY entrypoint.sh .
# templates are bundled inside corvid/ package dir — no extra COPY needed
RUN chmod +x entrypoint.sh RUN chmod +x entrypoint.sh
EXPOSE 8080 EXPOSE 8080

View file

@ -1,11 +1,17 @@
import os
from contextlib import asynccontextmanager 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 .config import TICKETS_API_KEY
from .database import get_db from .database import get_db
from .router import make_router from .router import make_router
_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
templates = Jinja2Templates(directory=_TEMPLATE_DIR)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): 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.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") @app.get("/health")
async def health(): async def health():
return {"status": "ok"} 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"} return {"username": "claude", "email": "claude@internal"}
if require_user is not None: if require_user is not None:
return require_user(request) return require_user(request)
if not api_key:
# No key configured — open / local mode
return {"username": "local", "email": "local@localhost"}
raise HTTPException(401, "unauthorized") raise HTTPException(401, "unauthorized")
# ── Ticket endpoints ────────────────────────────────────────────────────── # ── 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", "uvicorn[standard]>=0.32",
"alembic>=1.14", "alembic>=1.14",
"pydantic>=2.0", "pydantic>=2.0",
"jinja2>=3.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]