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:
parent
ff7620d5ed
commit
dd799cea8e
6 changed files with 1420 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
395
corvid/templates/base.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
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>
|
||||
1001
corvid/templates/tickets.html
Normal file
1001
corvid/templates/tickets.html
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -14,6 +14,7 @@ dependencies = [
|
|||
"uvicorn[standard]>=0.32",
|
||||
"alembic>=1.14",
|
||||
"pydantic>=2.0",
|
||||
"jinja2>=3.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue