feat: onboard.html + fix len(agents) i /run/dag + HANDOFF oppdatert
This commit is contained in:
parent
6367683d81
commit
7fc4df88e2
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 →</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 >$50k/mnd</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav">
|
||||||
|
<button class="btn btn-ghost" onclick="goTo(1)">← Tilbake</button>
|
||||||
|
<button class="btn btn-primary" onclick="goTo(3)">Neste →</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)">← 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 →</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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user