Preview: Admin-API und Modus-Vorgabe
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3316b562af
commit
07bef6971c
8 files changed
+412
-1
No files matched your search
@@ -22,6 +22,7 @@ import { runActivation } from './services/siteFeatures.js'
|
||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||
import pushTimeRoutes from './routes/pushTime.js'
|
||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||
import adminPreviewRoutes from './routes/admin/preview.js'
|
||||
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
|
||||
import adminServicesRoutes from './routes/admin/services.js'
|
||||
import adminCustomersRoutes from './routes/admin/customers.js'
|
||||
@@ -72,6 +73,7 @@ app.use('/api/abo', aboRoutes)
|
||||
app.use('/api/funktionen', funktionenRoutes)
|
||||
app.use('/api/portal-admin', portalAdminRoutes)
|
||||
app.use('/api/admin/gitea', adminGiteaRoutes)
|
||||
app.use('/api/admin/website-projects', adminPreviewRoutes)
|
||||
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
|
||||
app.use('/api/admin/services', adminServicesRoutes)
|
||||
app.use('/api/admin/customers', adminCustomersRoutes)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// Admin-API fuer den Preview-Block je Projekt (WOMS). Nie Variablenwerte ausgeben.
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../../services/previewEnv.js'
|
||||
import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js'
|
||||
import * as runnerClient from '../../services/previewRunnerClient.js'
|
||||
import { runDeploy, fetchPreviewConfig } from '../../services/previewDeploy.js'
|
||||
import { getRepoInfo } from '../../services/giteaAdmin.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAppwriteStaff)
|
||||
|
||||
const MODES = ['auto', 'build', 'dev', 'off']
|
||||
const SHA_RE = /^[0-9a-f]{7,40}$/
|
||||
|
||||
function fail(status, message) {
|
||||
const err = new Error(message)
|
||||
err.status = status
|
||||
return err
|
||||
}
|
||||
|
||||
const col = () => config.collections.websiteProjects
|
||||
|
||||
async function loadProject(id) {
|
||||
let doc
|
||||
try {
|
||||
doc = await getDocument(col(), id)
|
||||
} catch (err) {
|
||||
if (err.status === 404) throw fail(404, 'Projekt nicht gefunden')
|
||||
throw err
|
||||
}
|
||||
if (!doc.subdomain) throw fail(400, 'Projekt hat keine Preview-Subdomain')
|
||||
return doc
|
||||
}
|
||||
|
||||
function requireRunner() {
|
||||
if (!config.previewRunner.enabled) throw fail(409, 'Preview-Runner ist nicht aktiv')
|
||||
}
|
||||
|
||||
async function view(doc) {
|
||||
const key = envKey()
|
||||
let names = []
|
||||
if (doc.previewEnv && key) {
|
||||
try { names = envNames(doc.previewEnv, key) } catch { names = [] }
|
||||
}
|
||||
const mode = doc.previewMode || 'auto'
|
||||
return {
|
||||
mode,
|
||||
state: doc.previewState || (mode === 'off' ? 'off' : ''),
|
||||
detected: doc.previewDetected || '',
|
||||
builtSha: doc.previewBuiltSha || '',
|
||||
builtAt: doc.previewBuiltAt || '',
|
||||
error: doc.previewError || '',
|
||||
branch: doc.previewBranch || '',
|
||||
envNames: names,
|
||||
logs: await listRunLogs(doc.$id),
|
||||
}
|
||||
}
|
||||
|
||||
// Neubau anstossen (wie beim Bearbeiten/Reaktivieren eines Projekts)
|
||||
async function triggerRebuild(doc) {
|
||||
const repo = doc.repoFullName
|
||||
const branch = doc.previewBranch || (await getRepoInfo(repo).catch(() => null))?.defaultBranch || 'main'
|
||||
const found = await fetchPreviewConfig(repo, branch).catch(() => null)
|
||||
const cfg = {
|
||||
enabled: true,
|
||||
type: 'static',
|
||||
displayName: doc.projectName,
|
||||
...(found || {}),
|
||||
branch,
|
||||
subdomain: doc.subdomain,
|
||||
}
|
||||
return runDeploy(repo, branch, cfg, { project: doc })
|
||||
}
|
||||
|
||||
const wrap = (fn) => async (req, res) => {
|
||||
try {
|
||||
await fn(req, res)
|
||||
} catch (err) {
|
||||
const status = err.status || 500
|
||||
if (status >= 500 && status !== 503) console.error('[admin/preview]', err.message)
|
||||
res.status(status).json({ error: status >= 500 ? err.message || 'Preview-Aktion fehlgeschlagen' : err.message })
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/:id/preview', wrap(async (req, res) => {
|
||||
res.json(await view(await loadProject(req.params.id)))
|
||||
}))
|
||||
|
||||
router.patch('/:id/preview', wrap(async (req, res) => {
|
||||
requireRunner()
|
||||
const mode = req.body?.mode
|
||||
if (!MODES.includes(mode)) throw fail(400, 'Ungültiger Modus (auto, build, dev oder off)')
|
||||
const doc = await loadProject(req.params.id)
|
||||
if (mode === 'off') {
|
||||
try { await runnerClient.stopDev(doc.subdomain) } catch (err) {
|
||||
console.error('[admin/preview] Dev stoppen fehlgeschlagen:', err.message)
|
||||
}
|
||||
const updated = await updateDocument(col(), doc.$id, { previewMode: mode, previewState: 'off' })
|
||||
return res.json(await view(updated))
|
||||
}
|
||||
const updated = await updateDocument(col(), doc.$id, { previewMode: mode })
|
||||
await triggerRebuild(updated)
|
||||
res.json(await view(await getDocument(col(), doc.$id)))
|
||||
}))
|
||||
|
||||
router.put('/:id/preview/env', wrap(async (req, res) => {
|
||||
requireRunner()
|
||||
const key = envKey()
|
||||
if (!key) throw fail(503, 'Preview-Variablen sind nicht eingerichtet')
|
||||
const doc = await loadProject(req.params.id)
|
||||
let current
|
||||
try { current = decryptEnv(doc.previewEnv, key) } catch {
|
||||
throw fail(409, 'Vorhandene Preview-Variablen sind nicht entschlüsselbar')
|
||||
}
|
||||
const { set, remove } = req.body || {}
|
||||
const result = validateEnvChange(current, { set, remove })
|
||||
if (!result.ok) throw fail(400, result.error)
|
||||
const stored = Object.keys(result.env).length ? encryptEnv(result.env, key) : ''
|
||||
const updated = await updateDocument(col(), doc.$id, { previewEnv: stored })
|
||||
if ((updated.previewMode || 'auto') !== 'off') await triggerRebuild(updated)
|
||||
res.json(await view(await getDocument(col(), doc.$id)))
|
||||
}))
|
||||
|
||||
router.post('/:id/preview/rebuild', wrap(async (req, res) => {
|
||||
requireRunner()
|
||||
const doc = await loadProject(req.params.id)
|
||||
if ((doc.previewMode || 'auto') === 'off') throw fail(409, 'Preview ist ausgeschaltet')
|
||||
await triggerRebuild(doc)
|
||||
res.json(await view(await getDocument(col(), doc.$id)))
|
||||
}))
|
||||
|
||||
router.post('/:id/preview/dev/start', wrap(async (req, res) => {
|
||||
requireRunner()
|
||||
const doc = await loadProject(req.params.id)
|
||||
if ((doc.previewMode || 'auto') === 'off') throw fail(409, 'Preview ist ausgeschaltet')
|
||||
// Gespeicherten Stand verwenden (R17), nie raten
|
||||
if (!doc.repoFullName || !doc.previewBranch || !SHA_RE.test(String(doc.previewBuiltSha || ''))) {
|
||||
throw fail(409, 'Noch kein Stand gespeichert – bitte zuerst neu bauen')
|
||||
}
|
||||
let env = {}
|
||||
if (doc.previewEnv) {
|
||||
const key = envKey()
|
||||
try { env = decryptEnv(doc.previewEnv, key) } catch {
|
||||
throw fail(409, 'Preview-Variablen nicht entschlüsselbar (PREVIEW_ENV_KEY fehlt)')
|
||||
}
|
||||
}
|
||||
const out = await runnerClient.startDevNow(doc.subdomain, {
|
||||
sub: doc.subdomain, repo: doc.repoFullName, sha: doc.previewBuiltSha, branch: doc.previewBranch, mode: 'dev', env,
|
||||
})
|
||||
const state = ['starting', 'online'].includes(out?.state) ? out.state : 'starting'
|
||||
const updated = await updateDocument(col(), doc.$id, { previewState: state })
|
||||
res.json(await view(updated))
|
||||
}))
|
||||
|
||||
router.post('/:id/preview/dev/stop', wrap(async (req, res) => {
|
||||
requireRunner()
|
||||
const doc = await loadProject(req.params.id)
|
||||
await runnerClient.stopDev(doc.subdomain)
|
||||
const updated = await updateDocument(col(), doc.$id, { previewState: 'sleeping' })
|
||||
res.json(await view(updated))
|
||||
}))
|
||||
|
||||
router.get('/:id/preview/logs/:runFile', wrap(async (req, res) => {
|
||||
const doc = await loadProject(req.params.id)
|
||||
if (!isRunFile(req.params.runFile)) throw fail(400, 'Ungültiger Protokollname')
|
||||
const text = await readRunLog(doc.$id, req.params.runFile)
|
||||
if (text === null) throw fail(404, 'Protokoll nicht gefunden')
|
||||
res.json({ lines: text.replace(/\n$/, '').split('\n') })
|
||||
}))
|
||||
|
||||
export default router
|
||||
@@ -51,6 +51,7 @@ router.get('/', async (_req, res) => {
|
||||
ticketId: p.ticketId || '',
|
||||
woid: tickets.get(p.ticketId)?.woid || '',
|
||||
previewUrl: previewUrlFor(p),
|
||||
previewState: p.previewState || '',
|
||||
features: {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export async function saveRunLog(projectId, lines, env) {
|
||||
return runFile
|
||||
}
|
||||
|
||||
export const isRunFile = (name) => FILE_RE.test(String(name || ''))
|
||||
|
||||
// Neueste zuerst
|
||||
export async function listRunLogs(projectId) {
|
||||
let files
|
||||
|
||||
@@ -34,5 +34,7 @@ export const submitBuild = (job) => call('POST', '/jobs/build', job)
|
||||
export const getRun = (runId) => call('GET', `/jobs/${encodeURIComponent(runId)}`)
|
||||
// Dev-Auftrag beim Runner hinterlegen (startet nicht, R12)
|
||||
export const startDev = (sub, job) => call('PUT', `/dev/${encodeURIComponent(sub)}`, job)
|
||||
// Dev-Server jetzt starten (mit gespeichertem Auftrag)
|
||||
export const startDevNow = (sub, job) => call('POST', `/dev/${encodeURIComponent(sub)}/start`, job)
|
||||
export const stopDev = (sub) => call('POST', `/dev/${encodeURIComponent(sub)}/stop`, {})
|
||||
export const status = () => call('GET', '/status')
|
||||
@@ -95,6 +95,8 @@ export async function completeAssignment(
|
||||
forCustomer: customer,
|
||||
kindSetBy: staffName || '',
|
||||
kindSetAt: now.toISOString(),
|
||||
// Preview-Modus nur vorbelegen, wenn noch keiner gesetzt ist
|
||||
...(project.previewMode ? {} : { previewMode: kind === 'website' || kind === 'webapp' ? 'auto' : 'off' }),
|
||||
})
|
||||
|
||||
const result = { ticketId: finalTicketId }
|
||||
|
||||
Reference in new issue
Block a user