1138 lines
63 KiB
HTML
1138 lines
63 KiB
HTML
<!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><button class="vm-btn" onclick="showSshKey('osvauco-dev-vm')">🔑 SSH-nøkkel</button</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="MASTERPLAN.md">🏛️ MASTERPLAN</option>
|
||
<option value="ROADMAP.md">🗺️ ROADMAP</option>
|
||
<option value="HANDOFF.md">🤝 HANDOFF</option>
|
||
<option value="AGENT_RULEBOOK.md">📏 AGENT RULEBOOK</option>
|
||
<option value="LEARNINGS.md">📚 LEARNINGS</option>
|
||
<option value="gemma/world.md">🌍 EMMA world.md</option>
|
||
<option value="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:'MASTERPLAN.md', desc:'Autoritativ sannhet — vinner alltid ved konflikt', badge:'autoritativ', badgeCls:'auth' },
|
||
{ icon:'🗺️', name:'ROADMAP', path:'ROADMAP.md', desc:'Hva som skjer akkurat nå — prioriteringer per sprint', badge:'aktiv', badgeCls:'' },
|
||
{ icon:'🤝', name:'HANDOFF', path:'HANDOFF.md', desc:'Siste sesjonsstatus — overskrives hver sesjon', badge:'sesjon', badgeCls:'' },
|
||
{ icon:'📏', name:'AGENT RULEBOOK',path:'AGENT_RULEBOOK.md', desc:'Låste regler for alle agenter — R-01 til R-08', badge:'låst', badgeCls:'auth' },
|
||
{ icon:'📚', name:'LEARNINGS', path:'LEARNINGS.md', desc:'Append-only lærdom fra alle sesjoner', badge:'append-only', badgeCls:'append' },
|
||
{ icon:'🌍', name:'EMMA world.md', path:'gemma/world.md', desc:'Emmas kontekstpakke — leses ved oppstart', badge:'emma', badgeCls:'' },
|
||
{ icon:'🏗️', name:'ARCHITECTURE', path:'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 fetchWithTimeout('/opax/build-status', 5000).then(r=>r.ok ? r.json() : null)
|
||
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
|
||
}
|
||
}
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
// Sjekk auth-status
|
||
try {
|
||
const r = await fetchWithTimeout('/auth/me', 5000)
|
||
if(r.ok){
|
||
const u = await r.json()
|
||
document.getElementById('sb-session').textContent = u.email || 'innlogget'
|
||
document.getElementById('send-btn').disabled = false
|
||
} else {
|
||
window.location.href = '/auth/login'
|
||
}
|
||
} catch(e) {
|
||
document.getElementById('sb-session').textContent = 'session: feil'
|
||
}
|
||
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, credentials: 'include' })
|
||
.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') }
|
||
}
|
||
}
|
||
async function showSshKey(vm){ const r=await fetch('/vm/ssh-key?vm='+vm) const d=await r.json() const key=d.ssh_public_key||d.error||'Ingen nøkkel funnet' let m=document.getElementById('ssh-key-modal') if(!m){ m=document.createElement('div') m.id='ssh-key-modal' m.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:9999;display:flex;align-items:center;justify-content:center' m.innerHTML='<div style="background:var(--bg-2);border:1px solid var(--border);border-radius:12px;padding:24px;max-width:600px;width:90%;position:relative"><h3 style="margin:0 0 12px">🔑 SSH Public Key — '+vm+'</h3><textarea readonly style="width:100%;height:120px;background:var(--bg-3);border:1px solid var(--border);border-radius:6px;padding:8px;color:var(--text);font-family:var(--mono);font-size:11px;resize:none" id="ssh-key-txt"></textarea><div style="display:flex;gap:8px;margin-top:12px"><button onclick="navigator.clipboard.writeText(document.getElementById(\'ssh-key-txt\').value);this.textContent=\'✅ Kopiert!\'" style="background:var(--accent);color:#000;border:none;border-radius:6px;padding:8px 16px;cursor:pointer;font-weight:600">📋 Kopier</button><button onclick="document.getElementById(\'ssh-key-modal\').remove()" style="background:var(--bg-3);border:1px solid var(--border);border-radius:6px;padding:8px 16px;cursor:pointer">Lukk</button></div></div>' document.body.appendChild(m) } document.getElementById('ssh-key-txt').value=key m.style.display='flex' } 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,'&').replace(/</g,'<').replace(/>/g,'>') }
|
||
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 chatInp=document.getElementById('msg-input'),sbtn=document.getElementById('send-btn')
|
||
chatInp.addEventListener('input',()=>sbtn.disabled=!chatInp.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){ chatInp.value=btn.textContent.trim();chatInp.dispatchEvent(new Event('input', { bubbles: true }));autoResize(chatInp);sbtn.disabled=false;sendMessage() }
|
||
|
||
let busy=false
|
||
async function sendMessage(){
|
||
if(busy)return
|
||
const txt=chatInp.value.trim();if(!txt)return
|
||
busy=true;sbtn.disabled=true;chatInp.value='';chatInp.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=!chatInp.value.trim();chatInp.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 })()
|
||
chatInp.focus()
|
||
renderTierGrid(5000)
|
||
updatePricing(5000)
|
||
</script>
|
||
</body>
|
||
</html>
|