Preview: Block im WOMS

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 21:06:53 +00:00
1 parent 0f884e41e8
commit 2de846436b
6 files changed
+369 -1

No files matched your search

+20
View File
@@ -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 =
<StatusBadge label="Provisioning" value={project.provisioningStatus} />
</div>
{showActions && (
<div style={{ marginTop: 10 }}>
<button
type="button"
className="btn btn-sm"
aria-expanded={showPreviewBlock}
onClick={() => setShowPreviewBlock((v) => !v)}
>
Preview {showPreviewBlock ? '▴' : '▾'}
</button>
{showPreviewBlock && (
<div style={{ marginTop: 8 }}>
<PreviewBlock projectId={project.$id} />
</div>
)}
</div>
)}
{showActions && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
+270
View File
@@ -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 <div className="text-grey" style={{ fontSize: 12 }}>Lädt …</div>
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 (
<div style={{ fontSize: 13 }}>
{data && (
<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<label className="text-grey" htmlFor={`pv-mode-${projectId}`} style={small}>Modus</label>
<select
id={`pv-mode-${projectId}`}
className="form-control"
style={{ maxWidth: 200 }}
value={mode}
disabled={busy || inactive}
onChange={(e) => run(() => setPreviewMode(projectId, e.target.value))}
>
{MODE_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
{hint && <div className="text-grey" style={{ ...small, marginTop: 4 }}>{hint}</div>}
<div style={{ marginTop: 8, display: 'flex', gap: 6, alignItems: 'flex-start' }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: DOT[status.color], display: 'inline-block', flexShrink: 0, marginTop: 4 }} />
<span style={{ overflowWrap: 'anywhere' }}>{status.text}</span>
</div>
{data.state === 'failed' && data.error && (
<div style={{ color: '#f87171', ...small, marginTop: 4, overflowWrap: 'anywhere' }}>{data.error}</div>
)}
</>
)}
{inactive && <div className="text-grey" style={{ ...small, marginTop: 8 }}>{NOT_ACTIVE}.</div>}
{error && <div style={{ color: '#f87171', ...small, marginTop: 8, overflowWrap: 'anywhere' }}>{error}</div>}
{data && !inactive && (
<>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}>
<button type="button" className="btn btn-sm" disabled={busy || state === 'building'} onClick={() => run(() => rebuildPreview(projectId))}>
Neu bauen
</button>
<button type="button" className="btn btn-sm" disabled={busy || logs.length === 0} onClick={() => setShowLogs((v) => !v)}>
Protokoll ansehen
</button>
{dev && (
<>
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => run(() => startPreviewDev(projectId))}>
Jetzt starten
</button>
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => run(() => stopPreviewDev(projectId))}>
Stoppen
</button>
</>
)}
</div>
{showLogs && (
<div style={{ marginTop: 8 }}>
<select
className="form-control"
style={{ maxWidth: '100%' }}
aria-label="Lauf wählen"
value={logFile}
disabled={busy}
onChange={(e) => openLog(e.target.value)}
>
<option value="">Lauf wählen …</option>
{logs.map((l) => <option key={l.runFile} value={l.runFile}>{logDate(l.at)}</option>)}
</select>
{logFile && (
<pre style={{ marginTop: 6, maxHeight: 260, overflow: 'auto', background: 'rgba(0,0,0,0.35)', padding: 8, borderRadius: 6, fontFamily: 'monospace', fontSize: 11, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{logText || '…'}
</pre>
)}
</div>
)}
<div style={{ marginTop: 14 }}>
<strong style={small}>Variablen</strong>
<div style={{ marginTop: 6 }}>
{names.length === 0 && <div className="text-grey" style={small}>Keine Variablen.</div>}
{names.map((n) => (
<div key={n} style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', padding: '4px 0', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<code style={{ flex: '1 1 140px', overflowWrap: 'anywhere' }}>{n}</code>
<span className="text-grey">•••</span>
{editName === n ? (
<>
<input type="text" className="form-control" style={{ flex: '1 1 120px', minWidth: 0 }} placeholder="Neuer Wert" value={editValue} onChange={(e) => setEditValue(e.target.value)} />
<button type="button" className="btn btn-sm" onClick={handleChange}>Übernehmen</button>
</>
) : (
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => { setEditName(n); setEditValue(''); setEnvError('') }}>Ändern</button>
)}
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => handleRemove(n)}>Entfernen</button>
</div>
))}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginTop: 8 }}>
<input type="text" className="form-control" style={{ flex: '1 1 140px', minWidth: 0 }} placeholder="NAME" value={newName} onChange={(e) => setNewName(e.target.value)} />
<input type="text" className="form-control" style={{ flex: '1 1 140px', minWidth: 0 }} placeholder="Wert" value={newValue} onChange={(e) => setNewValue(e.target.value)} />
<button type="button" className="btn btn-sm" disabled={busy} onClick={handleAdd}>Hinzufügen</button>
</div>
{envError && <div style={{ color: '#f87171', ...small, marginTop: 4 }}>{envError}</div>}
{dirty && (
<div style={{ marginTop: 8 }}>
<button type="button" className="btn btn-sm" disabled={busy} onClick={handleSaveEnv}>Variablen speichern</button>
</div>
)}
<div className="text-grey" style={{ ...small, marginTop: 6 }}>Alles mit VITE_ / NEXT_PUBLIC_ ist später im Browser sichtbar.</div>
</div>
</div>
</>
)}
</div>
)
}
+24
View File
@@ -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:')
}
+8
View File
@@ -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() {
</div>
)}
{editForm.projectType !== 'project' && (
<div className="form-group">
<label className="form-label">Preview</label>
<PreviewBlock projectId={editProject.$id} />
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', marginTop: '16px', flexWrap: 'wrap' }}>
<button
type="button"
+10 -1
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { FaSpinner } from 'react-icons/fa6'
import { dotColor } from '../lib/previewEnv'
import { fetchServices } from '../lib/projectKindsApi'
import { servicesCards } from '../lib/servicesView'
import { PageHeader, Card, Button, Badge } from '../components/ui'
@@ -52,7 +53,15 @@ export default function ServicesPage() {
{c.projects.map((p) => (
<div key={p.id} className="list-row" style={{ flexWrap: 'wrap' }}>
<div className="grow" style={{ minWidth: 0, overflowWrap: 'anywhere' }}>
<div style={{ fontWeight: 700 }}>{p.projectName || p.repoFullName}</div>
<div style={{ fontWeight: 700 }}>
{p.previewState && (
<span
title={`Preview: ${p.previewState}`}
style={{ display: 'inline-block', width: 9, height: 9, borderRadius: '50%', background: dotColor(p.previewState), marginRight: 6 }}
/>
)}
{p.projectName || p.repoFullName}
</div>
<div className="muted">
{[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'}
</div>
+37
View File
@@ -0,0 +1,37 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { isValidEnvName, buildEnvPayload, dotColor, isDevPreview } = await loadModule('src/lib/previewEnv.js')
test('isValidEnvName', () => {
assert.equal(isValidEnvName('VITE_API_URL'), true)
assert.equal(isValidEnvName('_X1'), true)
assert.equal(isValidEnvName('vite_x'), false)
assert.equal(isValidEnvName('1ABC'), false)
assert.equal(isValidEnvName(''), false)
assert.equal(isValidEnvName('A-B'), false)
})
test('buildEnvPayload: set und remove, set gewinnt nicht gegen remove', () => {
const p = buildEnvPayload({ A: '1', B: '2' }, ['B', 'C'])
assert.deepEqual(p, { set: { A: '1' }, remove: ['B', 'C'] })
})
test('dotColor', () => {
assert.equal(dotColor('online'), '#10b981')
assert.equal(dotColor('building'), '#f59e0b')
assert.equal(dotColor('sleeping'), '#f59e0b')
assert.equal(dotColor('starting'), '#f59e0b')
assert.equal(dotColor('failed'), '#ef4444')
assert.equal(dotColor('off'), '#a0aec0')
assert.equal(dotColor(undefined), '#a0aec0')
})
test('isDevPreview', () => {
assert.equal(isDevPreview({ mode: 'dev' }), true)
assert.equal(isDevPreview({ mode: 'auto', detected: 'dev:npm' }), true)
assert.equal(isDevPreview({ mode: 'auto', detected: 'build:npm' }), false)
assert.equal(isDevPreview({ mode: 'build', detected: 'dev:npm' }), false)
assert.equal(isDevPreview(null), false)
})