From 2de846436b0ce8bfaa96b6dad0b8a8dbe8177314 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:06:53 +0000 Subject: [PATCH] Preview: Block im WOMS Co-Authored-By: Claude Sonnet 5.5 --- src/components/AssignedProjectCard.jsx | 20 ++ src/components/PreviewBlock.jsx | 270 +++++++++++++++++++++++++ src/lib/previewEnv.js | 24 +++ src/pages/ProjectsPage.jsx | 8 + src/pages/ServicesPage.jsx | 11 +- tests/unit/previewEnv.test.mjs | 37 ++++ 6 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 src/components/PreviewBlock.jsx create mode 100644 src/lib/previewEnv.js create mode 100644 tests/unit/previewEnv.test.mjs diff --git a/src/components/AssignedProjectCard.jsx b/src/components/AssignedProjectCard.jsx index 0288088..1e7daa9 100644 --- a/src/components/AssignedProjectCard.jsx +++ b/src/components/AssignedProjectCard.jsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { FaCodeBranch } from 'react-icons/fa' import { FaLinkSlash } from 'react-icons/fa6' +import PreviewBlock from './PreviewBlock' import GiteaLinkButton from './GiteaLinkButton' import PreviewLinkButton from './PreviewLinkButton' import { suggestSubdomain } from '../lib/subdomain' @@ -39,6 +40,7 @@ export default function AssignedProjectCard({ project, onUnassign, showActions = const [previewMessage, setPreviewMessage] = useState('') const [pushBusy, setPushBusy] = useState(false) const [pushMessage, setPushMessage] = useState('') + const [showPreviewBlock, setShowPreviewBlock] = useState(false) const openPreviewInput = () => { setSubdomain(suggestSubdomain(customerName, project.projectName)) @@ -110,6 +112,24 @@ export default function AssignedProjectCard({ project, onUnassign, showActions = + {showActions && ( +
+ + {showPreviewBlock && ( +
+ +
+ )} +
+ )} + {showActions && (
diff --git a/src/components/PreviewBlock.jsx b/src/components/PreviewBlock.jsx new file mode 100644 index 0000000..44db794 --- /dev/null +++ b/src/components/PreviewBlock.jsx @@ -0,0 +1,270 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { MODE_OPTIONS, previewStatus, modeHint } from '../lib/previewView' +import { isValidEnvName, buildEnvPayload, isDevPreview } from '../lib/previewEnv' +import { + fetchPreview, + setPreviewMode, + savePreviewEnv, + rebuildPreview, + startPreviewDev, + stopPreviewDev, + fetchPreviewLog, +} from '../lib/previewApi' + +const DOT = { green: '#10b981', yellow: '#f59e0b', red: '#ef4444', grey: '#a0aec0' } +const NOT_ACTIVE = 'Preview-Runner ist nicht aktiv' + +const logDate = (at) => { + const d = new Date(at) + if (!at || Number.isNaN(d.getTime())) return String(at || '') + return d.toLocaleString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) +} + +export default function PreviewBlock({ projectId }) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [inactive, setInactive] = useState(false) + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [logText, setLogText] = useState('') + const [logFile, setLogFile] = useState('') + const [showLogs, setShowLogs] = useState(false) + // Variablen: Änderungen werden gesammelt und mit "Variablen speichern" übertragen + const [pendingSet, setPendingSet] = useState({}) + const [pendingRemove, setPendingRemove] = useState([]) + const [newName, setNewName] = useState('') + const [newValue, setNewValue] = useState('') + const [editName, setEditName] = useState('') + const [editValue, setEditValue] = useState('') + const [envError, setEnvError] = useState('') + const alive = useRef(true) + + useEffect(() => { + alive.current = true + return () => { alive.current = false } + }, []) + + const handleError = useCallback((err) => { + if (!alive.current) return + if (err?.status === 409 && err.message === NOT_ACTIVE) { + setInactive(true) + return + } + setError(err?.message || 'Unbekannter Fehler') + }, []) + + const load = useCallback(async () => { + try { + const r = await fetchPreview(projectId) + if (!alive.current) return + setData(r) + setInactive(false) + } catch (err) { + handleError(err) + } finally { + if (alive.current) setLoading(false) + } + }, [projectId, handleError]) + + useEffect(() => { load() }, [load]) + + // Solange gebaut/gestartet wird: alle 5 s nachladen + const state = data?.state + useEffect(() => { + if (state !== 'building' && state !== 'starting') return undefined + const t = setInterval(load, 5000) + return () => clearInterval(t) + }, [state, load]) + + const run = async (fn) => { + setBusy(true) + setError('') + try { + await fn() + await load() + } catch (err) { + handleError(err) + } finally { + if (alive.current) setBusy(false) + } + } + + const openLog = (runFile) => { + setLogFile(runFile) + setLogText('') + if (!runFile) return + run(async () => { + const r = await fetchPreviewLog(projectId, runFile) + setLogText(Array.isArray(r.lines) ? r.lines.join('\n') : String(r.log ?? r.text ?? '')) + }) + } + + const addPending = (name, value) => { + setPendingSet((p) => ({ ...p, [name]: value })) + setPendingRemove((r) => r.filter((x) => x !== name)) + } + + const handleAdd = () => { + if (!isValidEnvName(newName)) { + setEnvError('Name nur mit A–Z, 0–9 und _ (nicht mit einer Ziffer beginnen).') + return + } + if (!newValue) { + setEnvError('Bitte einen Wert eingeben.') + return + } + setEnvError('') + addPending(newName, newValue) + setNewName('') + setNewValue('') + } + + const handleRemove = (name) => { + setPendingSet((p) => { + const { [name]: _drop, ...rest } = p + return rest + }) + if ((data?.envNames || []).includes(name)) setPendingRemove((r) => [...new Set([...r, name])]) + } + + const handleChange = () => { + if (!editValue) { + setEnvError('Bitte einen neuen Wert eingeben.') + return + } + setEnvError('') + addPending(editName, editValue) + setEditName('') + setEditValue('') + } + + const handleSaveEnv = () => run(async () => { + await savePreviewEnv(projectId, buildEnvPayload(pendingSet, pendingRemove)) + setPendingSet({}) + setPendingRemove([]) + }) + + if (loading) return
Lädt …
+ + const view = data ? { ...data } : { state: 'off' } + const status = previewStatus(view) + const mode = data?.mode || 'auto' + const hint = modeHint(mode, data?.detected) + const names = [...new Set([...(data?.envNames || []), ...Object.keys(pendingSet)])].filter((n) => !pendingRemove.includes(n)) + const dirty = Object.keys(pendingSet).length > 0 || pendingRemove.length > 0 + const dev = isDevPreview(data) + const logs = (data?.logs || []).slice(0, 5) + + const small = { fontSize: 12 } + + return ( +
+ {data && ( + <> +
+ + +
+ {hint &&
{hint}
} +
+ + {status.text} +
+ {data.state === 'failed' && data.error && ( +
{data.error}
+ )} + + )} + + {inactive &&
{NOT_ACTIVE}.
} + {error &&
{error}
} + + {data && !inactive && ( + <> +
+ + + {dev && ( + <> + + + + )} +
+ + {showLogs && ( +
+ + {logFile && ( +
+                  {logText || '…'}
+                
+ )} +
+ )} + +
+ Variablen +
+ {names.length === 0 &&
Keine Variablen.
} + {names.map((n) => ( +
+ {n} + ••• + {editName === n ? ( + <> + setEditValue(e.target.value)} /> + + + ) : ( + + )} + +
+ ))} +
+ setNewName(e.target.value)} /> + setNewValue(e.target.value)} /> + +
+ {envError &&
{envError}
} + {dirty && ( +
+ +
+ )} +
Alles mit VITE_ / NEXT_PUBLIC_ ist später im Browser sichtbar.
+
+
+ + )} +
+ ) +} diff --git a/src/lib/previewEnv.js b/src/lib/previewEnv.js new file mode 100644 index 0000000..11d8a02 --- /dev/null +++ b/src/lib/previewEnv.js @@ -0,0 +1,24 @@ +// Hilfen für den Preview-Block (Variablen, Status-Punkt, Dev-Erkennung) + +export function isValidEnvName(name) { + return /^[A-Z_][A-Z0-9_]*$/.test(String(name ?? '')) +} + +// Änderungen ⇒ Body für PUT /preview/env; entfernte Namen werden nicht zugleich gesetzt +export function buildEnvPayload(setMap = {}, removeList = []) { + const remove = [...new Set(removeList)] + const set = {} + for (const [k, v] of Object.entries(setMap)) if (!remove.includes(k)) set[k] = v + return { set, remove } +} + +const DOT_COLORS = { online: '#10b981', building: '#f59e0b', sleeping: '#f59e0b', starting: '#f59e0b', failed: '#ef4444' } +export function dotColor(state) { + return DOT_COLORS[state] || '#a0aec0' +} + +export function isDevPreview(p) { + if (!p) return false + if (p.mode === 'dev') return true + return (p.mode || 'auto') === 'auto' && typeof p.detected === 'string' && p.detected.startsWith('dev:') +} diff --git a/src/pages/ProjectsPage.jsx b/src/pages/ProjectsPage.jsx index 506722a..01e5b86 100644 --- a/src/pages/ProjectsPage.jsx +++ b/src/pages/ProjectsPage.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo } from 'react' import { useNavigate } from 'react-router-dom' +import PreviewBlock from '../components/PreviewBlock' import { FaFolder, FaPlus, @@ -734,6 +735,13 @@ export default function ProjectsPage() {
)} + {editForm.projectType !== 'project' && ( +
+ + +
+ )} +