diff --git a/.gitignore b/.gitignore index 4d9a47e..da3cf51 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,8 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts /frontend/data/uploads/ +/frontend/data/*.db +/frontend/data/*.db-wal +/frontend/data/*.db-shm +/frontend/data/meta.json.migrated +/frontend/data/users.json.migrated diff --git a/frontend/app/admin/AdminTabLinkClient.tsx b/frontend/app/admin/AdminTabLinkClient.tsx new file mode 100644 index 0000000..5294ea5 --- /dev/null +++ b/frontend/app/admin/AdminTabLinkClient.tsx @@ -0,0 +1,30 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' + +export default function AdminTabLinkClient({ + href, + label, + exact, +}: { + href: string + label: string + exact?: boolean +}) { + const pathname = usePathname() + const isActive = exact ? pathname === href : pathname.startsWith(href) + + return ( + + {label} + + ) +} diff --git a/frontend/app/admin/LinksClient.tsx b/frontend/app/admin/LinksClient.tsx new file mode 100644 index 0000000..49e503f --- /dev/null +++ b/frontend/app/admin/LinksClient.tsx @@ -0,0 +1,256 @@ +'use client' + +import { useState, useCallback } from 'react' +import type { LinkEntry } from '@/lib/links-db' + +interface Props { + initialEntries: LinkEntry[] +} + +const inputStyle: React.CSSProperties = { + background: 'rgba(255,255,255,0.04)', + border: '1px solid rgba(255,255,255,0.1)', + color: '#e2e8f0', + borderRadius: '0.625rem', + padding: '0.5rem 0.75rem', + fontSize: '0.875rem', + outline: 'none', + width: '100%', + fontFamily: 'var(--font-geist-mono)', +} + +export default function LinksClient({ initialEntries }: Props) { + const [entries, setEntries] = useState(initialEntries) + const [editing, setEditing] = useState>({}) + const [saving, setSaving] = useState(null) + const [resetting, setResetting] = useState(null) + const [error, setError] = useState('') + const [successMsg, setSuccessMsg] = useState('') + + function flash(msg: string) { + setSuccessMsg(msg) + setTimeout(() => setSuccessMsg(''), 3000) + } + + const refresh = useCallback(async () => { + const res = await fetch('/api/admin/links') + if (res.ok) setEntries((await res.json()) as LinkEntry[]) + }, []) + + function startEdit(key: string, current: string) { + setEditing((prev) => ({ ...prev, [key]: current })) + } + + function cancelEdit(key: string) { + setEditing((prev) => { + const next = { ...prev } + delete next[key] + return next + }) + setError('') + } + + async function handleSave(key: string) { + const value = (editing[key] ?? '').trim() + if (!value) { + setError('Value cannot be empty') + return + } + setError('') + setSaving(key) + const res = await fetch('/api/admin/links', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, value }), + }) + const data = (await res.json()) as { error?: string } + setSaving(null) + if (!res.ok) { + setError(data.error ?? 'Failed to save') + return + } + cancelEdit(key) + await refresh() + flash('Link updated — changes are live immediately') + } + + async function handleReset(key: string, defaultValue: string) { + if (!confirm(`Reset "${key}" to default?\n\nDefault: ${defaultValue}`)) return + setError('') + setResetting(key) + const res = await fetch(`/api/admin/links/${encodeURIComponent(key)}`, { method: 'DELETE' }) + const data = (await res.json()) as { error?: string } + setResetting(null) + if (!res.ok) { + setError(data.error ?? 'Failed to reset') + return + } + cancelEdit(key) + await refresh() + flash(`"${key}" reset to default`) + } + + // Group entries + const groups = Array.from(new Set(entries.map((e) => e.group))) + + return ( +
+ {/* Flash messages */} + {successMsg && ( +
+ + {successMsg} +
+ )} + {error && ( +
+ + {error} +
+ )} + +

+ Overrides are stored in the database and take effect immediately — no redeploy needed. + Any key without an override falls back to the built-in default. +

+ + {groups.map((group) => ( +
+

+ {group} +

+
+ {entries + .filter((e) => e.group === group) + .map((entry) => { + const isEditing = entry.key in editing + const hasOverride = entry.override !== null + const isSaving = saving === entry.key + const isResetting = resetting === entry.key + + return ( +
+
+
+
+ {entry.label} + {hasOverride && ( + + + overridden + + )} +
+

{entry.description}

+
+
+ {!isEditing && ( + + )} + {hasOverride && !isEditing && ( + + )} +
+
+ + {/* Current value display */} + {!isEditing && ( +
+ + {entry.currentValue} +
+ )} + + {/* Edit form */} + {isEditing && ( +
+ setEditing((prev) => ({ ...prev, [entry.key]: e.target.value }))} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave(entry.key) + if (e.key === 'Escape') cancelEdit(entry.key) + }} + autoFocus + style={inputStyle} + placeholder={entry.defaultValue} + /> + {hasOverride && ( +

+ Default: {entry.defaultValue} +

+ )} +
+ + +
+
+ )} +
+ ) + })} +
+
+ ))} +
+ ) +} diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx new file mode 100644 index 0000000..c251607 --- /dev/null +++ b/frontend/app/admin/layout.tsx @@ -0,0 +1,49 @@ +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/session' +import Link from 'next/link' + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const session = await getSession() + if (!session || session.role !== 'admin') { + redirect('/') + } + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Admin

+

Site management

+
+
+ + {/* Tab nav */} +
+ + +
+
+ + {children} +
+ ) +} + +function AdminTabLink({ href, label, exact }: { href: string; label: string; exact?: boolean }) { + // This is a server component but Next.js doesn't give us pathname here easily. + // We use a client wrapper below. + return +} + +// Small client component just for active-state detection +import AdminTabLinkClient from './AdminTabLinkClient' diff --git a/frontend/app/admin/links/page.tsx b/frontend/app/admin/links/page.tsx new file mode 100644 index 0000000..47aa4c7 --- /dev/null +++ b/frontend/app/admin/links/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/session' +import { getAllLinkEntries } from '@/lib/links-db' +import LinksClient from '../LinksClient' + +export const metadata: Metadata = { + title: 'Admin — Links — Backerup', +} + +export default async function AdminLinksPage() { + const session = await getSession() + if (!session || session.role !== 'admin') { + redirect('/') + } + + const entries = await getAllLinkEntries() + + return +} diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index 01cfe14..de74625 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -16,27 +16,5 @@ export default async function AdminPage() { const users = await listUsers() - return ( -
- {/* Header */} -
-
-
- -
-
-

User Management

-

Manage accounts and permissions

-
-
-
- - -
- ) + return } diff --git a/frontend/app/api/admin/links/[key]/route.ts b/frontend/app/api/admin/links/[key]/route.ts new file mode 100644 index 0000000..da9c6e3 --- /dev/null +++ b/frontend/app/api/admin/links/[key]/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/session' +import { resetLink, type LinkKey } from '@/lib/links-db' +import { LINKS } from '@/lib/links' + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ key: string }> }, +) { + const session = await getSession() + if (!session || session.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const { key } = await params + if (!(key in LINKS)) { + return NextResponse.json({ error: 'Unknown link key' }, { status: 400 }) + } + + await resetLink(key as LinkKey) + return NextResponse.json({ ok: true }) +} diff --git a/frontend/app/api/admin/links/route.ts b/frontend/app/api/admin/links/route.ts new file mode 100644 index 0000000..90a07c1 --- /dev/null +++ b/frontend/app/api/admin/links/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/session' +import { getAllLinkEntries, setLink, type LinkKey } from '@/lib/links-db' +import { LINKS } from '@/lib/links' + +export async function GET() { + const session = await getSession() + if (!session || session.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + const entries = await getAllLinkEntries() + return NextResponse.json(entries) +} + +export async function PUT(request: Request) { + const session = await getSession() + if (!session || session.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + let body: { key?: string; value?: string } + try { + body = (await request.json()) as { key?: string; value?: string } + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + const key = String(body.key ?? '').trim() as LinkKey + const value = String(body.value ?? '').trim() + + if (!key || !(key in LINKS)) { + return NextResponse.json({ error: 'Unknown link key' }, { status: 400 }) + } + if (!value) { + return NextResponse.json({ error: 'Value cannot be empty' }, { status: 400 }) + } + + await setLink(key, value) + return NextResponse.json({ ok: true }) +} diff --git a/frontend/app/components/ProductTabs.tsx b/frontend/app/components/ProductTabs.tsx new file mode 100644 index 0000000..1fee4f8 --- /dev/null +++ b/frontend/app/components/ProductTabs.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export default function ProductTabs() { + const pathname = usePathname(); + const isUpdaterup = pathname.startsWith("/updaterup"); + + return ( +
+ + Backerup + + + Updaterup + +
+ ); +} diff --git a/frontend/app/components/TerminalTyper.tsx b/frontend/app/components/TerminalTyper.tsx index 7919775..00f766e 100644 --- a/frontend/app/components/TerminalTyper.tsx +++ b/frontend/app/components/TerminalTyper.tsx @@ -3,39 +3,49 @@ import { useEffect, useState } from "react"; import { LINKS } from "@/lib/links"; +export interface TerminalLinks { + dockerImage?: string; + uiPort?: string; + updaterupDockerImage?: string; +} + interface Line { type: "comment" | "cmd" | "cmd-cont" | "output" | "success" | "error"; text: string; copyable?: boolean; } -function buildDockerRunLines(): Line[] { +function buildDockerRunLines(links: TerminalLinks): Line[] { + const dockerImage = links.dockerImage ?? LINKS.dockerImage; + const uiPort = links.uiPort ?? LINKS.uiPort; return [ { type: "comment", text: "# Quick start: Run Backerup directly" }, { type: "cmd", text: "docker run -d --name backerup \\", copyable: true }, - { type: "cmd-cont", text: ` -p ${LINKS.uiPort}:80 \\`, copyable: true }, + { type: "cmd-cont", text: ` -p ${uiPort}:80 \\`, copyable: true }, { type: "cmd-cont", text: " -v /var/run/docker.sock:/var/run/docker.sock \\", copyable: true }, { type: "cmd-cont", text: " -v ${BACKUP_DIR:-/backups}:/backups \\", copyable: true }, { type: "cmd-cont", text: " -v backerup-config:/app/config \\", copyable: true }, { type: "cmd-cont", text: " -e BACKUP_DIR=/backups \\", copyable: true }, { type: "cmd-cont", text: " -e BACKUP_CONFIG_DIR=/app/config \\", copyable: true }, { type: "cmd-cont", text: ` -e HOST_BACKUP_DIR=/backups \\`, copyable: true }, - { type: "cmd-cont", text: ` ${LINKS.dockerImage}`, copyable: true }, + { type: "cmd-cont", text: ` ${dockerImage}`, copyable: true }, { type: "output", text: "a7f82c3d1b09" }, { type: "success", text: "✓ Container started" }, - { type: "success", text: "✓ Ready at http://localhost:8080" }, + { type: "success", text: `✓ Ready at http://localhost:${uiPort}` }, ]; } -function buildDockerComposeLines(): Line[] { +function buildDockerComposeLines(links: TerminalLinks): Line[] { + const dockerImage = links.dockerImage ?? LINKS.dockerImage; + const uiPort = links.uiPort ?? LINKS.uiPort; return [ { type: "comment", text: "# Using docker-compose for easier management" }, { type: "cmd", text: "cat > docker-compose.yml << 'EOF'", copyable: false }, { type: "output", text: "services:" }, { type: "output", text: " backerup:" }, - { type: "output", text: ` image: ${LINKS.dockerImage}` }, + { type: "output", text: ` image: ${dockerImage}` }, { type: "output", text: " ports:" }, - { type: "output", text: ` - \"${LINKS.uiPort}:80\"` }, + { type: "output", text: ` - \"${uiPort}:80\"` }, { type: "output", text: " volumes:" }, { type: "output", text: " - /var/run/docker.sock:/var/run/docker.sock" }, { type: "output", text: " - ${BACKUP_DIR:-/backups}:/backups" }, @@ -51,12 +61,56 @@ function buildDockerComposeLines(): Line[] { { type: "success", text: "✓ docker-compose.yml created" }, { type: "cmd", text: "docker compose up -d", copyable: false }, { type: "output", text: "[+] Building 2.3s (12/12) FINISHED" }, + { type: "success", text: `✓ Ready at http://localhost:${uiPort}` }, + ]; +} + +function buildUpdaterupRunLines(links: TerminalLinks): Line[] { + const updaterupDockerImage = links.updaterupDockerImage ?? LINKS.updaterupDockerImage; + return [ + { type: "comment", text: "# Quick start: Run Updaterup directly" }, + { type: "cmd", text: "docker run -d --name updaterup \\", copyable: true }, + { type: "cmd-cont", text: " -p 8080:80 \\", copyable: true }, + { type: "cmd-cont", text: " -v /var/run/docker.sock:/var/run/docker.sock \\", copyable: true }, + { type: "cmd-cont", text: " -v updaterup-config:/app/config \\", copyable: true }, + { type: "cmd-cont", text: ` ${updaterupDockerImage}`, copyable: true }, + { type: "output", text: "b3c91d2e4f07" }, + { type: "success", text: "✓ Container started" }, { type: "success", text: "✓ Ready at http://localhost:8080" }, ]; } -function buildLines(mode: "docker-run" | "docker-compose" = "docker-run"): Line[] { - return mode === "docker-run" ? buildDockerRunLines() : buildDockerComposeLines(); +function buildUpdaterupComposeLines(links: TerminalLinks): Line[] { + const updaterupDockerImage = links.updaterupDockerImage ?? LINKS.updaterupDockerImage; + return [ + { type: "comment", text: "# Using docker-compose for Updaterup" }, + { type: "cmd", text: "cat > docker-compose.yml << 'EOF'", copyable: false }, + { type: "output", text: "services:" }, + { type: "output", text: " updaterup:" }, + { type: "output", text: ` image: ${updaterupDockerImage}` }, + { type: "output", text: " ports:" }, + { type: "output", text: " - \"8080:80\"" }, + { type: "output", text: " volumes:" }, + { type: "output", text: " - /var/run/docker.sock:/var/run/docker.sock" }, + { type: "output", text: " - updaterup-config:/app/config" }, + { type: "output", text: " restart: unless-stopped" }, + { type: "output", text: "volumes:" }, + { type: "output", text: " updaterup-config:" }, + { type: "output", text: "EOF" }, + { type: "success", text: "✓ docker-compose.yml created" }, + { type: "cmd", text: "docker compose up -d", copyable: false }, + { type: "output", text: "[+] Building 1.8s (10/10) FINISHED" }, + { type: "success", text: "✓ Ready at http://localhost:8080" }, + ]; +} + +function buildLines(mode: "docker-run" | "docker-compose" | "updaterup" | "updaterup-compose" = "docker-run", links: TerminalLinks = {}): Line[] { + switch (mode) { + case "docker-compose": return buildDockerComposeLines(links); + case "updaterup": return buildUpdaterupRunLines(links); + case "updaterup-compose": return buildUpdaterupComposeLines(links); + default: return buildDockerRunLines(links); + } } function lineSpeed(line: Line) { @@ -88,8 +142,8 @@ function linePrompt(line: Line): string { return ""; } -export default function TerminalTyper({ mode = "docker-run" }: { mode?: "docker-run" | "docker-compose" } = {}) { - const LINES = buildLines(mode); +export default function TerminalTyper({ mode = "docker-run", links = {} }: { mode?: "docker-run" | "docker-compose" | "updaterup" | "updaterup-compose"; links?: TerminalLinks } = {}) { + const LINES = buildLines(mode, links); const [doneLines, setDoneLines] = useState([]); const [current, setCurrent] = useState<{ idx: number; chars: number } | null>({ idx: 0, chars: 0 }); const [copied, setCopied] = useState(false); @@ -97,7 +151,7 @@ export default function TerminalTyper({ mode = "docker-run" }: { mode?: "docker- const copyToClipboard = () => { let textToCopy = ""; - if (mode === "docker-compose") { + if (mode === "docker-compose" || mode === "updaterup-compose") { // For docker-compose, copy the YAML content only const startIdx = LINES.findIndex(line => line.type === "output" && line.text === "services:"); const endIdx = LINES.findIndex((line, i) => i > startIdx && line.type === "output" && line.text === "EOF"); diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index fb62190..5cd1cb2 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -2,8 +2,10 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import Link from "next/link"; import { LINKS } from "@/lib/links"; +import { getLinks } from "@/lib/links-db"; import { getSession } from "@/lib/session"; import LogoutButton from "./components/LogoutButton"; +import ProductTabs from "./components/ProductTabs"; import "./globals.css"; const geistSans = Geist({ @@ -44,6 +46,7 @@ export default async function RootLayout({ children: React.ReactNode; }>) { const session = await getSession(); + const links = await getLinks(); return (
- + {/* Logo */} +
- Backerup + DoomLabs -
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index ebdb840..d952d98 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -4,6 +4,7 @@ import AnimateIn from "./components/AnimateIn"; import CountUp from "./components/CountUp"; import SpotlightCard from "./components/SpotlightCard"; import { LINKS } from "@/lib/links"; +import { getLinks } from "@/lib/links-db"; /* ── SVG Icons ─────────────────────────────────────────────── */ const CubeIcon = () => ( @@ -149,7 +150,8 @@ const stats = [ ]; /* ── Page ───────────────────────────────────────────────────── */ -export default function LandingPage() { +export default async function LandingPage() { + const links = await getLinks(); return ( <> {/* ═══ Hero ═══════════════════════════════════════════════ */} @@ -245,7 +247,7 @@ export default function LandingPage() {
- +
@@ -508,13 +510,137 @@ export default function LandingPage() { docker-compose setup
- +
+ {/* ═══ More tools ═════════════════════════════════════════ */} +
+
+ +
+ + More tools + +

+ Built for Docker operators +

+

+ Backerup is part of a growing suite of self-hosted Docker management tools — all + open-source, dark-mode-first, and designed to run alongside your containers. +

+
+
+ +
+ {/* Backerup card */} + + +
+
+ +
+ + + Current + +
+

Backerup

+

+ Full Docker container backup manager. Archive volumes, bind mounts, and config + into a single portable .backerup bundle. + Restore, migrate, or share — all from a self-hosted UI. +

+
    + {[ + "Full volume + config archives", + "Config-only export for fast migrations", + "One-click restore or container recreation", + "Cron-based schedules with retention policies", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ + + + {/* Updaterup card */} + + +
+
+ +
+ + + New + +
+

Updaterup

+

+ Docker container update manager. Continuously checks your running containers for + new image versions and lets you update them with a single click — or automatically + on a cron schedule. +

+
    + {[ + "Digest-based update detection", + "One-click update — preserves all original config", + "Batch check & update all containers", + "Named cron schedules with full audit history", + "Live log viewer with level filtering", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ + Explore Updaterup → + + + + Gitea + +
+
+
+
+
+
+ {/* ═══ Final CTA ══════════════════════════════════════════ */}
@@ -540,7 +666,7 @@ export default function LandingPage() {

( + +); +const CubeIcon = () => ( + +); +const MagnifyIcon = () => ( + +); +const LayersIcon = () => ( + +); +const ClockIcon = () => ( + +); +const BookIcon = () => ( + +); +const TerminalIcon = () => ( + +); +const DownloadIcon = () => ( + +); +const EyeIcon = () => ( + +); +const ArrowUpIcon = () => ( + +); +const LinkIcon = () => ( + +); +const GitHubIcon = () => ( + +); + +/* ── Data ───────────────────────────────────────────────────── */ +const features = [ + { + icon: , + gradient: "from-violet-500/15 to-purple-600/15", + border: "border-violet-500/20", + iconBg: "bg-violet-500/10", + iconColor: "text-violet-400", + title: "Container inventory", + body: "Lists all running (and optionally stopped) containers with their current image, tag, and digest — your live fleet at a glance.", + }, + { + icon: , + gradient: "from-purple-500/15 to-fuchsia-600/15", + border: "border-purple-500/20", + iconBg: "bg-purple-500/10", + iconColor: "text-purple-400", + title: "Digest-based detection", + body: "Pulls the latest image manifest and compares SHA digests — no false positives from re-tagged images. Knows exactly when a real update is available.", + }, + { + icon: , + gradient: "from-fuchsia-500/15 to-violet-600/15", + border: "border-fuchsia-500/20", + iconBg: "bg-fuchsia-500/10", + iconColor: "text-fuchsia-400", + title: "One-click update", + body: "Stops, removes, and recreates the container with the new image — preserving all original binds, ports, env vars, restart policy, and networks.", + }, + { + icon: , + gradient: "from-indigo-500/15 to-violet-600/15", + border: "border-indigo-500/20", + iconBg: "bg-indigo-500/10", + iconColor: "text-indigo-400", + title: "Batch operations", + body: "Check or update all containers at once with a single click. Updaterup handles each container sequentially to avoid Docker socket contention.", + }, + { + icon: , + gradient: "from-violet-500/15 to-indigo-600/15", + border: "border-violet-500/20", + iconBg: "bg-violet-500/10", + iconColor: "text-violet-400", + title: "Cron schedules", + body: "Create named schedules with check-only or check & auto-update modes, targeting specific containers or your entire fleet.", + }, + { + icon: , + gradient: "from-purple-500/15 to-violet-600/15", + border: "border-purple-500/20", + iconBg: "bg-purple-500/10", + iconColor: "text-purple-400", + title: "Audit history & live logs", + body: "Full update audit trail with container, image, status, trigger, and digest. Plus a live ring-buffer log viewer with level and context filtering.", + }, +]; + +const steps = [ + { + n: "01", + icon: , + color: "bg-violet-500/20 text-violet-400", + title: "Connect", + body: "Mount the Docker socket. Updaterup auto-discovers all running containers immediately on startup.", + }, + { + n: "02", + icon: , + color: "bg-purple-500/20 text-purple-400", + title: "Check", + body: "Compare local image digests against the registry. Outdated containers are flagged instantly.", + }, + { + n: "03", + icon: , + color: "bg-fuchsia-500/20 text-fuchsia-400", + title: "Review", + body: "The dashboard shows all pending updates with image info and last-check timestamps.", + }, + { + n: "04", + icon: , + color: "bg-indigo-500/20 text-indigo-400", + title: "Update", + body: "One click to update — Updaterup stops, removes, and recreates the container with zero config drift.", + }, +]; + +const pills = [ + { label: "Open source", color: "text-violet-400 bg-violet-500/10 border-violet-500/20" }, + { label: "Zero cloud deps", color: "text-purple-400 bg-purple-500/10 border-purple-500/20" }, + { label: "Self-hosted", color: "text-fuchsia-400 bg-fuchsia-500/10 border-fuchsia-500/20" }, + { label: "Docker native", color: "text-indigo-400 bg-indigo-500/10 border-indigo-500/20" }, +]; + +const stats = [ + { num: 100, suffix: "%", label: "Open source", sub: "source available" }, + { num: 0, suffix: "", label: "Config drift", sub: "on container update" }, + { num: 7, suffix: "", label: "UI pages", sub: "full management" }, + { num: 0, suffix: "", label: "Cloud dependencies", sub: "runs on your infra" }, +]; + +/* ── Page ───────────────────────────────────────────────────── */ +export default async function UpdaterupPage() { + const links = await getLinks(); + return ( + <> + {/* ═══ Hero ═══════════════════════════════════════════════ */} +
+ {/* ── Animated aurora blobs ── */} +
+ + {/* ═══ Stats bar ══════════════════════════════════════════ */} +
+
+
+ {stats.map((s, i) => ( + +
+
+ +
+
{s.label}
+
{s.sub}
+
+
+ ))} +
+
+
+ + {/* ═══ Features ═══════════════════════════════════════════ */} +
+
+ +
+ + Features + +

+ Everything you need +

+

+ A complete Docker update lifecycle — from continuous digest monitoring to + scheduled auto-updates and a full audit trail, all from one self-hosted UI. +

+
+
+ +
+ {features.map((f, i) => ( + + +
+ {f.icon} +
+

{f.title}

+

{f.body}

+
+
+ ))} +
+
+
+ + {/* ═══ How it works ════════════════════════════════════════ */} +
+
+ +
+ + How it works + +

+ Up and running in minutes +

+
+
+ +
+
+ {steps.map((s, i) => ( + +
+
+
+ {s.icon} +
+
+
+
{s.n}
+

{s.title}

+

{s.body}

+
+
+
+ ))} +
+
+
+ + {/* ═══ Zero config drift callout ══════════════════════════ */} +
+
+ +
+
+ {/* Left: text */} +
+ + How updates work + +

+ Zero config drift, guaranteed +

+

+ When Updaterup recreates a container, it reads the full{" "} + docker inspect output + and rebuilds the container with the exact same configuration — nothing is lost + or changed except the image digest. +

+
    + {[ + { name: "Binds & volumes", desc: "all mount points preserved" }, + { name: "Port mappings", desc: "identical host bindings" }, + { name: "Env vars & labels", desc: "full environment intact" }, + { name: "Restart policy & networks", desc: "same behaviour post-update" }, + ].map((item) => ( +
  • + + + + + {item.name} + — {item.desc} + +
  • + ))} +
+ +
+ + {/* Right: update flow diagram */} +
+
+ {[ + { icon: "🔍", step: "inspect", desc: "docker inspect ", color: "text-violet-400" }, + { icon: "⬇️", step: "pull", desc: "docker pull :latest", color: "text-purple-400" }, + { icon: "⏹", step: "stop", desc: "docker stop ", color: "text-amber-400" }, + { icon: "🗑", step: "remove", desc: "docker rm ", color: "text-rose-400" }, + { icon: "🚀", step: "create", desc: "docker run [original config]", color: "text-emerald-400" }, + ].map(({ icon, step, desc, color }, i) => ( +
+ {String(i + 1).padStart(2, "0")} + {icon} + {step} + {desc} +
+ ))} +
+
+
+
+
+
+
+ + {/* ═══ Quick start ════════════════════════════════════════ */} +
+ +
+ + Quick start + +

+ Setup with docker-compose +

+

+ The easiest way to get Updaterup running is with docker-compose for declarative, reproducible deployments. +

+ +
+
+ + + + docker-compose setup +
+
+ +
+
+
+
+
+ + {/* ═══ Final CTA ══════════════════════════════════════════ */} +
+
+ +
+
+
+
+

+ Keep your containers current. +

+

+ Open source, self-hosted, and free forever. Stop manually checking for image updates + and let Updaterup handle it automatically. +

+
+ + + Star on Gitea + + + Get started → + + + Also see Backerup + +
+
+
+
+ +
+
+ + ); +} diff --git a/frontend/lib/db.ts b/frontend/lib/db.ts index f0866d2..acac2ca 100644 --- a/frontend/lib/db.ts +++ b/frontend/lib/db.ts @@ -65,6 +65,12 @@ async function applyMigrations(db: Client): Promise { role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS links ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `) } diff --git a/frontend/lib/links-db.ts b/frontend/lib/links-db.ts new file mode 100644 index 0000000..aa4244f --- /dev/null +++ b/frontend/lib/links-db.ts @@ -0,0 +1,78 @@ +/** + * Runtime-editable link overrides stored in SQLite. + * Falls back to the static LINKS defaults for any key without an override. + */ +import { getDb, ensureReady } from './db' +import { LINKS } from './links' + +export type LinkKey = keyof typeof LINKS + +/** Human-readable labels for each link key (used in the admin UI). */ +export const LINK_META: Record = { + gitea: { label: 'Backerup — Gitea repo', description: 'Repository root used in nav, footer, and CTA buttons', group: 'Backerup' }, + giteaReleases: { label: 'Backerup — Releases', description: 'Releases / changelog page', group: 'Backerup' }, + giteaIssues: { label: 'Backerup — Issues', description: 'Issue tracker', group: 'Backerup' }, + giteaDiscussions: { label: 'Backerup — Discussions', description: 'Gitea Discussions forum', group: 'Backerup' }, + giteaContributing: { label: 'Backerup — Contributing', description: 'CONTRIBUTING.md guide', group: 'Backerup' }, + docs: { label: 'Backerup — Docs', description: 'Primary documentation URL', group: 'Backerup' }, + dockerImage: { label: 'Backerup — Docker image', description: 'Full image reference shown in install snippets', group: 'Backerup' }, + uiPort: { label: 'Backerup — UI port', description: 'Default port the Backerup container exposes', group: 'Backerup' }, + updaterupGitea: { label: 'Updaterup — Gitea repo', description: 'Repository root used in Updaterup pages', group: 'Updaterup' }, + updaterupDockerImage: { label: 'Updaterup — Docker image', description: 'Full image reference shown in Updaterup install snippets', group: 'Updaterup' }, +} + +export interface LinkEntry { + key: LinkKey + label: string + description: string + group: string + defaultValue: string + override: string | null + currentValue: string +} + +/** Returns merged links: DB overrides take precedence over static defaults. */ +export async function getLinks(): Promise { + await ensureReady() + const { rows } = await getDb().execute('SELECT key, value FROM links') + const overrides: Partial> = {} + for (const row of rows) { + overrides[row.key as string] = row.value as string + } + return { ...LINKS, ...overrides } as typeof LINKS +} + +/** Returns all link entries with defaults, overrides, and current values — for the admin UI. */ +export async function getAllLinkEntries(): Promise { + await ensureReady() + const { rows } = await getDb().execute('SELECT key, value FROM links') + const overrides: Partial> = {} + for (const row of rows) { + overrides[row.key as string] = row.value as string + } + + return (Object.keys(LINK_META) as LinkKey[]).map((key) => ({ + key, + ...LINK_META[key], + defaultValue: LINKS[key], + override: overrides[key] ?? null, + currentValue: overrides[key] ?? LINKS[key], + })) +} + +/** Upserts a link override. Pass the static default to effectively reset it, or use resetLink(). */ +export async function setLink(key: LinkKey, value: string): Promise { + await ensureReady() + await getDb().execute({ + sql: `INSERT INTO links (key, value, updated_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + args: [key, value], + }) +} + +/** Removes the DB override for a key, restoring the static default. */ +export async function resetLink(key: LinkKey): Promise { + await ensureReady() + await getDb().execute({ sql: 'DELETE FROM links WHERE key = ?', args: [key] }) +} diff --git a/frontend/lib/links.ts b/frontend/lib/links.ts index d2305d7..5e3fd33 100644 --- a/frontend/lib/links.ts +++ b/frontend/lib/links.ts @@ -26,6 +26,12 @@ export const LINKS = { dockerImage: "gitea.doomlabs.de/kptltd00m/backerup:latest", /** Default UI port the container exposes */ uiPort: "8080", + + // ── Updaterup ───────────────────────────────────────────── + /** Updaterup repository root */ + updaterupGitea: "https://gitea.doomlabs.de/KptltD00M/updaterup", + /** Updaterup Docker image reference */ + updaterupDockerImage: "gitea.doomlabs.de/kptltd00m/updaterup:latest", } as const; export type Links = typeof LINKS;