diff --git a/Dockerfile b/Dockerfile index 4b42e3d..7bf1513 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,9 +5,14 @@ RUN go mod download COPY . . ARG VERSION=dev RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=${VERSION}" -o /out/vibeproxy ./cmd/vibeproxy +# distroless:nonroot runs as uid:gid 65532:65532 with no shell, so create +# and own /data here — Docker seeds a fresh named volume's permissions +# from the image directory it overlays, which is otherwise root:root. +RUN mkdir -p /out/data && chown 65532:65532 /out/data FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /out/vibeproxy /usr/local/bin/vibeproxy +COPY --from=build --chown=65532:65532 /out/data /data VOLUME ["/data"] # The nonroot container user can't bind ports <1024, so vibeproxy listens # on 8080/8443 inside the container; map host 80/443 to these in diff --git a/internal/web/static/css/app.css b/internal/web/static/css/app.css index df2581c..59f7016 100644 --- a/internal/web/static/css/app.css +++ b/internal/web/static/css/app.css @@ -1,74 +1,170 @@ -:root { +:root, +[data-bs-theme="dark"] { + --vp-bg: #0a0d12; + --vp-surface: #11151c; + --vp-surface-2: #161b24; + --vp-border: #232833; + --vp-text: #e6e9ef; + --vp-text-muted: #8b93a3; + --vp-text-faint: #5c6577; + --vp-blue: #3b82f6; + --vp-blue-dark: #1d4ed8; + --vp-blue-light: #60a5fa; + --vp-green: #22c55e; + --vp-red: #ef4444; + --vp-amber: #f59e0b; + --vp-topbar-h: 56px; --vp-sidebar-w: 240px; } +[data-bs-theme="light"] { + --vp-bg: #f3f5f8; + --vp-surface: #ffffff; + --vp-surface-2: #f0f2f6; + --vp-border: #e0e4ea; + --vp-text: #1a1f29; + --vp-text-muted: #5c6577; + --vp-text-faint: #8b93a3; + --vp-blue: #2563eb; + --vp-blue-dark: #1d4ed8; + --vp-blue-light: #3b82f6; + --vp-green: #16a34a; + --vp-red: #dc2626; + --vp-amber: #d97706; +} + body { - background-color: #f4f6f9; + background-color: var(--vp-bg); + color: var(--vp-text); } -.vp-shell { - display: flex; - min-height: 100vh; +a { + color: var(--vp-blue-light); } -.vp-sidebar { - width: var(--vp-sidebar-w); - flex-shrink: 0; - background-color: #1b2430; - color: #cfd6e2; +/* ---------- Top bar ---------- */ + +.vp-topbar { + height: var(--vp-topbar-h); + padding: 0 1.25rem; + background: linear-gradient(90deg, var(--vp-blue-dark) 0%, var(--vp-blue) 55%, var(--vp-blue-light) 100%); position: sticky; top: 0; - height: 100vh; + z-index: 1030; } .vp-brand { color: #fff; text-decoration: none; - font-weight: 600; - font-size: 1.1rem; - padding: 1.1rem 1.25rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); + font-weight: 700; + font-size: 1.15rem; + letter-spacing: -0.01em; } .vp-brand i { - color: #4dabf7; + font-size: 1.3rem; +} + +.vp-icon-btn { + background: rgba(255, 255, 255, 0.14); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; + border-radius: 0.5rem; + width: 2.25rem; + height: 2.25rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.vp-icon-btn:hover { + background: rgba(255, 255, 255, 0.24); + color: #fff; +} + +.vp-user-badge { + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(255, 255, 255, 0.18); + color: #fff; + border-radius: 0.5rem; + padding: 0.4rem 0.85rem; + font-weight: 600; + font-size: 0.9rem; + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +/* ---------- Shell / sidebar ---------- */ + +.vp-shell { + display: flex; + min-height: calc(100vh - var(--vp-topbar-h)); +} + +.vp-sidebar { + width: var(--vp-sidebar-w); + flex-shrink: 0; + background-color: var(--vp-bg); + border-right: 1px solid var(--vp-border); + position: sticky; + top: var(--vp-topbar-h); + height: calc(100vh - var(--vp-topbar-h)); } .vp-nav { - padding: 0.75rem 0.5rem; + padding: 1rem 0.75rem; flex: 1; + overflow-y: auto; +} + +.vp-nav-label { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--vp-text-faint); + text-transform: uppercase; + padding: 1rem 0.6rem 0.35rem; } .vp-nav .nav-link { - color: #a9b4c4; + color: var(--vp-text-muted); border-radius: 0.4rem; - padding: 0.55rem 0.85rem; - margin-bottom: 0.15rem; + padding: 0.5rem 0.7rem; + margin-bottom: 0.1rem; font-size: 0.92rem; + display: flex; + align-items: center; } .vp-nav .nav-link i { - width: 1.25rem; - display: inline-block; + width: 1.4rem; + font-size: 1rem; text-align: center; - margin-right: 0.4rem; + margin-right: 0.55rem; + color: inherit; } .vp-nav .nav-link:hover { - background-color: rgba(255, 255, 255, 0.06); - color: #fff; + background-color: var(--vp-surface-2); + color: var(--vp-text); } .vp-nav .nav-link.active { - background-color: #2c5cc5; - color: #fff; + background-color: rgba(59, 130, 246, 0.16); + color: var(--vp-blue-light); + font-weight: 600; } .vp-sidebar-footer { padding: 0.85rem 1.25rem; - border-top: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid var(--vp-border); + color: var(--vp-text-faint) !important; } +/* ---------- Main content ---------- */ + .vp-main { flex: 1; min-width: 0; @@ -76,17 +172,257 @@ body { flex-direction: column; } -.vp-topbar { - background: #fff; - border-bottom: 1px solid #e5e9f0; - padding: 0.9rem 1.5rem; +.vp-page-header { + padding: 1.25rem 1.75rem 0; +} + +.vp-page-header h1 { + font-weight: 700; } .vp-content { - padding: 1.5rem; + padding: 1.25rem 1.75rem 2rem; flex: 1; } +/* ---------- Cards / tables ---------- */ + +.card { + background-color: var(--vp-surface); + border: 1px solid var(--vp-border); + border-radius: 0.65rem; +} + +.card-header { + background-color: transparent; + border-bottom: 1px solid var(--vp-border); + font-weight: 600; + color: var(--vp-text); +} + +.table { + --bs-table-bg: transparent; + --bs-table-color: var(--vp-text); + --bs-table-border-color: var(--vp-border); + --bs-table-hover-bg: var(--vp-surface-2); + --bs-table-hover-color: var(--vp-text); + margin-bottom: 0; +} + +.table thead th { + text-transform: uppercase; + font-size: 0.72rem; + letter-spacing: 0.06em; + font-weight: 700; + color: var(--vp-text-faint); + border-bottom: 1px solid var(--vp-border); + background-color: transparent; +} + +.table-light, +.table > :not(caption) > * > * { + background-color: transparent; +} + +/* ---------- Forms ---------- */ + +.form-control, +.form-select { + background-color: var(--vp-surface-2); + border: 1px solid var(--vp-border); + color: var(--vp-text); +} + +.form-control:focus, +.form-select:focus { + background-color: var(--vp-surface-2); + border-color: var(--vp-blue); + color: var(--vp-text); + box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.2); +} + +.form-control::placeholder { + color: var(--vp-text-faint); +} + +.form-label { + color: var(--vp-text-muted); + font-size: 0.85rem; + font-weight: 600; +} + +.input-icon { + position: relative; +} + +.input-icon i { + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: var(--vp-text-faint); +} + +.input-icon .form-control { + padding-left: 2.25rem; +} + +/* ---------- Buttons ---------- */ + +.btn-primary { + background-color: var(--vp-blue); + border-color: var(--vp-blue); +} + +.btn-primary:hover { + background-color: var(--vp-blue-dark); + border-color: var(--vp-blue-dark); +} + +.btn-outline-primary { + color: var(--vp-blue-light); + border-color: var(--vp-blue); +} + +.btn-outline-primary:hover { + background-color: var(--vp-blue); + border-color: var(--vp-blue); +} + +.btn-outline-secondary { + color: var(--vp-text-muted); + border-color: var(--vp-border); +} + +.btn-outline-secondary:hover { + background-color: var(--vp-surface-2); + color: var(--vp-text); + border-color: var(--vp-border); +} + +.btn-outline-danger { + color: #f87171; + border-color: #7f1d1d; +} + +.btn-outline-danger:hover { + background-color: var(--vp-red); + border-color: var(--vp-red); +} + +/* ---------- Badges ---------- */ + +.badge.text-bg-success { + background-color: var(--vp-green) !important; + color: #062b13; +} + +.badge.text-bg-danger { + background-color: var(--vp-red) !important; +} + +.badge.text-bg-secondary { + background-color: var(--vp-surface-2) !important; + color: var(--vp-text-muted) !important; + border: 1px solid var(--vp-border); +} + +.badge.text-bg-info { + background-color: rgba(59, 130, 246, 0.18) !important; + color: var(--vp-blue-light) !important; +} + +.badge.text-bg-warning { + background-color: rgba(245, 158, 11, 0.18) !important; + color: var(--vp-amber) !important; +} + +.badge.text-bg-light { + background-color: var(--vp-surface-2) !important; + color: var(--vp-text-muted) !important; + border: 1px solid var(--vp-border) !important; +} + +/* ---------- Stat cards ---------- */ + +.stat-card { + border: 1px solid var(--vp-border); + border-radius: 0.65rem; + background-color: var(--vp-surface); +} + +.stat-card .card-body { + padding: 1.1rem 1.25rem; +} + +.stat-card .stat-label { + text-transform: uppercase; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--vp-text-faint); +} + +.stat-card .stat-icon { + color: var(--vp-text-faint); + font-size: 1.1rem; +} + +.stat-card .stat-value { + font-size: 1.9rem; + font-weight: 700; + color: var(--vp-text); + line-height: 1.2; + margin-top: 0.15rem; +} + +.stat-card .stat-sub { + color: var(--vp-text-faint); + font-size: 0.8rem; + margin-top: 0.15rem; +} + +/* ---------- Modals ---------- */ + +.modal-content { + background-color: var(--vp-surface); + border: 1px solid var(--vp-border); + color: var(--vp-text); +} + +.modal-header, +.modal-footer { + border-color: var(--vp-border); +} + +/* ---------- Nav pills / tabs ---------- */ + +.nav-tabs { + border-bottom-color: var(--vp-border); +} + +.nav-tabs .nav-link { + color: var(--vp-text-muted); + border: none; + border-bottom: 2px solid transparent; +} + +.nav-tabs .nav-link.active { + background: transparent; + color: var(--vp-blue-light); + border-bottom: 2px solid var(--vp-blue); +} + +.nav-pills .nav-link { + color: var(--vp-text-muted); +} + +.nav-pills .nav-link.active { + background-color: var(--vp-blue); +} + +/* ---------- Misc ---------- */ + .vp-chip-list { display: flex; flex-wrap: wrap; @@ -97,22 +433,33 @@ body { .vp-chip { font-weight: 500; padding: 0.4rem 0.6rem; + background-color: var(--vp-surface-2) !important; + border: 1px solid var(--vp-border); } -.stat-card { - border: none; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); -} - -.stat-card .stat-value { - font-size: 1.7rem; - font-weight: 700; -} - -.table-hover tbody tr { - cursor: default; -} - -code.vp-token { +code, pre, code.vp-token { + color: var(--vp-blue-light); word-break: break-all; } + +pre.bg-light { + background-color: var(--vp-surface-2) !important; + color: var(--vp-text); + border: 1px solid var(--vp-border); +} + +.text-secondary { + color: var(--vp-text-muted) !important; +} + +.pagination .page-link { + background-color: var(--vp-surface); + border-color: var(--vp-border); + color: var(--vp-text-muted); +} + +.pagination .page-item.active .page-link { + background-color: var(--vp-blue); + border-color: var(--vp-blue); + color: #fff; +} diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index 480085e..0e32155 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -2,6 +2,24 @@ (function () { "use strict"; + // Light/dark theme toggle, persisted in localStorage. + var themeBtn = document.querySelector("[data-theme-toggle]"); + if (themeBtn) { + var icon = themeBtn.querySelector("i"); + function syncIcon() { + var isLight = document.documentElement.getAttribute("data-bs-theme") === "light"; + icon.className = isLight ? "bi bi-sun" : "bi bi-moon-stars"; + } + syncIcon(); + themeBtn.addEventListener("click", function () { + var current = document.documentElement.getAttribute("data-bs-theme") === "light" ? "light" : "dark"; + var next = current === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-bs-theme", next); + localStorage.setItem("vp-theme", next); + syncIcon(); + }); + } + // Generic delete-confirmation modal. Any element with // data-confirm-delete-form="