From 30f1b452a89cde1b5d82f9a0a0ff95ba9d1e0811 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 17:48:51 +0200 Subject: [PATCH] feat(caddy): /caddy page with config view and cert status table - app/caddy/page.tsx: server component fetches caddy_show_config (shiki-highlighted Caddyfile) and caddy_list_certs (parsed CertInfo[]) - app/caddy/_components/caddy-view.tsx: client component shows cert table (domain, issuer CN, notBefore, notAfter) with Valid/Expiring soon/Expired badges; auto-refreshes every 60 seconds Co-Authored-By: Claude Sonnet 4.6 --- app/caddy/_components/caddy-view.tsx | 165 +++++++++++++++++++++++++++ app/caddy/page.tsx | 61 ++++++++++ 2 files changed, 226 insertions(+) create mode 100644 app/caddy/_components/caddy-view.tsx create mode 100644 app/caddy/page.tsx diff --git a/app/caddy/_components/caddy-view.tsx b/app/caddy/_components/caddy-view.tsx new file mode 100644 index 0000000..62f7bd4 --- /dev/null +++ b/app/caddy/_components/caddy-view.tsx @@ -0,0 +1,165 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { parseCertList, type CertInfo } from '@/lib/parse-caddy' + +async function fetchCerts(): Promise { + const res = await fetch('/api/agent/exec', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command_key: 'caddy_list_certs', args: [] }), + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(`agent ${res.status}: ${text}`) + } + + const reader = res.body?.getReader() + if (!reader) throw new Error('no response body') + + const decoder = new TextDecoder() + let buffer = '' + let output = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (line.startsWith('data:')) { + try { + const parsed = JSON.parse(line.slice(5).trim()) as { data?: string } + if (parsed.data !== undefined) output += parsed.data + } catch { + // ignore malformed + } + } + } + } + + return parseCertList(output) +} + +type Props = { + initialCerts: CertInfo[] + certsError: string | null +} + +export default function CaddyView({ initialCerts, certsError }: Props) { + const [certs, setCerts] = useState(initialCerts) + const [error, setError] = useState(certsError) + const [refreshing, setRefreshing] = useState(false) + const [lastUpdated, setLastUpdated] = useState(new Date()) + + const refresh = useCallback(async () => { + setRefreshing(true) + try { + const updated = await fetchCerts() + setCerts(updated) + setError(null) + setLastUpdated(new Date()) + } catch (err) { + setError(err instanceof Error ? err.message : 'failed') + } finally { + setRefreshing(false) + } + }, []) + + useEffect(() => { + const id = setInterval(refresh, 60000) + return () => clearInterval(id) + }, [refresh]) + + return ( +
+
+

TLS Certificates

+
+ {refreshing && ( + refreshing… + )} + + updated {lastUpdated.toLocaleTimeString()} + + +
+
+ + {error ? ( +
+ {error} +
+ ) : certs.length === 0 ? ( +
+ No certificates found in /data/caddy/certificates/. +
+ ) : ( +
+ + + + + + + + + + + + {certs.map((cert) => ( + + + + + + + + ))} + +
DomainIssuerValid fromExpiresStatus
{cert.domain}{cert.issuerCN}{cert.notBefore || '—'}{cert.notAfter || '—'} + +
+
+ )} +
+ ) +} + +function CertStatusBadge({ cert }: { cert: CertInfo }) { + if (cert.expired) { + return ( + + + Expired + + ) + } + if (cert.expiringWarning) { + return ( + + + Expiring soon + + ) + } + return ( + + + Valid + + ) +} diff --git a/app/caddy/page.tsx b/app/caddy/page.tsx new file mode 100644 index 0000000..93b6f83 --- /dev/null +++ b/app/caddy/page.tsx @@ -0,0 +1,61 @@ +import { redirect } from 'next/navigation' +import { codeToHtml } from 'shiki' +import { getCurrentUser } from '@/lib/session' +import { execAgent } from '@/lib/agent-client' +import { parseCertList, type CertInfo } from '@/lib/parse-caddy' +import CaddyView from './_components/caddy-view' + +export const dynamic = 'force-dynamic' + +export default async function CaddyPage() { + const user = await getCurrentUser() + if (!user) redirect('/login') + + let configHtml = '' + let configError: string | null = null + try { + const raw = await execAgent('caddy_show_config') + configHtml = await codeToHtml(raw || '# (empty)', { + lang: 'caddyfile', + theme: 'github-dark', + }) + } catch (err) { + configError = err instanceof Error ? err.message : 'failed' + } + + let initialCerts: CertInfo[] = [] + let certsError: string | null = null + try { + const raw = await execAgent('caddy_list_certs') + initialCerts = parseCertList(raw) + } catch (err) { + certsError = err instanceof Error ? err.message : 'failed' + } + + return ( +
+
+
+

Caddy

+

Config view and TLS certificate status

+
+ +
+

Caddyfile

+ {configError ? ( +
+ {configError} +
+ ) : ( +
+ )} +
+ + +
+
+ ) +}