OSVauco/static/opax.html
2026-06-15 05:11:05 +02:00

1123 lines
61 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="no">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>OPAX — Vauco Operator Hub</title>
<style>
:root{
--bg:#0a0a0f;--bg-2:#11131a;--bg-3:#161922;
--border:rgba(255,255,255,0.08);--border-2:rgba(255,255,255,0.14);
--text:#e8eaf0;--text-dim:#8b909c;--text-faint:#5a5f6b;
--accent:#7c5cff;--accent-2:#00d4a8;
--green:#3ecf8e;--amber:#ffb547;--danger:#ff5c7c;
--radius:12px;
--sans:-apple-system,BlinkMacSystemFont,'Inter',system-ui,sans-serif;
--mono:'SF Mono',ui-monospace,'JetBrains Mono',Menlo,monospace;
--sidebar:220px;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--sans);-webkit-font-smoothing:antialiased;overflow:hidden}
button{font-family:inherit;cursor:pointer;border:none;background:none;color:inherit}
::-webkit-scrollbar{width:6px}
::-webkit-scrollbar-thumb{background:var(--bg-3);border-radius:6px}
.shell{display:flex;height:100vh;overflow:hidden}
/* SIDEBAR */
.sidebar{
width:var(--sidebar);flex-shrink:0;
background:var(--bg-2);border-right:1px solid var(--border);
display:flex;flex-direction:column;padding:20px 12px;gap:2px;overflow-y:auto;
}
.brand{display:flex;align-items:center;gap:10px;padding:8px 10px 18px}
.logo{width:30px;height:30px;border-radius:9px;background:linear-gradient(135deg,var(--accent),var(--accent-2));display:flex;align-items:center;justify-content:center;color:#0a0a0f;font-weight:900;font-size:13px;flex-shrink:0}
.brand-name{font-weight:800;font-size:15px;letter-spacing:-0.02em}
.brand-sub{font-size:10px;color:var(--text-faint);font-family:var(--mono)}
.side-label{font-size:10px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-faint);font-weight:700;padding:14px 10px 4px}
.nav-item{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:9px;font-size:13px;color:var(--text-dim);font-weight:500;cursor:pointer;transition:background .14s,color .14s;user-select:none}
.nav-item:hover{background:var(--bg-3);color:var(--text)}
.nav-item.active{background:var(--bg-3);color:var(--text)}
.nav-icon{font-size:15px;width:20px;text-align:center;flex-shrink:0}
.nav-badge{margin-left:auto;font-size:10px;font-weight:700;background:var(--green);color:#0a0a0f;padding:1px 6px;border-radius:99px}
.build-dot{width:7px;height:7px;border-radius:50%;background:var(--text-faint);margin-left:auto;flex-shrink:0}
.build-dot.ok{background:var(--green)}
.build-dot.fail{background:var(--danger)}
.build-dot.running{background:var(--amber);animation:pulse 1.2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.side-foot{margin-top:auto;padding-top:14px;border-top:1px solid var(--border)}
.me-card{padding:10px;border-radius:9px;background:var(--bg-3);border:1px solid var(--border)}
.me-name{font-size:12px;font-weight:600}
.me-role{font-size:10px;color:var(--text-faint);margin-top:1px;font-family:var(--mono)}
.me-session{font-size:9px;color:var(--text-faint);font-family:var(--mono);margin-top:5px;word-break:break-all}
/* MAIN */
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}
/* TOPBAR */
.topbar{display:flex;align-items:center;gap:12px;padding:12px 20px;border-bottom:1px solid var(--border);background:rgba(10,10,15,0.92);backdrop-filter:blur(16px);flex-shrink:0;height:52px}
.topbar-title{font-size:14px;font-weight:700;letter-spacing:-0.01em}
.topbar-sub{font-size:11px;color:var(--text-faint);font-family:var(--mono);margin-left:4px}
.topbar-right{margin-left:auto;display:flex;align-items:center;gap:10px}
.topbar-user{font-size:12px;color:var(--text-dim);background:var(--bg-2);border:1px solid var(--border);padding:4px 10px;border-radius:7px}
.build-pill{font-size:11px;font-weight:600;padding:4px 10px;border-radius:7px;background:var(--bg-2);border:1px solid var(--border);display:flex;align-items:center;gap:6px;cursor:pointer}
.build-pill:hover{border-color:var(--border-2)}
.build-pill-dot{width:6px;height:6px;border-radius:50%;background:var(--text-faint);flex-shrink:0}
.build-pill-dot.ok{background:var(--green)}
.build-pill-dot.fail{background:var(--danger)}
.build-pill-dot.running{background:var(--amber);animation:pulse 1.2s infinite}
/* PANELS */
.panels{flex:1;overflow:hidden;position:relative}
.panel{position:absolute;inset:0;display:none;flex-direction:column;overflow:hidden}
.panel.active{display:flex}
/* SHARED panel header */
.panel-head{display:flex;align-items:center;gap:12px;padding:14px 24px;border-bottom:1px solid var(--border);flex-shrink:0}
.p-avatar{width:34px;height:34px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:17px;flex-shrink:0}
.p-title{font-size:14px;font-weight:700;letter-spacing:-0.01em}
.p-sub{font-size:11px;color:var(--text-dim);display:flex;align-items:center;gap:5px;margin-top:2px}
.online-dot{width:6px;height:6px;border-radius:50%;background:var(--green);flex-shrink:0;animation:pulse 2.4s infinite}
.p-actions{margin-left:auto;display:flex;gap:8px}
.hbtn{padding:5px 12px;border-radius:7px;font-size:11px;font-weight:600;background:var(--bg-2);border:1px solid var(--border);color:var(--text-dim);transition:background .14s,color .14s}
.hbtn:hover{background:var(--bg-3);color:var(--text)}
.hbtn.danger:hover{color:var(--danger);border-color:rgba(255,92,124,.3)}
/* panel scroll body */
.panel-body{flex:1;overflow-y:auto;padding:28px 32px}
/* JASON */
.messages{flex:1;overflow-y:auto;padding:24px 28px;display:flex;flex-direction:column;gap:18px}
.msg{display:flex;gap:12px;max-width:820px;width:100%}
.msg.user{align-self:flex-end;flex-direction:row-reverse}
.msg-av{width:30px;height:30px;border-radius:8px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:13px}
.msg.user .msg-av{background:var(--accent);color:#fff;font-weight:700;font-size:11px}
.msg.jason .msg-av{background:linear-gradient(135deg,var(--accent),var(--accent-2));font-size:14px}
.msg-body{display:flex;flex-direction:column;gap:3px;min-width:0}
.msg.user .msg-body{align-items:flex-end}
.msg-meta{font-size:10px;color:var(--text-faint);font-family:var(--mono)}
.bubble{padding:11px 15px;border-radius:13px;font-size:13.5px;line-height:1.65;max-width:700px;word-break:break-word;white-space:pre-wrap}
.msg.user .bubble{background:var(--accent);color:#fff;border-radius:13px 4px 13px 13px}
.msg.jason .bubble{background:var(--bg-2);border:1px solid var(--border);color:var(--text);border-radius:4px 13px 13px 13px}
.bubble code{font-family:var(--mono);font-size:12px;background:rgba(0,0,0,.3);padding:1px 5px;border-radius:4px}
.bubble pre{background:rgba(0,0,0,.35);padding:12px;border-radius:8px;overflow-x:auto;font-family:var(--mono);font-size:12px;margin:8px 0}
.typing-dots{display:flex;gap:4px;align-items:center;padding:4px 0}
.typing-dots span{width:6px;height:6px;border-radius:50%;background:var(--text-faint);animation:bounce 1.2s infinite}
.typing-dots span:nth-child(2){animation-delay:.2s}
.typing-dots span:nth-child(3){animation-delay:.4s}
@keyframes bounce{0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-5px)}}
.input-wrap{padding:14px 24px 18px;border-top:1px solid var(--border);flex-shrink:0}
.input-row{display:flex;gap:10px;align-items:flex-end;background:var(--bg-2);border:1px solid var(--border-2);border-radius:13px;padding:9px 13px;transition:border-color .2s}
.input-row:focus-within{border-color:var(--accent)}
#msg-input{flex:1;background:none;border:none;color:var(--text);font-family:var(--sans);font-size:14px;resize:none;outline:none;min-height:22px;max-height:160px;line-height:1.55}
#msg-input::placeholder{color:var(--text-faint)}
.send-btn{width:32px;height:32px;border-radius:8px;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,opacity .15s}
.send-btn:hover{background:#6a4cff}
.send-btn:disabled{opacity:.35;cursor:default}
.input-hint{font-size:11px;color:var(--text-faint);margin-top:6px;padding:0 3px}
.empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;padding:40px;text-align:center}
.empty-icon{font-size:44px}
.empty-title{font-size:19px;font-weight:700;letter-spacing:-0.02em}
.empty-desc{font-size:13.5px;color:var(--text-dim);max-width:380px;line-height:1.6}
.suggestions{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:6px}
.sug-btn{padding:7px 15px;border-radius:99px;background:var(--bg-2);border:1px solid var(--border);font-size:12.5px;color:var(--text-dim);transition:background .14s,color .14s,border-color .14s}
.sug-btn:hover{background:var(--bg-3);color:var(--text);border-color:var(--border-2)}
.error-bar{display:none;background:rgba(255,92,124,.1);border:1px solid rgba(255,92,124,.2);color:var(--danger);font-size:12px;padding:7px 16px;border-radius:8px;margin:0 24px 6px;text-align:center}
.error-bar.show{display:block}
/* BILLING */
.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;margin-bottom:28px}
.stat-card{background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);padding:18px 20px}
.stat-label{font-size:11px;color:var(--text-faint);text-transform:uppercase;letter-spacing:0.06em;font-weight:600}
.stat-value{font-size:26px;font-weight:800;letter-spacing:-0.03em;margin-top:6px}
.stat-sub{font-size:11px;color:var(--text-dim);margin-top:3px}
.stat-card.c-accent{border-color:rgba(124,92,255,.3);background:rgba(124,92,255,.06)}
.stat-card.c-green{border-color:rgba(62,207,142,.3);background:rgba(62,207,142,.06)}
.stat-card.c-amber{border-color:rgba(255,181,71,.3);background:rgba(255,181,71,.06)}
.sec-title{font-size:12px;font-weight:700;color:var(--text-faint);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:12px}
.anomaly-list{display:flex;flex-direction:column;gap:8px;margin-bottom:28px}
.anom{display:flex;align-items:center;gap:12px;background:var(--bg-2);border:1px solid var(--border);border-radius:10px;padding:12px 16px}
.anom-icon{font-size:18px;flex-shrink:0}
.anom-info{flex:1;min-width:0}
.anom-title{font-size:13px;font-weight:600}
.anom-desc{font-size:11px;color:var(--text-dim);margin-top:2px}
.anom-badge{font-size:10px;font-weight:700;padding:3px 8px;border-radius:99px;background:rgba(255,181,71,.15);color:var(--amber);white-space:nowrap}
.anom-badge.high{background:rgba(255,92,124,.15);color:var(--danger)}
.mod-table{width:100%;border-collapse:collapse;font-size:13px}
.mod-table th{text-align:left;padding:8px 12px;font-size:10px;font-weight:700;color:var(--text-faint);text-transform:uppercase;letter-spacing:0.06em;border-bottom:1px solid var(--border)}
.mod-table td{padding:10px 12px;border-bottom:1px solid var(--border)}
.mod-table tr:last-child td{border-bottom:none}
.mod-table tr:hover td{background:rgba(255,255,255,.02)}
.mod-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px}
/* BUILD */
.build-list{display:flex;flex-direction:column;gap:10px}
.build-item{display:flex;align-items:center;gap:14px;background:var(--bg-2);border:1px solid var(--border);border-radius:10px;padding:14px 18px}
.build-item:hover{border-color:var(--border-2)}
.build-ico{font-size:20px;flex-shrink:0}
.build-info{flex:1;min-width:0}
.build-id{font-size:12px;font-family:var(--mono);color:var(--text-faint)}
.build-branch{font-size:13px;font-weight:600;margin-top:2px}
.build-time{font-size:11px;color:var(--text-dim);margin-top:2px}
.log-box{background:var(--bg-2);border:1px solid var(--border);border-radius:10px;padding:16px;margin-top:20px;font-family:var(--mono);font-size:12px;color:var(--text-dim);max-height:300px;overflow-y:auto;line-height:1.7}
.ll-ok{color:var(--green)}
.ll-fail{color:var(--danger)}
.ll-info{color:var(--accent-2)}
/* TERMINAL */
.term-wrap{flex:1;display:flex;flex-direction:column;background:#0d0d12;overflow:hidden}
.term-toolbar{display:flex;align-items:center;gap:8px;padding:10px 16px;border-bottom:1px solid var(--border);flex-shrink:0}
.tdot{width:10px;height:10px;border-radius:50%}
.tdot.r{background:#ff5c7c}.tdot.y{background:#ffb547}.tdot.g{background:#3ecf8e}
.term-title{font-size:12px;font-family:var(--mono);color:var(--text-faint);margin:0 auto}
.term-body{flex:1;overflow-y:auto;padding:16px 20px;font-family:var(--mono);font-size:13px;line-height:1.7;color:#c8ccd8}
.tl{white-space:pre-wrap;word-break:break-all}
.tl.cmd{color:#7c5cff}.tl.ok{color:#3ecf8e}.tl.err{color:#ff5c7c}.tl.info{color:#00d4a8}
.term-input-row{display:flex;align-items:center;gap:8px;padding:10px 20px;border-top:1px solid var(--border);flex-shrink:0}
.term-prompt{font-family:var(--mono);font-size:13px;color:var(--accent-2);white-space:nowrap}
.term-in{flex:1;background:none;border:none;outline:none;font-family:var(--mono);font-size:13px;color:var(--text)}
.term-in::placeholder{color:var(--text-faint)}
/* SITES */
.sites-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px}
.site-card{background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);padding:20px;display:flex;flex-direction:column;gap:10px;transition:border-color .14s}
.site-card:hover{border-color:var(--border-2)}
.site-hd{display:flex;align-items:center;gap:10px}
.site-icon{font-size:22px}
.site-name{font-size:14px;font-weight:700}
.site-url{font-size:11px;font-family:var(--mono);color:var(--text-faint)}
.site-desc{font-size:12px;color:var(--text-dim);line-height:1.5}
.site-ft{display:flex;align-items:center;justify-content:space-between;margin-top:4px}
.site-status{font-size:11px;font-weight:600;display:flex;align-items:center;gap:5px}
.site-status.live::before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--green)}
.site-status.pending::before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--amber)}
.site-btn{font-size:11px;font-weight:600;padding:5px 12px;border-radius:7px;background:var(--bg-3);border:1px solid var(--border);color:var(--text-dim);transition:background .14s,color .14s}
.site-btn:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
/* VMs */
.vm-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:14px}
.vm-card{background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);padding:20px;display:flex;flex-direction:column;gap:12px}
.vm-hd{display:flex;align-items:center;gap:10px}
.vm-name{font-size:14px;font-weight:700}
.vm-type{font-size:11px;font-family:var(--mono);color:var(--text-faint)}
.vm-meta{display:flex;flex-direction:column;gap:4px}
.vm-row{display:flex;justify-content:space-between;font-size:12px}
.vm-k{color:var(--text-faint)}
.vm-v{font-family:var(--mono);color:var(--text-dim)}
.vm-actions{display:flex;gap:8px;margin-top:4px}
.vm-btn{flex:1;padding:7px;border-radius:8px;font-size:12px;font-weight:600;background:var(--bg-3);border:1px solid var(--border);color:var(--text-dim);transition:background .14s,color .14s;text-align:center;text-decoration:none;display:block}
.vm-btn:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
.vm-btn.stop:hover{background:var(--danger);color:#fff;border-color:var(--danger)}
.vm-running{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600;color:var(--green)}
.vm-running::before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--green);animation:pulse 2s infinite}
/* iframe overlay */
.if-bar{display:flex;align-items:center;gap:10px;padding:8px 16px;border-bottom:1px solid var(--border);background:var(--bg-2);flex-shrink:0}
.if-url{font-size:11px;font-family:var(--mono);color:var(--text-faint);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.if-btn{font-size:11px;padding:4px 10px;border-radius:6px;background:var(--bg-3);border:1px solid var(--border);color:var(--text-dim);transition:background .14s;white-space:nowrap}
.if-btn:hover{background:var(--bg);color:var(--text)}
.if-frame{flex:1;border:none;width:100%;height:100%}
/* MD MODULE */
.md-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}
.md-card{background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);padding:20px;display:flex;flex-direction:column;gap:10px;cursor:pointer;transition:border-color .14s,background .14s}
.md-card:hover{border-color:var(--accent);background:rgba(124,92,255,.04)}
.md-hd{display:flex;align-items:center;gap:10px}
.md-icon{font-size:22px;flex-shrink:0}
.md-name{font-size:14px;font-weight:700}
.md-path{font-size:11px;font-family:var(--mono);color:var(--text-faint);margin-top:2px}
.md-desc{font-size:12px;color:var(--text-dim);line-height:1.5}
.md-ft{display:flex;align-items:center;justify-content:space-between;margin-top:4px}
.md-badge{font-size:10px;font-weight:700;padding:2px 8px;border-radius:99px;background:rgba(124,92,255,.15);color:var(--accent)}
.md-badge.auth{background:rgba(62,207,142,.15);color:var(--green)}
.md-badge.append{background:rgba(0,212,168,.15);color:var(--accent-2)}
.md-open-btn{font-size:11px;font-weight:600;padding:5px 12px;border-radius:7px;background:var(--bg-3);border:1px solid var(--border);color:var(--text-dim);transition:background .14s,color .14s}
.md-open-btn:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
/* inline MD viewer */
.md-viewer{flex:1;overflow-y:auto;padding:32px 40px;font-size:14px;line-height:1.75;color:var(--text)}
.md-viewer h1{font-size:22px;font-weight:800;margin-bottom:16px;letter-spacing:-0.02em}
.md-viewer h2{font-size:16px;font-weight:700;margin:24px 0 10px;color:var(--text)}
.md-viewer h3{font-size:14px;font-weight:600;margin:18px 0 8px;color:var(--text-dim)}
.md-viewer p{margin-bottom:12px;color:var(--text-dim)}
.md-viewer code{font-family:var(--mono);font-size:12px;background:rgba(255,255,255,.06);padding:2px 6px;border-radius:4px}
.md-viewer pre{background:var(--bg-2);border:1px solid var(--border);padding:14px 16px;border-radius:10px;overflow-x:auto;font-family:var(--mono);font-size:12px;margin:12px 0;line-height:1.6}
.md-viewer pre code{background:none;padding:0}
.md-viewer table{width:100%;border-collapse:collapse;margin:14px 0;font-size:13px}
.md-viewer th{text-align:left;padding:8px 12px;border-bottom:1px solid var(--border);font-size:11px;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-faint)}
.md-viewer td{padding:9px 12px;border-bottom:1px solid var(--border);color:var(--text-dim)}
.md-viewer tr:last-child td{border-bottom:none}
.md-viewer ul,.md-viewer ol{margin:0 0 12px 20px;color:var(--text-dim)}
.md-viewer li{margin-bottom:4px}
.md-viewer blockquote{border-left:3px solid var(--accent);padding:8px 16px;margin:12px 0;background:rgba(124,92,255,.06);border-radius:0 8px 8px 0;color:var(--text-dim)}
.md-viewer hr{border:none;border-top:1px solid var(--border);margin:20px 0}
.md-viewer a{color:var(--accent);text-decoration:none}
.md-viewer a:hover{text-decoration:underline}
.md-viewer-loading{display:flex;align-items:center;justify-content:center;height:200px;color:var(--text-faint);font-family:var(--mono);font-size:13px;gap:10px}
/* PRICING PANEL (CG4d) */
.pricing-hero{text-align:center;padding:32px 0 28px}
.pricing-hero-title{font-size:22px;font-weight:800;letter-spacing:-0.02em;margin-bottom:6px}
.pricing-hero-sub{font-size:13px;color:var(--text-dim)}
.slider-wrap{background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);padding:24px 28px;margin-bottom:28px}
.slider-label-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
.slider-label{font-size:12px;font-weight:700;color:var(--text-faint);text-transform:uppercase;letter-spacing:0.06em}
.slider-value{font-size:22px;font-weight:800;letter-spacing:-0.02em;color:var(--accent-2)}
#spend-slider{width:100%;-webkit-appearance:none;appearance:none;height:6px;border-radius:3px;background:var(--bg-3);outline:none;cursor:pointer}
#spend-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:20px;height:20px;border-radius:50%;background:var(--accent);cursor:pointer;border:3px solid var(--bg);box-shadow:0 0 0 2px var(--accent)}
#spend-slider::-moz-range-thumb{width:20px;height:20px;border-radius:50%;background:var(--accent);cursor:pointer;border:3px solid var(--bg)}
.slider-ticks{display:flex;justify-content:space-between;margin-top:8px}
.slider-tick{font-size:10px;color:var(--text-faint);font-family:var(--mono)}
.tier-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:28px}
@media(max-width:800px){.tier-grid{grid-template-columns:repeat(2,1fr)}}
.tier-card{background:var(--bg-2);border:2px solid var(--border);border-radius:var(--radius);padding:18px 16px;transition:border-color .18s,background .18s,box-shadow .18s;cursor:default}
.tier-card.active{border-color:var(--accent);background:rgba(124,92,255,.08);box-shadow:0 0 0 1px var(--accent),0 4px 24px rgba(124,92,255,.18)}
.tier-name{font-size:13px;font-weight:800;letter-spacing:0.02em;margin-bottom:4px}
.tier-range{font-size:11px;color:var(--text-faint);font-family:var(--mono);margin-bottom:12px}
.tier-price{font-size:24px;font-weight:800;letter-spacing:-0.03em}
.tier-price-sub{font-size:10px;color:var(--text-faint);margin-top:2px}
.tier-card.active .tier-name{color:var(--accent)}
.tier-card.active .tier-price{color:var(--text)}
.roi-box{background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);padding:24px 28px;display:flex;gap:28px;align-items:center;flex-wrap:wrap}
.roi-stat{flex:1;min-width:140px}
.roi-label{font-size:11px;color:var(--text-faint);text-transform:uppercase;letter-spacing:0.06em;font-weight:600;margin-bottom:6px}
.roi-value{font-size:32px;font-weight:800;letter-spacing:-0.03em;color:var(--green)}
.roi-sub{font-size:12px;color:var(--text-dim);margin-top:4px;line-height:1.5}
.roi-divider{width:1px;height:60px;background:var(--border);flex-shrink:0}
@media(max-width:600px){.roi-divider{display:none}.roi-box{gap:16px}}
/* DOCS BROWSER PANEL — Fix 4 */
.docsbrowser-toolbar{display:flex;align-items:center;gap:8px;padding:10px 16px;border-bottom:1px solid var(--border);background:var(--bg-2);flex-shrink:0}
.docsbrowser-select{flex:1;background:var(--bg-3);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-family:var(--mono);font-size:12px;color:var(--text);outline:none;cursor:pointer;transition:border-color .2s}
.docsbrowser-select:focus{border-color:var(--accent)}
.docsbrowser-refresh{padding:6px 14px;border-radius:8px;background:var(--accent);color:#fff;font-size:12px;font-weight:600;transition:background .14s;white-space:nowrap}
.docsbrowser-refresh:hover{background:#6a4cff}
.docsbrowser-body{flex:1;overflow-y:auto}
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
</head>
<body>
<div class="shell">
<!-- SIDEBAR -->
<nav class="sidebar">
<div class="brand">
<div class="logo">V</div>
<div>
<div class="brand-name">OPAX</div>
<div class="brand-sub">operator hub</div>
</div>
</div>
<div class="side-label">Agenter</div>
<div class="nav-item active" onclick="go('jason')" id="nav-jason">
<span class="nav-icon">🤖</span> Jason
<span class="nav-badge">live</span>
</div>
<div class="side-label">Oversikt</div>
<div class="nav-item" onclick="go('billing')" id="nav-billing">
<span class="nav-icon">💰</span> CostGuard
</div>
<div class="nav-item" onclick="go('pricing')" id="nav-pricing">
<span class="nav-icon">🧮</span> Kalkulator
</div>
<div class="nav-item" onclick="go('build')" id="nav-build">
<span class="nav-icon">🔨</span> Cloud Build
<div class="build-dot" id="sb-build-dot"></div>
</div>
<div class="side-label">Infrastruktur</div>
<div class="nav-item" onclick="go('vms')" id="nav-vms">
<span class="nav-icon">💻</span> VM-er
</div>
<div class="nav-item" onclick="go('sites')" id="nav-sites">
<span class="nav-icon">🌐</span> Sites & Moduler
</div>
<div class="nav-item" onclick="go('terminal')" id="nav-terminal">
<span class="nav-icon">⌨️</span> Terminal
</div>
<div class="side-label">Dokumentasjon</div>
<div class="nav-item" onclick="go('md')" id="nav-md">
<span class="nav-icon">📄</span> MD
</div>
<div class="nav-item" onclick="go('browser')" id="nav-browser">
<span class="nav-icon">📖</span> Docs Browser
</div>
<div class="side-foot">
<div class="me-card">
<div class="me-name">Chris Christiansen</div>
<div class="me-role">operator · vauco.no</div>
<div class="me-session" id="sb-session">session: laster...</div>
</div>
</div>
</nav>
<!-- MAIN -->
<div class="main">
<div class="topbar">
<div class="topbar-title" id="tb-title">Jason</div>
<span class="topbar-sub" id="tb-sub">Gemini 2.5 Pro · ADK</span>
<div class="topbar-right">
<div class="build-pill" onclick="go('build')">
<div class="build-pill-dot" id="tb-build-dot"></div>
<span id="tb-build-txt">Build: sjekker...</span>
</div>
<div class="topbar-user">👤 chris · admin</div>
</div>
</div>
<div class="panels">
<!-- JASON -->
<div class="panel active" id="panel-jason">
<div class="panel-head">
<div class="p-avatar" style="background:linear-gradient(135deg,var(--accent),var(--accent-2))">🤖</div>
<div>
<div class="p-title">Jason Vauger</div>
<div class="p-sub"><span class="online-dot"></span>Online · ADK + Gemini 2.5 Pro</div>
</div>
<div class="p-actions">
<button class="hbtn" onclick="exportHistory()">⬇ Eksport</button>
<button class="hbtn danger" onclick="clearHistory()">Tøm chat</button>
</div>
</div>
<div class="error-bar" id="error-bar"></div>
<div class="messages" id="messages">
<div class="empty-state" id="empty-state">
<div class="empty-icon">🤖</div>
<div class="empty-title">Jason er klar</div>
<div class="empty-desc">Primærgrensesnitt for Vauco OS. Still spørsmål, gi oppgaver, be om status på infrastruktur, builds og agenter.</div>
<div class="suggestions">
<button class="sug-btn" onclick="suggest(this)">Hva er status på siste build?</button>
<button class="sug-btn" onclick="suggest(this)">Oppsummer token-forbruk siste 7 dager</button>
<button class="sug-btn" onclick="suggest(this)">Hvilke oppgaver er aktive i ROADMAP?</button>
<button class="sug-btn" onclick="suggest(this)">Sjekk anomalier i dag</button>
</div>
</div>
</div>
<div class="input-wrap">
<div class="input-row">
<textarea id="msg-input" rows="1" placeholder="Skriv til Jason... (Enter sender, Shift+Enter ny linje)" oninput="autoResize(this)" onkeydown="handleKey(event)"></textarea>
<button class="send-btn" id="send-btn" onclick="sendMessage()" disabled>
<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
<div class="input-hint">Jason har tilgang til GCP, GitHub og Vauco-agenter. Historikk lagres i denne nettleseren.</div>
</div>
</div>
<!-- BILLING -->
<div class="panel" id="panel-billing">
<div class="panel-head">
<div class="p-avatar" style="background:linear-gradient(135deg,#00d4a8,#3ecf8e)">💰</div>
<div>
<div class="p-title">CostGuard — Token & Kostnad</div>
<div class="p-sub"><span class="online-dot"></span>Live data fra OPAX</div>
</div>
<div class="p-actions"><button class="hbtn" onclick="loadBilling()">↻ Refresh</button></div>
</div>
<div class="panel-body" id="billing-body">
<div class="empty-state"><div class="empty-icon">💰</div><div class="empty-title">Laster...</div></div>
</div>
</div>
<!-- PRICING (CG4d) -->
<div class="panel" id="panel-pricing">
<div class="panel-head">
<div class="p-avatar" style="background:linear-gradient(135deg,#7c5cff,#00d4a8)">🧮</div>
<div>
<div class="p-title">Prisingskalkulator</div>
<div class="p-sub">CostGuard · ROI-estimat basert på GCP-spend</div>
</div>
</div>
<div class="panel-body">
<div class="pricing-hero">
<div class="pricing-hero-title">Hva koster CostGuard deg?</div>
<div class="pricing-hero-sub">Dra slideren til din månedlige GCP-spend — se tier og ROI øyeblikkelig</div>
</div>
<div class="slider-wrap">
<div class="slider-label-row">
<span class="slider-label">Månedlig GCP-spend</span>
<span class="slider-value" id="spend-display">$5 000</span>
</div>
<input type="range" id="spend-slider" min="0" max="100000" step="500" value="5000" oninput="updatePricing(this.value)">
<div class="slider-ticks">
<span class="slider-tick">$0</span>
<span class="slider-tick">$25k</span>
<span class="slider-tick">$50k</span>
<span class="slider-tick">$75k</span>
<span class="slider-tick">$100k</span>
</div>
</div>
<div class="tier-grid" id="tier-grid"></div>
<div class="roi-box">
<div class="roi-stat">
<div class="roi-label">Estimert månedlig besparelse</div>
<div class="roi-value" id="roi-savings">$750</div>
<div class="roi-sub" id="roi-savings-sub">15% av GCP-spend via optimalisering og varsler</div>
</div>
<div class="roi-divider"></div>
<div class="roi-stat">
<div class="roi-label">ROI — CostGuard betaler seg</div>
<div class="roi-value" id="roi-multiplier">0.75×</div>
<div class="roi-sub" id="roi-multiplier-sub">per måned · basert på $999/mnd</div>
</div>
<div class="roi-divider"></div>
<div class="roi-stat">
<div class="roi-label">Netto gevinst per år</div>
<div class="roi-value" id="roi-annual">$3 012</div>
<div class="roi-sub">Besparelse minus abonnement · 12 mnd</div>
</div>
</div>
</div>
</div>
<!-- BUILD -->
<div class="panel" id="panel-build">
<div class="panel-head">
<div class="p-avatar" style="background:linear-gradient(135deg,#ffb547,#ff5c7c)">🔨</div>
<div>
<div class="p-title">Cloud Build — Pipeline</div>
<div class="p-sub" id="build-panel-sub"><span style="width:6px;height:6px;border-radius:50%;background:var(--amber);display:inline-block;margin-right:5px"></span>Sjekker...</div>
</div>
<div class="p-actions"><button class="hbtn" onclick="loadBuild()">↻ Refresh</button></div>
</div>
<div class="panel-body" id="build-body">
<div class="empty-state"><div class="empty-icon">🔨</div><div class="empty-title">Laster...</div></div>
</div>
</div>
<!-- VMs -->
<div class="panel" id="panel-vms">
<div class="panel-head">
<div class="p-avatar" style="background:linear-gradient(135deg,#7c5cff,#5c8fff)">💻</div>
<div>
<div class="p-title">VM-er</div>
<div class="p-sub">Compute Engine · us-central1-b</div>
</div>
</div>
<div class="panel-body">
<div class="vm-grid">
<div class="vm-card">
<div class="vm-hd">
<span style="font-size:22px">🖥️</span>
<div>
<div class="vm-name">osvauco-dev-vm</div>
<div class="vm-type">e2-standard-2 · us-central1-b</div>
</div>
</div>
<div class="vm-meta">
<div class="vm-row"><span class="vm-k">IP ekstern</span><span class="vm-v">34.59.131.162</span></div>
<div class="vm-row"><span class="vm-k">IP intern</span><span class="vm-v">10.128.0.5</span></div>
<div class="vm-row"><span class="vm-k">Prosjekt</span><span class="vm-v">propane-will-491900-m5</span></div>
</div>
<div class="vm-running">RUNNING</div>
<div class="vm-actions">
<a class="vm-btn" href="https://ssh.cloud.google.com/v2/ssh/projects/propane-will-491900-m5/zones/us-central1-b/instances/osvauco-dev-vm" target="_blank">🔗 SSH</a>
<a class="vm-btn" href="https://console.cloud.google.com/compute/instancesDetail/zones/us-central1-b/instances/osvauco-dev-vm?project=propane-will-491900-m5" target="_blank">⚙ GCP</a>
<button class="vm-btn stop" onclick="confirmVmStop()">⏹ Stop</button>
</div>
</div>
</div>
</div>
</div>
<!-- SITES -->
<div class="panel" id="panel-sites">
<div class="panel-head">
<div class="p-avatar" style="background:linear-gradient(135deg,#00d4a8,#7c5cff)">🌐</div>
<div>
<div class="p-title">Sites & Moduler</div>
<div class="p-sub">Alle Vauco-flater</div>
</div>
</div>
<div class="panel-body">
<div class="sites-grid" id="sites-grid"></div>
</div>
</div>
<!-- TERMINAL -->
<div class="panel" id="panel-terminal">
<div class="term-wrap">
<div class="term-toolbar">
<div class="tdot r"></div><div class="tdot y"></div><div class="tdot g"></div>
<div class="term-title">OPAX Terminal · osvauco-agent · us-central1</div>
</div>
<div class="term-body" id="term-body"></div>
<div class="term-input-row">
<span class="term-prompt">chris@opax:~$</span>
<input class="term-in" id="term-in" placeholder="health / billing / build / clear / help" onkeydown="termKey(event)">
</div>
</div>
</div>
<!-- MD DOCS -->
<div class="panel" id="panel-md">
<div class="panel-head">
<div class="p-avatar" style="background:linear-gradient(135deg,#5c8fff,#00d4a8)">📄</div>
<div>
<div class="p-title">MD — Dokumenter</div>
<div class="p-sub">Kjerndoks · vauco-saas/OSVauco</div>
</div>
<div class="p-actions">
<button class="hbtn" id="md-back-btn" style="display:none" onclick="mdShowGrid()">← Tilbake</button>
<a class="hbtn" id="md-gh-btn" style="display:none" href="#" target="_blank">GitHub ↗</a>
</div>
</div>
<div id="md-grid-view" class="panel-body">
<div class="md-grid" id="md-grid"></div>
</div>
<div id="md-viewer-view" style="display:none;flex:1;overflow:hidden;flex-direction:column">
<div class="md-viewer" id="md-viewer"></div>
</div>
</div>
<!-- DOCS BROWSER — Fix 4: markdown-only, no iframe, no external URLs -->
<div class="panel" id="panel-browser">
<div class="panel-head">
<div class="p-avatar" style="background:linear-gradient(135deg,#5c8fff,#7c5cff)">📖</div>
<div>
<div class="p-title">Docs Browser</div>
<div class="p-sub" id="docsbrowser-sub">docs/MASTERPLAN.md</div>
</div>
<div class="p-actions">
<a class="hbtn" id="docsbrowser-gh-btn" href="https://github.com/vauco-saas/OSVauco/blob/main/docs/MASTERPLAN.md" target="_blank">GitHub ↗</a>
<button class="hbtn" onclick="docsBrowserLoad()">↻ Refresh</button>
</div>
</div>
<div class="docsbrowser-toolbar">
<select id="docsbrowser-select" class="docsbrowser-select" onchange="docsBrowserSelect(this.value)">
<option value="docs/MASTERPLAN.md">🏛️ MASTERPLAN</option>
<option value="docs/ROADMAP.md">🗺️ ROADMAP</option>
<option value="docs/HANDOFF.md">🤝 HANDOFF</option>
<option value="docs/AGENT_RULEBOOK.md">📏 AGENT RULEBOOK</option>
<option value="docs/LEARNINGS.md">📚 LEARNINGS</option>
<option value="docs/gemma/world.md">🌍 EMMA world.md</option>
<option value="docs/ARCHITECTURE.md">🏗️ ARCHITECTURE</option>
<option value="AGENTS.md">🤖 AGENTS</option>
</select>
<button class="docsbrowser-refresh" onclick="docsBrowserLoad()">Last inn →</button>
</div>
<div class="docsbrowser-body">
<div class="md-viewer" id="docsbrowser-viewer"></div>
</div>
</div>
</div><!-- /panels -->
</div><!-- /main -->
</div><!-- /shell -->
<script>
// CONFIG
const AGENT_URL = ''
const USER_ID = 'chris'
const STORAGE_KEY = 'jason_history_v2'
const SESSION_KEY = 'jason_session_v2'
const GH_BLOB = 'https://github.com/vauco-saas/OSVauco/blob/main/'
// Configure marked.js once
marked.setOptions({ gfm: true, breaks: true })
const MD_DOCS = [
{ icon:'🏛️', name:'MASTERPLAN', path:'docs/MASTERPLAN.md', desc:'Autoritativ sannhet — vinner alltid ved konflikt', badge:'autoritativ', badgeCls:'auth' },
{ icon:'🗺️', name:'ROADMAP', path:'docs/ROADMAP.md', desc:'Hva som skjer akkurat nå — prioriteringer per sprint', badge:'aktiv', badgeCls:'' },
{ icon:'🤝', name:'HANDOFF', path:'docs/HANDOFF.md', desc:'Siste sesjonsstatus — overskrives hver sesjon', badge:'sesjon', badgeCls:'' },
{ icon:'📏', name:'AGENT RULEBOOK',path:'docs/AGENT_RULEBOOK.md', desc:'Låste regler for alle agenter — R-01 til R-08', badge:'låst', badgeCls:'auth' },
{ icon:'📚', name:'LEARNINGS', path:'docs/LEARNINGS.md', desc:'Append-only lærdom fra alle sesjoner', badge:'append-only', badgeCls:'append' },
{ icon:'🌍', name:'EMMA world.md', path:'docs/gemma/world.md', desc:'Emmas kontekstpakke — leses ved oppstart', badge:'emma', badgeCls:'' },
{ icon:'🏗️', name:'ARCHITECTURE', path:'docs/ARCHITECTURE.md', desc:'Teknisk arkitektur — tjenester, flows, infra', badge:'tech', badgeCls:'' },
{ icon:'🤖', name:'AGENTS', path:'AGENTS.md', desc:'Agent-oversikt — roller, ML-faser, state store', badge:'ml-visjon', badgeCls:'' },
]
const PANEL_META = {
jason: ['Jason', 'Gemini 2.5 Pro · ADK'],
billing: ['CostGuard', 'Token & kostnad · live'],
pricing: ['Prisingskalkulator', 'CostGuard · ROI-estimat'],
build: ['Cloud Build', 'Pipeline · propane-will-491900-m5'],
vms: ['VM-er', 'Compute Engine · us-central1-b'],
sites: ['Sites & Moduler', 'Alle Vauco-flater'],
terminal: ['Terminal', 'OPAX agent · Cloud Run'],
md: ['MD — Dokumenter', 'Kjerndoks · vauco-saas/OSVauco'],
browser: ['Docs Browser', 'Intern dokumentasjonsleser · /docs/ proxy'],
}
const SITES_DATA = [
{icon:'🏢', name:'vauco.no', url:'https://vauco.no', desc:'Bedriftens salgsportal', status:'live'},
{icon:'🤖', name:'OPAX Hub', url:'https://opax.vauco.no', desc:'Operator cockpit — du er her', status:'live'},
{icon:'💰', name:'CostGuard', url:'https://costguard.oss.vauco.no', desc:'Kostnadsmodul · staging', status:'live'},
{icon:'🧵', name:'Threadstone', url:'https://threadstone.vauco.no', desc:'Landingsside', status:'live'},
{icon:'🧵', name:'Threadstone App', url:'https://app.threadstone.vauco.no', desc:'App frontend · under bygg', status:'pending'},
{icon:'🔧', name:'Jason Chat', url:'https://opax.vauco.no/static/jason.html', desc:'Jason standalone UI', status:'live'},
]
// PRICING TIERS (CG4d — locked from MASTERPLAN)
const TIERS = [
{ name:'Starter', min:0, max:3000, price:499, color:'var(--accent-2)' },
{ name:'Guard', min:3000, max:15000, price:999, color:'var(--accent)' },
{ name:'Shield', min:15000, max:50000, price:1999, color:'var(--amber)' },
{ name:'Enterprise', min:50000, max:Infinity,price:3500, color:'var(--danger)' },
]
function fmtUSD(n){
if(n>=1000) return '$'+Math.round(n).toLocaleString('en-US')
return '$'+n
}
function activeTier(spend){
return TIERS.find(t => spend >= t.min && spend < t.max) || TIERS[TIERS.length-1]
}
function renderTierGrid(spend){
const active = activeTier(spend)
const grid = document.getElementById('tier-grid')
grid.innerHTML = TIERS.map(t => {
const isActive = t.name === active.name
const rangeStr = t.max === Infinity ? fmtUSD(t.min)+'+' : fmtUSD(t.min)+''+fmtUSD(t.max)
return `<div class="tier-card${isActive?' active':''}">
<div class="tier-name" style="color:${isActive?t.color:'var(--text-faint)'}">${t.name}</div>
<div class="tier-range">${rangeStr}/mnd</div>
<div class="tier-price" style="color:${isActive?t.color:'var(--text-dim)'}">${fmtUSD(t.price)}</div>
<div class="tier-price-sub">per måned</div>
</div>`
}).join('')
}
function updatePricing(val){
const spend = Number(val)
const tier = activeTier(spend)
document.getElementById('spend-display').textContent = fmtUSD(spend)
renderTierGrid(spend)
const savings = spend * 0.15
const net_monthly = savings - tier.price
const net_annual = net_monthly * 12
const multiplier = tier.price > 0 ? (savings / tier.price) : 0
document.getElementById('roi-savings').textContent = fmtUSD(Math.round(savings))
document.getElementById('roi-savings-sub').textContent = '15% av ' + fmtUSD(spend) + ' via optimalisering og varsler'
document.getElementById('roi-multiplier').textContent = multiplier.toFixed(2) + '×'
document.getElementById('roi-multiplier-sub').textContent = 'per måned · basert på ' + fmtUSD(tier.price) + '/mnd'
const annualEl = document.getElementById('roi-annual')
if(net_annual >= 0){
annualEl.textContent = fmtUSD(Math.round(net_annual))
annualEl.style.color = 'var(--green)'
} else {
annualEl.textContent = '' + fmtUSD(Math.round(Math.abs(net_annual)))
annualEl.style.color = 'var(--danger)'
}
}
// SESSION
function sid(){
let s = sessionStorage.getItem(SESSION_KEY)
if(!s){ s='ses-'+Date.now()+'-'+Math.random().toString(36).slice(2,8); sessionStorage.setItem(SESSION_KEY,s) }
return s
}
const SESSION_ID = sid()
document.getElementById('sb-session').textContent = 'session: '+SESSION_ID.slice(0,18)+'…'
// PANEL SWITCH
const PANELS = Object.keys(PANEL_META)
function go(name){
document.getElementById('panel-iframe-overlay')?.remove()
PANELS.forEach(p=>{
document.getElementById('panel-'+p).classList.remove('active')
document.getElementById('nav-'+p)?.classList.remove('active')
})
document.getElementById('panel-'+name).classList.add('active')
document.getElementById('nav-'+name)?.classList.add('active')
const [t,s] = PANEL_META[name]
document.getElementById('tb-title').textContent = t
document.getElementById('tb-sub').textContent = s
if(name==='billing') loadBilling()
if(name==='build') loadBuild()
if(name==='sites') renderSites()
if(name==='terminal') initTerminal()
if(name==='md') renderMdGrid()
if(name==='pricing') initPricing()
if(name==='browser') docsBrowserInit()
}
function initPricing(){
const slider = document.getElementById('spend-slider')
renderTierGrid(Number(slider.value))
updatePricing(slider.value)
}
// DOCS BROWSER — Fix 4: /docs/ proxy only, marked.js rendering, no external URLs
let _docsBrowserLoaded = false
function docsBrowserInit(){
if(_docsBrowserLoaded) return
_docsBrowserLoaded = true
docsBrowserLoad()
}
async function docsBrowserLoad(){
const sel = document.getElementById('docsbrowser-select')
const path = sel ? sel.value : 'docs/MASTERPLAN.md'
const viewer = document.getElementById('docsbrowser-viewer')
viewer.innerHTML = '<div class="md-viewer-loading"><span style="animation:pulse 1s infinite">⏳</span> Laster '+esc(path)+'...</div>'
document.getElementById('docsbrowser-sub').textContent = path
document.getElementById('docsbrowser-gh-btn').href = GH_BLOB + path
try{
const res = await fetch('/docs/'+path+'?t='+Date.now())
if(!res.ok) throw new Error('HTTP '+res.status)
const raw = await res.text()
viewer.innerHTML = marked.parse(raw)
}catch(e){
viewer.innerHTML = '<div class="md-viewer-loading">❌ Kunne ikke laste: '+esc(e.message)+'</div>'
}
}
function docsBrowserSelect(path){
docsBrowserLoad()
}
// BUILD STATUS POLL
async function pollBuild(){
try{
const d = await fetch('/opax/build-status').then(r=>r.json())
const builds = d.builds || []
const latest = builds[0] || {}
const st = (latest.status || d.status || '').toUpperCase()
const cls = st==='SUCCESS'?'ok':['FAILURE','TIMEOUT','CANCELLED'].includes(st)?'fail':st?'running':''
;['sb-build-dot','tb-build-dot'].forEach(id=>{ document.getElementById(id).className=(id==='sb-build-dot'?'build-dot ':'build-pill-dot ')+cls })
document.getElementById('tb-build-txt').textContent = 'Build: '+(st||'ukjent')
return d
}catch(e){
document.getElementById('tb-build-txt').textContent = 'Build: feil'
return null
}
}
pollBuild()
setInterval(pollBuild,30000)
// ─── Fix 3: CostGuard — fetchWithTimeout helper (AbortController, 10s) ───
function fetchWithTimeout(url, ms=10000){
const ctrl = new AbortController()
const timer = setTimeout(()=>ctrl.abort(), ms)
return fetch(url, { signal: ctrl.signal })
.then(r=>{ clearTimeout(timer); return r })
.catch(e=>{ clearTimeout(timer); throw e })
}
// BILLING — Fix 3: /billing/credits?days=30 + AbortController 10s + fallback
async function loadBilling(){
const el=document.getElementById('billing-body')
el.innerHTML='<div class="empty-state"><div class="empty-icon">⏳</div><div class="empty-title">Laster data...</div></div>'
try{
const [sum, anm, mod, cred] = await Promise.all([
fetchWithTimeout('/billing/summary')
.then(r=>r.ok?r.json():null).catch(()=>null),
fetchWithTimeout('/billing/anomalies')
.then(r=>r.ok?r.json():null).catch(()=>null),
fetchWithTimeout('/billing/tokens/by-module')
.then(r=>r.ok?r.json():null).catch(()=>null),
fetchWithTimeout('/billing/credits?days=30')
.then(r=>r.ok?r.json():null).catch(()=>null),
])
const total = sum?.total_tokens ?? '—'
const cost = sum?.estimated_cost_usd!=null ? '$'+Number(sum.estimated_cost_usd).toFixed(4) : '—'
const budg = sum?.budget_limit!=null ? '$'+sum.budget_limit : '—'
const credVal = cred?.credits_remaining!=null
? '$'+Number(cred.credits_remaining).toFixed(2)
: 'Ingen data tilgjengelig'
let h = `<div class="stat-grid">
<div class="stat-card c-accent"><div class="stat-label">Totale tokens (30d)</div><div class="stat-value">${typeof total==='number'?total.toLocaleString('no'):total}</div><div class="stat-sub">Alle moduler</div></div>
<div class="stat-card c-green"><div class="stat-label">Estimert kostnad</div><div class="stat-value">${cost}</div><div class="stat-sub">Vertex AI · Gemini 2.5 Pro</div></div>
<div class="stat-card c-amber"><div class="stat-label">Budsjettgrense</div><div class="stat-value">${budg}</div><div class="stat-sub">Månedlig maks</div></div>
<div class="stat-card" style="border-color:rgba(0,212,168,.3);background:rgba(0,212,168,.06)"><div class="stat-label">Credits igjen (30d)</div><div class="stat-value" style="font-size:${credVal.length>8?'18px':'26px'}">${esc(credVal)}</div><div class="stat-sub">/billing/credits?days=30</div></div>
</div>`
const anoms = anm?.anomalies||[]
if(anoms.length){
h+='<div class="sec-title">⚠️ Anomalier</div><div class="anomaly-list">'
anoms.slice(0,5).forEach(a=>{
const sev=(a.severity||'').toLowerCase()
h+=`<div class="anom"><div class="anom-icon">${sev==='high'?'🔴':'🟡'}</div><div class="anom-info"><div class="anom-title">${esc(a.service||a.type||'Ukjent')}</div><div class="anom-desc">${esc(a.description||a.message||'')}</div></div><div class="anom-badge ${sev==='high'?'high':''}">${esc(a.severity||'medium')}</div></div>`
})
h+='</div>'
}else{ h+='<div class="sec-title" style="color:var(--green)">✅ Ingen anomalier</div>' }
const mods = mod?.modules||mod?.data||[]
const cols = ['#7c5cff','#00d4a8','#ffb547','#ff5c7c','#3ecf8e','#5c8fff','#ff9f7c','#c084fc']
if(mods.length){
h+='<div class="sec-title" style="margin-top:24px">Token-forbruk per modul</div>'
h+='<table class="mod-table"><thead><tr><th>Modul</th><th>Tokens</th><th>Input</th><th>Output</th></tr></thead><tbody>'
mods.forEach((m,i)=>{ h+=`<tr><td><span class="mod-dot" style="background:${cols[i%cols.length]}"></span>${esc(m.module||m.name||'—')}</td><td>${(m.total_tokens||0).toLocaleString('no')}</td><td>${(m.input_tokens||0).toLocaleString('no')}</td><td>${(m.output_tokens||0).toLocaleString('no')}</td></tr>` })
h+='</tbody></table>'
h+=`<div style="display:flex;justify-content:center;margin-top:28px"><canvas id="mod-donut" width="260" height="260"></canvas></div>`
el.innerHTML=h
renderDonut(mods,cols)
return
}
el.innerHTML=h
}catch(e){
el.innerHTML=`<div class="empty-state"><div class="empty-icon">❌</div><div class="empty-title">Feil</div><div class="empty-desc">${esc(e.message)}</div></div>`
}
}
function renderDonut(mods, cols) {
const canvas = document.getElementById('mod-donut')
if (!canvas) return
if (canvas._chart) canvas._chart.destroy()
canvas._chart = new Chart(canvas, {
type: 'doughnut',
data: {
labels: mods.map(m => m.module || m.name || '—'),
datasets: [{
data: mods.map(m => m.total_tokens || 0),
backgroundColor: mods.map((_, i) => cols[i % cols.length]),
borderColor: '#0a0a0f',
borderWidth: 3,
hoverOffset: 6,
}]
},
options: {
cutout: '68%',
plugins: {
legend: {
position: 'bottom',
labels: {
color: '#8b909c',
font: { size: 11, family: '-apple-system,BlinkMacSystemFont,Inter,system-ui,sans-serif' },
padding: 14, boxWidth: 10, boxHeight: 10,
}
},
tooltip: {
callbacks: { label: ctx => ` ${ctx.label}: ${ctx.parsed.toLocaleString('no')} tokens` }
}
}
}
})
}
// BUILD PANEL
async function loadBuild(){
const el=document.getElementById('build-body')
el.innerHTML='<div class="empty-state"><div class="empty-icon">⏳</div><div class="empty-title">Laster builds...</div></div>'
const d=await pollBuild()
if(!d){el.innerHTML='<div class="empty-state"><div class="empty-icon">❌</div><div class="empty-title">Kunne ikke hente build</div></div>';return}
const builds = d.builds || []
if(!builds.length){
el.innerHTML='<div class="empty-state"><div class="empty-icon">🔨</div><div class="empty-title">Ingen builds funnet</div></div>'
return
}
let h='<div class="build-list">'
builds.forEach(b=>{
const st=(b.status||'').toUpperCase()
const ico=st==='SUCCESS'?'✅':['FAILURE','TIMEOUT'].includes(st)?'❌':'⏳'
h+=`<div class="build-item"><div class="build-ico">${ico}</div><div class="build-info"><div class="build-id">${esc(b.id||'—')}</div><div class="build-branch">${esc(b.branch||'main')}</div><div class="build-time">${esc(b.createTime||'—')}</div></div></div>`
})
h+='</div>'
const latest=builds[0]
document.getElementById('build-panel-sub').innerHTML=`<span style="width:6px;height:6px;border-radius:50%;background:${latest.status==='SUCCESS'?'var(--green)':['FAILURE','TIMEOUT'].includes(latest.status)?'var(--danger)':'var(--amber)'};display:inline-block;margin-right:5px"></span>${latest.status||'ukjent'}`
el.innerHTML=h
}
// SITES
function renderSites(){
const g=document.getElementById('sites-grid')
if(g.children.length) return
g.innerHTML=SITES_DATA.map(s=>`
<div class="site-card">
<div class="site-hd"><span class="site-icon">${s.icon}</span><div><div class="site-name">${s.name}</div><div class="site-url">${s.url}</div></div></div>
<div class="site-desc">${s.desc}</div>
<div class="site-ft">
<span class="site-status ${s.status}">${s.status==='live'?'Live':'Under bygg'}</span>
<button class="site-btn" onclick="openInline('${s.url}','${s.name}')">Åpne inline</button>
</div>
</div>`).join('')
}
function openInline(url, name){
document.getElementById('panel-iframe-overlay')?.remove()
const p=document.createElement('div')
p.id='panel-iframe-overlay'
p.className='panel active'
p.style.cssText='z-index:10;background:var(--bg);display:flex;flex-direction:column'
p.innerHTML=`
<div class="if-bar">
<button class="if-btn" onclick="document.getElementById('panel-iframe-overlay').remove();go('sites')">← Tilbake</button>
<span class="if-url">${esc(url)}</span>
<button class="if-btn" onclick="document.getElementById('the-iframe').src=document.getElementById('the-iframe').src">↻</button>
</div>
<iframe id="the-iframe" class="if-frame" src="${url}" sandbox="allow-scripts allow-same-origin allow-forms allow-popups"></iframe>`
document.querySelector('.panels').appendChild(p)
document.getElementById('tb-title').textContent=name
document.getElementById('tb-sub').textContent=url
}
// MD MODULE — Fix 2: marked.js rendering
function renderMdGrid(){
const g=document.getElementById('md-grid')
if(g.children.length) return
g.innerHTML=MD_DOCS.map(d=>`
<div class="md-card" onclick="openMd('${d.path}','${d.name}')">
<div class="md-hd">
<span class="md-icon">${d.icon}</span>
<div>
<div class="md-name">${d.name}</div>
<div class="md-path">${d.path}</div>
</div>
</div>
<div class="md-desc">${d.desc}</div>
<div class="md-ft">
<span class="md-badge ${d.badgeCls}">${d.badge}</span>
<button class="md-open-btn" onclick="event.stopPropagation();openMd('${d.path}','${d.name}')">Åpne</button>
</div>
</div>`).join('')
}
async function openMd(path, name){
document.getElementById('md-grid-view').style.display='none'
const vv=document.getElementById('md-viewer-view')
vv.style.display='flex'
document.getElementById('md-back-btn').style.display=''
const ghBtn=document.getElementById('md-gh-btn')
ghBtn.href=GH_BLOB+path
ghBtn.style.display=''
document.getElementById('tb-title').textContent='MD · '+name
document.getElementById('tb-sub').textContent=path
const viewer=document.getElementById('md-viewer')
viewer.innerHTML='<div class="md-viewer-loading"><span style="animation:pulse 1s infinite">⏳</span> Laster '+esc(path)+'...</div>'
try{
const res = await fetch('/docs/'+path+'?t='+Date.now())
if(!res.ok) throw new Error('HTTP '+res.status)
const raw = await res.text()
viewer.innerHTML = marked.parse(raw)
}catch(e){
viewer.innerHTML=`<div class="md-viewer-loading">❌ Kunne ikke laste: ${esc(e.message)}</div>`
}
}
function mdShowGrid(){
document.getElementById('md-grid-view').style.display=''
document.getElementById('md-viewer-view').style.display='none'
document.getElementById('md-back-btn').style.display='none'
document.getElementById('md-gh-btn').style.display='none'
document.getElementById('tb-title').textContent='MD — Dokumenter'
document.getElementById('tb-sub').textContent='Kjerndoks · vauco-saas/OSVauco'
}
// TERMINAL
let termInited=false
function initTerminal(){
if(termInited) return
termInited=true
tprint('OPAX Terminal v1.0 · Cloud Run osvauco-agent','info')
tprint('Kommandoer: health billing build clear help','info')
tprint('')
}
function tprint(txt,cls=''){
const b=document.getElementById('term-body')
const d=document.createElement('div');d.className='tl '+(cls||'');d.textContent=txt
b.appendChild(d);b.scrollTop=b.scrollHeight
}
async function termKey(e){
if(e.key!=='Enter') return
const inp=document.getElementById('term-in')
const cmd=inp.value.trim();inp.value=''
if(!cmd) return
tprint('$ '+cmd,'cmd')
const parts=cmd.toLowerCase().split(' ')
switch(parts[0]){
case 'clear': document.getElementById('term-body').innerHTML=''; break
case 'help':
tprint(' health — sjekk /health')
tprint(' billing — hent billing summary')
tprint(' build — hent siste build-status')
tprint(' clear — tøm terminal')
break
case 'health':
try{ const d=await fetch('/health').then(r=>r.json()); tprint(JSON.stringify(d,null,2),'ok') }catch(e){ tprint('Feil: '+e.message,'err') }
break
case 'billing':
try{ const d=await fetch('/billing/summary').then(r=>r.json()); tprint(JSON.stringify(d,null,2),'ok') }
catch(e){ tprint('Feil: '+e.message,'err') }
break
case 'build':
try{ const d=await fetch('/opax/build-status').then(r=>r.json()); tprint(JSON.stringify(d,null,2),'ok') }
catch(e){ tprint('Feil: '+e.message,'err') }
break
default: try{ const r=await fetch('/terminal/exec',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({command:cmd})}) const d=await r.json() if(d.stdout) tprint(d.stdout,'ok') if(d.stderr) tprint(d.stderr,'err') if(d.returncode!==0&&!d.stdout&&!d.stderr) tprint('Exit '+d.returncode,'err') }catch(err){ tprint('Feil: '+err.message,'err') }
}
}
function confirmVmStop(){
if(!confirm('⚠️ Stop osvauco-dev-vm? Du mister SSH-tilgang til den startes igjen.')) return
tprint('→ Stopper VM via: bash scripts/stop-osvauco-dev-vm.sh','info')
go('terminal')
}
// JASON CHAT
function lh(){ try{return JSON.parse(localStorage.getItem(STORAGE_KEY)||'[]')}catch{return[]} }
function sh(h){ try{localStorage.setItem(STORAGE_KEY,JSON.stringify(h.slice(-120)))}catch{} }
function esc(s){ if(typeof s!=='string')s=String(s||''); return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
function fmt(ts){ return new Date(ts).toLocaleTimeString('no-NO',{hour:'2-digit',minute:'2-digit'}) }
function md(text){
let s=esc(text)
s=s.replace(/```([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
s=s.replace(/`([^`]+)`/g,'<code>$1</code>')
s=s.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
return s
}
function appendMsg(role,text,ts){
document.getElementById('empty-state')?.remove()
const w=document.getElementById('messages'),iu=role==='user'
const d=document.createElement('div');d.className='msg '+(iu?'user':'jason')
d.innerHTML=`<div class="msg-av">${iu?'CC':'🤖'}</div><div class="msg-body"><div class="msg-meta">${iu?'deg':'Jason'} · ${fmt(ts||Date.now())}</div><div class="bubble">${iu?esc(text):md(text)}</div></div>`
w.appendChild(d);w.scrollTop=w.scrollHeight
}
function appendTyping(){
document.getElementById('empty-state')?.remove()
const w=document.getElementById('messages'),d=document.createElement('div')
d.className='msg jason';d.id='typing'
d.innerHTML='<div class="msg-av">🤖</div><div class="msg-body"><div class="msg-meta">Jason · skriver...</div><div class="bubble"><div class="typing-dots"><span></span><span></span><span></span></div></div></div>'
w.appendChild(d);w.scrollTop=w.scrollHeight
}
function rmTyping(){ document.getElementById('typing')?.remove() }
const inp=document.getElementById('msg-input'),sbtn=document.getElementById('send-btn')
inp.addEventListener('input',()=>sbtn.disabled=!inp.value.trim())
function autoResize(el){ el.style.height='auto';el.style.height=Math.min(el.scrollHeight,160)+'px' }
function handleKey(e){ if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();if(!sbtn.disabled)sendMessage()} }
function suggest(btn){ inp.value=btn.textContent.trim();autoResize(inp);sbtn.disabled=false;sendMessage() }
let busy=false
async function sendMessage(){
if(busy)return
const txt=inp.value.trim();if(!txt)return
busy=true;sbtn.disabled=true;inp.value='';inp.style.height='auto'
document.getElementById('error-bar').classList.remove('show')
const ts=Date.now(),hist=lh();hist.push({role:'user',text:txt,ts});sh(hist)
appendMsg('user',txt,ts);appendTyping()
try{
const res=await fetch(AGENT_URL+'/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user_id:USER_ID,session_id:SESSION_ID,message:txt})})
rmTyping();if(!res.ok)throw new Error('HTTP '+res.status)
const data=await res.json(),reply=data.response||'(tomt svar)',rts=Date.now()
hist.push({role:'jason',text:reply,ts:rts});sh(hist);appendMsg('jason',reply,rts)
}catch(err){
rmTyping()
const b=document.getElementById('error-bar');b.textContent='Jason svarte ikke: '+err.message;b.classList.add('show')
}finally{busy=false;sbtn.disabled=!inp.value.trim();inp.focus()}
}
function clearHistory(){ if(!confirm('Tøm hele chat-historikken?'))return; localStorage.removeItem(STORAGE_KEY);sessionStorage.removeItem(SESSION_KEY);location.reload() }
function exportHistory(){
const h=lh(),lines=h.map(m=>`[${new Date(m.ts).toISOString()}] ${m.role.toUpperCase()}: ${m.text}`)
const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([lines.join('\n\n')],{type:'text/plain'}));a.download='jason-historikk-'+new Date().toISOString().slice(0,10)+'.txt';a.click()
}
;(function(){ const h=lh();if(!h.length)return;h.forEach(m=>appendMsg(m.role,m.text,m.ts));document.getElementById('messages').scrollTop=99999 })()
inp.focus()
renderTierGrid(5000)
updatePricing(5000)
</script>
</body>
</html>