OSVauco/static/opax.html

1115 lines
60 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 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-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}}
/* BROWSER PANEL */
.browser-bar{display:flex;align-items:center;gap:8px;padding:10px 16px;border-bottom:1px solid var(--border);background:var(--bg-2);flex-shrink:0}
.browser-url-input{flex:1;background:var(--bg-3);border:1px solid var(--border);border-radius:8px;padding:6px 12px;font-family:var(--mono);font-size:12px;color:var(--text);outline:none;transition:border-color .2s}
.browser-url-input:focus{border-color:var(--accent)}
.browser-url-input::placeholder{color:var(--text-faint)}
.browser-go-btn{padding:6px 14px;border-radius:8px;background:var(--accent);color:#fff;font-size:12px;font-weight:600;transition:background .14s;white-space:nowrap}
.browser-go-btn:hover{background:#6a4cff}
.browser-frame{flex:1;border:none;width:100%;height:100%;background:var(--bg)}
.browser-error{display:none;flex:1;align-items:center;justify-content:center;flex-direction:column;gap:12px;color:var(--text-faint);font-family:var(--mono);font-size:13px}
.browser-error.show{display:flex}
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.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> 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>
<!-- BROWSER -->
<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">Browser</div>
<div class="p-sub">Intern dokumentasjonsleser · /docs/ proxy</div>
</div>
<div class="p-actions">
<button class="hbtn" onclick="browserRefresh()">↻ Refresh</button>
</div>
</div>
<div class="browser-bar">
<input id="browser-url" class="browser-url-input" type="text" placeholder="https://... eller /docs/MASTERPLAN.md" onkeydown="if(event.key==='Enter')browserNav()">
<button class="browser-go-btn" onclick="browserNav()">Gå →</button>
</div>
<div id="browser-error" class="browser-error">
<span style="font-size:32px">⚠️</span>
<span id="browser-error-msg">Kunne ikke laste siden</span>
</div>
<iframe id="browser-frame" class="browser-frame" src="" sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
onload="browserFrameLoad()" onerror="browserFrameError()"></iframe>
</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/'
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: ['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()
}
function initPricing(){
const slider = document.getElementById('spend-slider')
renderTierGrid(Number(slider.value))
updatePricing(slider.value)
}
// BROWSER PANEL
function browserNav(){
const raw = document.getElementById('browser-url').value.trim()
if(!raw) return
const url = raw.startsWith('http') ? raw : '/docs/'+raw.replace(/^\/docs\//,'')
document.getElementById('browser-error').classList.remove('show')
document.getElementById('browser-frame').src = url
}
function browserRefresh(){
const f = document.getElementById('browser-frame')
if(f.src) f.src = f.src
}
function browserFrameLoad(){
document.getElementById('browser-error').classList.remove('show')
}
function browserFrameError(){
document.getElementById('browser-error-msg').textContent = 'Siden kunne ikke lastes — sjekk URL eller /docs/-proxy'
document.getElementById('browser-error').classList.add('show')
}
// BUILD STATUS POLL
async function pollBuild(){
try{
const d = await fetch('/opax/build-status').then(r=>r.json())
const st = (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)
// BILLING
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] = await Promise.all([
fetch('/billing/summary').then(r=>r.json()).catch(()=>null),
fetch('/billing/anomalies').then(r=>r.json()).catch(()=>null),
fetch('/billing/tokens/by-module').then(r=>r.json()).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:'—'
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>`
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">${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 st=(d.status||'').toUpperCase()
const ico=st==='SUCCESS'?'✅':['FAILURE','TIMEOUT'].includes(st)?'❌':'⏳'
document.getElementById('build-panel-sub').innerHTML=`<span style="width:6px;height:6px;border-radius:50%;background:${st==='SUCCESS'?'var(--green)':['FAILURE','TIMEOUT'].includes(st)?'var(--danger)':'var(--amber)'};display:inline-block;margin-right:5px"></span>${st}`
let h=`<div class="build-list"><div class="build-item"><div class="build-ico">${ico}</div><div class="build-info"><div class="build-id">${esc(d.build_id||d.id||'—')}</div><div class="build-branch">${esc(d.branch||d.trigger||'main')}</div><div class="build-time">${esc(d.created||d.timestamp||'—')}</div></div></div></div>`
const logs=d.log_lines||d.logs||[]
if(logs.length){
h+='<div class="log-box">'
logs.forEach(l=>{ const c=/error|fail/i.test(l)?'ll-fail':/success|done/i.test(l)?'ll-ok':''; h+=`<div class="${c}">${esc(l)}</div>` })
h+='</div>'
}
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
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{
// OQ-30: bruk /docs/ proxy i stedet for GH_RAW
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=renderMd(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'
}
function renderMd(text){
let s=esc(text)
s=s.replace(/```[^\n]*\n([\s\S]*?)```/g,'<pre><code>$1</code></pre>')
s=s.replace(/`([^`]+)`/g,'<code>$1</code>')
s=s.replace(/^### (.+)$/gm,'<h3>$1</h3>')
s=s.replace(/^## (.+)$/gm,'<h2>$1</h2>')
s=s.replace(/^# (.+)$/gm,'<h1>$1</h1>')
s=s.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
s=s.replace(/^---$/gm,'<hr>')
s=s.replace(/^&gt; (.+)$/gm,'<blockquote>$1</blockquote>')
s=s.replace(/((?:^\|.+\|\n)+)/gm, tbl=>{
const rows=tbl.trim().split('\n').filter(r=>!/^\|[-| :]+\|$/.test(r))
if(!rows.length) return tbl
let out='<table>'
rows.forEach((r,i)=>{
const cells=r.split('|').slice(1,-1)
out+=i===0?'<thead><tr>':'<tr>'
cells.forEach(c=>{ out+=i===0?`<th>${c.trim()}</th>`:`<td>${c.trim()}</td>` })
out+=i===0?'</tr></thead><tbody>':'</tr>'
})
out+='</tbody></table>'
return out
})
s=s.replace(/^[-*] (.+)$/gm,'<li>$1</li>')
s=s.replace(/(<li>.*<\/li>)/s, m=>'<ul>'+m+'</ul>')
s=s.replace(/^\d+\. (.+)$/gm,'<li>$1</li>')
s=s.replace(/^(?!<[htublp|]).+$/gm, line=>line.trim()?`<p>${line}</p>`:'')
return s
}
// 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: tprint('Ukjent kommando: '+cmd+' (skriv «help»)','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>