From 263ac54a6017cdd6730d712bc81436f22e5dab0b Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Sat, 20 Jun 2026 16:05:02 -0500 Subject: [PATCH] feat(ui): add dashboard theming and docs --- .env.example | 9 + .gitignore | 3 + README.md | 132 +++++++++++++- web/static/css/app.css | 379 +++++++++++++++++++++++++++++++++++++++++ web/static/js/app.js | 34 ++++ 5 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 web/static/css/app.css create mode 100644 web/static/js/app.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..23e3432 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +MAINTAINARR_ADDR=:8080 +MAINTAINARR_DB_PATH=data/maintainarr.db +MAINTAINARR_SESSION_KEY=change-me-session-key-please +MAINTAINARR_ENCRYPTION_KEY=change-me-encryption-key-32bytes +MAINTAINARR_ORG_NAME=Maintainarr +MAINTAINARR_BASE_URL=http://localhost:8080 +MAINTAINARR_THEME=emerald +MAINTAINARR_REFRESH_CRON=@every 5m +MAINTAINARR_BOOTSTRAP_ADMIN=true diff --git a/.gitignore b/.gitignore index 5b90e79..ce1e4ff 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ go.work.sum # env file .env +# app data +data/ + diff --git a/README.md b/README.md index a3269c0..25a67aa 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,133 @@ # Maintainarr -A selfhosted tool to automatically, configure, update, monitor, and deploy VM's \ No newline at end of file +Maintainarr is a self-hosted Go application for managing a single organization's Linux VM fleet from one dashboard. + +It ships with: + +- SQLite-backed persistence +- User registration and login +- OTP-based 2FA setup and verification +- Role model with `admin`, `editor`, and `viewer` +- Sidebar dashboard with theme tokens compatible with [UI Colors](https://uicolors.app/generate) +- VM chip grid with distro icon, CPU/RAM bars, IP, and uptime +- Node overview pages with action buttons and SSH web console +- Wake-on-LAN, restart, shutdown, stat refresh, and apt-upgrade action hooks +- VM groups and recurring/triggered automation job scaffolding +- Go-first backend structure intended for adding more fleet features without rewrites + +## Branch Model + +- `main`: stable branch +- `dev`: active feature branch + +The repository has been initialized with both `main` and `dev`. Work should land in `dev` first. + +## Stack + +- Go `1.25` +- `chi` router +- `modernc.org/sqlite` for embedded SQLite +- `gorilla/sessions` for cookie sessions +- `pquerna/otp` for TOTP 2FA +- `golang.org/x/crypto/ssh` for remote command and console transport +- Embedded Go HTML templates plus local static assets + +## Project Layout + +```text +cmd/maintainarr entrypoint +internal/app app bootstrap and routing +internal/config environment configuration +internal/db sqlite connection and schema bootstrap +internal/handlers HTTP handlers and websocket console +internal/middleware auth and role enforcement +internal/models domain models +internal/services auth, crypto, nodes, scheduler, repository +internal/views embedded templates and view helpers +web/static CSS and browser-side JS +``` + +## Run + +```powershell +go run ./cmd/maintainarr +``` + +Default address: `http://localhost:8080` + +## Bootstrap + +If the database is empty and `MAINTAINARR_BOOTSTRAP_ADMIN=true`, the app seeds: + +- email: `admin@maintainarr.local` +- password: `admin123!` + +That account is forced through OTP setup on first sign-in. + +The app also seeds example nodes, groups, and automation jobs so the UI is not blank on first boot. + +## Environment + +```env +MAINTAINARR_ADDR=:8080 +MAINTAINARR_DB_PATH=data/maintainarr.db +MAINTAINARR_SESSION_KEY=change-me-session-key-please +MAINTAINARR_ENCRYPTION_KEY=change-me-encryption-key-32bytes +MAINTAINARR_ORG_NAME=Maintainarr +MAINTAINARR_BASE_URL=http://localhost:8080 +MAINTAINARR_THEME=emerald +MAINTAINARR_REFRESH_CRON=@every 5m +MAINTAINARR_BOOTSTRAP_ADMIN=true +``` + +## Roles + +- `admin`: full access, intended for user management and future organization settings +- `editor`: can operate nodes and create automation jobs +- `viewer`: read-only access + +## Theme System + +The dashboard is styled around `uicolors.app` style primary scale variables: + +```css +--color-primary-50 +--color-primary-100 +--color-primary-200 +--color-primary-300 +--color-primary-400 +--color-primary-500 +--color-primary-600 +--color-primary-700 +--color-primary-800 +--color-primary-900 +--color-primary-950 +``` + +Replace those values in [`web/static/css/app.css`](C:/Users/spenc/Documents/GitHub/Maintainarr/web/static/css/app.css) or feed them through a future admin theme editor to recolor the interface. + +## Current Scope + +This repository now includes the base architecture for: + +- auth and sessions +- role-aware route protection +- dashboard rendering +- node actions +- SSH-backed console transport +- SQLite schema for future fleet features +- recurring automation scheduling + +## Next Expansions + +The backend shape is ready for: + +- user management screens +- node create/edit flows +- SSH key auth +- richer live metrics collection +- audit logs +- package policy models +- triggered event ingestion +- per-group maintenance windows +- multi-step provisioning workflows diff --git a/web/static/css/app.css b/web/static/css/app.css new file mode 100644 index 0000000..4273186 --- /dev/null +++ b/web/static/css/app.css @@ -0,0 +1,379 @@ +:root { + --color-primary-50: 239 246 255; + --color-primary-100: 219 234 254; + --color-primary-200: 191 219 254; + --color-primary-300: 147 197 253; + --color-primary-400: 96 165 250; + --color-primary-500: 59 130 246; + --color-primary-600: 37 99 235; + --color-primary-700: 29 78 216; + --color-primary-800: 30 64 175; + --color-primary-900: 30 58 138; + --color-primary-950: 23 37 84; + --bg: #08111f; + --panel: rgba(11, 21, 38, 0.78); + --panel-strong: rgba(9, 17, 30, 0.96); + --text: #edf4ff; + --muted: #8fa3c0; + --border: rgba(170, 196, 255, 0.12); + --danger: #ef4444; + --success: #22c55e; + --warning: #f59e0b; + --shadow: 0 24px 80px rgba(2, 6, 23, 0.45); + --radius: 26px; + --radius-sm: 16px; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(var(--color-primary-500), 0.26), transparent 32%), + radial-gradient(circle at top right, rgba(var(--color-primary-700), 0.24), transparent 28%), + linear-gradient(135deg, #050b15 0%, #091423 45%, #08111f 100%); + font-family: "Space Grotesk", "Segoe UI", sans-serif; + min-height: 100vh; +} + +a { color: inherit; text-decoration: none; } +img { max-width: 100%; display: block; } +input, select, textarea, button { + font: inherit; +} + +.auth-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 2rem; +} + +.auth-card, .panel, .node-chip, .stat-card, .group-card { + backdrop-filter: blur(18px); + background: var(--panel); + border: 1px solid var(--border); + box-shadow: var(--shadow); +} + +.auth-card { + width: min(1080px, 100%); + display: grid; + grid-template-columns: 1.1fr .9fr; + gap: 2rem; + padding: 2rem; + border-radius: 32px; +} + +.auth-brand { + padding: 2rem; + border-radius: 28px; + background: + linear-gradient(160deg, rgba(var(--color-primary-600), 0.35), rgba(15, 23, 42, 0.25)), + rgba(255, 255, 255, 0.02); +} + +.auth-brand h1, .page-head h1 { margin: .5rem 0 1rem; font-size: clamp(2.2rem, 4vw, 4.25rem); line-height: .95; } +.auth-brand p, .page-head p, .section-title p, .form-header p { color: var(--muted); } +.auth-form-wrap { padding: 1rem; display: flex; align-items: center; } +.form-header h2 { margin: 0 0 .5rem; } +.brand-pill, .eyebrow { + display: inline-flex; + align-items: center; + gap: .4rem; + border: 1px solid rgba(var(--color-primary-300), .2); + color: rgb(var(--color-primary-200)); + background: rgba(var(--color-primary-500), .12); + border-radius: 999px; + padding: .45rem .8rem; + font-size: .8rem; + letter-spacing: .08em; + text-transform: uppercase; +} + +.stack { display: grid; gap: 1rem; width: 100%; } +label span { display: block; margin-bottom: .4rem; color: var(--muted); font-size: .9rem; } +input, select, textarea { + width: 100%; + border: 1px solid var(--border); + background: rgba(8, 15, 27, 0.84); + color: var(--text); + border-radius: 14px; + padding: .95rem 1rem; +} + +.button { + border: none; + border-radius: 14px; + padding: .9rem 1.1rem; + display: inline-flex; + justify-content: center; + align-items: center; + gap: .6rem; + cursor: pointer; + transition: transform .2s ease, background .2s ease, border-color .2s ease; +} +.button:hover { transform: translateY(-1px); } +.button.primary { + color: white; + background: linear-gradient(135deg, rgb(var(--color-primary-500)), rgb(var(--color-primary-700))); +} +.button.ghost { + background: rgba(255, 255, 255, 0.04); + color: var(--text); + border: 1px solid var(--border); +} +.button.danger { background: rgba(239, 68, 68, .18); color: #fecaca; border: 1px solid rgba(239, 68, 68, .25); } + +.alert { + padding: .9rem 1rem; + border-radius: 14px; + background: rgba(239, 68, 68, .12); + border: 1px solid rgba(239, 68, 68, .2); + color: #fecaca; +} + +.auth-meta { margin-top: 1rem; color: var(--muted); } +.auth-meta a { color: rgb(var(--color-primary-300)); } + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 300px 1fr; +} + +.sidebar { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 1.5rem; + background: rgba(5, 10, 18, 0.78); + border-right: 1px solid var(--border); +} + +.sidebar-brand { + display: flex; + gap: 1rem; + align-items: center; + padding: 1rem; + margin-bottom: 1.5rem; + background: rgba(255, 255, 255, 0.03); + border-radius: 22px; +} + +.brand-mark { + width: 52px; + height: 52px; + display: grid; + place-items: center; + border-radius: 18px; + background: linear-gradient(135deg, rgba(var(--color-primary-500), .45), rgba(var(--color-primary-800), .35)); +} + +.brand-mark svg, .sidebar-nav svg, .logout-link svg { width: 20px; height: 20px; } +.sidebar-brand span, .user-card small { color: var(--muted); display: block; margin-top: .2rem; } + +.sidebar-nav { + display: grid; + gap: .6rem; +} + +.sidebar-nav a, .logout-link { + display: flex; + gap: .8rem; + align-items: center; + padding: .95rem 1rem; + border-radius: 16px; + color: var(--muted); +} + +.sidebar-nav a.active, +.sidebar-nav a:hover, +.logout-link:hover { + color: var(--text); + background: rgba(var(--color-primary-500), .12); +} + +.sidebar-footer { + display: grid; + gap: 1rem; +} + +.user-card { + padding: 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.03); +} + +.content { + padding: 2rem; + overflow: auto; +} + +.page-head { + display: flex; + justify-content: space-between; + gap: 2rem; + align-items: end; + margin-bottom: 1.8rem; +} + +.hero-stat { + min-width: 180px; + padding: 1.25rem; + border-radius: 22px; + background: linear-gradient(160deg, rgba(var(--color-primary-500), .18), rgba(255, 255, 255, .03)); + border: 1px solid rgba(var(--color-primary-300), .15); +} +.hero-stat strong { display: block; font-size: 2.4rem; } +.hero-stat span { color: var(--muted); } + +.chip-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); + gap: 1.2rem; +} + +.node-chip { + padding: 1.2rem; + border-radius: var(--radius); + transition: transform .2s ease, border-color .2s ease; +} +.node-chip:hover { transform: translateY(-4px); border-color: rgba(var(--color-primary-300), .25); } + +.chip-top, .chip-bottom, .metric-label, .section-title, .action-row, .meta-list div, .run-item { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.chip-top { align-items: center; margin-bottom: 1rem; } +.chip-icon { + width: 52px; + height: 52px; + display: grid; + place-items: center; + border-radius: 18px; + color: white; + background: linear-gradient(135deg, rgba(var(--color-primary-500), .75), rgba(var(--color-primary-800), .92)); +} +.chip-icon svg { width: 28px; height: 28px; } +.chip-top h3 { margin: 0; } +.chip-top p, .chip-bottom, .metric-label span { margin: 0; color: var(--muted); } + +.status-dot { + width: 12px; + height: 12px; + border-radius: 999px; + background: rgba(255,255,255,.2); +} +.status-dot.online { background: var(--success); box-shadow: 0 0 0 8px rgba(34, 197, 94, .12); } + +.metric { margin-top: .85rem; } +.metric-label strong { font-size: .95rem; } +.bar { + margin-top: .5rem; + height: 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; +} +.bar span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, rgb(var(--color-primary-400)), rgb(var(--color-primary-600))); +} + +.panel { + padding: 1.4rem; + border-radius: var(--radius); + margin-top: 1.6rem; +} + +.table-wrap { overflow: auto; } +table { width: 100%; border-collapse: collapse; } +th, td { padding: 1rem .75rem; text-align: left; border-bottom: 1px solid var(--border); } +th { color: var(--muted); font-weight: 500; } + +.stats-grid, .group-grid, .theme-preview { + display: grid; + gap: 1rem; +} +.stats-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } +.group-grid, .theme-preview { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } + +.stat-card, .group-card, .preview-card { + padding: 1.25rem; + border-radius: 24px; +} +.stat-card strong { display: block; margin-top: .6rem; font-size: 2rem; } +.stat-card span { color: var(--muted); } + +.node-layout, .two-col, .otp-panel { + display: grid; + gap: 1.2rem; +} +.node-layout, .two-col { grid-template-columns: 1fr 1fr; } +.otp-panel { grid-template-columns: 300px 1fr; align-items: center; } +.qr-panel { + padding: 1rem; + border-radius: 24px; + background: white; +} + +.meta-list { + display: grid; + gap: .85rem; + margin-top: 1.2rem; +} +.meta-list span, .run-item p { color: var(--muted); } +.meta-list strong { text-align: right; } +.run-list { display: grid; gap: 1rem; } +.run-item { + align-items: start; + padding: 1rem; + border-radius: 18px; + background: rgba(255,255,255,.03); +} +.run-item pre, .code-block, .console-output { + white-space: pre-wrap; + word-break: break-word; + font-family: "JetBrains Mono", monospace; + font-size: .9rem; +} +.run-item pre, .code-block { + flex: 1; + margin: 0; + color: #c9dcff; +} + +.console-output { + min-height: 420px; + max-height: 60vh; + overflow: auto; + padding: 1rem; + border-radius: 18px; + background: rgba(3, 7, 18, .88); + border: 1px solid var(--border); +} +.console-form { + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; + margin-top: 1rem; +} + +.preview-card { + min-height: 120px; + background: linear-gradient(145deg, rgba(var(--color-primary-500), .4), rgba(var(--color-primary-900), .8)); +} +.preview-card.accent { background: linear-gradient(145deg, rgba(var(--color-primary-200), .35), rgba(var(--color-primary-700), .8)); } +.preview-card.dim { background: rgba(255, 255, 255, .03); } + +@media (max-width: 960px) { + .app-shell, .auth-card, .node-layout, .two-col, .otp-panel { grid-template-columns: 1fr; } + .sidebar { position: sticky; top: 0; z-index: 20; } + .page-head { flex-direction: column; align-items: start; } +} diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..5c4e60b --- /dev/null +++ b/web/static/js/app.js @@ -0,0 +1,34 @@ +document.addEventListener("DOMContentLoaded", () => { + const form = document.getElementById("console-form"); + const output = document.getElementById("console"); + const input = document.getElementById("console-input"); + + if (!form || !output || !input) { + return; + } + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const socket = new WebSocket(`${protocol}//${window.location.host}${form.dataset.ws}`); + + socket.addEventListener("message", (event) => { + output.textContent += event.data; + output.scrollTop = output.scrollHeight; + }); + + socket.addEventListener("open", () => { + output.textContent += "Connected to remote shell.\n"; + }); + + socket.addEventListener("close", () => { + output.textContent += "\nSession closed.\n"; + }); + + form.addEventListener("submit", (event) => { + event.preventDefault(); + if (socket.readyState !== WebSocket.OPEN) { + return; + } + socket.send(`${input.value}\n`); + input.value = ""; + }); +});