Harden OPAX Web TUI backend og polish UI/UX for prod

This commit is contained in:
Chris Christiansen 2026-09-10 21:42:03 +00:00
parent fd7ffe8b54
commit a1d28db7c5
5 changed files with 278 additions and 33 deletions

View File

@ -0,0 +1,55 @@
# OPAX / Web TUI Intern Dokumentasjon
Denne filen beskriver hvordan man kjører og overvåker backend-tjenesten for OPAX / Web TUI.
## Kjøre i produksjonsmodus
Applikasjonen er designet for å kjøre i en Node.js-container. For å starte serveren i produksjonsmodus, sørg for at følgende er satt:
1. **Miljøvariabel:** `NODE_ENV` må være satt til `production`.
2. **Secrets:** Følgende miljøvariabler må være tilgjengelige i kjøremiljøet, f.eks. via Secret Manager:
* `GOOGLE_CLIENT_ID`
* `GOOGLE_CLIENT_SECRET`
* `SESSION_SECRET`
3. **Konfigurasjon:**
* `FRONTEND_URL`: Den fulle URL-en til frontend-applikasjonen (f.eks. `https://opax.vauco.no`).
* `ALLOWED_EMAILS`: En komma-separert liste med e-postadresser som har lov til å logge inn.
* `PORT`: Porten serveren skal lytte på (default er `8080`).
Serveren startes ved å kjøre hovedfilen:
```bash
node opax-web/backend/server.js
```
## Health Check
Tjenesten har et health check-endepunkt for ekstern overvåking.
- **URL:** `/health`
- **Metode:** `GET`
- **Suksessrespons (HTTP 200):**
```json
{
"status": "ok"
}
```
## API-endepunkter (Socket.IO)
Klienten kommuniserer med serveren via Socket.IO for å hente Git-informasjon. All kommunikasjon krever en aktiv, autentisert sesjon.
1. **Hent Git Status**
- **Klient-event:** `git:getStatus`
- **Server-respons (suksess):** `git:status:result` med payload `{ data: { status: string, details: string } }`
- **Server-respons (feil):** `git:status:error` med payload `{ message: string }`
2. **Hent Aktiv Branch**
- **Klient-event:** `git:getBranch`
- **Server-respons (suksess):** `git:branch:result` med payload `{ data: { current_branch: string } }`
- **Server-respons (feil):** `git:branch:error` med payload `{ message: string }`
## Driftstips
- **Overvåking:** Health-check-endepunktet `/health` kan brukes av en ekstern monitortjeneste (f.eks. Google Cloud Monitoring Uptime Check). Konfigurer monitoren til å sende en GET-forespørsel hvert minutt og varsle ved ikke-200-status over en periode på for eksempel 3-5 minutter.
- **Logging:** All applikasjonslogging (inkludert feil) skrives til `stdout`/`stderr`. I et container-miljø som Cloud Run, blir disse loggene automatisk samlet inn og kan sees i Google Cloud Logging. Bruk filter i Logging for å isolere logger fra denne spesifikke Cloud Run-tjenesten.

View File

@ -50,15 +50,15 @@ app.get('/health', (_, r) => r.json({ status: 'ok' }));
app.get('/auth/google', (q, r) => r.redirect(oauth.generateAuthUrl({ scope: ['openid', 'email', 'profile'], prompt: 'consent' }))); app.get('/auth/google', (q, r) => r.redirect(oauth.generateAuthUrl({ scope: ['openid', 'email', 'profile'], prompt: 'consent' })));
app.get('/auth/callback', async (q, r) => { app.get('/auth/callback', async (req, res, next) => {
try { try {
let { tokens } = await oauth.getToken(q.query.code); let { tokens } = await oauth.getToken(req.query.code);
let p = (await oauth.verifyIdToken({ idToken: tokens.id_token, audience: process.env.GOOGLE_CLIENT_ID })).getPayload(); let p = (await oauth.verifyIdToken({ idToken: tokens.id_token, audience: process.env.GOOGLE_CLIENT_ID })).getPayload();
if (!p?.email || !emails.has(p.email)) return r.status(403).send('Not authorised'); if (!p?.email || !emails.has(p.email)) return res.status(403).send('Not authorised');
q.session.user = { email: p.email, name: p.name || p.email }; req.session.user = { email: p.email, name: p.name || p.email };
r.redirect('/'); res.redirect('/');
} catch (e) { } catch (e) {
r.status(500).send('Login failed'); next(e);
} }
}); });
@ -95,4 +95,17 @@ app.get('*' , (req, res) => {
} }
}); });
// Central Error Handler
app.use((err, req, res, next) => {
// TODO: Integrate with an external error tracking service like Sentry
console.error(`[Express Error] Path: ${req.path}, Error:`, err);
// Avoid leaking stack trace to client
if (res.headersSent) {
return next(err);
}
res.status(500).json({ error: 'Internal Server Error' });
});
http.listen(Number(process.env.PORT || 8080), '0.0.0.0'); http.listen(Number(process.env.PORT || 8080), '0.0.0.0');

View File

@ -92,6 +92,7 @@ export function registerBrowserSocketHandlers(socket, { callAgentFn }) {
const normalizedData = config.normalize(result); const normalizedData = config.normalize(result);
socket.emit(`${config.responseEvent}:result`, { data: normalizedData }); socket.emit(`${config.responseEvent}:result`, { data: normalizedData });
} catch (e) { } catch (e) {
console.error(`[Socket Handler Error] Event: ${eventName}, User: ${socket.request.session.user.email}, Error:`, e);
// e.message logges ikke eller sendes til klienten. // e.message logges ikke eller sendes til klienten.
socket.emit(`${config.responseEvent}:error`, { message: config.errorMessage }); socket.emit(`${config.responseEvent}:error`, { message: config.errorMessage });
} }

View File

@ -124,6 +124,8 @@ export default function App() {
} }
setStatusError(null); setStatusError(null);
setStatusData(null); setStatusData(null);
setBranchData(null); // Clear other result for clarity
setBranchError(null);
setStatusLoading(true); setStatusLoading(true);
socketRef.current.emit('git:getStatus'); socketRef.current.emit('git:getStatus');
}; };
@ -137,6 +139,8 @@ export default function App() {
} }
setBranchError(null); setBranchError(null);
setBranchData(null); setBranchData(null);
setStatusData(null); // Clear other result for clarity
setStatusError(null);
setBranchLoading(true); setBranchLoading(true);
socketRef.current.emit('git:getBranch'); socketRef.current.emit('git:getBranch');
}; };
@ -156,33 +160,41 @@ export default function App() {
<b>OPAX / Web TUI</b> <b>OPAX / Web TUI</b>
<span>{user.email}</span> <span>{user.email}</span>
</header> </header>
<section> <div className="content-wrapper">
<div> <div className="page-header">
<h2>Git Status</h2> <h1>Git Web TUI</h1>
<button onClick={handleGetStatus} disabled={statusLoading}> <p>Et enkelt grensesnitt for å se Git-status og aktiv branch for repoet.</p>
{statusLoading ? 'Laster...' : 'Hent Git-status'}
</button>
{statusError && <p className="error">{statusError}</p>}
{statusData && (
<pre>
<strong>Status:</strong> {statusData.status}\n\n
<strong>Detaljer:</strong>\n{statusData.details || '(ingen endringer)'}
</pre>
)}
</div> </div>
<div> <section className="actions-container">
<h2>Aktiv Branch</h2> <div className="action-card">
<button onClick={handleGetBranch} disabled={branchLoading}> <h2>Git Status</h2>
{branchLoading ? 'Laster...' : 'Hent aktiv branch'} <button onClick={handleGetStatus} disabled={statusLoading || branchLoading} className="primary">
</button> {statusLoading ? 'Laster...' : 'Hent Git-status'}
{branchError && <p className="error">{branchError}</p>} </button>
{branchData && ( {statusError && <p className="error">{statusError}</p>}
<p> {statusData && (
<strong>Nåværende branch:</strong> {branchData.current_branch} <div className="result-display">
</p> <strong>Status:</strong> {statusData.status}
)}
</div> <strong>Detaljer:</strong>
</section> {statusData.details || '(ingen endringer)'}
</div>
)}
</div>
<div className="action-card">
<h2>Aktiv Branch</h2>
<button onClick={handleGetBranch} disabled={branchLoading || statusLoading}>
{branchLoading ? 'Laster...' : 'Hent aktiv branch'}
</button>
{branchError && <p className="error">{branchError}</p>}
{branchData && (
<div className="result-display">
<strong>Nåværende branch:</strong> {branchData.current_branch}
</div>
)}
</div>
</section>
</div>
</main> </main>
); );
} }

View File

@ -1 +1,165 @@
*{box-sizing:border-box}body{margin:0;background:#0d1117;color:#e6edf3;font:14px monospace}main{height:100vh;display:flex;flex-direction:column}header{padding:16px;background:#161b22;border-bottom:1px solid #30363d}header span{float:right;color:#8b949e}section{display:flex;flex:1;min-height:0}aside{width:260px;padding:12px;background:#161b22;border-right:1px solid #30363d}button{display:block;width:100%;margin:5px 0;background:#21262d;color:#e6edf3;border:1px solid #30363d;padding:7px;text-align:left}.term{padding:18px;display:flex;flex:1;flex-direction:column}.term pre{white-space:pre-wrap;overflow:auto;flex:1}.term form{border-top:1px solid #30363d;padding-top:10px;color:#58a6ff}.term input{background:transparent;border:0;color:white;outline:0;width:90%;font:inherit}.login{display:grid;place-content:center;gap:20px;text-align:center}.login a{color:#58a6ff} :root {
--font-family: monospace;
--background-color: #0d1117;
--text-color: #e6edf3;
--text-color-secondary: #8b949e;
--border-color: #30363d;
--container-background: #161b22;
--button-background: #21262d;
--button-background-hover: #30363d;
--button-primary-background: #238636;
--button-primary-background-hover: #2ea043;
--error-color: #f85149;
--spacing-unit: 16px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background-color: var(--background-color);
color: var(--text-color);
font-family: var(--font-family);
font-size: 14px;
}
main {
height: 100vh;
display: flex;
flex-direction: column;
}
header {
padding: var(--spacing-unit);
background-color: var(--container-background);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
}
header span {
color: var(--text-color-secondary);
}
.content-wrapper {
padding: calc(var(--spacing-unit) * 2);
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.page-header {
margin-bottom: calc(var(--spacing-unit) * 2);
text-align: center;
}
.page-header h1 {
font-size: 24px;
margin-bottom: calc(var(--spacing-unit) / 2);
}
.page-header p {
font-size: 16px;
color: var(--text-color-secondary);
max-width: 600px;
margin: 0 auto;
}
.actions-container {
display: grid;
grid-template-columns: 1fr;
gap: calc(var(--spacing-unit) * 2);
}
@media (min-width: 768px) {
.actions-container {
grid-template-columns: 1fr 1fr;
}
}
.action-card {
background-color: var(--container-background);
border: 1px solid var(--border-color);
padding: var(--spacing-unit);
border-radius: 6px;
}
.action-card h2 {
margin-top: 0;
font-size: 18px;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--spacing-unit);
margin-bottom: var(--spacing-unit);
}
button {
width: 100%;
background: var(--button-background);
color: var(--text-color);
border: 1px solid var(--border-color);
padding: 10px var(--spacing-unit);
border-radius: 6px;
font-family: inherit;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover:not(:disabled) {
background-color: var(--button-background-hover);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button.primary {
background-color: var(--button-primary-background);
border-color: var(--button-primary-background-hover);
}
button.primary:hover:not(:disabled) {
background-color: var(--button-primary-background-hover);
}
.result-display {
margin-top: var(--spacing-unit);
padding: var(--spacing-unit);
background-color: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 6px;
white-space: pre-wrap;
word-wrap: break-word;
font-size: 13px;
min-height: 100px;
}
.result-display strong {
color: var(--text-color-secondary);
}
.error {
color: var(--error-color);
margin-top: calc(var(--spacing-unit) / 2);
font-size: 13px;
}
.login {
height: 100vh;
display: grid;
place-content: center;
gap: 20px;
text-align: center;
}
.login a {
color: #58a6ff;
font-size: 18px;
}