Harden OPAX Web TUI backend og polish UI/UX for prod
This commit is contained in:
parent
fd7ffe8b54
commit
a1d28db7c5
55
opax-web/README-opax-web.md
Normal file
55
opax-web/README-opax-web.md
Normal 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.
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user