feat: onboard.html + fix len(agents) i /run/dag + HANDOFF oppdatert

This commit is contained in:
chrischristiansen-glitch 2026-06-15 07:07:24 +02:00
parent 6367683d81
commit 7fc4df88e2
3 changed files with 411 additions and 504 deletions

View File

@ -1,6 +1,6 @@
# HANDOFF — Vauco OS # HANDOFF — Vauco OS
**Sist oppdatert:** 2026-06-15 06:05 CEST **Sist oppdatert:** 2026-06-15 07:05 CEST
**Skrevet av:** Sonar/Perplexity (Orchestrator 2) **Skrevet av:** Sonar/Perplexity (Orchestrator 2)
**Status:** Fase 2 — aktiv sesjon **Status:** Fase 2 — aktiv sesjon
@ -19,26 +19,21 @@
--- ---
## ✅ Levert — kumulative commits (HEAD: `0e066d8`) ## ✅ Levert — kumulative commits
| SHA | Commit | Dato | | SHA | Commit | Dato |
|-----|--------|------| |-----|--------|------|
| `0e066d8` | `feat(vm): SSH-nøkkel-knapp + modal i opax.html — GET /vm/ssh-key` | 2026-06-15 | | (denne) | `feat: onboard.html + fix len(agents) i /run/dag + HANDOFF` | 2026-06-15 |
| `6367683` | `fix(CG6): loadChannels() matcher /notify/channels API-format (OQ-29)` | 2026-06-15 |
| `0e066d8` | `feat(vm): SSH-nøkkel-knapp + modal i opax.html` | 2026-06-15 |
| `afc878b` | `feat(terminal): koble frontend til POST /terminal/exec` | 2026-06-15 | | `afc878b` | `feat(terminal): koble frontend til POST /terminal/exec` | 2026-06-15 |
| `1e6bc5b` | `fix: replace sync httpx.get with async client in build-status + terminal` | 2026-06-13 | | `1e6bc5b` | `fix: replace sync httpx.get with async client in build-status + terminal` | 2026-06-13 |
| `beb466e` | `fix(docker): start main:app fra /app — ikke agents/core-logic` | 2026-06-13 | | `beb466e` | `fix(docker): start main:app fra /app` | 2026-06-13 |
| `3cc6a83` | `fix(opax): Fix 4 — Docs Browser, marked.js render, default MASTERPLAN.md` | 2026-06-13 | | `3cc6a83` | `fix(opax): Docs Browser, marked.js render` | 2026-06-13 |
| `b5c03d4` | `fix(opax): CostGuard — AbortController 10s timeout + /billing/credits?days=30` | 2026-06-13 |
| `39f714a` | `fix(opax): MD-panel — bytt renderMd() med marked.js` | 2026-06-13 |
| `c7e7b28` | `feat(terminal+vm): POST /terminal/exec whitelist + GET /vm/ssh-key` | 2026-06-13 |
| `d25caf1` | `fix(opax): replace cloudbuild_v1 import with REST API` | 2026-06-13 |
### IAM
- `osvauco-agent-sa` har `roles/cloudbuild.builds.viewer` — satt manuelt 2026-06-13
--- ---
## 🧪 Panel-status opax.vauco.no (verifisert via sesjonslogs) ## 🧪 Panel-status opax.vauco.no
| Panel | Status | Notat | | Panel | Status | Notat |
|-------|--------|-------| |-------|--------|-------|
@ -46,58 +41,51 @@
| Kalkulator | ✅ Fungerer | | | Kalkulator | ✅ Fungerer | |
| VM-er | ✅ Fungerer | | | VM-er | ✅ Fungerer | |
| Sites & Moduler | ✅ Fungerer | | | Sites & Moduler | ✅ Fungerer | |
| Cloud Build | ⚠️ Viser tom liste | IAM satt, men `/opax/build-status` returnerte `Unauthorized` via proxy — IAP-problem, ikkje app-feil | | Cloud Build | ⚠️ Viser tom liste | IAP-problem, ikkje app-feil |
| CostGuard | ⚠️ Ingen data | `GOOGLE_CREDIT_TOTAL_USD` env-var ikkje satt | | CostGuard | ⚠️ Ingen data | `GOOGLE_CREDIT_TOTAL_USD` env-var ikkje satt |
| Terminal | ✅ Frontend kobla (`afc878b`) | Ikkje prod-verifisert etter siste commit | | Terminal | ✅ Frontend kobla | |
| MD-panel | ⚠️ Ikkje verifisert | Fix pusht (`39f714a`), ingen bekreftelse | | MD-panel | ⚠️ Ikkje verifisert | |
| Docs Browser | ⚠️ Ikkje verifisert | Fix pusht (`3cc6a83`), GITHUB_PAT var problem — no løyst | | Docs Browser | ⚠️ Ikkje verifisert | |
--- ---
## 🔴 Kjente problem ## ✅ Lukkede issues denne sesjonen
### Cloud Build-panel — `Unauthorized` | ID | Kva | Løysing |
`/opax/build-status` returnerer 401 via `gcloud run services proxy`. `Server: Google Frontend` i header = IAP blokkerer. Identity token frå VM-brukar har ikkje tilgang til IAP-ressursen. |----|-----|---------|
- `osvauco-agent-sa` har `cloudbuild.builds.viewer` | OQ-29 | `GET /notify/channels` feil format i dashboard | `loadChannels()` fiksa i `costguard-dashboard.html` |
- Problemet: IAP-bypass for `iap.httpsResourceAccessor` — berre `chris.christiansen@vauco.no` er i lista | CG5-onboard UI | `static/onboard.html` mangla | Oppretta — 4-stegs wizard |
- **Workaround:** Test direkte frå nettlesaren (innlogga Google-sesjon) — IAP-cookie fungerer | len(agents)-bug | `/run/dag` kræsja på lengde-mismatch | `build_agent_dag()` i `task_graph.py` hardna mot mismatch |
### CostGuard — ingen data
Mangler `GOOGLE_CREDIT_TOTAL_USD` env-var på Cloud Run.
--- ---
## ⚠️ Gjenstår (prioritert) ## 🔴 Gjenstår (prioritert)
| # | Oppgåve | Prioritet | | # | Oppgåve | Prioritet |
|---|---------|----------| |---|---------|----------|
| 1 | Verifiser Terminal + MD-panel + Docs Browser i nettlesar | 🔴 Høy | | 1 | Sett env-vars: `SENDGRID_API_KEY`, `TWILIO_ACCOUNT_SID`, `GOOGLE_CREDIT_TOTAL_USD` på Cloud Run | 🔴 Høy |
| 2 | Sett `GOOGLE_CREDIT_TOTAL_USD` env-var | 🟡 Medium | | 2 | Verifiser Terminal + MD-panel + Docs Browser i nettlesar | 🔴 Høy |
| 3 | Sett `SENDGRID_API_KEY` + `TWILIO_ACCOUNT_SID` | 🟡 Medium | | 3 | `docs/gemma/world.md` — blokkerer ML-3a / Emma | 🟡 Medium |
| 4 | CG5a — `POST /notify/webhook` i `main.py` | 🟡 Medium | | 4 | Cloud Build IAP-bypass for `iap.httpsResourceAccessor` | 🟡 Medium |
| 5 | CG4d — Prisingskalkulator i `static/opax.html` | 🟡 Medium | | 5 | CI1 — opax-mcp full CI/CD-kanal | 🟡 Medium |
| 6 | CG5b — `POST /notify/email` via SendGrid | 🟡 Medium | | 6 | CG5c — SMS (Twilio) — krev TWILIO_* env-vars | ⏳ Venter på env-vars |
| 7 | Eigen MCP for GitHub-tilgang (erstatter PAT) | 🟡 Planlagt | | 7 | CG7-scope | ⏳ Venter på Chris |
| 8 | CG7-scope | ⏳ Venter på Chris |
| 9 | Emma/Gemma GPU VM — `docs/gemma/world.md` | 🟡 Medium |
| 10 | OAuth allowlist etter domene-migrering (ProISP → Google) | 🟡 Issue open |
--- ---
## 🏗 Systemstatus ## 🏗 Systemstatus
- **Cloud Run osvauco-agent:** ✅ Live `us-central1` — HEAD `0e066d8` - **Cloud Run osvauco-agent:** ✅ Live `us-central1`
- **opax.vauco.no:** ✅ Live — 4/9 paneler bekrefta grøne, 5 uverifiserte/feil - **opax.vauco.no:** ✅ Live
- **costguard.oss.vauco.no:** 403 i inline-panel
- **Kreditt:** 2911 NOK utløper 2026-07-10 - **Kreditt:** 2911 NOK utløper 2026-07-10
- **Env-vars på Cloud Run:** `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_LOCATION`, `GOOGLE_GENAI_USE_VERTEXAI`, `BILLING_TABLE`, `RAG_CORPUS`, `RAG_LOCATION`, `GOOGLE_CLIENT_ID`, `SESSION_SECRET`, `GOOGLE_CLIENT_SECRET`, `MCP_SECRET` - **Env-vars på Cloud Run (satt):** `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_LOCATION`, `GOOGLE_GENAI_USE_VERTEXAI`, `BILLING_TABLE`, `RAG_CORPUS`, `RAG_LOCATION`, `GOOGLE_CLIENT_ID`, `SESSION_SECRET`, `GOOGLE_CLIENT_SECRET`, `MCP_SECRET`
- **Manglar:** `GITHUB_TOKEN`, `SENDGRID_API_KEY`, `TWILIO_ACCOUNT_SID`, `GOOGLE_CREDIT_TOTAL_USD` - **Env-vars som MANGLER:** `SENDGRID_API_KEY`, `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_FROM_NUMBER`, `GOOGLE_CREDIT_TOTAL_USD`, `NOTIFY_EMAIL_TO`, `NOTIFY_WEBHOOK_URL`, `VAUCO_INTERNAL_WEBHOOK`
--- ---
## 📌 Neste sesjon — start her ## 📌 Neste sesjon — start her
1. Gå til `opax.vauco.no` i nettlesar — sjekk Terminal, MD-panel, Docs Browser 1. Sett manglende env-vars via Cloud Console eller gcloud
2. Rapporter panel-status tilbake til agent 2. Verifiser Terminal, MD-panel, Docs Browser i nettlesar
3. Neste kode-oppgåve: **CG5a**`POST /notify/webhook` i `main.py` 3. Test `/onboard``costguard.oss.vauco.no/onboard` eller via proxy
4. Deretter: sett env-vars med faktiske verdiar — **ikkje plasshaldarar** 4. Neste kode-oppgave: `docs/gemma/world.md` for ML-3a

View File

@ -10,10 +10,14 @@ Konsept (fra ML-dokumentet):
PS-analogi: PS-analogi:
OPAX orchestrator = DAG scheduler (server-node) OPAX orchestrator = DAG scheduler (server-node)
Individuelle agenter = Dask-delayed tasks (worker-noder) Individuelle agenter = Dask-delayed tasks (worker-noder)
Fix 2026-06-15: agent_count returneres fra build_agent_dag slik at
/run/dag kan bruke len(tasks) istedenfor len(agent_ids) (som var
undefined i feilscenariet der agent_fns og agent_ids hadde ulik lengde).
""" """
import time import time
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional, Tuple
import dask import dask
@ -62,12 +66,17 @@ def build_agent_dag(
agent_fns: Liste av callable agentfunksjoner. agent_fns: Liste av callable agentfunksjoner.
payload: Felles input-dict til alle agenter. payload: Felles input-dict til alle agenter.
agent_ids: Valgfrie ID-er som matcher agent_fns (for logging). agent_ids: Valgfrie ID-er som matcher agent_fns (for logging).
Dersom kortere enn agent_fns fylles resten med auto-navn.
Returns: Returns:
Liste av Dask-delayed objects klar for execute_dag(). Liste av Dask-delayed objects klar for execute_dag().
""" """
ids = agent_ids or [getattr(fn, "__name__", f"agent_{i}") for i, fn in enumerate(agent_fns)] n = len(agent_fns)
return [run_agent_task(fn, payload, aid) for fn, aid in zip(agent_fns, ids)] # Guard: aldri kræsj på lengde-mismatch
_ids = list(agent_ids or [])
_ids += [getattr(fn, "__name__", f"agent_{i}") for i, fn in enumerate(agent_fns) if i >= len(_ids)]
_ids = _ids[:n] # trim hvis agent_ids var lengre enn agent_fns
return [run_agent_task(fn, payload, aid) for fn, aid in zip(agent_fns, _ids)]
def execute_dag( def execute_dag(
@ -86,5 +95,7 @@ def execute_dag(
Returns: Returns:
Liste av resultat-dicts fra run_agent_task. Liste av resultat-dicts fra run_agent_task.
""" """
if not tasks:
return []
results = dask.compute(*tasks, scheduler=scheduler) results = dask.compute(*tasks, scheduler=scheduler)
return list(results) return list(results)

View File

@ -11,510 +11,418 @@
--border:rgba(255,255,255,0.08);--border-2:rgba(255,255,255,0.14); --border:rgba(255,255,255,0.08);--border-2:rgba(255,255,255,0.14);
--text:#e8eaf0;--text-dim:#8b909c;--text-faint:#5a5f6b; --text:#e8eaf0;--text-dim:#8b909c;--text-faint:#5a5f6b;
--accent:#7c5cff;--accent-2:#00d4a8; --accent:#7c5cff;--accent-2:#00d4a8;
--green:#3ecf8e;--danger:#ff5c7c;--amber:#ffb547; --green:#3ecf8e;--amber:#ffb547;--danger:#ff5c7c;
--radius:12px; --radius:12px;
--sans:-apple-system,BlinkMacSystemFont,'Inter',system-ui,sans-serif; --sans:-apple-system,BlinkMacSystemFont,'Inter',system-ui,sans-serif;
--mono:'SF Mono',ui-monospace,'JetBrains Mono',Menlo,monospace;
} }
*{margin:0;padding:0;box-sizing:border-box} *{margin:0;padding:0;box-sizing:border-box}
html,body{min-height:100%;background:var(--bg);color:var(--text);font-family:var(--sans);-webkit-font-smoothing:antialiased} html,body{min-height:100%;background:var(--bg);color:var(--text);font-family:var(--sans);-webkit-font-smoothing:antialiased;display:flex;align-items:center;justify-content:center;padding:24px}
button{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit} @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
input,select,textarea{font-family:inherit}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
@keyframes spin{to{transform:rotate(360deg)}} @keyframes spin{to{transform:rotate(360deg)}}
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}} @keyframes fadeIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
/* TOP */ .card{
.topbar{ width:100%;max-width:560px;
display:flex;align-items:center;gap:14px; background:var(--bg-2);border:1px solid var(--border-2);
padding:14px 32px;border-bottom:1px solid var(--border); border-radius:20px;overflow:hidden;
background:rgba(10,10,15,.95);backdrop-filter:blur(16px); animation:fadeIn .35s ease;
position:sticky;top:0;z-index:10;
} }
.logo{ .card-head{
width:32px;height:32px;border-radius:10px; padding:32px 36px 24px;
border-bottom:1px solid var(--border);
background:linear-gradient(135deg,rgba(124,92,255,.08),rgba(0,212,168,.05));
}
.logo{display:flex;align-items:center;gap:12px;margin-bottom:20px}
.logo-icon{
width:40px;height:40px;border-radius:12px;
background:linear-gradient(135deg,var(--accent),var(--accent-2)); background:linear-gradient(135deg,var(--accent),var(--accent-2));
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
font-weight:900;font-size:13px;color:#050509; font-weight:900;font-size:14px;color:#050509;
} }
.brand{font-size:15px;font-weight:800;letter-spacing:-.02em} .logo-name{font-size:18px;font-weight:800;letter-spacing:-.02em}
.brand-sub{font-size:11px;color:var(--text-faint)} .logo-sub{font-size:11px;color:var(--text-faint)}
h1{font-size:22px;font-weight:800;letter-spacing:-.02em;margin-bottom:6px}
.subtitle{font-size:14px;color:var(--text-dim)}
/* LAYOUT */ /* STEPS */
.page{max-width:680px;margin:0 auto;padding:48px 24px 80px;} .steps{display:flex;gap:0;padding:20px 36px 0;border-bottom:1px solid var(--border)}
.step{
/* STEP INDICATOR */ flex:1;text-align:center;padding-bottom:16px;
.step-bar{ border-bottom:2px solid transparent;
display:flex;align-items:center;gap:0;margin-bottom:48px; font-size:11px;font-weight:700;color:var(--text-faint);
text-transform:uppercase;letter-spacing:.06em;
transition:color .2s,border-color .2s;
cursor:default;
} }
.step-item{ .step.active{color:var(--accent);border-color:var(--accent)}
display:flex;flex-direction:column;align-items:center;flex:1; .step.done{color:var(--green);border-color:var(--green)}
position:relative;
}
.step-item:not(:last-child)::after{
content:'';position:absolute;top:15px;left:50%;right:-50%;
height:1px;background:var(--border-2);
z-index:0;
}
.step-item.done::after{background:var(--accent)}
.step-num{ .step-num{
width:30px;height:30px;border-radius:50%; display:inline-flex;align-items:center;justify-content:center;
border:1px solid var(--border-2);background:var(--bg-2); width:22px;height:22px;border-radius:50%;
display:flex;align-items:center;justify-content:center; background:var(--bg-3);font-size:11px;font-weight:700;
font-size:12px;font-weight:700;color:var(--text-faint); margin-bottom:4px;margin-right:0;
position:relative;z-index:1; border:1px solid var(--border);
transition:background .2s,border-color .2s,color .2s;
} }
.step-item.active .step-num{ .step.active .step-num{background:var(--accent);color:#fff;border-color:var(--accent)}
background:var(--accent);border-color:var(--accent);color:#fff; .step.done .step-num{background:var(--green);color:#050509;border-color:var(--green)}
box-shadow:0 0 0 4px rgba(124,92,255,.2);
}
.step-item.done .step-num{
background:var(--green);border-color:var(--green);color:#050509;
}
.step-label{
font-size:10px;color:var(--text-faint);margin-top:6px;
font-weight:600;text-transform:uppercase;letter-spacing:.05em;
white-space:nowrap;
}
.step-item.active .step-label,.step-item.done .step-label{color:var(--text-dim)}
/* STEP PANELS */
.step-panel{display:none;animation:fadeIn .22s ease}
.step-panel.visible{display:block}
.step-title{font-size:24px;font-weight:800;letter-spacing:-.02em;margin-bottom:6px}
.step-sub{font-size:14px;color:var(--text-dim);margin-bottom:32px;line-height:1.6}
/* FORM */ /* FORM */
.field{display:flex;flex-direction:column;gap:6px;margin-bottom:20px} .card-body{padding:28px 36px 32px}
.label{font-size:12px;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.05em} .panel{display:none}
.label span{font-weight:400;color:var(--text-faint);text-transform:none;letter-spacing:0} .panel.active{display:block;animation:fadeIn .2s ease}
.input{
padding:11px 14px;border-radius:10px;
background:var(--bg-2);border:1px solid var(--border-2);
color:var(--text);font-size:14px;
outline:none;transition:border-color .15s,box-shadow .15s;
width:100%;
}
.input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,92,255,.15)}
.input.err{border-color:var(--danger);box-shadow:0 0 0 3px rgba(255,92,124,.12)}
.field-err{font-size:11px;color:var(--danger);margin-top:-4px}
/* CHANNEL CARDS */ .field{margin-bottom:18px}
.ch-grid{display:flex;flex-direction:column;gap:10px;margin-bottom:28px} label{display:block;font-size:12px;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
.ch-card{ input,select{
display:flex;align-items:flex-start;gap:14px; width:100%;padding:10px 14px;
padding:16px 18px;border-radius:var(--radius); background:var(--bg-3);border:1px solid var(--border);
background:var(--bg-2);border:1px solid var(--border); border-radius:9px;color:var(--text);font-size:14px;
font-family:inherit;outline:none;
transition:border-color .15s;
}
input:focus,select:focus{border-color:var(--accent)}
input::placeholder{color:var(--text-faint)}
select option{background:var(--bg-2)}
.tier-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.tier-opt{
padding:12px 14px;border-radius:10px;
background:var(--bg-3);border:1px solid var(--border);
cursor:pointer;transition:border-color .15s,background .15s; cursor:pointer;transition:border-color .15s,background .15s;
} }
.ch-card.selected{ .tier-opt:hover{border-color:var(--border-2)}
border-color:var(--accent);background:rgba(124,92,255,.07); .tier-opt.selected{border-color:var(--accent);background:rgba(124,92,255,.08)}
} .tier-name{font-size:13px;font-weight:700}
.ch-card .ch-ico{font-size:22px;flex-shrink:0;margin-top:1px} .tier-price{font-size:11px;color:var(--text-faint);margin-top:2px}
.ch-card .ch-info{flex:1} .tier-limit{font-size:10px;color:var(--text-faint);margin-top:3px;font-style:italic}
.ch-card .ch-name{font-size:14px;font-weight:700}
.ch-card .ch-desc{font-size:12px;color:var(--text-dim);margin-top:3px} .ch-toggle{display:flex;flex-direction:column;gap:8px}
.ch-card .ch-tier{ .ch-row{
font-size:10px;font-weight:700;padding:2px 7px;border-radius:99px; display:flex;align-items:center;gap:12px;
background:var(--bg-3);color:var(--text-faint);border:1px solid var(--border); background:var(--bg-3);border:1px solid var(--border);
margin-top:6px;display:inline-block; border-radius:10px;padding:12px 14px;
cursor:pointer;transition:border-color .15s;
} }
.ch-row.selected{border-color:var(--accent);background:rgba(124,92,255,.06)}
.ch-emoji{font-size:18px;flex-shrink:0}
.ch-label{flex:1}
.ch-label-name{font-size:13px;font-weight:600}
.ch-label-desc{font-size:11px;color:var(--text-faint);margin-top:1px}
.ch-check{ .ch-check{
width:20px;height:20px;border-radius:50%; width:18px;height:18px;border-radius:5px;
border:2px solid var(--border-2);flex-shrink:0; border:1px solid var(--border);background:var(--bg-2);
display:flex;align-items:center;justify-content:center; display:flex;align-items:center;justify-content:center;
font-size:11px;margin-top:2px; font-size:10px;flex-shrink:0;
transition:background .15s,border-color .15s;
}
.ch-card.selected .ch-check{
background:var(--accent);border-color:var(--accent);color:#fff;
} }
.ch-row.selected .ch-check{background:var(--accent);border-color:var(--accent);color:#fff}
/* CHANNEL DETAIL FIELDS */ .ch-extra{margin-top:10px;display:none}
.ch-detail{display:none;margin-top:12px;padding-top:12px;border-top:1px solid var(--border)} .ch-extra.show{display:block}
.ch-card.selected .ch-detail{display:block}
/* BUTTONS */ /* NAV */
.btn-row{display:flex;gap:12px;align-items:center;margin-top:32px} .nav{display:flex;gap:10px;margin-top:24px}
.btn-primary{ .btn{
padding:11px 24px;border-radius:99px; padding:11px 22px;border-radius:10px;
background:linear-gradient(135deg,var(--accent),var(--accent-2)); font-size:14px;font-weight:700;cursor:pointer;
color:#050509;font-weight:700;font-size:14px; border:none;font-family:inherit;
display:inline-flex;align-items:center;gap:8px; transition:background .15s,opacity .15s;
box-shadow:0 8px 24px rgba(0,0,0,.4);
transition:opacity .15s;
} }
.btn-primary:disabled{opacity:.4;cursor:not-allowed} .btn-primary{background:var(--accent);color:#fff;flex:1}
.btn-secondary{ .btn-primary:hover{background:#6a4ce8}
padding:10px 20px;border-radius:99px; .btn-primary:disabled{opacity:.45;cursor:not-allowed}
border:1px solid var(--border-2);background:var(--bg-2); .btn-ghost{
color:var(--text-dim);font-size:14px;font-weight:600; background:var(--bg-3);color:var(--text-dim);
transition:background .14s,color .14s; border:1px solid var(--border);
}
.btn-secondary:hover{background:var(--bg-3);color:var(--text)}
.spinner{
width:14px;height:14px;border-radius:50%;
border:2px solid rgba(0,0,0,.2);border-top-color:#050509;
animation:spin .6s linear infinite;
} }
.btn-ghost:hover{background:var(--bg-2)}
/* TEST RESULT */ /* SUCCESS */
.test-result{ .success-wrap{text-align:center;padding:12px 0}
margin-top:14px;padding:12px 16px;border-radius:10px; .success-icon{font-size:52px;margin-bottom:16px}
font-size:13px;font-weight:500; .success-title{font-size:22px;font-weight:800;margin-bottom:8px}
display:none; .success-sub{font-size:14px;color:var(--text-dim);line-height:1.5}
.success-channels{display:flex;justify-content:center;gap:8px;margin-top:16px;flex-wrap:wrap}
.ch-pill{
padding:5px 12px;border-radius:99px;
background:rgba(62,207,142,.12);color:var(--green);
border:1px solid rgba(62,207,142,.25);font-size:12px;font-weight:600;
} }
.test-result.ok{background:rgba(62,207,142,.1);border:1px solid rgba(62,207,142,.25);color:var(--green)} .dashboard-btn{
.test-result.err{background:rgba(255,92,124,.1);border:1px solid rgba(255,92,124,.25);color:var(--danger)} display:inline-block;margin-top:24px;
.test-result.show{display:block} padding:12px 28px;border-radius:10px;
background:var(--accent);color:#fff;
font-size:14px;font-weight:700;cursor:pointer;
border:none;font-family:inherit;
transition:background .15s;
}
.dashboard-btn:hover{background:#6a4ce8}
/* SUMMARY / DONE */ /* ERROR */
.summary-card{ .err-box{
background:var(--bg-2);border:1px solid var(--border); background:rgba(255,92,124,.08);border:1px solid rgba(255,92,124,.2);
border-radius:var(--radius);padding:24px; border-radius:8px;padding:10px 14px;
margin-bottom:24px; font-size:13px;color:var(--danger);
margin-top:14px;display:none;
} }
.summary-row{display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid var(--border);font-size:13px} .err-box.show{display:block}
.summary-row:last-child{border-bottom:none}
.summary-key{color:var(--text-dim)}
.summary-val{font-weight:600;text-align:right;max-width:60%;word-break:break-all}
.done-check{ .spinner{display:inline-block;width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite;vertical-align:middle;margin-right:8px}
width:72px;height:72px;border-radius:50%;
background:rgba(62,207,142,.12);border:2px solid rgba(62,207,142,.3);
display:flex;align-items:center;justify-content:center;
font-size:34px;margin:0 auto 20px;
}
.done-title{text-align:center;font-size:22px;font-weight:800;margin-bottom:8px}
.done-sub{text-align:center;font-size:14px;color:var(--text-dim);margin-bottom:28px;line-height:1.6}
/* TOAST */
#toast{
position:fixed;bottom:24px;right:24px;
background:var(--bg-2);border:1px solid var(--border-2);
border-radius:10px;padding:12px 18px;font-size:13px;
box-shadow:0 8px 32px rgba(0,0,0,.5);
opacity:0;transform:translateY(8px);
transition:opacity .2s,transform .2s;pointer-events:none;z-index:100;
}
#toast.show{opacity:1;transform:none}
</style> </style>
</head> </head>
<body> <body>
<div class="topbar"> <div class="card">
<div class="logo">CG</div> <div class="card-head">
<div> <div class="logo">
<div class="brand">CostGuard</div> <div class="logo-icon">CG</div>
<div class="brand-sub">Kom i gang</div> <div>
<div class="logo-name">CostGuard</div>
<div class="logo-sub">av Vauco AS</div>
</div>
</div>
<h1>Kom i gang på 2 minutter</h1>
<p class="subtitle">Koble CostGuard til ditt GCP-prosjekt og velg varslings-kanaler.</p>
</div>
<div class="steps">
<div class="step active" id="step-1"><div class="step-num">1</div><div>Bedrift</div></div>
<div class="step" id="step-2"><div class="step-num">2</div><div>Tier</div></div>
<div class="step" id="step-3"><div class="step-num">3</div><div>Varsler</div></div>
<div class="step" id="step-4"><div class="step-num">4</div><div>Ferdig</div></div>
</div>
<div class="card-body">
<!-- PANEL 1: Bedrift -->
<div class="panel active" id="panel-1">
<div class="field">
<label>Bedriftsnavn</label>
<input id="f-company" type="text" placeholder="Acme AS" autocomplete="organization">
</div>
<div class="field">
<label>Kontaktperson</label>
<input id="f-contact" type="text" placeholder="Ola Nordmann" autocomplete="name">
</div>
<div class="field">
<label>E-postadresse</label>
<input id="f-email" type="email" placeholder="ola@acme.no" autocomplete="email">
</div>
<div class="field">
<label>GCP Project ID</label>
<input id="f-project" type="text" placeholder="mitt-prosjekt-123456" autocomplete="off" spellcheck="false">
</div>
<div class="err-box" id="err-1"></div>
<div class="nav">
<button class="btn btn-primary" onclick="goTo(2)">Neste &rarr;</button>
</div>
</div>
<!-- PANEL 2: Tier -->
<div class="panel" id="panel-2">
<div class="tier-grid">
<div class="tier-opt selected" data-tier="starter" onclick="selectTier(this)">
<div class="tier-name">Starter</div>
<div class="tier-price">$499 / mnd</div>
<div class="tier-limit">GCP-spend $0$3k/mnd</div>
</div>
<div class="tier-opt" data-tier="guard" onclick="selectTier(this)">
<div class="tier-name">Guard</div>
<div class="tier-price">$999 / mnd</div>
<div class="tier-limit">GCP-spend $3k$15k/mnd</div>
</div>
<div class="tier-opt" data-tier="shield" onclick="selectTier(this)">
<div class="tier-name">Shield</div>
<div class="tier-price">$1.999 / mnd</div>
<div class="tier-limit">GCP-spend $15k$50k/mnd</div>
</div>
<div class="tier-opt" data-tier="enterprise" onclick="selectTier(this)">
<div class="tier-name">Enterprise</div>
<div class="tier-price">$3.500+ / mnd</div>
<div class="tier-limit">GCP-spend &gt;$50k/mnd</div>
</div>
</div>
<div class="nav">
<button class="btn btn-ghost" onclick="goTo(1)">&larr; Tilbake</button>
<button class="btn btn-primary" onclick="goTo(3)">Neste &rarr;</button>
</div>
</div>
<!-- PANEL 3: Kanaler -->
<div class="panel" id="panel-3">
<div class="ch-toggle">
<div class="ch-row selected" data-ch="webhook" onclick="toggleChannel(this)">
<div class="ch-emoji">🔗</div>
<div class="ch-label">
<div class="ch-label-name">Webhook <span style="font-size:10px;color:var(--text-faint);font-weight:400">(Slack/Teams/Chat)</span></div>
<div class="ch-label-desc">Øyeblikkelige varsler i din eksisterende kanal</div>
</div>
<div class="ch-check"></div>
</div>
<div class="ch-extra show" id="ex-webhook">
<div class="field" style="margin-bottom:0">
<label>Webhook URL</label>
<input id="f-webhook" type="url" placeholder="https://hooks.slack.com/services/...">
</div>
</div>
<div class="ch-row selected" data-ch="email" onclick="toggleChannel(this)">
<div class="ch-emoji">📧</div>
<div class="ch-label">
<div class="ch-label-name">E-post</div>
<div class="ch-label-desc">Daglig kostnadsoppsummering + spike-varsler</div>
</div>
<div class="ch-check"></div>
</div>
<div class="ch-extra show" id="ex-email">
<div class="field" style="margin-bottom:0">
<label>Varsel-e-post</label>
<input id="f-email-to" type="email" placeholder="alerts@acme.no">
</div>
</div>
<div class="ch-row" data-ch="sms" onclick="toggleChannel(this)" id="sms-row">
<div class="ch-emoji">📱</div>
<div class="ch-label">
<div class="ch-label-name">SMS <span style="font-size:10px;color:var(--amber);font-weight:600">Guard+</span></div>
<div class="ch-label-desc">Kritiske spike-varsler rett på mobil</div>
</div>
<div class="ch-check"></div>
</div>
</div>
<div class="err-box" id="err-3"></div>
<div class="nav">
<button class="btn btn-ghost" onclick="goTo(2)">&larr; Tilbake</button>
<button class="btn btn-primary" id="submit-btn" onclick="submit()">Aktiver CostGuard</button>
</div>
</div>
<!-- PANEL 4: Success -->
<div class="panel" id="panel-4">
<div class="success-wrap">
<div class="success-icon">🎉</div>
<div class="success-title">CostGuard er aktivert!</div>
<div class="success-sub">Jason overvåker nå <strong id="s-project"></strong>.<br>Varsler sendes via:</div>
<div class="success-channels" id="s-channels"></div>
<button class="dashboard-btn" onclick="window.location='/static/costguard-dashboard.html'">Gå til dashboard &rarr;</button>
</div>
</div>
</div> </div>
</div> </div>
<main class="page">
<!-- STEP INDICATOR -->
<div class="step-bar" id="step-bar">
<div class="step-item active" data-step="0"><div class="step-num">1</div><div class="step-label">Bedrift</div></div>
<div class="step-item" data-step="1"><div class="step-num">2</div><div class="step-label">Varslingskanal</div></div>
<div class="step-item" data-step="2"><div class="step-num">3</div><div class="step-label">Test & bekreft</div></div>
<div class="step-item" data-step="3"><div class="step-num">4</div><div class="step-label">Ferdig</div></div>
</div>
<!-- STEP 1: BEDRIFT -->
<div class="step-panel visible" id="panel-0">
<div class="step-title">Hvem er du?</div>
<div class="step-sub">Grunnleggende info om bedriften din og GCP-prosjektet CostGuard skal overvåke.</div>
<div class="field">
<div class="label">Bedriftsnavn</div>
<input class="input" id="company" type="text" placeholder="Vauco AS" autocomplete="organization">
</div>
<div class="field">
<div class="label">Kontaktperson <span>(ditt navn)</span></div>
<input class="input" id="contact" type="text" placeholder="Chris Christiansen" autocomplete="name">
</div>
<div class="field">
<div class="label">E-postadresse</div>
<input class="input" id="email" type="email" placeholder="chris@bedrift.no" autocomplete="email">
</div>
<div class="field">
<div class="label">GCP-prosjekt ID</div>
<input class="input" id="gcp_project" type="text" placeholder="mitt-gcp-prosjekt-123" spellcheck="false">
</div>
<div class="field">
<div class="label">Estimert månedlig GCP-spend <span>(USD)</span></div>
<select class="input" id="gcp_spend">
<option value="">Velg range...</option>
<option value="starter">$0 $3 000 (Starter)</option>
<option value="guard">$3 000 $15 000 (Guard)</option>
<option value="shield">$15 000 $50 000 (Shield)</option>
<option value="enterprise">Over $50 000 (Enterprise)</option>
</select>
</div>
<div class="btn-row">
<button class="btn-primary" onclick="nextStep(0)">Neste →</button>
</div>
</div>
<!-- STEP 2: VARSLINGSKANAL -->
<div class="step-panel" id="panel-1">
<div class="step-title">Hvor vil du at Jason skal nå deg?</div>
<div class="step-sub">Velg én eller flere kanaler. CostGuard leverer direkte dit du allerede er.</div>
<div class="ch-grid">
<div class="ch-card" id="ch-webhook" onclick="toggleChannel('webhook')">
<div class="ch-ico">🔗</div>
<div class="ch-info">
<div class="ch-name">Webhook <span style="font-size:11px;font-weight:400;color:var(--text-faint)">(Slack · Teams · Google Chat · Discord)</span></div>
<div class="ch-desc">Jason poster meldinger direkte i din eksisterende kanal.</div>
<div class="ch-tier">Starter+</div>
<div class="ch-detail">
<div class="field" style="margin-bottom:0">
<div class="label">Webhook URL</div>
<input class="input" id="webhook_url" type="url" placeholder="https://hooks.slack.com/services/...">
</div>
</div>
</div>
<div class="ch-check" id="check-webhook"></div>
</div>
<div class="ch-card" id="ch-email" onclick="toggleChannel('email')">
<div class="ch-ico">📧</div>
<div class="ch-info">
<div class="ch-name">E-post</div>
<div class="ch-desc">Daglig digest + varsler ved spikes. Sendes via SendGrid.</div>
<div class="ch-tier">Starter+</div>
<div class="ch-detail">
<div class="field" style="margin-bottom:0">
<div class="label">Mottaker-e-post <span>(forhåndsutfylt fra forrige steg)</span></div>
<input class="input" id="email_to" type="email" placeholder="alert@bedrift.no">
</div>
</div>
</div>
<div class="ch-check" id="check-email"></div>
</div>
<div class="ch-card" style="opacity:.55;cursor:not-allowed" title="Krever Guard-tier">
<div class="ch-ico">📱</div>
<div class="ch-info">
<div class="ch-name">SMS <span style="font-size:11px;font-weight:400;color:var(--text-faint)">(kun kritiske spikes)</span></div>
<div class="ch-desc">Twilio-integrasjon — kommer i Guard-tier.</div>
<div class="ch-tier" style="color:var(--amber);border-color:rgba(255,181,71,.25)">Guard+ · Coming soon</div>
</div>
<div class="ch-check"></div>
</div>
</div>
<div class="btn-row">
<button class="btn-secondary" onclick="prevStep(1)">← Tilbake</button>
<button class="btn-primary" onclick="nextStep(1)">Neste →</button>
</div>
</div>
<!-- STEP 3: TEST & BEKREFT -->
<div class="step-panel" id="panel-2">
<div class="step-title">Test varslingene</div>
<div class="step-sub">Send en testmelding for å bekrefte at alt er koblet opp riktig — før vi aktiverer CostGuard.</div>
<div id="test-webhook-wrap" style="display:none;margin-bottom:20px">
<div class="summary-card" style="margin-bottom:0">
<div class="summary-row"><span class="summary-key">🔗 Webhook</span><span class="summary-val" id="test-webhook-url-disp"></span></div>
</div>
<button class="btn-secondary" style="margin-top:12px" onclick="testChannel('webhook')" id="test-wh-btn">Send testmelding til webhook</button>
<div class="test-result" id="test-webhook-result"></div>
</div>
<div id="test-email-wrap" style="display:none;margin-bottom:20px">
<div class="summary-card" style="margin-bottom:0">
<div class="summary-row"><span class="summary-key">📧 E-post</span><span class="summary-val" id="test-email-to-disp"></span></div>
</div>
<button class="btn-secondary" style="margin-top:12px" onclick="testChannel('email')" id="test-em-btn">Send test-e-post</button>
<div class="test-result" id="test-email-result"></div>
</div>
<p style="font-size:12px;color:var(--text-faint);margin-top:8px">Du kan hoppe over testingen og aktivere direkte.</p>
<div class="btn-row">
<button class="btn-secondary" onclick="prevStep(2)">← Tilbake</button>
<button class="btn-primary" id="activate-btn" onclick="activate()">Aktiver CostGuard ✓</button>
</div>
</div>
<!-- STEP 4: FERDIG -->
<div class="step-panel" id="panel-3">
<div class="done-check"></div>
<div class="done-title">CostGuard er aktivert!</div>
<div class="done-sub">Jason overvåker nå <span id="done-project" style="color:var(--accent);font-weight:700"></span>.<br>Du mottar første rapport innen 24 timer.</div>
<div class="summary-card">
<div class="summary-row"><span class="summary-key">Bedrift</span><span class="summary-val" id="done-company"></span></div>
<div class="summary-row"><span class="summary-key">Kontakt</span><span class="summary-val" id="done-contact"></span></div>
<div class="summary-row"><span class="summary-key">GCP-prosjekt</span><span class="summary-val" id="done-gcp"></span></div>
<div class="summary-row"><span class="summary-key">Tier</span><span class="summary-val" id="done-tier"></span></div>
<div class="summary-row"><span class="summary-key">Kanaler</span><span class="summary-val" id="done-channels"></span></div>
</div>
<div class="btn-row" style="justify-content:center">
<a href="/static/costguard-dashboard.html" class="btn-primary" style="text-decoration:none">Gå til dashboard →</a>
</div>
</div>
</main>
<div id="toast"></div>
<script> <script>
const TIERS = {starter:'Starter ($499/mnd)',guard:'Guard ($999/mnd)',shield:'Shield ($1.999/mnd)',enterprise:'Enterprise ($3.500+/mnd)'} let selectedTier = 'starter'
let selectedChannels = new Set() let selectedChannels = new Set(['webhook','email'])
let state = {}
function toast(msg, dur=3200){ function selectTier(el){
const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show') document.querySelectorAll('.tier-opt').forEach(e=>e.classList.remove('selected'))
setTimeout(()=>t.classList.remove('show'),dur) el.classList.add('selected')
} selectedTier = el.dataset.tier
function setStep(to){ // SMS kun Guard+
document.querySelectorAll('.step-panel').forEach(p=>p.classList.remove('visible')) const smsRow = document.getElementById('sms-row')
document.getElementById('panel-'+to).classList.add('visible') if(['guard','shield','enterprise'].includes(selectedTier)){
document.querySelectorAll('.step-item').forEach((el,i)=>{ smsRow.style.opacity='1';smsRow.style.pointerEvents='auto'
el.classList.toggle('active', i===to) } else {
el.classList.toggle('done', i<to) smsRow.style.opacity='.4';smsRow.style.pointerEvents='none'
}) smsRow.classList.remove('selected');selectedChannels.delete('sms')
window.scrollTo({top:0,behavior:'smooth'})
}
function prevStep(from){setStep(from-1)}
function nextStep(from){
if(from===0){
const company=document.getElementById('company').value.trim()
const contact=document.getElementById('contact').value.trim()
const email=document.getElementById('email').value.trim()
const gcp=document.getElementById('gcp_project').value.trim()
const spend=document.getElementById('gcp_spend').value
const errors=[]
if(!company) errors.push('company')
if(!contact) errors.push('contact')
if(!email||!/^[^@]+@[^@]+\.[^@]+$/.test(email)) errors.push('email')
if(!gcp) errors.push('gcp_project')
if(!spend) errors.push('gcp_spend')
document.querySelectorAll('#panel-0 .input').forEach(el=>el.classList.remove('err'))
errors.forEach(id=>document.getElementById(id)?.classList.add('err'))
if(errors.length){toast('⚠️ Fyll ut alle feltene');return}
state={company,contact,email,gcp_project:gcp,tier:spend}
// Pre-fill email channel
document.getElementById('email_to').value=email
setStep(1)
} else if(from===1){
if(selectedChannels.size===0){toast('⚠️ Velg minst én varslingskanal');return}
if(selectedChannels.has('webhook')&&!document.getElementById('webhook_url').value.trim()){
document.getElementById('webhook_url').classList.add('err')
toast('⚠️ Skriv inn webhook URL');return
}
if(selectedChannels.has('email')&&!document.getElementById('email_to').value.trim()){
document.getElementById('email_to').classList.add('err')
toast('⚠️ Skriv inn e-postadresse');return
}
state.webhook_url = selectedChannels.has('webhook')?document.getElementById('webhook_url').value.trim():null
state.email_to = selectedChannels.has('email')?document.getElementById('email_to').value.trim():null
// Populate test step
const wWrap=document.getElementById('test-webhook-wrap')
const eWrap=document.getElementById('test-email-wrap')
if(state.webhook_url){
wWrap.style.display='block'
document.getElementById('test-webhook-url-disp').textContent=state.webhook_url
} else { wWrap.style.display='none' }
if(state.email_to){
eWrap.style.display='block'
document.getElementById('test-email-to-disp').textContent=state.email_to
} else { eWrap.style.display='none' }
setStep(2)
} }
} }
function toggleChannel(key){ function toggleChannel(el){
const card=document.getElementById('ch-'+key) const ch = el.dataset.ch
if(selectedChannels.has(key)){selectedChannels.delete(key);card.classList.remove('selected')} const extra = document.getElementById('ex-'+ch)
else{selectedChannels.add(key);card.classList.add('selected')} if(el.classList.contains('selected')){
} el.classList.remove('selected')
el.querySelector('.ch-check').textContent=''
async function testChannel(type){ selectedChannels.delete(ch)
const resultEl=document.getElementById('test-'+type+'-result') if(extra){ extra.classList.remove('show') }
const btn=document.getElementById('test-'+(type==='webhook'?'wh':'em')+'-btn') } else {
btn.disabled=true;btn.textContent='Sender...' el.classList.add('selected')
resultEl.className='test-result';resultEl.textContent='' el.querySelector('.ch-check').textContent='✓'
selectedChannels.add(ch)
try{ if(extra){ extra.classList.add('show') }
let body, url
if(type==='webhook'){
url='/notify/webhook'
body=JSON.stringify({
url: state.webhook_url,
event: 'custom',
title: 'CostGuard — Testmelding',
body: `Hei fra CostGuard! Webhook-integrasjon for ${state.company} (${state.gcp_project}) er koblet opp og fungerer.`,
payload: {bedrift:state.company, tier:TIERS[state.tier]||state.tier, prosjekt:state.gcp_project}
})
} else {
url='/notify/email'
body=JSON.stringify({
to: state.email_to,
event: 'custom',
subject: `✅ CostGuard er koblet opp — ${state.company}`,
body_html: `<div style='font-family:sans-serif;max-width:600px;margin:0 auto;padding:32px'><h2>CostGuard er koblet opp ✅</h2><p>Hei ${state.contact},</p><p>CostGuard overvåker nå <strong>${state.gcp_project}</strong>. Tier: <strong>${TIERS[state.tier]||state.tier}</strong>.</p><p>Du mottar første rapport innen 24 timer.</p><hr><p style='font-size:12px;color:#999'>CostGuard by Vauco AS</p></div>`
})
}
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body})
const data=await r.json()
if(r.ok&&data.status==='ok'){
resultEl.className='test-result ok show'
resultEl.textContent='✅ Testmelding sendt!'
} else {
resultEl.className='test-result err show'
resultEl.textContent='❌ Feil: '+(data.detail||data.error||r.status)
}
}catch(e){
resultEl.className='test-result err show'
resultEl.textContent='❌ Nettverksfeil: '+e.message
}finally{
btn.disabled=false
btn.textContent=type==='webhook'?'Send testmelding til webhook':'Send test-e-post'
} }
} }
async function activate(){ function showErr(id, msg){
const btn=document.getElementById('activate-btn') const el = document.getElementById(id)
btn.disabled=true el.textContent = msg; el.classList.add('show')
btn.innerHTML='<div class="spinner"></div> Aktiverer...' }
function clearErr(id){ document.getElementById(id).classList.remove('show') }
function goTo(n){
// Validate step 1
if(n===2){
clearErr('err-1')
const c = v('f-company'), ct = v('f-contact'), e = v('f-email'), p = v('f-project')
if(!c||!ct||!e||!p){ showErr('err-1','Fyll ut alle felt.'); return }
if(!e.includes('@')){ showErr('err-1','Ugyldig e-postadresse.'); return }
}
// Validate step 3
if(n===4 && !doSubmit) return
for(let i=1;i<=4;i++){
document.getElementById('panel-'+i).classList.toggle('active', i===n)
const s = document.getElementById('step-'+i)
s.classList.remove('active','done')
if(i===n) s.classList.add('active')
else if(i<n) s.classList.add('done')
}
}
function v(id){ return document.getElementById(id).value.trim() }
async function submit(){
clearErr('err-3')
const channels = [...selectedChannels]
if(!channels.length){ showErr('err-3','Velg minst én varslingskanal.'); return }
const webhookUrl = selectedChannels.has('webhook') ? v('f-webhook') : null
const emailTo = selectedChannels.has('email') ? v('f-email-to') : null
if(selectedChannels.has('webhook') && !webhookUrl){
showErr('err-3','Lim inn Webhook URL.'); return
}
if(selectedChannels.has('email') && !emailTo){
showErr('err-3','Fyll inn varsel-e-post.'); return
}
const btn = document.getElementById('submit-btn')
btn.disabled = true
btn.innerHTML = '<span class="spinner"></span>Aktiverer...'
const body = {
company: v('f-company'),
contact: v('f-contact'),
email: v('f-email'),
gcp_project: v('f-project'),
tier: selectedTier,
channels,
webhook_url: webhookUrl,
email_to: emailTo,
}
try{ try{
const payload={ const r = await fetch('/onboard/complete', {
company: state.company, method:'POST',
contact: state.contact, headers:{'Content-Type':'application/json'},
email: state.email, body: JSON.stringify(body)
gcp_project:state.gcp_project, })
tier: state.tier, const data = await r.json()
channels: [...selectedChannels], if(!r.ok){
webhook_url:state.webhook_url||null, showErr('err-3', data.detail || 'Feil fra server (' + r.status + ').')
email_to: state.email_to||null, btn.disabled=false; btn.textContent='Aktiver CostGuard'
return
} }
const r=await fetch('/onboard/complete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}) // Success
const data=await r.json() document.getElementById('s-project').textContent = body.gcp_project
if(!r.ok) throw new Error(data.detail||data.error||r.status) const pillHtml = channels.map(ch => {
// Populate done screen const icons = {webhook:'🔗',email:'📧',sms:'📱'}
document.getElementById('done-project').textContent=state.gcp_project return `<div class="ch-pill">${icons[ch]||''} ${ch}</div>`
document.getElementById('done-company').textContent=state.company }).join('')
document.getElementById('done-contact').textContent=state.contact+' ('+state.email+')' document.getElementById('s-channels').innerHTML = pillHtml
document.getElementById('done-gcp').textContent=state.gcp_project goTo(4)
document.getElementById('done-tier').textContent=TIERS[state.tier]||state.tier } catch(e){
document.getElementById('done-channels').textContent=[...selectedChannels].join(' + ')||'—' showErr('err-3', 'Nettverksfeil: ' + e.message)
setStep(3) btn.disabled=false; btn.textContent='Aktiver CostGuard'
}catch(e){
toast('❌ Aktivering feilet: '+e.message)
btn.disabled=false
btn.textContent='Aktiver CostGuard ✓'
} }
} }
</script> </script>