feat(dashboard): vervang SECTIONS-grid door 5 live status-widgets
Parallel server-side fetches via Promise.allSettled voor Docker, Caddy, systemd, Git en Audit. Iedere widget toont geaggregeerde status en refresht elke 30s client-side onafhankelijk van de andere widgets. - lib/agent-fetch.ts: gedeelde client-side streaming helper - app/api/audit/latest/route.ts: GET endpoint voor AuditWidget refresh - app/_components/DockerWidget.tsx: running/total containers - app/_components/CaddyWidget.tsx: soonest cert expiry in dagen - app/_components/SystemdWidget.tsx: healthy/total units (of niet geconfigureerd) - app/_components/GitWidget.tsx: dirty repo count (of niet geconfigureerd) - app/_components/AuditWidget.tsx: laatste FlowRun status + relatief tijdstip - app/page.tsx: vervangt SECTIONS-grid, doet parallel fetches, rendert widgets Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ae63876f21
commit
faa1463cd7
8 changed files with 523 additions and 22 deletions
78
app/_components/AuditWidget.tsx
Normal file
78
app/_components/AuditWidget.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { apiFetch } from '@/lib/csrf'
|
||||
|
||||
type LatestRun = { id: string; flow_key: string; status: string; started_at: string }
|
||||
export type AuditInitial =
|
||||
| { data: LatestRun | null; error: null }
|
||||
| { data: null; error: string }
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
pending: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
|
||||
running: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
success: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
|
||||
failed: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
cancelled: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
|
||||
}
|
||||
|
||||
function relativeTime(isoString: string): string {
|
||||
const diff = Date.now() - new Date(isoString).getTime()
|
||||
const minutes = Math.floor(diff / 60_000)
|
||||
if (minutes < 1) return 'zojuist'
|
||||
if (minutes < 60) return `${minutes}m geleden`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}u geleden`
|
||||
return `${Math.floor(hours / 24)}d geleden`
|
||||
}
|
||||
|
||||
async function fetchLatestRun(): Promise<LatestRun | null> {
|
||||
const res = await apiFetch('/api/audit/latest')
|
||||
if (!res.ok) throw new Error(`${res.status}`)
|
||||
const json = (await res.json()) as { run: LatestRun | null }
|
||||
return json.run
|
||||
}
|
||||
|
||||
export default function AuditWidget({ initial }: { initial: AuditInitial }) {
|
||||
const [data, setData] = useState<LatestRun | null>(initial.data)
|
||||
const [error, setError] = useState<string | null>(initial.error)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const run = await fetchLatestRun()
|
||||
setData(run)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'refresh failed')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(refresh, 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [refresh])
|
||||
|
||||
return (
|
||||
<Link href="/audit" className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">Audit</h2>
|
||||
{error ? (
|
||||
<p className="mt-2 text-sm text-destructive truncate">{error}</p>
|
||||
) : data ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_STYLES[data.status] ?? ''}`}
|
||||
>
|
||||
{data.status}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{relativeTime(data.started_at)}</span>
|
||||
</div>
|
||||
<p className="font-mono text-xs text-muted-foreground truncate">{data.flow_key}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">geen runs</p>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
68
app/_components/CaddyWidget.tsx
Normal file
68
app/_components/CaddyWidget.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { parseCertList } from '@/lib/parse-caddy'
|
||||
import { fetchAgentOutput } from '@/lib/agent-fetch'
|
||||
|
||||
type CaddyData = { soonestExpiryMs: number | null; count: number }
|
||||
export type CaddyInitial = { data: CaddyData; error: null } | { data: null; error: string }
|
||||
|
||||
async function refreshCaddy(): Promise<CaddyData> {
|
||||
const output = await fetchAgentOutput('caddy_list_certs')
|
||||
const certs = parseCertList(output)
|
||||
const expiryTimes = certs
|
||||
.filter((c) => c.notAfter)
|
||||
.map((c) => new Date(c.notAfter).getTime())
|
||||
const soonestExpiryMs = expiryTimes.length > 0 ? Math.min(...expiryTimes) : null
|
||||
return { soonestExpiryMs, count: certs.length }
|
||||
}
|
||||
|
||||
function daysUntil(ms: number): number {
|
||||
return Math.floor((ms - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
export default function CaddyWidget({ initial }: { initial: CaddyInitial }) {
|
||||
const [data, setData] = useState<CaddyData | null>(initial.data)
|
||||
const [error, setError] = useState<string | null>(initial.error)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const d = await refreshCaddy()
|
||||
setData(d)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'refresh failed')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(refresh, 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [refresh])
|
||||
|
||||
return (
|
||||
<Link href="/caddy" className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">Caddy / TLS</h2>
|
||||
{error ? (
|
||||
<p className="mt-2 text-sm text-destructive truncate">{error}</p>
|
||||
) : data ? (
|
||||
<div className="mt-2">
|
||||
{data.soonestExpiryMs !== null ? (
|
||||
<p className="text-2xl font-semibold">
|
||||
{daysUntil(data.soonestExpiryMs)}
|
||||
<span className="text-sm font-normal text-muted-foreground"> days to expiry</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">no certs</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{data.count} cert{data.count !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">—</p>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
54
app/_components/DockerWidget.tsx
Normal file
54
app/_components/DockerWidget.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { parseDockerPs } from '@/lib/parse-docker'
|
||||
import { fetchAgentOutput } from '@/lib/agent-fetch'
|
||||
|
||||
type DockerData = { running: number; total: number }
|
||||
export type DockerInitial = { data: DockerData; error: null } | { data: null; error: string }
|
||||
|
||||
async function refreshDocker(): Promise<DockerData> {
|
||||
const output = await fetchAgentOutput('docker_ps')
|
||||
const containers = parseDockerPs(output)
|
||||
return {
|
||||
running: containers.filter((c) => c.status.toLowerCase().startsWith('up')).length,
|
||||
total: containers.length,
|
||||
}
|
||||
}
|
||||
|
||||
export default function DockerWidget({ initial }: { initial: DockerInitial }) {
|
||||
const [data, setData] = useState<DockerData | null>(initial.data)
|
||||
const [error, setError] = useState<string | null>(initial.error)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const d = await refreshDocker()
|
||||
setData(d)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'refresh failed')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(refresh, 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [refresh])
|
||||
|
||||
return (
|
||||
<Link href="/docker" className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">Docker</h2>
|
||||
{error ? (
|
||||
<p className="mt-2 text-sm text-destructive truncate">{error}</p>
|
||||
) : data ? (
|
||||
<p className="mt-2 text-2xl font-semibold">
|
||||
{data.running}
|
||||
<span className="text-sm font-normal text-muted-foreground"> / {data.total} running</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">—</p>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
72
app/_components/GitWidget.tsx
Normal file
72
app/_components/GitWidget.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { parseGitStatus } from '@/lib/parse-git'
|
||||
import { fetchAgentOutput } from '@/lib/agent-fetch'
|
||||
|
||||
type GitData = { dirty: number; total: number }
|
||||
export type GitInitial =
|
||||
| { configured: false }
|
||||
| { data: GitData; error: null }
|
||||
| { data: null; error: string }
|
||||
|
||||
async function refreshGit(repos: string[]): Promise<GitData> {
|
||||
const results = await Promise.allSettled(
|
||||
repos.map(async (path) => {
|
||||
const output = await fetchAgentOutput('git_status', [path])
|
||||
return parseGitStatus(output)
|
||||
}),
|
||||
)
|
||||
const dirty = results.filter(
|
||||
(r) => r.status === 'fulfilled' && r.value.dirty,
|
||||
).length
|
||||
return { dirty, total: repos.length }
|
||||
}
|
||||
|
||||
export default function GitWidget({ initial, repos }: { initial: GitInitial; repos: string[] }) {
|
||||
const notConfigured = 'configured' in initial && initial.configured === false
|
||||
const [data, setData] = useState<GitData | null>(
|
||||
!notConfigured && 'data' in initial ? initial.data : null,
|
||||
)
|
||||
const [error, setError] = useState<string | null>(
|
||||
!notConfigured && 'error' in initial ? initial.error : null,
|
||||
)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (notConfigured || repos.length === 0) return
|
||||
try {
|
||||
const d = await refreshGit(repos)
|
||||
setData(d)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'refresh failed')
|
||||
}
|
||||
}, [notConfigured, repos])
|
||||
|
||||
useEffect(() => {
|
||||
if (notConfigured) return
|
||||
const id = setInterval(refresh, 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [refresh, notConfigured])
|
||||
|
||||
return (
|
||||
<Link href="/git" className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">Git</h2>
|
||||
{notConfigured ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">niet geconfigureerd</p>
|
||||
) : error ? (
|
||||
<p className="mt-2 text-sm text-destructive truncate">{error}</p>
|
||||
) : data ? (
|
||||
<p className="mt-2 text-2xl font-semibold">
|
||||
{data.dirty}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{' '}repo{data.dirty !== 1 ? 's' : ''} dirty
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">—</p>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
70
app/_components/SystemdWidget.tsx
Normal file
70
app/_components/SystemdWidget.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { parseSystemctlStatus } from '@/lib/parse-systemd'
|
||||
import { fetchAgentOutput } from '@/lib/agent-fetch'
|
||||
|
||||
type SystemdData = { healthy: number; total: number }
|
||||
export type SystemdInitial =
|
||||
| { configured: false }
|
||||
| { data: SystemdData; error: null }
|
||||
| { data: null; error: string }
|
||||
|
||||
async function refreshSystemd(units: string[]): Promise<SystemdData> {
|
||||
const results = await Promise.allSettled(
|
||||
units.map(async (unit) => {
|
||||
const output = await fetchAgentOutput('systemctl_status', [unit])
|
||||
return parseSystemctlStatus(output, unit)
|
||||
}),
|
||||
)
|
||||
const healthy = results.filter(
|
||||
(r) => r.status === 'fulfilled' && r.value.activeState === 'active',
|
||||
).length
|
||||
return { healthy, total: units.length }
|
||||
}
|
||||
|
||||
export default function SystemdWidget({ initial, units }: { initial: SystemdInitial; units: string[] }) {
|
||||
const notConfigured = 'configured' in initial && initial.configured === false
|
||||
const [data, setData] = useState<SystemdData | null>(
|
||||
!notConfigured && 'data' in initial ? initial.data : null,
|
||||
)
|
||||
const [error, setError] = useState<string | null>(
|
||||
!notConfigured && 'error' in initial ? initial.error : null,
|
||||
)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (notConfigured || units.length === 0) return
|
||||
try {
|
||||
const d = await refreshSystemd(units)
|
||||
setData(d)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'refresh failed')
|
||||
}
|
||||
}, [notConfigured, units])
|
||||
|
||||
useEffect(() => {
|
||||
if (notConfigured) return
|
||||
const id = setInterval(refresh, 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [refresh, notConfigured])
|
||||
|
||||
return (
|
||||
<Link href="/systemd" className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">systemd</h2>
|
||||
{notConfigured ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">niet geconfigureerd</p>
|
||||
) : error ? (
|
||||
<p className="mt-2 text-sm text-destructive truncate">{error}</p>
|
||||
) : data ? (
|
||||
<p className="mt-2 text-2xl font-semibold">
|
||||
{data.healthy}/{data.total}
|
||||
<span className="text-sm font-normal text-muted-foreground"> healthy</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted-foreground">—</p>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
20
app/api/audit/latest/route.ts
Normal file
20
app/api/audit/latest/route.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
|
||||
const run = await prisma.flowRun.findFirst({
|
||||
where: { user_id: user.id },
|
||||
orderBy: { started_at: 'desc' },
|
||||
select: { id: true, flow_key: true, status: true, started_at: true },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
run: run ? { ...run, started_at: run.started_at.toISOString() } : null,
|
||||
})
|
||||
}
|
||||
142
app/page.tsx
142
app/page.tsx
|
|
@ -1,23 +1,126 @@
|
|||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getCurrentUser } from '@/lib/session'
|
||||
import { execAgent } from '@/lib/agent-client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { parseDockerPs } from '@/lib/parse-docker'
|
||||
import { parseCertList } from '@/lib/parse-caddy'
|
||||
import { parseSystemctlStatus } from '@/lib/parse-systemd'
|
||||
import { parseGitStatus } from '@/lib/parse-git'
|
||||
import DockerWidget, { type DockerInitial } from './_components/DockerWidget'
|
||||
import CaddyWidget, { type CaddyInitial } from './_components/CaddyWidget'
|
||||
import SystemdWidget, { type SystemdInitial } from './_components/SystemdWidget'
|
||||
import GitWidget, { type GitInitial } from './_components/GitWidget'
|
||||
import AuditWidget, { type AuditInitial } from './_components/AuditWidget'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const SECTIONS = [
|
||||
{ href: '/docker', title: 'Docker', desc: 'Containers en status' },
|
||||
{ href: '/git', title: 'Git', desc: 'Repo checkouts en diffs' },
|
||||
{ href: '/systemd', title: 'systemd', desc: 'Services en journals' },
|
||||
{ href: '/caddy', title: 'Caddy', desc: 'Config en certs' },
|
||||
{ href: '/flows', title: 'Flows', desc: 'Multi-step deployments' },
|
||||
{ href: '/audit', title: 'Audit', desc: 'Command-log en runs' },
|
||||
{ href: '/settings', title: 'Settings', desc: 'Backups en config' },
|
||||
]
|
||||
|
||||
export default async function Home() {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const systemdUnits = (process.env.SYSTEMD_UNITS ?? '')
|
||||
.split(',')
|
||||
.map((u) => u.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const repoPaths = (process.env.REPO_PATHS ?? '')
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const [[dockerResult, caddyResult, auditResult], unitResults, repoResults] = await Promise.all([
|
||||
Promise.allSettled([
|
||||
execAgent('docker_ps'),
|
||||
execAgent('caddy_list_certs'),
|
||||
prisma.flowRun.findFirst({
|
||||
where: { user_id: user.id },
|
||||
orderBy: { started_at: 'desc' },
|
||||
select: { id: true, flow_key: true, status: true, started_at: true },
|
||||
}),
|
||||
]),
|
||||
Promise.allSettled(systemdUnits.map((unit) => execAgent('systemctl_status', [unit]))),
|
||||
Promise.allSettled(repoPaths.map((path) => execAgent('git_status', [path]))),
|
||||
])
|
||||
|
||||
// Docker widget initial state
|
||||
const dockerInitial: DockerInitial =
|
||||
dockerResult.status === 'rejected'
|
||||
? { data: null, error: dockerResult.reason instanceof Error ? dockerResult.reason.message : 'failed' }
|
||||
: (() => {
|
||||
const containers = parseDockerPs(dockerResult.value)
|
||||
return {
|
||||
data: {
|
||||
running: containers.filter((c) => c.status.toLowerCase().startsWith('up')).length,
|
||||
total: containers.length,
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
})()
|
||||
|
||||
// Caddy widget initial state
|
||||
const caddyInitial: CaddyInitial =
|
||||
caddyResult.status === 'rejected'
|
||||
? { data: null, error: caddyResult.reason instanceof Error ? caddyResult.reason.message : 'failed' }
|
||||
: (() => {
|
||||
const certs = parseCertList(caddyResult.value)
|
||||
const expiryTimes = certs
|
||||
.filter((c) => c.notAfter)
|
||||
.map((c) => new Date(c.notAfter).getTime())
|
||||
return {
|
||||
data: {
|
||||
soonestExpiryMs: expiryTimes.length > 0 ? Math.min(...expiryTimes) : null,
|
||||
count: certs.length,
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
})()
|
||||
|
||||
// Systemd widget initial state
|
||||
let systemdInitial: SystemdInitial
|
||||
if (systemdUnits.length === 0) {
|
||||
systemdInitial = { configured: false }
|
||||
} else if (unitResults.every((r) => r.status === 'rejected')) {
|
||||
const first = unitResults[0]
|
||||
systemdInitial = {
|
||||
data: null,
|
||||
error: first.status === 'rejected' && first.reason instanceof Error ? first.reason.message : 'all units failed',
|
||||
}
|
||||
} else {
|
||||
const healthy = unitResults.reduce((count, r, i) => {
|
||||
if (r.status !== 'fulfilled') return count
|
||||
return parseSystemctlStatus(r.value, systemdUnits[i]).activeState === 'active' ? count + 1 : count
|
||||
}, 0)
|
||||
systemdInitial = { data: { healthy, total: systemdUnits.length }, error: null }
|
||||
}
|
||||
|
||||
// Git widget initial state
|
||||
let gitInitial: GitInitial
|
||||
if (repoPaths.length === 0) {
|
||||
gitInitial = { configured: false }
|
||||
} else if (repoResults.every((r) => r.status === 'rejected')) {
|
||||
const first = repoResults[0]
|
||||
gitInitial = {
|
||||
data: null,
|
||||
error: first.status === 'rejected' && first.reason instanceof Error ? first.reason.message : 'all repos failed',
|
||||
}
|
||||
} else {
|
||||
const dirty = repoResults.filter(
|
||||
(r) => r.status === 'fulfilled' && parseGitStatus(r.value).dirty,
|
||||
).length
|
||||
gitInitial = { data: { dirty, total: repoPaths.length }, error: null }
|
||||
}
|
||||
|
||||
// Audit widget initial state
|
||||
const auditInitial: AuditInitial =
|
||||
auditResult.status === 'rejected'
|
||||
? { data: null, error: auditResult.reason instanceof Error ? auditResult.reason.message : 'failed' }
|
||||
: {
|
||||
data: auditResult.value
|
||||
? { ...auditResult.value, started_at: auditResult.value.started_at.toISOString() }
|
||||
: null,
|
||||
error: null,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6">
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
|
|
@ -25,17 +128,12 @@ export default async function Home() {
|
|||
<h1 className="text-2xl font-semibold tracking-tight">Ops Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">Welkom {user.email}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
{SECTIONS.map((s) => (
|
||||
<Link
|
||||
key={s.href}
|
||||
href={s.href}
|
||||
className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent"
|
||||
>
|
||||
<h2 className="text-lg font-medium">{s.title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{s.desc}</p>
|
||||
</Link>
|
||||
))}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<DockerWidget initial={dockerInitial} />
|
||||
<CaddyWidget initial={caddyInitial} />
|
||||
<SystemdWidget initial={systemdInitial} units={systemdUnits} />
|
||||
<GitWidget initial={gitInitial} repos={repoPaths} />
|
||||
<AuditWidget initial={auditInitial} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue