CG3: simplify layout, center around service costs + donut, and move anomalies to top-line status only
This commit is contained in:
parent
868a0e496d
commit
f48fcd7422
|
|
@ -72,7 +72,7 @@ body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14
|
||||||
.page-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
|
.page-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
|
||||||
.page-title{font-size:24px;font-weight:600;letter-spacing:-.02em}
|
.page-title{font-size:24px;font-weight:600;letter-spacing:-.02em}
|
||||||
.page-title strong{font-weight:700;background:linear-gradient(135deg,#7c5cff,#00d4a8);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
|
.page-title strong{font-weight:700;background:linear-gradient(135deg,#7c5cff,#00d4a8);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
|
||||||
.kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px}
|
.kpi-row{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px}
|
||||||
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px 20px;transition:box-shadow var(--tr),border-color var(--tr)}
|
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px 20px;transition:box-shadow var(--tr),border-color var(--tr)}
|
||||||
.kpi:hover{border-color:var(--border-h);box-shadow:0 1px 8px rgba(0,0,0,.3)}
|
.kpi:hover{border-color:var(--border-h);box-shadow:0 1px 8px rgba(0,0,0,.3)}
|
||||||
.kpi-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}
|
.kpi-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}
|
||||||
|
|
@ -82,24 +82,13 @@ body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14
|
||||||
.ki-blue{background:var(--blue-bg)}.ki-blue .material-symbols-outlined{color:var(--blue)}
|
.ki-blue{background:var(--blue-bg)}.ki-blue .material-symbols-outlined{color:var(--blue)}
|
||||||
.ki-green{background:var(--green-bg)}.ki-green .material-symbols-outlined{color:var(--green)}
|
.ki-green{background:var(--green-bg)}.ki-green .material-symbols-outlined{color:var(--green)}
|
||||||
.ki-yellow{background:var(--yellow-bg)}.ki-yellow .material-symbols-outlined{color:var(--yellow)}
|
.ki-yellow{background:var(--yellow-bg)}.ki-yellow .material-symbols-outlined{color:var(--yellow)}
|
||||||
.ki-red{background:var(--red-bg)}.ki-red .material-symbols-outlined{color:var(--red)}
|
|
||||||
.kpi-val{font-family:var(--mono);font-size:24px;font-weight:500;letter-spacing:-.02em;margin-bottom:6px}
|
|
||||||
.kpi-delta{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:4px}
|
|
||||||
.charts-row{display:grid;grid-template-columns:2fr 1fr;gap:12px;margin-bottom:20px}
|
.charts-row{display:grid;grid-template-columns:2fr 1fr;gap:12px;margin-bottom:20px}
|
||||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px 20px}
|
.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px 20px}
|
||||||
.card-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
|
.card-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
|
||||||
.card-title{font-size:14px;font-weight:500}
|
.card-title{font-size:14px;font-weight:500}
|
||||||
.card-sub{font-size:12px;color:var(--muted)}
|
.card-sub{font-size:12px;color:var(--muted)}
|
||||||
.chart-wrap{position:relative;height:200px}
|
.chart-wrap{position:relative;height:200px}
|
||||||
.bottom-row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}
|
.bottom-row{display:grid;grid-template-columns:2fr 1fr;gap:12px}
|
||||||
.anomaly-row{display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--border);font-size:13px}
|
|
||||||
.anomaly-row:last-child{border-bottom:none}
|
|
||||||
.a-svc{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
||||||
.a-cost{font-family:var(--mono);font-size:12px;color:var(--muted)}
|
|
||||||
.a-ratio{font-family:var(--mono);font-size:11px;padding:2px 8px;border-radius:12px;font-weight:600}
|
|
||||||
.r-h{background:var(--red-bg);color:var(--red)}
|
|
||||||
.r-m{background:var(--yellow-bg);color:var(--yellow)}
|
|
||||||
.r-l{background:var(--green-bg);color:var(--green)}
|
|
||||||
.svc-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border)}
|
.svc-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border)}
|
||||||
.svc-row:last-child{border-bottom:none}
|
.svc-row:last-child{border-bottom:none}
|
||||||
.svc-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
.svc-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
||||||
|
|
@ -187,6 +176,28 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
|
||||||
border-color: var(--border-h);
|
border-color: var(--border-h);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
.sel-opt {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: default;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
.sel-opt.active {
|
||||||
|
background: var(--surface-3);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.blink {
|
||||||
|
animation: blink 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes blink {
|
||||||
|
0%,100% { opacity: 1; }
|
||||||
|
50% { opacity: .3; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -198,13 +209,13 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
|
||||||
</div>
|
</div>
|
||||||
<span class="tb-logo-name">Cost<span>Guard</span></span>
|
<span class="tb-logo-name">Cost<span>Guard</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tb-project">
|
<div class="tb-project">
|
||||||
<span class="material-symbols-outlined">folder_open</span>
|
<span class="material-symbols-outlined">folder_open</span>
|
||||||
propane-will-491900-m5
|
propane-will-491900-m5
|
||||||
</div>
|
</div>
|
||||||
<div class="tb-right">
|
<div class="tb-right">
|
||||||
<div id="provider-toggle" class="sel" style="display:flex; gap: 4px; padding: 2px;">
|
<div id="provider-toggle" class="sel" style="display:flex; gap: 4px; padding: 2px;">
|
||||||
<span class="sel-opt active" data-provider="gcp">GCP</span>
|
<span class="sel-opt active" data-provider="gcp">GCP</span>
|
||||||
</div>
|
</div>
|
||||||
<span id="push-badge" class="badge" style="display:none; background: #333; color: #aaa;">🔔 Varsler aktive</span>
|
<span id="push-badge" class="badge" style="display:none; background: #333; color: #aaa;">🔔 Varsler aktive</span>
|
||||||
<span class="pulse" id="live-dot"></span>
|
<span class="pulse" id="live-dot"></span>
|
||||||
|
|
@ -220,19 +231,19 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
|
||||||
<button id="push-btn" class="sel" onclick="setupPushNotifications()" style="cursor:pointer" title="Aktiver push-varsler">Aktiver push-varsler</button>
|
<button id="push-btn" class="sel" onclick="setupPushNotifications()" style="cursor:pointer" title="Aktiver push-varsler">Aktiver push-varsler</button>
|
||||||
<button class="sel" onclick="fetchAll()" style="cursor:pointer" title="Oppdater">↻</button>
|
<button class="sel" onclick="fetchAll()" style="cursor:pointer" title="Oppdater">↻</button>
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="sel">Eksporter</button>
|
<button class="sel">Eksporter</button>
|
||||||
<div class="dropdown-content">
|
<div class="dropdown-content">
|
||||||
<a href="#" onclick="exportCSV()">CSV</a>
|
<a href="#" onclick="exportCSV()">CSV</a>
|
||||||
<a href="#" onclick="exportPDF()">PDF</a>
|
<a href="#" onclick="exportPDF()">PDF</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sep"></div>
|
<div class="sep"></div>
|
||||||
<div id="user-profile" style="display: none; align-items: center; gap: 8px;">
|
<div id="user-profile" style="display: none; align-items: center; gap: 8px;">
|
||||||
<img id="user-avatar" src="" alt="avatar" style="width: 28px; height: 28px; border-radius: 50%;">
|
<img id="user-avatar" src="" alt="avatar" style="width: 28px; height: 28px; border-radius: 50%;">
|
||||||
<div style="display: flex; flex-direction: column; line-height: 1.2;">
|
<div style="display: flex; flex-direction: column; line-height: 1.2;">
|
||||||
<span id="user-name" style="font-size: 12px; font-weight: 600;"></span>
|
<span id="user-name" style="font-size: 12px; font-weight: 600;"></span>
|
||||||
<a href="/auth/logout" style="font-size: 10px; color: var(--muted); text-decoration: none;">Logg ut</a>
|
<a href="/auth/logout" style="font-size: 10px; color: var(--muted); text-decoration: none;">Logg ut</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -271,40 +282,6 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bottom-row">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-hdr"><span class="card-title">Anomali-deteksjon</span><span class="badge b-ok" id="anom-badge" style="font-size:10px">OK</span></div>
|
|
||||||
<div id="anom-list"><div class="empty-state">Laster...</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-hdr"><span class="card-title">Kostnad per tjeneste</span><span class="card-sub">Rangert</span></div>
|
|
||||||
<div id="svc-list"></div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-hdr">
|
|
||||||
<span class="card-title">Budsjett-status</span>
|
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
|
||||||
<span class="card-sub" id="budget-month"></span>
|
|
||||||
<svg onclick="openBudgetModal()" style="cursor: pointer; color: var(--muted);" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" title="Endre budsjett"><path d="M12.22 2h-4.44a2 2 0 0 0-2 2v.78a2 2 0 0 1-1.11 1.79l-.65.44a2 2 0 0 1-1.79 1.11H2v4.44a2 2 0 0 0 2 2h.78a2 2 0 0 1 1.79 1.11l.44.65a2 2 0 0 1 1.11 1.79V22h4.44a2 2 0 0 0 2-2v-.78a2 2 0 0 1 1.11-1.79l.65-.44a2 2 0 0 1 1.79-1.11H22v-4.44a2 2 0 0 0-2-2h-.78a2 2 0 0 1-1.79-1.11l-.44-.65a2 2 0 0 1-1.11-1.79V2z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="gauge-wrap">
|
|
||||||
<div class="gauge-ring">
|
|
||||||
<svg width="120" height="66" viewBox="0 0 120 66">
|
|
||||||
<path d="M10,60 A50,50 0 0,1 110,60" stroke="var(--surface-3)" stroke-width="10" fill="none" stroke-linecap="round"/>
|
|
||||||
<path d="M10,60 A50,50 0 0,1 110,60" stroke="var(--primary)" stroke-width="10" fill="none" stroke-linecap="round"
|
|
||||||
stroke-dasharray="157" id="gauge-arc" stroke-dashoffset="157" style="transition:stroke-dashoffset 1.2s cubic-bezier(.16,1,.3,1)"/>
|
|
||||||
</svg>
|
|
||||||
<span class="gauge-num" id="gauge-pct">0%</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style="font-family:var(--mono);font-size:13px;font-weight:600;text-align:center" id="gauge-used">kr 0,00</div>
|
|
||||||
<div class="gauge-meta" id="gauge-budget-total">av kr 500,00 budsjett</div>
|
|
||||||
<div class="gauge-meta" id="gauge-remain" style="margin-top:4px">— gjennstår</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="err-banner" id="err-banner">
|
<div class="err-banner" id="err-banner">
|
||||||
<span class="material-symbols-outlined" style="font-size:14px">warning</span>
|
<span class="material-symbols-outlined" style="font-size:14px">warning</span>
|
||||||
<span id="err-msg">Kan ikke nå API.</span>
|
<span id="err-msg">Kan ikke nå API.</span>
|
||||||
|
|
@ -315,28 +292,11 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
|
||||||
<span style="font-size:12px;color:var(--muted)" id="last-updated">Henter data...</span>
|
<span style="font-size:12px;color:var(--muted)" id="last-updated">Henter data...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="cg-section">
|
<section class="cg-section" style="margin-bottom:20px">
|
||||||
<h2>Kostnader siste 30 dager</h2>
|
<h2 style="font-size:14px;margin-bottom:4px">Kostnader siste 30 dager</h2>
|
||||||
<p>
|
<p style="font-size:13px;color:var(--muted);max-width:720px">
|
||||||
Denne oversikten viser dine faktiske GCP-kostnader de siste 30 dagene,
|
Denne oversikten viser dine faktiske GCP-kostnader de siste 30 dagene, hentet direkte fra BigQuery billing export for din billing-konto.
|
||||||
hentet direkte fra BigQuery billing export for din billing-konto.
|
Dataene er aggregert per <strong>dato</strong>, <strong>prosjekt</strong> og <strong>tjeneste</strong>, slik at du raskt ser hvor pengene går.
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Dataene er aggregert per <strong>dato</strong>, <strong>prosjekt</strong>
|
|
||||||
og <strong>tjeneste</strong>, slik at du raskt ser hvor pengene går.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li><strong>Dato</strong>: Basert på <code>usage_start_time</code> i billing-eksporten.</li>
|
|
||||||
<li><strong>Prosjekt</strong>: <code>project.id</code> for GCP-prosjektet kostnaden tilhører.</li>
|
|
||||||
<li><strong>Tjeneste</strong>: Tjenesten som har generert kostnaden (for eksempel Compute Engine, BigQuery, Cloud Run).</li>
|
|
||||||
<li><strong>Daglig kostnad</strong>: Summen av <code>cost</code> for den datoen, prosjektet og tjenesten.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p class="cg-note">
|
|
||||||
Viser inntil 100 linjer med de høyeste kostnadene i perioden. Tallene kommer fra tabellen
|
|
||||||
<code>billing_export.gcp_billing_export_v1_0171F6_057E6B_A260BA</code> i prosjektet
|
|
||||||
<code>propane-will-491900-m5</code>.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -365,14 +325,6 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
|
||||||
<div class="kpi-val" id="k-fc">—</div>
|
<div class="kpi-val" id="k-fc">—</div>
|
||||||
<div class="kpi-delta"><span id="k-fc-d">— dager gjenstår</span></div>
|
<div class="kpi-delta"><span id="k-fc-d">— dager gjenstår</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="kpi" id="k-anom-card">
|
|
||||||
<div class="kpi-top">
|
|
||||||
<span class="kpi-lbl">Anomalier</span>
|
|
||||||
<div class="kpi-icon ki-red"><span class="material-symbols-outlined">warning</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi-val" id="k-anom">—</div>
|
|
||||||
<div class="kpi-delta"><span id="k-anom-d">siste 24t</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="charts-row">
|
<div class="charts-row">
|
||||||
|
|
@ -404,6 +356,37 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bottom-row">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-hdr"><span class="card-title">Kostnad per tjeneste</span><span class="card-sub">Rangert</span></div>
|
||||||
|
<div id="svc-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-hdr">
|
||||||
|
<span class="card-title">Budsjett-status</span>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<span class="card-sub" id="budget-month"></span>
|
||||||
|
<svg onclick="openBudgetModal()" style="cursor: pointer; color: var(--muted);" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" title="Endre budsjett"><path d="M12.22 2h-4.44a2 2 0 0 0-2 2v.78a2 2 0 0 1-1.11 1.79l-.65.44a2 2 0 0 1-1.79 1.11H2v4.44a2 2 0 0 0 2 2h.78a2 2 0 0 1 1.79 1.11l.44.65a2 2 0 0 1 1.11 1.79V22h4.44a2 2 0 0 0 2-2v-.78a2 2 0 0 1 1.11-1.79l.65-.44a2 2 0 0 1 1.79-1.11H22v-4.44a2 2 0 0 0-2-2h-.78a2 2 0 0 1-1.79-1.11l-.44-.65a2 2 0 0 1-1.11-1.79V2z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gauge-wrap">
|
||||||
|
<div class="gauge-ring">
|
||||||
|
<svg width="120" height="66" viewBox="0 0 120 66">
|
||||||
|
<path d="M10,60 A50,50 0 0,1 110,60" stroke="var(--surface-3)" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||||
|
<path d="M10,60 A50,50 0 0,1 110,60" stroke="var(--primary)" stroke-width="10" fill="none" stroke-linecap="round"
|
||||||
|
stroke-dasharray="157" id="gauge-arc" stroke-dashoffset="157" style="transition:stroke-dashoffset 1.2s cubic-bezier(.16,1,.3,1)"/>
|
||||||
|
</svg>
|
||||||
|
<span class="gauge-num" id="gauge-pct">0%</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-family:var(--mono);font-size:13px;font-weight:600;text-align:center" id="gauge-used">kr 0,00</div>
|
||||||
|
<div class="gauge-meta" id="gauge-budget-total">av kr 500,00 budsjett</div>
|
||||||
|
<div class="gauge-meta" id="gauge-remain" style="margin-top:4px">— gjenstår</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -413,17 +396,17 @@ footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:cente
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<div id="budget-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 200; align-items: center; justify-content: center; backdrop-filter: blur(4px);">
|
<div id="budget-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 200; align-items: center; justify-content: center; backdrop-filter: blur(4px);">
|
||||||
<div style="background: var(--surface-2); padding: 24px; border-radius: var(--r); border: 1px solid var(--border); width: 320px; display: flex; flex-direction: column; gap: 16px;">
|
<div style="background: var(--surface-2); padding: 24px; border-radius: var(--r); border: 1px solid var(--border); width: 320px; display: flex; flex-direction: column; gap: 16px;">
|
||||||
<h3 style="font-size: 16px; font-weight: 600;">Sett månedsbudsjett</h3>
|
<h3 style="font-size: 16px; font-weight: 600;">Sett månedsbudsjett</h3>
|
||||||
<div>
|
<div>
|
||||||
<label for="budget-input" style="font-size: 11px; color: var(--muted); margin-bottom: 4px; display: block;">Budsjett (NOK)</label>
|
<label for="budget-input" style="font-size: 11px; color: var(--muted); margin-bottom: 4px; display: block;">Budsjett (NOK)</label>
|
||||||
<input id="budget-input" type="number" style="width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; color: var(--text); font-family: var(--mono); font-size: 14px;">
|
<input id="budget-input" type="number" style="width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 8px 10px; color: var(--text); font-family: var(--mono); font-size: 14px;">
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: flex-end; gap: 10px;">
|
|
||||||
<button onclick="closeBudgetModal()" style="background: var(--surface-3); border: 1px solid var(--border); color: var(--text); padding: 6px 14px; border-radius: 6px; cursor: pointer;">Avbryt</button>
|
|
||||||
<button id="budget-save-btn" onclick="saveBudget()" style="background: var(--primary); border: none; color: var(--bg); font-weight: 600; padding: 7px 16px; border-radius: 6px; cursor: pointer;">Lagre</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||||
|
<button onclick="closeBudgetModal()" style="background: var(--surface-3); border: 1px solid var(--border); color: var(--text); padding: 6px 14px; border-radius: 6px; cursor: pointer;">Avbryt</button>
|
||||||
|
<button id="budget-save-btn" onclick="saveBudget()" style="background: var(--primary); border: none; color: var(--bg); font-weight: 600; padding: 7px 16px; border-radius: 6px; cursor: pointer;">Lagre</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
@ -458,100 +441,98 @@ function animN(el,end,fmt,dur=700){
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiFetch(url, options = {}) {
|
async function apiFetch(url, options = {}) {
|
||||||
const response = await fetch(url, options);
|
const response = await fetch(url, options);
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
window.location.href = '/auth/login';
|
window.location.href = '/auth/login';
|
||||||
return new Promise(() => {});
|
return new Promise(() => {});
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchProvider(provider) {
|
function switchProvider(provider) {
|
||||||
// AWS-støtte er parkert; vi holder oss på GCP
|
// AWS-støtte er parkert; vi holder oss på GCP
|
||||||
currentProvider = 'gcp';
|
currentProvider = 'gcp';
|
||||||
localStorage.setItem('costguard_provider', 'gcp');
|
localStorage.setItem('costguard_provider', 'gcp');
|
||||||
document.querySelectorAll('#provider-toggle .sel-opt').forEach(btn => {
|
document.querySelectorAll('#provider-toggle .sel-opt').forEach(btn => {
|
||||||
btn.classList.toggle('active', btn.dataset.provider === 'gcp');
|
btn.classList.toggle('active', btn.dataset.provider === 'gcp');
|
||||||
});
|
});
|
||||||
fetchAll();
|
fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(API_BASE + '/auth/me');
|
const response = await apiFetch(API_BASE + '/auth/me');
|
||||||
if (!response.ok) throw new Error('Not logged in');
|
if (!response.ok) throw new Error('Not logged in');
|
||||||
userProfile = await response.json();
|
userProfile = await response.json();
|
||||||
|
|
||||||
document.getElementById('user-name').textContent = userProfile.name;
|
document.getElementById('user-name').textContent = userProfile.name;
|
||||||
document.getElementById('user-avatar').src = userProfile.picture;
|
document.getElementById('user-avatar').src = userProfile.picture;
|
||||||
document.getElementById('user-profile').style.display = 'flex';
|
document.getElementById('user-profile').style.display = 'flex';
|
||||||
|
|
||||||
switchProvider(currentProvider);
|
switchProvider(currentProvider);
|
||||||
await fetchBudget();
|
await fetchBudget();
|
||||||
setInterval(fetchAll, REFRESH_MS);
|
setInterval(fetchAll, REFRESH_MS);
|
||||||
|
} catch (err) {
|
||||||
} catch (err) {
|
console.error("Authentication check failed", err);
|
||||||
console.error("Authentication check failed", err);
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchBudget() {
|
async function fetchBudget() {
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(API_BASE + '/billing/budget');
|
const response = await apiFetch(API_BASE + '/billing/budget');
|
||||||
if (!response.ok) throw new Error('Failed to fetch budget');
|
if (!response.ok) throw new Error('Failed to fetch budget');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
currentBudget = data.budget;
|
currentBudget = data.budget;
|
||||||
document.getElementById('budget-input').value = currentBudget;
|
document.getElementById('budget-input').value = currentBudget;
|
||||||
renderAll();
|
renderAll();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Could not fetch budget, using default:', err.message);
|
console.warn('Could not fetch budget, using default:', err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveBudget() {
|
async function saveBudget() {
|
||||||
const btn = document.getElementById('budget-save-btn');
|
const btn = document.getElementById('budget-save-btn');
|
||||||
const originalText = btn.textContent;
|
const originalText = btn.textContent;
|
||||||
btn.textContent = 'Lagrer...';
|
btn.textContent = 'Lagrer...';
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
||||||
const input = document.getElementById('budget-input');
|
const input = document.getElementById('budget-input');
|
||||||
const newBudgetValue = parseFloat(input.value);
|
const newBudgetValue = parseFloat(input.value);
|
||||||
if (isNaN(newBudgetValue) || newBudgetValue <= 0) {
|
if (isNaN(newBudgetValue) || newBudgetValue <= 0) {
|
||||||
alert('Vennligst oppgi et gyldig, positivt tall for budsjettet.');
|
alert('Vennligst oppgi et gyldig, positivt tall for budsjettet.');
|
||||||
btn.textContent = originalText;
|
btn.textContent = originalText;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(API_BASE + '/billing/budget', {
|
const response = await apiFetch(API_BASE + '/billing/budget', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ budget: newBudgetValue })
|
body: JSON.stringify({ budget: newBudgetValue })
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to save budget');
|
if (!response.ok) throw new Error('Failed to save budget');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
currentBudget = data.budget;
|
currentBudget = data.budget;
|
||||||
closeBudgetModal();
|
closeBudgetModal();
|
||||||
renderAll();
|
renderAll();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Kunne ikke lagre budsjett: ' + err.message);
|
alert('Kunne ikke lagre budsjett: ' + err.message);
|
||||||
} finally {
|
} finally {
|
||||||
btn.textContent = originalText;
|
btn.textContent = originalText;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBudgetModal() {
|
function openBudgetModal() {
|
||||||
document.getElementById('budget-input').value = currentBudget;
|
document.getElementById('budget-input').value = currentBudget;
|
||||||
document.getElementById('budget-modal').style.display = 'flex';
|
document.getElementById('budget-modal').style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeBudgetModal() {
|
function closeBudgetModal() {
|
||||||
document.getElementById('budget-modal').style.display = 'none';
|
document.getElementById('budget-modal').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function fetchAll(){
|
async function fetchAll(){
|
||||||
const dot=document.getElementById('live-dot');
|
const dot=document.getElementById('live-dot');
|
||||||
dot.style.background='var(--warn)';
|
dot.style.background='var(--warn)';
|
||||||
|
|
@ -566,20 +547,20 @@ async function fetchAll(){
|
||||||
|
|
||||||
// GCP summary/forecast/anomalies er kritiske; history er nice-to-have
|
// GCP summary/forecast/anomalies er kritiske; history er nice-to-have
|
||||||
if (!gcpSR.ok || !gcpLR.ok || !gcpAR.ok) {
|
if (!gcpSR.ok || !gcpLR.ok || !gcpAR.ok) {
|
||||||
throw new Error('HTTP error on core billing endpoints');
|
throw new Error('HTTP error on core billing endpoints');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [gcpS, gcpL, gcpA] = await Promise.all([gcpSR.json(), gcpLR.json(), gcpAR.json()]);
|
const [gcpS, gcpL, gcpA] = await Promise.all([gcpSR.json(), gcpLR.json(), gcpAR.json()]);
|
||||||
|
|
||||||
let gcpH = { history: [] };
|
let gcpH = { history: [] };
|
||||||
if (gcpHR.ok) {
|
if (gcpHR.ok) {
|
||||||
try {
|
try {
|
||||||
gcpH = await gcpHR.json();
|
gcpH = await gcpHR.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Could not parse history JSON:', e.message);
|
console.warn('Could not parse history JSON:', e.message);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('History endpoint returned', gcpHR.status);
|
console.warn('History endpoint returned', gcpHR.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
currentData.gcp = { summary: gcpS.summary || [], forecast: gcpL, anomalies: gcpA.anomalies || [], history: gcpH.history || [] };
|
currentData.gcp = { summary: gcpS.summary || [], forecast: gcpL, anomalies: gcpA.anomalies || [], history: gcpH.history || [] };
|
||||||
|
|
@ -599,38 +580,37 @@ async function fetchAll(){
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVisibleData() {
|
function getVisibleData() {
|
||||||
// AWS er parkert; all visning baseres på GCP-data
|
// AWS er parkert; all visning baseres på GCP-data
|
||||||
return currentData.gcp;
|
return currentData.gcp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function calculateDailyCosts(history) {
|
function calculateDailyCosts(history) {
|
||||||
if (!history || history.length === 0) {
|
if (!history || history.length === 0) {
|
||||||
return { labels: [], costs: [] };
|
return { labels: [], costs: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyCosts = [];
|
||||||
|
const labels = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < history.length; i++) {
|
||||||
|
const entry = history[i];
|
||||||
|
const currentMtd = entry.mtd || 0;
|
||||||
|
let dailyCost;
|
||||||
|
|
||||||
|
const entryDate = new Date(entry.date);
|
||||||
|
if (entryDate.getDate() === 1) {
|
||||||
|
dailyCost = currentMtd;
|
||||||
|
} else {
|
||||||
|
const prevDayEntry = history[i-1];
|
||||||
|
const prevMtd = prevDayEntry ? prevDayEntry.mtd : 0;
|
||||||
|
dailyCost = currentMtd - prevMtd;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dailyCosts = [];
|
dailyCosts.push(Math.max(0, dailyCost));
|
||||||
const labels = [];
|
labels.push(entryDate.toLocaleDateString('no-NO', {day: 'numeric', month: 'short'}));
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < history.length; i++) {
|
return { labels, costs: dailyCosts };
|
||||||
const entry = history[i];
|
|
||||||
const currentMtd = entry.mtd || 0;
|
|
||||||
let dailyCost;
|
|
||||||
|
|
||||||
const entryDate = new Date(entry.date);
|
|
||||||
if (entryDate.getDate() === 1) {
|
|
||||||
dailyCost = currentMtd;
|
|
||||||
} else {
|
|
||||||
const prevDayEntry = history[i-1];
|
|
||||||
const prevMtd = prevDayEntry ? prevDayEntry.mtd : 0;
|
|
||||||
dailyCost = currentMtd - prevMtd;
|
|
||||||
}
|
|
||||||
|
|
||||||
dailyCosts.push(Math.max(0, dailyCost));
|
|
||||||
labels.push(entryDate.toLocaleDateString('no-NO', {day: 'numeric', month: 'short'}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { labels, costs: dailyCosts };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAll(){
|
function renderAll(){
|
||||||
|
|
@ -665,7 +645,6 @@ function buildBar(labels, costs){
|
||||||
document.getElementById('bar-empty').style.display='none';
|
document.getElementById('bar-empty').style.display='none';
|
||||||
document.getElementById('bar-chart').style.display='block';
|
document.getElementById('bar-chart').style.display='block';
|
||||||
|
|
||||||
const avg7=costs.map((_,i)=>{const s=costs.slice(Math.max(0,i-6),i+1);return s.reduce((a,b)=>a+b,0)/s.length;});
|
|
||||||
const avgC=costs.reduce((a,b)=>a+b,0)/costs.length;
|
const avgC=costs.reduce((a,b)=>a+b,0)/costs.length;
|
||||||
|
|
||||||
barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[
|
barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[
|
||||||
|
|
@ -718,17 +697,27 @@ function buildSvc(summary){
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAnom(anomalies){
|
function buildAnom(anomalies){
|
||||||
const el=document.getElementById('anom-list');
|
const tb=document.getElementById('top-badge');
|
||||||
const tb=document.getElementById('top-badge'),ab=document.getElementById('anom-badge');
|
if(!tb) return;
|
||||||
if(!anomalies.length){
|
|
||||||
el.innerHTML='<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen anomalier detektert</div>';
|
if(!anomalies || !anomalies.length){
|
||||||
tb.className='tb-badge ok';tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">check_circle</span> Ingen anomalier';
|
tb.className='tb-badge ok';
|
||||||
ab.className='tb-badge ok';ab.textContent='OK';
|
tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">check_circle</span> Ingen anomalier';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tb.className='tb-badge err';tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">warning</span> '+anomalies.length+' anomali'+(anomalies.length>1?'er':'');
|
|
||||||
ab.className='tb-badge err';ab.textContent='AKTIV';
|
const maxRatio=Math.max(...anomalies.map(a=>a.ratio||0));
|
||||||
el.innerHTML=anomalies.map(a=>{const r=a.ratio||0;return`<div class="anomaly-row"><span class="a-svc">${a.service}</span><span class="a-cost">${fmtNOK(a.today_cost||0)}</span><span class="a-ratio ${r>3?'r-h':r>1.8?'r-m':'r-l'}">${r.toFixed(1)}x</span></div>`;}).join('');
|
|
||||||
|
if(maxRatio<1.5){
|
||||||
|
tb.className='tb-badge ok';
|
||||||
|
tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">check_circle</span> Mild økning, innenfor normalen';
|
||||||
|
}else if(maxRatio<2.5){
|
||||||
|
tb.className='tb-badge warn';
|
||||||
|
tb.innerHTML='<span class="material-symbols-outlined" style="font-size:13px">error</span> Økt bruk på flere tjenester';
|
||||||
|
}else{
|
||||||
|
tb.className='tb-badge err';
|
||||||
|
tb.innerHTML='<span class="material-symbols-outlined blink" style="font-size:13px">warning</span> Kraftig avvik — sjekk kostnad per tjeneste';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGauge(mtd){
|
function buildGauge(mtd){
|
||||||
|
|
@ -757,7 +746,6 @@ function updateKPIs(forecast,anomCount){
|
||||||
document.getElementById('k-daily').textContent = 'kr —';
|
document.getElementById('k-daily').textContent = 'kr —';
|
||||||
}
|
}
|
||||||
animN(document.getElementById('k-fc'),fc,v=>fmtNOK(v));
|
animN(document.getElementById('k-fc'),fc,v=>fmtNOK(v));
|
||||||
animN(document.getElementById('k-anom'),anomCount,v=>Math.round(v).toString());
|
|
||||||
document.getElementById('k-fc-d').textContent=left+' dager gjenstår';
|
document.getElementById('k-fc-d').textContent=left+' dager gjenstår';
|
||||||
buildGauge(mtd);
|
buildGauge(mtd);
|
||||||
}
|
}
|
||||||
|
|
@ -767,35 +755,35 @@ function updatePeriod(n){
|
||||||
document.getElementById('period-lbl').textContent=n+' dager';
|
document.getElementById('period-lbl').textContent=n+' dager';
|
||||||
const data = getVisibleData();
|
const data = getVisibleData();
|
||||||
if (data && data.history) {
|
if (data && data.history) {
|
||||||
const { labels, costs } = calculateDailyCosts(data.history);
|
const { labels, costs } = calculateDailyCosts(data.history);
|
||||||
buildBar(labels, costs);
|
buildBar(labels, costs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportCSV() {
|
function exportCSV() {
|
||||||
const data = getVisibleData();
|
const data = getVisibleData();
|
||||||
if (!data || !data.summary) return;
|
if (!data || !data.summary) return;
|
||||||
|
|
||||||
const headers = "Tjeneste,Kostnad (NOK)";
|
const headers = "Tjeneste,Kostnad (NOK)";
|
||||||
const rows = data.summary.map(service => {
|
const rows = data.summary.map(service => {
|
||||||
const cost = (service.daily_cost ?? service.total_cost ?? service.cost ?? 0).toFixed(2);
|
const cost = (service.daily_cost ?? service.total_cost ?? service.cost ?? 0).toFixed(2);
|
||||||
return `"${service.service}",${cost}`;
|
return `"${service.service}",${cost}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const csvContent = "data:text/csv;charset=utf-8," + [headers, ...rows].join("\n");
|
const csvContent = "data:text/csv;charset=utf-8," + [headers, ...rows].join("\n");
|
||||||
|
|
||||||
const encodedUri = encodeURI(csvContent);
|
const encodedUri = encodeURI(csvContent);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.setAttribute("href", encodedUri);
|
link.setAttribute("href", encodedUri);
|
||||||
const date = new Date().toISOString().split('T')[0];
|
const date = new Date().toISOString().split('T')[0];
|
||||||
link.setAttribute("download", `costguard_export_${date}.csv`);
|
link.setAttribute("download", `costguard_export_${date}.csv`);
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportPDF() {
|
function exportPDF() {
|
||||||
window.print();
|
window.print();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -811,22 +799,6 @@ if ('serviceWorker' in navigator) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
|
||||||
.sel-opt {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--muted);
|
|
||||||
padding: 4px 10px;
|
|
||||||
cursor: default;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: var(--font);
|
|
||||||
}
|
|
||||||
.sel-opt.active {
|
|
||||||
background: var(--surface-3);
|
|
||||||
color: var(--text);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user