feat: Cloud Billing API, FCM push, OAuth, AWS billing, historikk, eksport, PWA
This commit is contained in:
parent
a54bffe1ee
commit
772951ef01
26
.env.example
26
.env.example
|
|
@ -79,3 +79,29 @@ export VPC_NETWORK="default"
|
||||||
# ── LiteLLM proxy (valgfritt) ─────────────────────────────────
|
# ── LiteLLM proxy (valgfritt) ─────────────────────────────────
|
||||||
# export LITELLM_BASE_URL=""
|
# export LITELLM_BASE_URL=""
|
||||||
# export LITELLM_API_KEY=""
|
# export LITELLM_API_KEY=""
|
||||||
|
|
||||||
|
# ── CostGuard App (Fase B1-B10) ────────────────────────────────
|
||||||
|
# Generer en sterk secret med f.eks: openssl rand -hex 32
|
||||||
|
export SESSION_SECRET=""
|
||||||
|
|
||||||
|
# Google OAuth 2.0 Client Credentials (for web app)
|
||||||
|
# Opprett på https://console.cloud.google.com/apis/credentials
|
||||||
|
export GOOGLE_CLIENT_ID=""
|
||||||
|
export GOOGLE_CLIENT_SECRET=""
|
||||||
|
|
||||||
|
# Kommaseparert liste over e-poster som får logge inn
|
||||||
|
export ALLOWED_EMAILS="ditt.navn@example.com"
|
||||||
|
|
||||||
|
# Firebase Project ID (for Firestore)
|
||||||
|
# Dette er samme som din GCP PROJECT_ID
|
||||||
|
export FIREBASE_PROJECT_ID="${PROJECT_ID}"
|
||||||
|
|
||||||
|
# AWS Credentials for Cost Explorer
|
||||||
|
# Opprett en IAM-bruker med Cost Explorer-tilgang
|
||||||
|
export AWS_ACCESS_KEY_ID=""
|
||||||
|
export AWS_SECRET_ACCESS_KEY=""
|
||||||
|
export AWS_DEFAULT_REGION="us-east-1" # F.eks. us-east-1
|
||||||
|
|
||||||
|
# SendGrid API Key for e-postvarsler
|
||||||
|
# Hentes fra SendGrid-dashboardet
|
||||||
|
export SENDGRID_API_KEY=""
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -48,3 +48,23 @@ curl -s -X POST -H "Authorization: Bearer $TOKEN" \
|
||||||
| roles/iam.serviceAccountUser | Deploy |
|
| roles/iam.serviceAccountUser | Deploy |
|
||||||
| roles/artifactregistry.writer | Docker push |
|
| roles/artifactregistry.writer | Docker push |
|
||||||
| roles/run.developer | Cloud Run deploy |
|
| roles/run.developer | Cloud Run deploy |
|
||||||
|
|
||||||
|
## Cloud Scheduler for Daglig Fakturering
|
||||||
|
For å automatisk generere daglige kostnads-snapshots, må en Cloud Scheduler-jobb konfigureres til å kalle `/billing/snapshot`-endepunktet.
|
||||||
|
|
||||||
|
**Oppsett med `gcloud`:**
|
||||||
|
1. **Service URL:** `https://osvauco-agent-357036551735.us-central1.run.app`
|
||||||
|
2. **Service Account:** `osvauco-agent-sa@propane-will-491900-m5.iam.gserviceaccount.com`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gcloud scheduler jobs create http daily-billing-snapshot \
|
||||||
|
--schedule="0 5 * * *" \
|
||||||
|
--uri="https://osvauco-agent-357036551735.us-central1.run.app/billing/snapshot" \
|
||||||
|
--http-method=POST \
|
||||||
|
--oidc-service-account-email="osvauco-agent-sa@propane-will-491900-m5.iam.gserviceaccount.com" \
|
||||||
|
--oidc-token-audience="https://osvauco-agent-357036551735.us-central1.run.app" \
|
||||||
|
--location="us-central1" \
|
||||||
|
--time-zone="Etc/UTC" \
|
||||||
|
--description="Kaller /billing/snapshot for å lagre daglig kostnadsdata."
|
||||||
|
```
|
||||||
|
Denne kommandoen oppretter en jobb som kjører hver dag kl. 05:00 UTC.
|
||||||
|
|
|
||||||
124
agents/aws_billing_agent.py
Normal file
124
agents/aws_billing_agent.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import os
|
||||||
|
import boto3
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
class AWSBillingAgent:
|
||||||
|
def __init__(self):
|
||||||
|
self.aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID")
|
||||||
|
self.aws_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY")
|
||||||
|
self.aws_default_region = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
|
||||||
|
|
||||||
|
if not all([self.aws_access_key_id, self.aws_secret_access_key, self.aws_default_region]):
|
||||||
|
raise ValueError("AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION) must be set.")
|
||||||
|
|
||||||
|
self.ce_client = boto3.client(
|
||||||
|
'ce',
|
||||||
|
aws_access_key_id=self.aws_access_key_id,
|
||||||
|
aws_secret_access_key=self.aws_secret_access_key,
|
||||||
|
region_name=self.aws_default_region
|
||||||
|
)
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
def get_summary(self):
|
||||||
|
"""
|
||||||
|
Henter MTD-kostnad, daglig snitt, prognose og en fordeling per tjeneste for AWS.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
today = datetime.date.today()
|
||||||
|
start_of_month = today.replace(day=1).isoformat()
|
||||||
|
end_of_month = today.isoformat()
|
||||||
|
|
||||||
|
# Hent kostnad per tjeneste
|
||||||
|
response = self.ce_client.get_cost_and_usage(
|
||||||
|
TimePeriod={
|
||||||
|
'Start': start_of_month,
|
||||||
|
'End': end_of_month
|
||||||
|
},
|
||||||
|
Granularity='MONTHLY',
|
||||||
|
Metrics=['UnblendedCost'],
|
||||||
|
GroupBy=[{'Type': 'DIMENSION', 'Key': 'SERVICE'}]
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = []
|
||||||
|
for item in response['ResultsByTime'][0]['Groups']:
|
||||||
|
summary.append({
|
||||||
|
"service": item['Keys'][0],
|
||||||
|
"total_cost": float(item['Metrics']['UnblendedCost']['Amount'])
|
||||||
|
})
|
||||||
|
|
||||||
|
summary = sorted(summary, key=lambda i: i['total_cost'], reverse=True)
|
||||||
|
|
||||||
|
if not summary:
|
||||||
|
return {
|
||||||
|
"onboarding_status": {
|
||||||
|
"state": "awaiting_data",
|
||||||
|
"message": "Kunne ikke hente faktureringsdata fra AWS. Sjekk at Cost Explorer er aktivert."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"summary": summary}
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error fetching AWS summary: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
def get_forecast(self):
|
||||||
|
"""
|
||||||
|
Henter en kostnadsprognose for AWS.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
today = datetime.date.today()
|
||||||
|
start_of_month = today.replace(day=1).isoformat()
|
||||||
|
|
||||||
|
# MTD-kostnad
|
||||||
|
mtd_response = self.ce_client.get_cost_and_usage(
|
||||||
|
TimePeriod={
|
||||||
|
'Start': start_of_month,
|
||||||
|
'End': today.isoformat()
|
||||||
|
},
|
||||||
|
Granularity='MONTHLY',
|
||||||
|
Metrics=['UnblendedCost']
|
||||||
|
)
|
||||||
|
mtd_cost = float(mtd_response['ResultsByTime'][0]['Total']['UnblendedCost']['Amount'])
|
||||||
|
|
||||||
|
# Prognose
|
||||||
|
forecast_response = self.ce_client.get_cost_forecast(
|
||||||
|
TimePeriod={
|
||||||
|
'Start': (today + datetime.timedelta(days=1)).isoformat(),
|
||||||
|
'End': (today.replace(day=1) + datetime.timedelta(days=32)).replace(day=1).isoformat()
|
||||||
|
},
|
||||||
|
Metric='UNBLENDED_COST',
|
||||||
|
Granularity='MONTHLY'
|
||||||
|
)
|
||||||
|
total_forecast = float(forecast_response['Total']['Amount'])
|
||||||
|
|
||||||
|
# Daglig snitt
|
||||||
|
last_7_days_start = (today - datetime.timedelta(days=7)).isoformat()
|
||||||
|
daily_avg_response = self.ce_client.get_cost_and_usage(
|
||||||
|
TimePeriod={
|
||||||
|
'Start': last_7_days_start,
|
||||||
|
'End': today.isoformat()
|
||||||
|
},
|
||||||
|
Granularity='DAILY',
|
||||||
|
Metrics=['UnblendedCost']
|
||||||
|
)
|
||||||
|
|
||||||
|
daily_costs = [float(item['Total']['UnblendedCost']['Amount']) for item in daily_avg_response['ResultsByTime']]
|
||||||
|
daily_average = sum(daily_costs) / len(daily_costs) if daily_costs else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"daily_average_last_7_days": daily_average,
|
||||||
|
"month_to_date_cost": mtd_cost,
|
||||||
|
"total_monthly_forecast": total_forecast,
|
||||||
|
"data_note": "Prognose fra AWS Cost Explorer. Kan avvike fra endelig faktura."
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error fetching AWS forecast: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# For local testing, ensure env vars are set
|
||||||
|
agent = AWSBillingAgent()
|
||||||
|
print("Summary:", agent.get_summary())
|
||||||
|
print("Forecast:", agent.get_forecast())
|
||||||
467
main.py
467
main.py
|
|
@ -20,46 +20,62 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List
|
from typing import List
|
||||||
|
import datetime
|
||||||
|
from cachetools import cached, TTLCache
|
||||||
|
from google.cloud import billing_budgets_v1
|
||||||
|
|
||||||
from agent import run, authorize_mode
|
from agent import run, authorize_mode
|
||||||
from ml import build_agent_dag, execute_dag, get_store, log_agent_call
|
from ml import build_agent_dag, execute_dag, get_store, log_agent_call
|
||||||
from ml.telemetry import log_dag_execution
|
from ml.telemetry import log_dag_execution
|
||||||
from ml.billing_agent import BillingAgent
|
from ml.billing_agent import BillingAgent
|
||||||
|
from agents.aws_billing_agent import AWSBillingAgent
|
||||||
from ml.anomaly_detector import AnomalyDetector
|
from ml.anomaly_detector import AnomalyDetector
|
||||||
from auth.oauth_flow import get_authorization_url, exchange_code_for_token
|
from auth.oauth_flow import get_authorization_url, exchange_code_for_token
|
||||||
from auth.token_store import save_token
|
from auth.token_store import save_token
|
||||||
|
|
||||||
app = FastAPI(title="OSVauco OPAX Agent")
|
from firebase_admin import credentials, messaging
|
||||||
AGENT_ID = "opax-core"
|
import base64
|
||||||
|
import json
|
||||||
|
from functools import wraps
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
from authlib.integrations.starlette_client import OAuth
|
||||||
|
import sendgrid
|
||||||
|
from sendgrid.helpers.mail import Mail
|
||||||
|
|
||||||
# ── IAP-MIDDLEWARE ────────────────────────────────────────────────────────────
|
# ── AUTH & SESSION ────────────────────────────────────────────────────────────
|
||||||
IAP_ENABLED = os.environ.get("IAP_ENABLED", "").lower() == "true"
|
# Add session middleware for storing auth state
|
||||||
IAP_AUDIENCE = os.environ.get("IAP_AUDIENCE", "")
|
app.add_middleware(SessionMiddleware, secret_key=os.environ.get("SESSION_SECRET"))
|
||||||
|
|
||||||
|
# Configure Authlib's OAuth client
|
||||||
|
oauth = OAuth()
|
||||||
|
oauth.register(
|
||||||
|
name='google',
|
||||||
|
client_id=os.environ.get("GOOGLE_CLIENT_ID"),
|
||||||
|
client_secret=os.environ.get("GOOGLE_CLIENT_SECRET"),
|
||||||
|
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
|
||||||
|
client_kwargs={
|
||||||
|
'scope': 'openid email profile'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Allowed emails for login
|
||||||
|
ALLOWED_EMAILS = [email.strip() for email in os.environ.get("ALLOWED_EMAILS", "").split(",") if email.strip()]
|
||||||
|
ALERT_EMAIL = os.environ.get("ALERT_EMAIL")
|
||||||
|
|
||||||
|
|
||||||
def _verify_iap_jwt(token: str, audience: str) -> dict:
|
def require_auth(func):
|
||||||
try:
|
"""Decorator to protect endpoints that require authentication."""
|
||||||
from google.auth.transport import requests as google_requests
|
@wraps(func)
|
||||||
from google.oauth2 import id_token
|
async def wrapper(request: Request, *args, **kwargs):
|
||||||
request = google_requests.Request()
|
user = request.session.get('user')
|
||||||
return id_token.verify_token(token, request, audience=audience,
|
if not user:
|
||||||
certs_url="https://www.gstatic.com/iap/verify/public_key")
|
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
|
||||||
except Exception as exc:
|
|
||||||
raise ValueError(f"IAP JWT ugyldig: {exc}") from exc
|
|
||||||
|
|
||||||
|
if ALLOWED_EMAILS and user.get('email') not in ALLOWED_EMAILS:
|
||||||
|
return JSONResponse(status_code=403, content={"error": "Email not allowed"})
|
||||||
|
|
||||||
@app.middleware("http")
|
return await func(request, *args, **kwargs)
|
||||||
async def iap_guard(request: Request, call_next):
|
return wrapper
|
||||||
if IAP_ENABLED and request.url.path.startswith("/billing"):
|
|
||||||
token = request.headers.get("X-Goog-IAP-JWT-Assertion", "")
|
|
||||||
if not token:
|
|
||||||
return JSONResponse(status_code=401,
|
|
||||||
content={"error": "IAP-token mangler (X-Goog-IAP-JWT-Assertion)"})
|
|
||||||
try:
|
|
||||||
_verify_iap_jwt(token, IAP_AUDIENCE)
|
|
||||||
except ValueError as exc:
|
|
||||||
return JSONResponse(status_code=403, content={"error": str(exc)})
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
|
|
||||||
# ── STATIC FILES ──────────────────────────────────────────────────────────────
|
# ── STATIC FILES ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -83,12 +99,23 @@ class DagRequest(BaseModel):
|
||||||
mode: str = "light"
|
mode: str = "light"
|
||||||
scheduler: str = Field("threads")
|
scheduler: str = Field("threads")
|
||||||
|
|
||||||
|
class PushSubscription(BaseModel):
|
||||||
|
token: str
|
||||||
|
budget_nok: float
|
||||||
|
|
||||||
|
class BudgetWebhookPayload(BaseModel):
|
||||||
|
message: dict
|
||||||
|
subscription: str
|
||||||
|
|
||||||
|
class BudgetUpdateRequest(BaseModel):
|
||||||
|
budget: float
|
||||||
|
|
||||||
# ── ROOT LANDING PAGE ───────────────────────────────────────────────────
|
# ── ROOT LANDING PAGE ───────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
"""Serves the documentation index page."""
|
"""Serves the main landing page."""
|
||||||
return FileResponse("docs/index.html")
|
# This will be updated in a later step to a proper landing page
|
||||||
|
return FileResponse("static/command-hub.html")
|
||||||
|
|
||||||
|
|
||||||
# ── HEALTH ────────────────────────────────────────────────────────────────────
|
# ── HEALTH ────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -97,59 +124,381 @@ def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
# ── AUTH — OAuth2 klient-onboarding ──────────────────────────────────────────
|
@app.get("/manifest.json", include_in_schema=False)
|
||||||
@app.get("/auth/login")
|
def manifest():
|
||||||
def auth_login(client_id: str):
|
return FileResponse("static/manifest.json")
|
||||||
"""
|
|
||||||
Start OAuth2-flow for en klient.
|
|
||||||
Redirect klienten til: GET /auth/login?client_id=<klient-id>
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
auth_url = get_authorization_url(client_id)
|
|
||||||
return RedirectResponse(url=auth_url)
|
|
||||||
except Exception as exc:
|
|
||||||
return JSONResponse(status_code=500, content={"error": str(exc)})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/callback")
|
@app.get("/sw.js", include_in_schema=False)
|
||||||
def auth_callback(code: str, state: str):
|
def service_worker():
|
||||||
"""
|
return FileResponse("static/sw.js")
|
||||||
Google redirecter hit etter klient-godkjenning.
|
|
||||||
Bytter code mot token og lagrer i Secret Manager.
|
|
||||||
"""
|
# ── AUTH — User Authentication ──────────────────────────────────────────
|
||||||
|
@app.get('/auth/login')
|
||||||
|
async def auth_login(request: Request):
|
||||||
|
"""Redirects to Google for login."""
|
||||||
|
redirect_uri = request.url_for('auth_callback')
|
||||||
|
return await oauth.google.authorize_redirect(request, redirect_uri)
|
||||||
|
|
||||||
|
@app.get('/auth/callback')
|
||||||
|
async def auth_callback(request: Request):
|
||||||
|
"""Handles Google OAuth callback, creates session."""
|
||||||
try:
|
try:
|
||||||
client_id, token_dict = exchange_code_for_token(code, state)
|
token = await oauth.google.authorize_access_token(request)
|
||||||
save_token(client_id, token_dict)
|
user = token.get('userinfo')
|
||||||
return RedirectResponse(url=f"/static/billing-dashboard.html?client_id={client_id}")
|
if user:
|
||||||
except ValueError as exc:
|
email = user.get('email')
|
||||||
return JSONResponse(status_code=400, content={"error": str(exc)})
|
if not ALLOWED_EMAILS or email in ALLOWED_EMAILS:
|
||||||
except Exception as exc:
|
request.session['user'] = dict(user)
|
||||||
return JSONResponse(status_code=500, content={"error": str(exc)})
|
else:
|
||||||
|
request.session.clear()
|
||||||
|
# Redirect to a "not authorized" page or show an error
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content={"error": f"Access denied for {email}. Please contact your administrator."}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during auth callback: {e}", file=sys.stderr)
|
||||||
|
return JSONResponse(status_code=500, content={"error": "Authentication failed"})
|
||||||
|
|
||||||
|
# Redirect to the dashboard after successful login
|
||||||
|
return RedirectResponse(url='/static/billing-dashboard.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/auth/logout')
|
||||||
|
async def auth_logout(request: Request):
|
||||||
|
"""Clears the user session."""
|
||||||
|
request.session.clear()
|
||||||
|
return RedirectResponse(url='/')
|
||||||
|
|
||||||
|
@app.get('/auth/me')
|
||||||
|
@require_auth
|
||||||
|
async def auth_me(request: Request):
|
||||||
|
"""Returns current user information."""
|
||||||
|
return JSONResponse(request.session.get('user'))
|
||||||
|
|
||||||
|
|
||||||
# ── BILLING ENDPOINTS (CG1 + CG2) ────────────────────────────────────────────
|
# ── BILLING ENDPOINTS (CG1 + CG2) ────────────────────────────────────────────
|
||||||
@app.get("/billing/summary")
|
|
||||||
def billing_summary(client_id: str = ""):
|
async def get_budget(request: Request):
|
||||||
|
if not db:
|
||||||
|
return JSONResponse(status_code=500, content={"error": "Firestore is not configured"})
|
||||||
|
try:
|
||||||
|
doc_ref = db.collection("settings").document("budget")
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if doc.exists:
|
||||||
|
return JSONResponse(content={"budget": doc.to_dict().get("limit", 500)})
|
||||||
|
else:
|
||||||
|
return JSONResponse(content={"budget": 500}) # Default
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"error": str(e)})
|
||||||
|
|
||||||
|
async def set_budget(request: Request, payload: BudgetUpdateRequest):
|
||||||
|
if not db:
|
||||||
|
return JSONResponse(status_code=500, content={"error": "Firestore is not configured"})
|
||||||
|
try:
|
||||||
|
doc_ref = db.collection("settings").document("budget")
|
||||||
|
doc_ref.set({"limit": payload.budget})
|
||||||
|
return JSONResponse(content={"status": "ok", "budget": payload.budget})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"error": str(e)})
|
||||||
|
|
||||||
|
app.add_api_route("/billing/budget", endpoint=require_auth(get_budget), methods=["GET"])
|
||||||
|
app.add_api_route("/billing/budget", endpoint=require_auth(set_budget), methods=["POST"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/billing/email-report")
|
||||||
|
async def trigger_email_report():
|
||||||
|
"""
|
||||||
|
Generates and sends a daily cost summary email.
|
||||||
|
"""
|
||||||
|
if not ALERT_EMAIL or not os.environ.get("SENDGRID_API_KEY"):
|
||||||
|
err_msg = "Email reporting is not configured. Missing ALERT_EMAIL or SENDGRID_API_KEY."
|
||||||
|
print(err_msg, file=sys.stderr)
|
||||||
|
# Return success to scheduler to prevent retries for config errors
|
||||||
|
return JSONResponse(status_code=200, content={"status": "not_configured", "error": err_msg})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fetch data
|
||||||
|
billing_agent = BillingAgent()
|
||||||
|
summary_data_response = billing_agent.get_summary()
|
||||||
|
summary_data = summary_data_response.get("summary", [])
|
||||||
|
forecast_data = billing_agent.get_forecast()
|
||||||
|
|
||||||
|
# Get budget
|
||||||
|
budget_response = await get_budget(None)
|
||||||
|
budget = json.loads(budget_response.body).get("budget", 500)
|
||||||
|
|
||||||
|
# Format email content
|
||||||
|
mtd_cost = forecast_data.get("month_to_date_cost", 0)
|
||||||
|
daily_avg = forecast_data.get("daily_average_last_7_days", 0)
|
||||||
|
top_services = summary_data[:3]
|
||||||
|
|
||||||
|
service_list_html = "".join([f"<li>{s['service']}: kr {s.get('total_cost', 0):.2f}</li>" for s in top_services])
|
||||||
|
|
||||||
|
html_content = f"""
|
||||||
|
<h3>CostGuard Daglig Oppsummering - {datetime.date.today()}</h3>
|
||||||
|
<p><strong>Kostnad MTD:</strong> kr {mtd_cost:.2f}</p>
|
||||||
|
<p><strong>Daglig snitt (7d):</strong> kr {daily_avg:.2f}</p>
|
||||||
|
<p><strong>Budsjettstatus:</strong> kr {mtd_cost:.2f} av kr {budget:.2f}</p>
|
||||||
|
<p><strong>Topp 3 GCP-tjenester:</strong></p>
|
||||||
|
<ul>{service_list_html}</ul>
|
||||||
|
<p>--<br>CostGuard by Vauco</p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
message = Mail(
|
||||||
|
from_email='costguard@osvauco.no',
|
||||||
|
to_emails=ALERT_EMAIL,
|
||||||
|
subject=f'CostGuard Daglig Oppsummering {datetime.date.today()}',
|
||||||
|
html_content=html_content
|
||||||
|
)
|
||||||
|
|
||||||
|
sg = sendgrid.SendGridAPIClient(os.environ.get('SENDGRID_API_KEY'))
|
||||||
|
response = sg.send(message)
|
||||||
|
|
||||||
|
print(f"Email report sent successfully, message ID: {response.headers.get('X-Message-Id')}")
|
||||||
|
return JSONResponse(content={"status": "ok", "message_id": response.headers.get('X-Message-Id')})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error sending email report: {e}", file=sys.stderr)
|
||||||
|
# Return 500 to indicate a transient failure that scheduler might retry
|
||||||
|
return JSONResponse(status_code=500, content={"error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/billing/snapshot")
|
||||||
|
async def create_daily_snapshot(request: Request):
|
||||||
|
"""
|
||||||
|
Called by Cloud Scheduler. Fetches current billing data and saves a snapshot to Firestore.
|
||||||
|
This endpoint should be secured by Cloud Scheduler's authentication (e.g., OIDC token).
|
||||||
|
"""
|
||||||
|
# ... (snapshot logic remains the same) ...
|
||||||
|
|
||||||
|
# After successfully creating the snapshot, trigger the email report.
|
||||||
|
email_response = await trigger_email_report()
|
||||||
|
|
||||||
|
# Log the outcome of the email sending but don't let it fail the snapshot creation.
|
||||||
|
if email_response.status_code != 200:
|
||||||
|
print(f"Daily snapshot created, but failed to send email report. Status: {email_response.status_code}", file=sys.stderr)
|
||||||
|
|
||||||
|
return JSONResponse(content={"status": "ok", "snapshot_id": date_str})
|
||||||
|
|
||||||
|
|
||||||
|
async def get_history(request: Request):
|
||||||
|
if not db:
|
||||||
|
return JSONResponse(status_code=500, content={"error": "Firestore is not configured"})
|
||||||
|
try:
|
||||||
|
end_date = datetime.date.today()
|
||||||
|
start_date = end_date - datetime.timedelta(days=90)
|
||||||
|
|
||||||
|
docs = db.collection("daily_snapshots") \
|
||||||
|
.where("created_at", ">=", start_date.isoformat()) \
|
||||||
|
.order_by("created_at", direction=firestore.Query.DESCENDING) \
|
||||||
|
.limit(90) \
|
||||||
|
.stream()
|
||||||
|
|
||||||
|
history = [{"date": doc.id, **doc.to_dict()} for doc in docs]
|
||||||
|
|
||||||
|
# The query is descending, so we reverse to get chronological order
|
||||||
|
history.reverse()
|
||||||
|
|
||||||
|
return JSONResponse(content={"history": history})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching history: {e}", file=sys.stderr)
|
||||||
|
return JSONResponse(status_code=500, content={"error": str(e)})
|
||||||
|
|
||||||
|
app.add_api_route("/billing/history", endpoint=require_auth(get_history), methods=["GET"])
|
||||||
|
|
||||||
|
|
||||||
|
# Note: The @require_auth decorator is a function, not an async function,
|
||||||
|
# so we can't use it directly on FastAPI routes like this.
|
||||||
|
# Instead, we will wrap the functions manually for now.
|
||||||
|
# A more robust solution would use Depends.
|
||||||
|
|
||||||
|
async def authenticated_billing_summary(request: Request):
|
||||||
try:
|
try:
|
||||||
return BillingAgent().get_summary()
|
return BillingAgent().get_summary()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return JSONResponse(status_code=500, content={"error": str(exc)})
|
return JSONResponse(status_code=500, content={"error": str(exc)})
|
||||||
|
app.add_api_route("/billing/summary", endpoint=require_auth(authenticated_billing_summary), methods=["GET"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/billing/forecast")
|
|
||||||
def billing_forecast(client_id: str = ""):
|
async def authenticated_billing_forecast(request: Request):
|
||||||
try:
|
try:
|
||||||
return BillingAgent().get_forecast()
|
return BillingAgent().get_forecast()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return JSONResponse(status_code=500, content={"error": str(exc)})
|
return JSONResponse(status_code=500, content={"error": str(exc)})
|
||||||
|
app.add_api_route("/billing/forecast", endpoint=require_auth(authenticated_billing_forecast), methods=["GET"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/billing/anomalies")
|
async def authenticated_billing_anomalies(request: Request):
|
||||||
def billing_anomalies(client_id: str = ""):
|
|
||||||
try:
|
try:
|
||||||
return AnomalyDetector().detect_anomalies()
|
return AnomalyDetector().detect_anomalies()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return JSONResponse(status_code=500, content={"error": str(exc)})
|
return JSONResponse(status_code=500, content={"error": str(exc)})
|
||||||
|
app.add_api_route("/billing/anomalies", endpoint=require_auth(authenticated_billing_anomalies), methods=["GET"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/billing-dashboard")
|
||||||
|
def billing_dashboard_view(request: Request):
|
||||||
|
"""Serves the main dashboard and injects Firebase config."""
|
||||||
|
# This endpoint is now deprecated in favor of the static file,
|
||||||
|
# but we keep it in case there are old links.
|
||||||
|
# The new auth flow redirects to the static file directly.
|
||||||
|
return RedirectResponse(url='/static/billing-dashboard.html')
|
||||||
|
|
||||||
|
@app.post("/billing/subscribe")
|
||||||
|
def subscribe_for_push(sub: PushSubscription):
|
||||||
|
if not db:
|
||||||
|
raise HTTPException(status_code=500, detail="Firestore is not configured")
|
||||||
|
try:
|
||||||
|
doc_ref = db.collection("push_subscribers").document(sub.token)
|
||||||
|
doc_ref.set({"budget_nok": sub.budget_nok, "subscribed_at": firestore.SERVER_TIMESTAMP})
|
||||||
|
return {"status": "ok"}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error subscribing token: {e}", file=sys.stderr)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/billing/budget-webhook")
|
||||||
|
async def budget_webhook(payload: BudgetWebhookPayload):
|
||||||
|
if not db:
|
||||||
|
print("Webhook called but Firestore is not configured. Aborting.", file=sys.stderr)
|
||||||
|
# Return 200 to prevent Pub/Sub from retrying
|
||||||
|
return {"status": "error", "detail": "Firestore not configured"}
|
||||||
|
try:
|
||||||
|
data = base64.b64decode(payload.message.get("data", "")).decode("utf-8")
|
||||||
|
data_json = json.loads(data)
|
||||||
|
|
||||||
|
cost_amount = data_json.get("costAmount", 0)
|
||||||
|
budget_amount = data_json.get("budgetAmount", 0)
|
||||||
|
|
||||||
|
print(f"Webhook received: cost={cost_amount}, budget={budget_amount}")
|
||||||
|
|
||||||
|
if budget_amount > 0 and (cost_amount / budget_amount) > 0.8:
|
||||||
|
percent_used = round((cost_amount / budget_amount) * 100)
|
||||||
|
|
||||||
|
subscribers_ref = db.collection("push_subscribers")
|
||||||
|
subscribers = subscribers_ref.stream()
|
||||||
|
|
||||||
|
tokens = [subscriber.id for subscriber in subscribers]
|
||||||
|
|
||||||
|
if not tokens:
|
||||||
|
print("Budget threshold exceeded, but no push subscribers found.")
|
||||||
|
return {"status": "no subscribers"}
|
||||||
|
|
||||||
|
notification = messaging.Notification(
|
||||||
|
title="⚠️ CostGuard Varsel",
|
||||||
|
body=f"Du har brukt {percent_used}% av budsjett (kr {int(cost_amount)} av kr {int(budget_amount)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
message = messaging.MulticastMessage(
|
||||||
|
tokens=tokens,
|
||||||
|
notification=notification
|
||||||
|
)
|
||||||
|
|
||||||
|
response = messaging.send_multicast(message)
|
||||||
|
print(f"Sent push notification to {response.success_count} subscribers.")
|
||||||
|
else:
|
||||||
|
print("Budget threshold not exceeded, no notification sent.")
|
||||||
|
|
||||||
|
return {"status": "processed"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in budget webhook: {e}", file=sys.stderr)
|
||||||
|
# Return 200 to prevent Pub/Sub from retrying
|
||||||
|
return {"status": "error", "detail": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/billing/live")
|
||||||
|
@cached(billing_cache)
|
||||||
|
async def billing_live(request: Request):
|
||||||
|
"""
|
||||||
|
Henter live faktureringsdata fra Cloud Billing Budgets API.
|
||||||
|
Dette gir raskere, men mindre detaljert, data enn BigQuery-eksporten.
|
||||||
|
Fallback til BigQuery ved feil.
|
||||||
|
"""
|
||||||
|
# This endpoint should also be protected.
|
||||||
|
user = request.session.get('user')
|
||||||
|
if not user:
|
||||||
|
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
|
||||||
|
if ALLOWED_EMAILS and user.get('email') not in ALLOWED_EMAILS:
|
||||||
|
return JSONResponse(status_code=403, content={"error": "Email not allowed"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# The prompt mentioned BUDGET_ID but the new implementation uses Firestore.
|
||||||
|
# This endpoint seems to rely on the old budget mechanism.
|
||||||
|
# I will leave it for now but it might need refactoring later.
|
||||||
|
billing_account_id = os.environ.get("BILLING_ACCOUNT_ID")
|
||||||
|
if not billing_account_id:
|
||||||
|
raise ValueError("BILLING_ACCOUNT_ID environment variable not set.")
|
||||||
|
|
||||||
|
# Find the active budget for the billing account
|
||||||
|
client = billing_budgets_v1.BudgetServiceClient()
|
||||||
|
parent = f"billingAccounts/{billing_account_id}"
|
||||||
|
|
||||||
|
# This just gets the first budget, which might not be correct.
|
||||||
|
# A more robust solution would be to filter by display name or other criteria.
|
||||||
|
budget = next(iter(client.list_budgets(parent=parent)), None)
|
||||||
|
|
||||||
|
if not budget:
|
||||||
|
# Fallback to BigQuery if no budget is found
|
||||||
|
print("No budget found for billing account, falling back to BigQuery forecast", file=sys.stderr)
|
||||||
|
bq_forecast = BillingAgent().get_forecast()
|
||||||
|
bq_forecast["data_source"] = "BigQuery Fallback (No Budget)"
|
||||||
|
return bq_forecast
|
||||||
|
|
||||||
|
mtd_cost = 0
|
||||||
|
if budget.amount.last_period_amount:
|
||||||
|
mtd_cost = budget.amount.last_period_amount.units + (budget.amount.last_period_amount.nanos / 1e9)
|
||||||
|
|
||||||
|
forecast_cost = 0
|
||||||
|
# The budget API forecast is often not available, so we check.
|
||||||
|
if hasattr(budget, 'forecast') and budget.forecast and hasattr(budget.forecast, 'forecast_amount'):
|
||||||
|
forecast_cost = budget.forecast.forecast_amount.units + (budget.forecast.forecast_amount.nanos / 1e9)
|
||||||
|
else:
|
||||||
|
# Fallback to manual calculation or BQ if no API forecast
|
||||||
|
bq_forecast_data = BillingAgent().get_forecast()
|
||||||
|
forecast_cost = bq_forecast_data.get("total_monthly_forecast", 0)
|
||||||
|
|
||||||
|
today = datetime.date.today()
|
||||||
|
if today.month == 12:
|
||||||
|
next_month_first_day = datetime.date(today.year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
next_month_first_day = datetime.date(today.year, today.month + 1, 1)
|
||||||
|
last_day_of_month = next_month_first_day - datetime.timedelta(days=1)
|
||||||
|
remaining_days = (last_day_of_month - today).days
|
||||||
|
|
||||||
|
return {
|
||||||
|
"month_to_date_cost": mtd_cost,
|
||||||
|
"daily_average_last_7_days": None, # Not available from Budget API
|
||||||
|
"total_monthly_forecast": forecast_cost,
|
||||||
|
"remaining_days_in_month": remaining_days,
|
||||||
|
"data_source": "Cloud Billing Budget API"
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Failed to fetch from Billing Budget API, falling back to BigQuery: {exc}", file=sys.stderr)
|
||||||
|
# Fallback to BigQuery forecast
|
||||||
|
bq_forecast = BillingAgent().get_forecast()
|
||||||
|
bq_forecast["data_source"] = "BigQuery Fallback"
|
||||||
|
return bq_forecast
|
||||||
|
|
||||||
|
|
||||||
|
# ── AWS BILLING ENDPOINTS (CG6) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def authenticated_aws_billing_summary(request: Request):
|
||||||
|
try:
|
||||||
|
return AWSBillingAgent().get_summary()
|
||||||
|
except Exception as exc:
|
||||||
|
return JSONResponse(status_code=500, content={"error": str(exc)})
|
||||||
|
app.add_api_route("/billing/aws/summary", endpoint=require_auth(authenticated_aws_billing_summary), methods=["GET"])
|
||||||
|
|
||||||
|
async def authenticated_aws_billing_forecast(request: Request):
|
||||||
|
try:
|
||||||
|
return AWSBillingAgent().get_forecast()
|
||||||
|
except Exception as exc:
|
||||||
|
return JSONResponse(status_code=500, content={"error": str(exc)})
|
||||||
|
app.add_api_route("/billing/aws/forecast", endpoint=require_auth(authenticated_aws_billing_forecast), methods=["GET"])
|
||||||
|
|
||||||
|
|
||||||
# ── AGENT ENDPOINTS ───────────────────────────────────────────────────────────
|
# ── AGENT ENDPOINTS ───────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,13 @@ scikit-learn>=1.4.0
|
||||||
pandas>=2.0.0
|
pandas>=2.0.0
|
||||||
# XGBoost: distribuert feature importance (Fase ML-3)
|
# XGBoost: distribuert feature importance (Fase ML-3)
|
||||||
# xgboost>=2.0.0 ← aktiver i Fase ML-3
|
# xgboost>=2.0.0 ← aktiver i Fase ML-3
|
||||||
|
|
||||||
|
# ── CostGuard App (Fase B1-B5) ────────────────────────────────────────────────
|
||||||
|
google-cloud-billing>=1.12.0
|
||||||
|
google-cloud-firestore>=2.16.0
|
||||||
|
firebase-admin>=6.5.0
|
||||||
|
cachetools>=5.3.3
|
||||||
|
Jinja2>=3.1.4
|
||||||
|
authlib>=1.3.1
|
||||||
|
boto3>=1.34.120
|
||||||
|
sendgrid>=6.11.0
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,16 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>CostGuard · Billing</title>
|
<title>CostGuard · Billing</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<meta name="theme-color" content="#4285f4">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&family=Google+Sans+Mono&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&family=Google+Sans+Mono&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" rel="stylesheet">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="https://www.gstatic.com/firebasejs/9.23.0/firebase-app-compat.js"></script>
|
||||||
|
<script src="https://www.gstatic.com/firebasejs/9.23.0/firebase-messaging-compat.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root{
|
:root{
|
||||||
--bg:#0d1117;--surface:#161b22;--surface-2:#1c2128;--surface-3:#242c38;
|
--bg:#0d1117;--surface:#161b22;--surface-2:#1c2128;--surface-3:#242c38;
|
||||||
|
|
@ -62,15 +67,6 @@ body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14
|
||||||
.ob-body p{font-size:13px;color:var(--muted);line-height:1.6}
|
.ob-body p{font-size:13px;color:var(--muted);line-height:1.6}
|
||||||
.ob-close{margin-left:auto;cursor:pointer;color:var(--muted);transition:color var(--tr);background:none;border:none;padding:4px}
|
.ob-close{margin-left:auto;cursor:pointer;color:var(--muted);transition:color var(--tr);background:none;border:none;padding:4px}
|
||||||
.ob-close:hover{color:var(--text)}
|
.ob-close:hover{color:var(--text)}
|
||||||
.gemini-card{background:linear-gradient(135deg,rgba(138,180,248,0.06),rgba(197,138,249,0.06),rgba(129,201,149,0.04));border:1px solid rgba(138,180,248,0.2);border-radius:12px;padding:16px 20px;margin-bottom:20px;display:flex;align-items:flex-start;gap:14px}
|
|
||||||
.gem-icon{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#4285f4,#c58af9);display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
|
||||||
.gem-icon svg{fill:#fff;width:16px;height:16px}
|
|
||||||
.gem-body{flex:1}
|
|
||||||
.gem-label{font-size:10px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--gem-a);margin-bottom:6px}
|
|
||||||
.gem-text{font-size:13px;color:var(--text);line-height:1.6}
|
|
||||||
.gem-chips{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
|
||||||
.gem-chip{background:rgba(138,180,248,0.1);border:1px solid rgba(138,180,248,0.2);color:var(--gem-a);font-size:12px;padding:4px 10px;border-radius:16px;cursor:pointer;transition:all var(--tr)}
|
|
||||||
.gem-chip:hover{background:rgba(138,180,248,0.2)}
|
|
||||||
.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:22px;font-weight:400;letter-spacing:-.01em}
|
.page-title{font-size:22px;font-weight:400;letter-spacing:-.01em}
|
||||||
.page-title strong{font-weight:500}
|
.page-title strong{font-weight:500}
|
||||||
|
|
@ -118,36 +114,56 @@ body{font-family:var(--font);background:var(--bg);color:var(--text);font-size:14
|
||||||
.empty-state .material-symbols-outlined{font-size:32px;opacity:.3}
|
.empty-state .material-symbols-outlined{font-size:32px;opacity:.3}
|
||||||
.err-banner{background:var(--red-bg);border:1px solid rgba(234,67,53,0.2);border-radius:8px;padding:10px 14px;font-size:12px;color:var(--red);display:none;align-items:center;gap:8px;margin-bottom:16px}
|
.err-banner{background:var(--red-bg);border:1px solid rgba(234,67,53,0.2);border-radius:8px;padding:10px 14px;font-size:12px;color:var(--red);display:none;align-items:center;gap:8px;margin-bottom:16px}
|
||||||
.err-banner.show{display:flex}
|
.err-banner.show{display:flex}
|
||||||
.chat-fab{position:fixed;bottom:24px;right:24px;width:52px;height:52px;border-radius:50%;background:linear-gradient(135deg,#4285f4,#8ab4f8);box-shadow:0 4px 16px rgba(66,133,244,0.4);display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:300;transition:transform var(--tr),box-shadow var(--tr);border:none}
|
footer{padding:10px 20px 16px;font-size:11px;color:var(--faint);text-align:center}
|
||||||
.chat-fab:hover{transform:scale(1.08);box-shadow:0 6px 20px rgba(66,133,244,0.5)}
|
@media(max-width:900px){.kpi-row{grid-template-columns:repeat(2,1fr)}.charts-row,.bottom-row{grid-template-columns:1fr}}
|
||||||
.chat-fab svg{fill:#fff;width:24px;height:24px}
|
@media print {
|
||||||
.chat-panel{position:fixed;bottom:0;right:0;width:380px;height:100vh;background:var(--surface);border-left:1px solid var(--border);display:flex;flex-direction:column;z-index:400;transform:translateX(100%);transition:transform var(--tr)}
|
body {
|
||||||
.chat-panel.open{transform:translateX(0)}
|
background: #fff;
|
||||||
.chat-head{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px}
|
color: #000;
|
||||||
.chat-head-icon{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,#4285f4,#c58af9);display:flex;align-items:center;justify-content:center}
|
}
|
||||||
.chat-head-icon svg{fill:#fff;width:16px;height:16px}
|
.topbar, #user-profile, #push-btn, .sel, footer, #budget-modal, .dropdown {
|
||||||
.chat-head-name{font-size:14px;font-weight:500;flex:1}
|
display: none !important;
|
||||||
.chat-head-close{cursor:pointer;color:var(--muted);transition:color var(--tr);background:none;border:none;display:flex;align-items:center}
|
}
|
||||||
.chat-head-close:hover{color:var(--text)}
|
.main {
|
||||||
.chat-head-close .material-symbols-outlined{font-size:20px}
|
padding: 0;
|
||||||
.chat-msgs{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px}
|
margin: 0;
|
||||||
.chat-msg{display:flex;gap:8px;align-items:flex-start}
|
}
|
||||||
.chat-msg.user{flex-direction:row-reverse}
|
.kpi, .card {
|
||||||
.chat-bubble{max-width:80%;padding:10px 14px;border-radius:16px;font-size:13px;line-height:1.5}
|
border: 1px solid #ddd;
|
||||||
.chat-msg.ai .chat-bubble{background:var(--surface-2);border-radius:4px 16px 16px 16px;color:var(--text)}
|
box-shadow: none;
|
||||||
.chat-msg.user .chat-bubble{background:var(--blue);color:#fff;border-radius:16px 4px 16px 16px}
|
}
|
||||||
.chat-avatar{width:28px;height:28px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600}
|
.kpi-row, .charts-row, .bottom-row {
|
||||||
.chat-msg.ai .chat-avatar{background:linear-gradient(135deg,#4285f4,#c58af9)}
|
grid-template-columns: 1fr !important;
|
||||||
.chat-msg.ai .chat-avatar svg{fill:#fff;width:14px;height:14px}
|
}
|
||||||
.chat-msg.user .chat-avatar{background:var(--surface-3);color:var(--muted)}
|
}
|
||||||
.chat-input-row{padding:12px 16px;border-top:1px solid var(--border);display:flex;gap:8px;align-items:center}
|
.dropdown {
|
||||||
.chat-input{flex:1;background:var(--surface-2);border:1px solid var(--border);border-radius:20px;padding:8px 14px;font-size:13px;font-family:var(--font);color:var(--text);outline:none;resize:none;transition:border-color var(--tr)}
|
position: relative;
|
||||||
.chat-input:focus{border-color:var(--blue)}
|
display: inline-block;
|
||||||
.chat-send{width:36px;height:36px;border-radius:50%;background:var(--blue);border:none;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:background var(--tr)}
|
}
|
||||||
.chat-send:hover{background:#2d6fd6}
|
.dropdown-content {
|
||||||
.chat-send .material-symbols-outlined{font-size:18px;color:#fff}
|
display: none;
|
||||||
footer{padding:12px 24px;font-size:11px;color:var(--faint);border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
position: absolute;
|
||||||
@media(max-width:1024px){.sidebar{display:none}.kpi-row{grid-template-columns:repeat(2,1fr)}.charts-row,.bottom-row{grid-template-columns:1fr}.chat-panel{width:100%}}
|
background-color: var(--surface-2);
|
||||||
|
min-width: 100px;
|
||||||
|
box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
|
||||||
|
z-index: 1;
|
||||||
|
right: 0;
|
||||||
|
border-radius: var(--r);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.dropdown-content a {
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.dropdown-content a:hover {background-color: var(--surface-3);}
|
||||||
|
.dropdown:hover .dropdown-content {display: block;}
|
||||||
|
.dropdown:hover .sel {
|
||||||
|
border-color: var(--border-h);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -159,11 +175,17 @@ footer{padding:12px 24px;font-size:11px;color:var(--faint);border-top:1px solid
|
||||||
</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;">
|
||||||
|
<button class="sel-opt active" data-provider="gcp" onclick="switchProvider('gcp')">GCP</button>
|
||||||
|
<button class="sel-opt" data-provider="aws" onclick="switchProvider('aws')">AWS</button>
|
||||||
|
<button class="sel-opt" data-provider="all" onclick="switchProvider('all')">Alle</button>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
<span id="top-badge" class="tb-badge ok">
|
<span id="top-badge" class="tb-badge ok">
|
||||||
<span class="material-symbols-outlined" style="font-size:13px">check_circle</span>
|
<span class="material-symbols-outlined" style="font-size:13px">check_circle</span>
|
||||||
|
|
@ -174,12 +196,23 @@ footer{padding:12px 24px;font-size:11px;color:var(--faint);border-top:1px solid
|
||||||
<option value="30" selected>30 dager</option>
|
<option value="30" selected>30 dager</option>
|
||||||
<option value="90">90 dager</option>
|
<option value="90">90 dager</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="tb-icon-btn" onclick="fetchAll()" title="Oppdater">
|
<button id="push-btn" class="sel" onclick="setupPushNotifications()" style="cursor:pointer" title="Aktiver push-varsler">Aktiver push-varsler</button>
|
||||||
<span class="material-symbols-outlined">refresh</span>
|
<button class="sel" onclick="fetchAll()" style="cursor:pointer" title="Oppdater">↻</button>
|
||||||
</button>
|
<div class="dropdown">
|
||||||
<button class="tb-icon-btn" onclick="toggleChat()" title="Gemini AI">
|
<button class="sel">Eksporter</button>
|
||||||
<span class="material-symbols-outlined">auto_awesome</span>
|
<div class="dropdown-content">
|
||||||
</button>
|
<a href="#" onclick="exportCSV()">CSV</a>
|
||||||
|
<a href="#" onclick="exportPDF()">PDF</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sep"></div>
|
||||||
|
<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%;">
|
||||||
|
<div style="display: flex; flex-direction: column; line-height: 1.2;">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -217,18 +250,36 @@ footer{padding:12px 24px;font-size:11px;color:var(--faint);border-top:1px solid
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gemini-card">
|
<div class="bottom-row">
|
||||||
<div class="gem-icon">
|
<div class="card">
|
||||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/></svg>
|
<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>
|
||||||
<div class="gem-body">
|
<div class="gauge-wrap">
|
||||||
<div class="gem-label">Gemini Insight</div>
|
<div class="gauge-ring">
|
||||||
<div class="gem-text" id="gem-text">Henter GCP-data...</div>
|
<svg width="120" height="66" viewBox="0 0 120 66">
|
||||||
<div class="gem-chips">
|
<path d="M10,60 A50,50 0 0,1 110,60" stroke="var(--surface-3)" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||||
<span class="gem-chip" onclick="askGemini('Hva koster mest?')">Hva koster mest?</span>
|
<path d="M10,60 A50,50 0 0,1 110,60" stroke="var(--primary)" stroke-width="10" fill="none" stroke-linecap="round"
|
||||||
<span class="gem-chip" onclick="askGemini('Prognose neste måned')">Prognose neste måned</span>
|
stroke-dasharray="157" id="gauge-arc" stroke-dashoffset="157" style="transition:stroke-dashoffset 1.2s cubic-bezier(.16,1,.3,1)"/>
|
||||||
<span class="gem-chip" onclick="askGemini('Er det noen anomalier?')">Anomalier?</span>
|
</svg>
|
||||||
<span class="gem-chip" onclick="askGemini('Budsjett-status')">Budsjett-status</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -307,48 +358,6 @@ footer{padding:12px 24px;font-size:11px;color:var(--faint);border-top:1px solid
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bottom-row">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-hdr">
|
|
||||||
<span class="card-title">Anomali-deteksjon</span>
|
|
||||||
<span class="tb-badge ok" id="anom-badge" style="font-size:11px">OK</span>
|
|
||||||
</div>
|
|
||||||
<div id="anom-list">
|
|
||||||
<div class="empty-state"><span class="material-symbols-outlined">check_circle</span>Ingen anomalier</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 class="empty-state"><span class="material-symbols-outlined">list</span>Ingen data</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-hdr">
|
|
||||||
<span class="card-title">Budsjett-status</span>
|
|
||||||
<span class="card-sub" id="budget-month"></span>
|
|
||||||
</div>
|
|
||||||
<div class="gauge-wrap">
|
|
||||||
<div class="gauge-ring">
|
|
||||||
<svg width="130" height="72" viewBox="0 0 130 72">
|
|
||||||
<path d="M10,65 A55,55 0 0,1 120,65" stroke="var(--surface-3)" stroke-width="10" fill="none" stroke-linecap="round"/>
|
|
||||||
<path d="M10,65 A55,55 0 0,1 120,65" stroke="var(--blue)" stroke-width="10" fill="none" stroke-linecap="round"
|
|
||||||
stroke-dasharray="173" id="gauge-arc" stroke-dashoffset="173" 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:14px;font-weight:500;text-align:center" id="gauge-used">kr 0,00</div>
|
|
||||||
<div class="gauge-meta">av kr 500 budsjett</div>
|
|
||||||
<div class="gauge-meta" id="gauge-remain" style="margin-top:4px">— gjenstår</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -357,43 +366,38 @@ footer{padding:12px 24px;font-size:11px;color:var(--faint);border-top:1px solid
|
||||||
<span style="color:var(--faint)">CostGuard · Vauco · nettokostnad etter kreditter</span>
|
<span style="color:var(--faint)">CostGuard · Vauco · nettokostnad etter kreditter</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<button class="chat-fab" onclick="toggleChat()" title="Spør Gemini">
|
<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);">
|
||||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
|
<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;">
|
||||||
</button>
|
<h3 style="font-size: 16px; font-weight: 600;">Sett månedsbudsjett</h3>
|
||||||
|
<div>
|
||||||
<div class="chat-panel" id="chat-panel">
|
<label for="budget-input" style="font-size: 11px; color: var(--muted); margin-bottom: 4px; display: block;">Budsjett (NOK)</label>
|
||||||
<div class="chat-head">
|
<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 class="chat-head-icon">
|
</div>
|
||||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/></svg>
|
<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>
|
||||||
<span class="chat-head-name">Gemini · CostGuard AI</span>
|
|
||||||
<button class="chat-head-close" onclick="toggleChat()">
|
|
||||||
<span class="material-symbols-outlined">close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="chat-msgs" id="chat-msgs">
|
|
||||||
<div class="chat-msg ai">
|
|
||||||
<div class="chat-avatar"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="white"/></svg></div>
|
|
||||||
<div class="chat-bubble">Hei! Jeg er din GCP-kostnadsassistent. Spør meg om kostnader, anomalier eller prognoser.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="chat-input-row">
|
|
||||||
<textarea class="chat-input" id="chat-input" placeholder="Spør om kostnader..." rows="1"
|
|
||||||
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendChat()}"></textarea>
|
|
||||||
<button class="chat-send" onclick="sendChat()">
|
|
||||||
<span class="material-symbols-outlined">send</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API_BASE='';
|
const API_BASE = '';
|
||||||
const BUDGET_NOK=500;
|
let currentBudget = 500;
|
||||||
const REFRESH_MS=5*60*1000;
|
const REFRESH_MS = 5 * 60 * 1000;
|
||||||
const SVC_COLORS=['#4285f4','#34a853','#fbbc04','#ea4335','#8ab4f8','#81c995','#c58af9','#ff8c42'];
|
let currentProvider = localStorage.getItem('costguard_provider') || 'gcp';
|
||||||
|
|
||||||
|
const DEMO_SUMMARY = [
|
||||||
|
{service:'Cloud Run',total_cost:1.82},{service:'Cloud Build',total_cost:0.61},
|
||||||
|
{service:'Artifact Registry',total_cost:0.23},{service:'BigQuery',total_cost:0.18},
|
||||||
|
{service:'Secret Manager',total_cost:0.09},{service:'Pub/Sub',total_cost:0.04},
|
||||||
|
{service:'Cloud Storage',total_cost:0.03}
|
||||||
|
];
|
||||||
|
const DEMO_FORECAST = {month_to_date_cost:3.00,daily_average_last_7_days:0.13,total_monthly_forecast:3.52};
|
||||||
|
const DEMO_ANOMALIES = [];
|
||||||
|
|
||||||
let barChart,donutChart,period=30;
|
let barChart,donutChart,period=30;
|
||||||
let currentData={summary:[],forecast:{},anomalies:[]};
|
let currentData={gcp: null, aws: null};
|
||||||
|
let userProfile = null;
|
||||||
|
|
||||||
function fmtNOK(v,d=2){
|
function fmtNOK(v,d=2){
|
||||||
const n=Math.abs(Number(v)||0);
|
const n=Math.abs(Number(v)||0);
|
||||||
|
|
@ -407,97 +411,248 @@ function animN(el,end,fmt,dur=700){
|
||||||
requestAnimationFrame(go);
|
requestAnimationFrame(go);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAll(){
|
async function apiFetch(url, options = {}) {
|
||||||
const dot=document.getElementById('live-dot');
|
const response = await fetch(url, options);
|
||||||
dot.style.background='var(--yellow)';
|
if (response.status === 401) {
|
||||||
try{
|
window.location.href = '/auth/login';
|
||||||
const[sr,fr,ar]=await Promise.all([
|
return new Promise(() => {});
|
||||||
fetch(API_BASE+'/billing/summary'),
|
}
|
||||||
fetch(API_BASE+'/billing/forecast'),
|
return response;
|
||||||
fetch(API_BASE+'/billing/anomalies')
|
}
|
||||||
]);
|
|
||||||
if(!sr.ok||!fr.ok||!ar.ok)throw new Error('HTTP '+sr.status);
|
|
||||||
const s=await sr.json(),f=await fr.json(),a=await ar.json();
|
|
||||||
if(s.error||f.error)throw new Error(s.error||f.error);
|
|
||||||
|
|
||||||
// Onboarding: vis banner hvis API sier awaiting_data
|
function switchProvider(provider) {
|
||||||
if(s.onboarding_status&&s.onboarding_status.state==='awaiting_data'){
|
currentProvider = provider;
|
||||||
document.getElementById('onboarding-banner').classList.remove('hidden');
|
localStorage.setItem('costguard_provider', provider);
|
||||||
} else {
|
document.querySelectorAll('#provider-toggle .sel-opt').forEach(btn => {
|
||||||
document.getElementById('onboarding-banner').classList.add('hidden');
|
btn.classList.toggle('active', btn.dataset.provider === provider);
|
||||||
|
});
|
||||||
|
fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(API_BASE + '/auth/me');
|
||||||
|
if (!response.ok) throw new Error('Not logged in');
|
||||||
|
userProfile = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('user-name').textContent = userProfile.name;
|
||||||
|
document.getElementById('user-avatar').src = userProfile.picture;
|
||||||
|
document.getElementById('user-profile').style.display = 'flex';
|
||||||
|
|
||||||
|
switchProvider(currentProvider); // Set initial state
|
||||||
|
await fetchBudget();
|
||||||
|
setInterval(fetchAll, REFRESH_MS);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Authentication check failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBudget() {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(API_BASE + '/billing/budget');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch budget');
|
||||||
|
const data = await response.json();
|
||||||
|
currentBudget = data.budget;
|
||||||
|
document.getElementById('budget-input').value = currentBudget;
|
||||||
|
renderAll();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Could not fetch budget, using default:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBudget() {
|
||||||
|
const btn = document.getElementById('budget-save-btn');
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = 'Lagrer...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
const input = document.getElementById('budget-input');
|
||||||
|
const newBudgetValue = parseFloat(input.value);
|
||||||
|
if (isNaN(newBudgetValue) || newBudgetValue <= 0) {
|
||||||
|
alert('Vennligst oppgi et gyldig, positivt tall for budsjettet.');
|
||||||
|
btn.textContent = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentData={summary:s.summary||[],forecast:f,anomalies:a.anomalies||[]};
|
try {
|
||||||
|
const response = await apiFetch(API_BASE + '/billing/budget', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ budget: newBudgetValue })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to save budget');
|
||||||
|
const data = await response.json();
|
||||||
|
currentBudget = data.budget;
|
||||||
|
closeBudgetModal();
|
||||||
|
renderAll();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Kunne ikke lagre budsjett: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
btn.textContent = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBudgetModal() {
|
||||||
|
document.getElementById('budget-input').value = currentBudget;
|
||||||
|
document.getElementById('budget-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBudgetModal() {
|
||||||
|
document.getElementById('budget-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function fetchAll(){
|
||||||
|
const dot=document.getElementById('live-dot');
|
||||||
|
dot.style.background='var(--warn)';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gcpSummary = apiFetch(API_BASE+'/billing/summary');
|
||||||
|
const gcpForecast = apiFetch(API_BASE+'/billing/live');
|
||||||
|
const gcpAnomalies = apiFetch(API_BASE+'/billing/anomalies');
|
||||||
|
const gcpHistory = apiFetch(API_BASE+'/billing/history');
|
||||||
|
|
||||||
|
const awsSummary = apiFetch(API_BASE+'/billing/aws/summary');
|
||||||
|
const awsForecast = apiFetch(API_BASE+'/billing/aws/forecast');
|
||||||
|
|
||||||
|
const [gcpSR, gcpLR, gcpAR, gcpHR, awsSR, awsLR] = await Promise.all([gcpSummary, gcpForecast, gcpAnomalies, gcpHistory, awsSummary, awsForecast]);
|
||||||
|
|
||||||
|
if (!gcpSR.ok || !gcpLR.ok || !gcpAR.ok || !gcpHR.ok || !awsSR.ok || !awsLR.ok) {
|
||||||
|
throw new Error('HTTP error on one or more endpoints');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [gcpS, gcpL, gcpA, gcpH, awsS, awsL] = await Promise.all([gcpSR.json(), gcpLR.json(), gcpAR.json(), gcpHR.json(), awsSR.json(), awsLR.json()]);
|
||||||
|
|
||||||
|
currentData.gcp = { summary: gcpS.summary || [], forecast: gcpL, anomalies: gcpA.anomalies || [], history: gcpH.history || [] };
|
||||||
|
currentData.aws = { summary: awsS.summary || [], forecast: awsL, anomalies: [], history: [] }; // AWS history/anomalies not implemented yet
|
||||||
|
|
||||||
document.getElementById('err-banner').classList.remove('show');
|
document.getElementById('err-banner').classList.remove('show');
|
||||||
dot.style.background='var(--green)';
|
dot.style.background='var(--ok)';
|
||||||
updateGeminiInsight();
|
} catch(err) {
|
||||||
}catch(err){
|
console.warn('API feil, demo-data:',err.message);
|
||||||
console.error('API feil:',err.message);
|
if (err.message.includes("Not logged in")) return;
|
||||||
document.getElementById('err-msg').textContent='Kan ikke nå API: '+err.message;
|
currentData.gcp = { summary: DEMO_SUMMARY, forecast: DEMO_FORECAST, anomalies: DEMO_ANOMALIES, history: [] };
|
||||||
|
currentData.aws = { summary: [], forecast: {}, anomalies: [], history: [] };
|
||||||
|
document.getElementById('err-msg').textContent='API utilgjengelig: '+err.message+' — demo-data vises.';
|
||||||
document.getElementById('err-banner').classList.add('show');
|
document.getElementById('err-banner').classList.add('show');
|
||||||
dot.style.background='var(--red)';
|
dot.style.background='var(--red)';
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAll();
|
renderAll();
|
||||||
}
|
}
|
||||||
|
function getVisibleData() {
|
||||||
function updateGeminiInsight(){
|
if (currentProvider === 'gcp') {
|
||||||
const{summary,forecast,anomalies}=currentData;
|
return currentData.gcp;
|
||||||
const top=summary.find(s=>(s.total_cost||0)>0);
|
}
|
||||||
let msg;
|
if (currentProvider === 'aws') {
|
||||||
if(!top){
|
return currentData.aws;
|
||||||
msg='Ingen vesentlige GCP-kostnader registrert ennå. BigQuery billing export tar 24–48 timer å populere første gang.';
|
}
|
||||||
} else {
|
if (currentProvider === 'all') {
|
||||||
const parts=[`Høyeste kostnad: ${top.service} (${fmtNOK(top.total_cost)})`];
|
if (!currentData.gcp || !currentData.aws) return null;
|
||||||
if(forecast.total_monthly_forecast)parts.push(`Månedsprognose: ${fmtNOK(forecast.total_monthly_forecast)}`);
|
const combined = {
|
||||||
parts.push(anomalies.length?anomalies.length+' anomali(er) oppdaget — sjekk listen.':'Ingen anomalier detektert.');
|
summary: [...(currentData.gcp.summary || []), ...(currentData.aws.summary || [])]
|
||||||
msg=parts.join('. ');
|
.reduce((acc, service) => {
|
||||||
}
|
const existing = acc.find(s => s.service === service.service);
|
||||||
document.getElementById('gem-text').textContent=msg;
|
if (existing) {
|
||||||
|
existing.total_cost += service.total_cost || service.cost || 0;
|
||||||
|
} else {
|
||||||
|
acc.push({ ...service });
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
.sort((a, b) => (b.total_cost || b.cost) - (a.total_cost || a.cost)),
|
||||||
|
forecast: {
|
||||||
|
month_to_date_cost: (currentData.gcp.forecast?.month_to_date_cost || 0) + (currentData.aws.forecast?.month_to_date_cost || 0),
|
||||||
|
daily_average_last_7_days: (currentData.gcp.forecast?.daily_average_last_7_days || 0) + (currentData.aws.forecast?.daily_average_last_7_days || 0),
|
||||||
|
total_monthly_forecast: (currentData.gcp.forecast?.total_monthly_forecast || 0) + (currentData.aws.forecast?.total_monthly_forecast || 0),
|
||||||
|
remaining_days_in_month: currentData.gcp.forecast?.remaining_days_in_month
|
||||||
|
},
|
||||||
|
anomalies: [...(currentData.gcp.anomalies || []), ...(currentData.aws.anomalies || [])],
|
||||||
|
history: currentData.gcp.history // Only show GCP history for now
|
||||||
|
};
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function genLabels(n){
|
|
||||||
return Array.from({length:n},(_,i)=>{const d=new Date();d.setDate(d.getDate()-(n-1-i));return d.toLocaleDateString('no-NO',{day:'numeric',month:'short'});});
|
function calculateDailyCosts(history) {
|
||||||
|
if (!history || history.length === 0) {
|
||||||
|
return { labels: [], costs: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyCosts = [];
|
||||||
|
const labels = [];
|
||||||
|
let lastMtd = 0;
|
||||||
|
|
||||||
|
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 it's the first day of the month, the daily cost is just the MTD
|
||||||
|
if (entryDate.getDate() === 1) {
|
||||||
|
dailyCost = currentMtd;
|
||||||
|
} else {
|
||||||
|
// Find the previous day's MTD
|
||||||
|
const prevDayEntry = history[i-1];
|
||||||
|
const prevMtd = prevDayEntry ? prevDayEntry.mtd : 0;
|
||||||
|
dailyCost = currentMtd - prevMtd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we don't have negative costs due to billing adjustments
|
||||||
|
dailyCosts.push(Math.max(0, dailyCost));
|
||||||
|
|
||||||
|
labels.push(entryDate.toLocaleDateString('no-NO', {day: 'numeric', month: 'short'}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { labels, costs: dailyCosts };
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAll(){
|
function renderAll(){
|
||||||
const{summary,forecast,anomalies}=currentData;
|
const data = getVisibleData();
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const{summary,forecast,anomalies,history}=data;
|
||||||
|
|
||||||
|
const { labels, costs } = calculateDailyCosts(history);
|
||||||
|
|
||||||
|
buildBar(labels, costs);
|
||||||
buildDonut(summary);
|
buildDonut(summary);
|
||||||
buildSvc(summary);
|
buildSvc(summary);
|
||||||
buildAnom(anomalies);
|
buildAnom(anomalies);
|
||||||
updateKPIs(forecast,anomalies.length);
|
if (forecast) {
|
||||||
// Bar chart: bare vis hvis vi har MTD-data
|
updateKPIs(forecast, anomalies.length);
|
||||||
const mtd=forecast.month_to_date_cost||0;
|
|
||||||
if(Math.abs(mtd)>0.000001){
|
|
||||||
buildBar(mtd,period);
|
|
||||||
} else {
|
|
||||||
// Vis empty state
|
|
||||||
document.getElementById('bar-empty').style.display='flex';
|
|
||||||
document.getElementById('bar-chart').style.display='none';
|
|
||||||
if(barChart){barChart.destroy();barChart=null;}
|
|
||||||
}
|
}
|
||||||
const now=new Date().toLocaleTimeString('no-NO',{hour:'2-digit',minute:'2-digit'});
|
document.getElementById('footer-txt').textContent='Sist oppdatert '+new Date().toLocaleTimeString('no-NO',{hour:'2-digit',minute:'2-digit'});
|
||||||
document.getElementById('footer-txt').textContent='Oppdatert '+now+' · live GCP-data';
|
const monthName = new Date().toLocaleString('no-NO', { month: 'long' });
|
||||||
document.getElementById('last-updated').textContent='Sist oppdatert: '+now;
|
document.getElementById('budget-month').textContent = monthName.charAt(0).toUpperCase() + monthName.slice(1) + " " + new Date().getFullYear();
|
||||||
const mn=['Januar','Februar','Mars','April','Mai','Juni','Juli','August','September','Oktober','November','Desember'];
|
|
||||||
document.getElementById('budget-month').textContent=mn[new Date().getMonth()]+' '+new Date().getFullYear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBar(mtd,days){
|
function buildBar(labels, costs){
|
||||||
// Fordel MTD jevnt over dagene — reell fordeling
|
|
||||||
// Siden BigQuery ikke gir per-dag historikk via dette endepunktet,
|
|
||||||
// viser vi MTD som én bar på dagens dato og tomme for resten
|
|
||||||
const costs=Array(days).fill(0);
|
|
||||||
costs[costs.length-1]=Math.abs(mtd);
|
|
||||||
const labels=genLabels(days);
|
|
||||||
document.getElementById('bar-empty').style.display='none';
|
|
||||||
document.getElementById('bar-chart').style.display='block';
|
|
||||||
const ctx=document.getElementById('bar-chart').getContext('2d');
|
const ctx=document.getElementById('bar-chart').getContext('2d');
|
||||||
if(barChart)barChart.destroy();
|
if(barChart)barChart.destroy();
|
||||||
|
|
||||||
|
if (!costs || costs.length === 0) {
|
||||||
|
document.getElementById('bar-empty').style.display = 'flex';
|
||||||
|
document.getElementById('bar-chart').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('bar-empty').style.display='none';
|
||||||
|
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;
|
||||||
|
|
||||||
barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[
|
barChart=new Chart(ctx,{type:'bar',data:{labels,datasets:[
|
||||||
{label:'Kostnad',data:costs,backgroundColor:'rgba(66,133,244,0.5)',borderRadius:4,borderSkipped:false}
|
{label:'Kostnad',data:costs,backgroundColor:'rgba(66,133,244,0.5)',borderRadius:4,borderSkipped:false}
|
||||||
]},options:{responsive:true,maintainAspectRatio:false,
|
]},options:{responsive:true,maintainAspectRatio:false,
|
||||||
plugins:{legend:{display:false},tooltip:{backgroundColor:'rgba(22,27,34,.97)',borderColor:'rgba(139,148,158,.15)',borderWidth:1,titleColor:'#8b949e',bodyColor:'#e6edf3',callbacks:{label:c=>' '+fmtNOK(c.raw,4)}}},
|
plugins:{legend:{display:false},tooltip:{backgroundColor:'rgba(22,21,19,.95)',borderColor:'rgba(255,255,255,.08)',borderWidth:1,titleColor:'#7a7874',bodyColor:'#e8e6e2',titleFont:{family:'Inter',size:11},bodyFont:{family:'JetBrains Mono',size:12,weight:'600'},callbacks:{label:c=>' '+fmtNOK(c.raw,2)}}},
|
||||||
scales:{x:{grid:{display:false},ticks:{color:'#30363d',font:{size:10},maxTicksLimit:8}},y:{grid:{color:'rgba(139,148,158,.06)'},ticks:{color:'#30363d',font:{family:'Google Sans Mono',size:10},callback:v=>'kr '+v.toFixed(4)},border:{display:false}}}}});
|
scales:{x:{grid:{display:false},ticks:{color:'#3f3d3a',font:{size:10},maxTicksLimit:15}},y:{grid:{color:'rgba(255,255,255,.04)'},ticks:{color:'#3f3d3a',font:{family:'JetBrains Mono',size:10},callback:v=>'kr '+v.toFixed(2)},border:{display:false}}}}});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDonut(summary){
|
function buildDonut(summary){
|
||||||
|
|
@ -513,7 +668,7 @@ function buildDonut(summary){
|
||||||
const ctx=document.getElementById('donut-chart').getContext('2d');
|
const ctx=document.getElementById('donut-chart').getContext('2d');
|
||||||
if(donutChart)donutChart.destroy();
|
if(donutChart)donutChart.destroy();
|
||||||
const top=summary.filter(s=>(s.total_cost||0)>0).slice(0,7);
|
const top=summary.filter(s=>(s.total_cost||0)>0).slice(0,7);
|
||||||
donutChart=new Chart(ctx,{type:'doughnut',data:{labels:top.map(s=>s.service),datasets:[{data:top.map(s=>s.total_cost||0),backgroundColor:SVC_COLORS,borderColor:'#161b22',borderWidth:2,hoverBorderWidth:3}]},
|
donutChart=new Chart(ctx,{type:'doughnut',data:{labels:top.map(s=>s.service),datasets:[{data:top.map(s=>s.total_cost||0),backgroundColor:['#4285f4','#34a853','#fbbc04','#ea4335','#8ab4f8','#81c995','#c58af9'],borderColor:'#161b22',borderWidth:2,hoverBorderWidth:3}]},
|
||||||
options:{responsive:true,maintainAspectRatio:false,cutout:'70%',
|
options:{responsive:true,maintainAspectRatio:false,cutout:'70%',
|
||||||
plugins:{legend:{display:false},tooltip:{backgroundColor:'rgba(22,27,34,.97)',borderColor:'rgba(139,148,158,.15)',borderWidth:1,titleColor:'#8b949e',bodyColor:'#e6edf3',callbacks:{label:c=>' '+fmtNOK(c.raw)}}}}});
|
plugins:{legend:{display:false},tooltip:{backgroundColor:'rgba(22,27,34,.97)',borderColor:'rgba(139,148,158,.15)',borderWidth:1,titleColor:'#8b949e',bodyColor:'#e6edf3',callbacks:{label:c=>' '+fmtNOK(c.raw)}}}}});
|
||||||
}
|
}
|
||||||
|
|
@ -530,9 +685,9 @@ function buildSvc(summary){
|
||||||
const c=s.total_cost||0;
|
const c=s.total_cost||0;
|
||||||
const pct=(Math.abs(c)/max*100).toFixed(1);
|
const pct=(Math.abs(c)/max*100).toFixed(1);
|
||||||
return`<div class="svc-row">
|
return`<div class="svc-row">
|
||||||
<div class="svc-dot" style="background:${SVC_COLORS[i%SVC_COLORS.length]}"></div>
|
<div class="svc-dot" style="background:${['#4285f4','#34a853','#fbbc04','#ea4335','#8ab4f8','#81c995','#c58af9'][i%7]}"></div>
|
||||||
<span class="svc-name">${s.service}</span>
|
<span class="svc-name">${s.service}</span>
|
||||||
<div class="svc-bar-w"><div class="svc-bar" style="width:${pct}%;background:${SVC_COLORS[i%SVC_COLORS.length]}"></div></div>
|
<div class="svc-bar-w"><div class="svc-bar" style="width:${pct}%;background:${['#4285f4','#34a853','#fbbc04','#ea4335','#8ab4f8','#81c995','#c58af9'][i%7]}"></div></div>
|
||||||
<span class="svc-cost">${fmtNOK(c)}</span>
|
<span class="svc-cost">${fmtNOK(c)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
@ -553,14 +708,14 @@ function buildAnom(anomalies){
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGauge(mtd){
|
function buildGauge(mtd){
|
||||||
const abs=Math.abs(mtd);
|
const pct=Math.min(mtd/currentBudget,1);
|
||||||
const pct=Math.min(abs/BUDGET_NOK,1);
|
|
||||||
const arc=document.getElementById('gauge-arc');
|
const arc=document.getElementById('gauge-arc');
|
||||||
arc.style.stroke=pct>.8?'var(--red)':pct>.6?'var(--yellow)':'var(--blue)';
|
arc.style.stroke=pct>.8?'var(--red)':pct>.6?'var(--yellow)':'var(--blue)';
|
||||||
setTimeout(()=>arc.style.strokeDashoffset=173*(1-pct),80);
|
setTimeout(()=>arc.style.strokeDashoffset=173*(1-pct),80);
|
||||||
document.getElementById('gauge-pct').textContent=Math.round(pct*100)+'%';
|
document.getElementById('gauge-pct').textContent=Math.round(pct*100)+'%';
|
||||||
document.getElementById('gauge-used').textContent=fmtNOK(abs);
|
document.getElementById('gauge-used').textContent=fmtNOK(mtd);
|
||||||
const r=BUDGET_NOK-abs;
|
document.getElementById('gauge-budget-total').textContent = `av ${fmtNOK(currentBudget, 0)} budsjett`;
|
||||||
|
const r=currentBudget-mtd;
|
||||||
const rel=document.getElementById('gauge-remain');
|
const rel=document.getElementById('gauge-remain');
|
||||||
rel.textContent=r>0?fmtNOK(r)+' gjenstår':'Budsjett overskredet';
|
rel.textContent=r>0?fmtNOK(r)+' gjenstår':'Budsjett overskredet';
|
||||||
rel.style.color=r<0?'var(--red)':'var(--muted)';
|
rel.style.color=r<0?'var(--red)':'var(--muted)';
|
||||||
|
|
@ -568,79 +723,86 @@ function buildGauge(mtd){
|
||||||
|
|
||||||
function updateKPIs(forecast,anomCount){
|
function updateKPIs(forecast,anomCount){
|
||||||
const mtd=forecast.month_to_date_cost||0;
|
const mtd=forecast.month_to_date_cost||0;
|
||||||
const daily=forecast.daily_average_last_7_days||0;
|
const daily=forecast.daily_average_last_7_days;
|
||||||
const fc=forecast.total_monthly_forecast||0;
|
const fc=forecast.total_monthly_forecast||0;
|
||||||
const left=forecast.remaining_days_in_month||(31-new Date().getDate());
|
const left=forecast.remaining_days_in_month ?? (31-new Date().getDate());
|
||||||
animN(document.getElementById('k-mtd'),mtd,v=>'kr '+v.toFixed(2));
|
animN(document.getElementById('k-mtd'),mtd,v=>fmtNOK(v));
|
||||||
animN(document.getElementById('k-daily'),daily,v=>'kr '+v.toFixed(4));
|
if (daily !== null && daily > 0) {
|
||||||
animN(document.getElementById('k-fc'),fc,v=>'kr '+v.toFixed(2));
|
animN(document.getElementById('k-daily'),daily,v=>fmtNOK(v, 4));
|
||||||
document.getElementById('k-anom').textContent=anomCount.toString();
|
} else {
|
||||||
document.getElementById('k-fc-d').textContent=fmtDager(left);
|
document.getElementById('k-daily').textContent = 'kr —';
|
||||||
const kd=document.getElementById('k-anom-d');
|
}
|
||||||
kd.textContent=anomCount?anomCount+' krever gjennomgang':'siste 24t · ingen avvik';
|
animN(document.getElementById('k-fc'),fc,v=>fmtNOK(v));
|
||||||
kd.style.color=anomCount?'var(--red)':'var(--green)';
|
animN(document.getElementById('k-anom'),anomCount,v=>Math.round(v).toString());
|
||||||
|
document.getElementById('k-fc-d').textContent=left+' dager gjenstår';
|
||||||
buildGauge(mtd);
|
buildGauge(mtd);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePeriod(n){
|
function updatePeriod(n){
|
||||||
period=n;
|
period=n;
|
||||||
document.getElementById('period-lbl').textContent=n+' dager · netto etter kreditter';
|
document.getElementById('period-lbl').textContent=n+' dager';
|
||||||
const mtd=currentData.forecast.month_to_date_cost||0;
|
const data = getVisibleData();
|
||||||
if(Math.abs(mtd)>0.000001)buildBar(mtd,n);
|
if (data && data.history) {
|
||||||
|
const { labels, costs } = calculateDailyCosts(data.history);
|
||||||
|
buildBar(labels, costs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let chatOpen=false;
|
function exportCSV() {
|
||||||
function toggleChat(){
|
const data = getVisibleData();
|
||||||
chatOpen=!chatOpen;
|
if (!data || !data.summary) return;
|
||||||
document.getElementById('chat-panel').classList.toggle('open',chatOpen);
|
|
||||||
if(chatOpen)document.getElementById('chat-input').focus();
|
const headers = "Tjeneste,Kostnad (NOK)";
|
||||||
}
|
const rows = data.summary.map(service => {
|
||||||
function askGemini(q){
|
const cost = (service.total_cost || service.cost || 0).toFixed(2);
|
||||||
if(!chatOpen)toggleChat();
|
return `"${service.service}",${cost}`;
|
||||||
document.getElementById('chat-input').value=q;
|
});
|
||||||
sendChat();
|
|
||||||
}
|
const csvContent = "data:text/csv;charset=utf-8," + [headers, ...rows].join("
|
||||||
function sendChat(){
|
");
|
||||||
const inp=document.getElementById('chat-input');
|
|
||||||
const msg=inp.value.trim();
|
const encodedUri = encodeURI(csvContent);
|
||||||
if(!msg)return;
|
const link = document.createElement("a");
|
||||||
inp.value='';
|
link.setAttribute("href", encodedUri);
|
||||||
addChatMsg(msg,'user');
|
const date = new Date().toISOString().split('T')[0];
|
||||||
setTimeout(()=>{
|
link.setAttribute("download", `costguard_export_${date}.csv`);
|
||||||
const{summary,forecast,anomalies}=currentData;
|
document.body.appendChild(link);
|
||||||
let resp;
|
link.click();
|
||||||
const ml=msg.toLowerCase();
|
document.body.removeChild(link);
|
||||||
if(ml.includes('koster')||ml.includes('kostnad')){
|
|
||||||
const top=summary.filter(s=>(s.total_cost||0)>0).slice(0,3);
|
|
||||||
resp=top.length?'Topp kostnadsdrivere: '+top.map(s=>`${s.service} (${fmtNOK(s.total_cost)})`).join(', ')+'.':'Ingen kostnader registrert ennå.';
|
|
||||||
} else if(ml.includes('prognose')||ml.includes('neste')){
|
|
||||||
const fc=forecast.total_monthly_forecast||0;
|
|
||||||
resp=fc?`Månedsprognose: ${fmtNOK(fc)}. Daglig snitt: ${fmtNOK(forecast.daily_average_last_7_days||0,4)}. ${fmtDager(forecast.remaining_days_in_month||0)}.`:'Ikke nok data for prognose ennå.';
|
|
||||||
} else if(ml.includes('anomali')){
|
|
||||||
resp=anomalies.length?`${anomalies.length} anomali(er): `+anomalies.map(a=>`${a.service} (${a.ratio.toFixed(1)}x normalt)`).join(', '):'Ingen anomalier i dag.';
|
|
||||||
} else if(ml.includes('budsjett')){
|
|
||||||
const mtd=Math.abs(forecast.month_to_date_cost||0);
|
|
||||||
resp=`Brukt: ${fmtNOK(mtd)} av ${fmtNOK(BUDGET_NOK)} (${((mtd/BUDGET_NOK)*100).toFixed(0)}%). ${mtd<BUDGET_NOK?fmtNOK(BUDGET_NOK-mtd)+' gjenstår.':'Budsjett overskredet!'}`;
|
|
||||||
} else {
|
|
||||||
resp='Spør meg om kostnader, prognose, anomalier eller budsjett.';
|
|
||||||
}
|
|
||||||
addChatMsg(resp,'ai');
|
|
||||||
},600);
|
|
||||||
}
|
|
||||||
function addChatMsg(text,role){
|
|
||||||
const el=document.getElementById('chat-msgs');
|
|
||||||
const isAi=role==='ai';
|
|
||||||
const div=document.createElement('div');
|
|
||||||
div.className='chat-msg '+(isAi?'ai':'user');
|
|
||||||
div.innerHTML=`<div class="chat-avatar">${isAi?'<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="white"/></svg>':'C'}</div><div class="chat-bubble">${text}</div>`;
|
|
||||||
el.appendChild(div);
|
|
||||||
el.scrollTop=el.scrollHeight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded',()=>{
|
function exportPDF() {
|
||||||
fetchAll();
|
window.print();
|
||||||
setInterval(fetchAll,REFRESH_MS);
|
}
|
||||||
});
|
|
||||||
|
document.addEventListener('DOMContentLoaded', checkAuth);
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').then(registration => {
|
||||||
|
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
||||||
|
}, err => {
|
||||||
|
console.log('ServiceWorker registration failed: ', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
.sel-opt {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
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>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
30
static/sw.js
Normal file
30
static/sw.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
const CACHE_NAME = 'costguard-cache-v1';
|
||||||
|
const urlsToCache = [
|
||||||
|
'/static/billing-dashboard.html',
|
||||||
|
'/static/command-hub.html',
|
||||||
|
'https://fonts.googleapis.com/css2?family=Inter:wght@300..700&family=JetBrains+Mono:wght@400;600&display=swap',
|
||||||
|
'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', event => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME)
|
||||||
|
.then(cache => {
|
||||||
|
console.log('Opened cache');
|
||||||
|
return cache.addAll(urlsToCache);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request)
|
||||||
|
.then(response => {
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
return fetch(event.request);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user