Preview: Block im WOMS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0f884e41e8
commit
2de846436b
6 files changed
+369
-1
No files matched your search
@@ -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' }}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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:')
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user