diff --git a/.gitignore b/.gitignore index 9a0f431..e0f83f3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,9 @@ cockpit/static/uploads/ .cursor/ tmp/ cockpit/tmp-upload/ + +# Local TLS private keys (self-signed cockpit HTTPS) +cockpit/caddy/certs/*.key +cockpit/caddy/certs/key.pem +cockpit/caddy/certs/*.srl +cockpit/caddy/certs/*.csr diff --git a/cockpit/app/config.py b/cockpit/app/config.py index d8e975a..dfdd64b 100644 --- a/cockpit/app/config.py +++ b/cockpit/app/config.py @@ -23,6 +23,7 @@ class Settings: WHISPER_API_URL: str = os.getenv("WHISPER_API_URL", "http://10.4.7.19:8877/v1/audio/transcriptions") WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "Systran/faster-whisper-base") WHISPER_LANGUAGE: str = os.getenv("WHISPER_LANGUAGE", "nl") + HERMES_FEED_URL: str = os.getenv("HERMES_FEED_URL", "http://10.4.7.10:8755") HERMES_BUILD_URL: str = os.getenv("HERMES_BUILD_URL", "http://10.4.7.27:8798") CHROMA_HOST: str = os.getenv("CHROMA_HOST", "chroma") CHROMA_PORT: int = int(os.getenv("CHROMA_PORT", "8000")) diff --git a/cockpit/app/routes/admin_api.py b/cockpit/app/routes/admin_api.py index 83f1e7b..ca3e9a1 100644 --- a/cockpit/app/routes/admin_api.py +++ b/cockpit/app/routes/admin_api.py @@ -2369,18 +2369,128 @@ class HermesSearchBody(BaseModel): limit: int = Field(default=15, ge=1, le=30) + +def _hermes_feed_base() -> str: + return (getattr(settings, "HERMES_FEED_URL", None) or "http://10.4.7.10:8755").rstrip("/") + @admin_router.get("/hermes/stats") async def hermes_stats_proxy() -> dict[str, Any]: import httpx + try: + async with httpx.AsyncClient(timeout=10.0) as client: + r = await client.get(f"{_hermes_feed_base()}/stats") + if r.status_code == 200: + return r.json() + except Exception: + pass async with httpx.AsyncClient(timeout=30.0) as client: r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/stats") r.raise_for_status() return r.json() + + + + +@admin_router.get("/hermes/kpis") +async def hermes_kpis_proxy() -> dict[str, Any]: + """Technical KPIs from Hermes Agent + Herman cockpit event counts.""" + import httpx + from app.db import fetch_all + + payload: dict[str, Any] = { + "online": False, + "synced_at": None, + "source": "hermes-agent", + "overview": {}, + "buckets": [], + "tools_top": [], + "sources": [], + "herman_cockpit": [], + } + try: + async with httpx.AsyncClient(timeout=12.0) as client: + r = await client.get(f"{_hermes_feed_base()}/kpis") + if r.status_code == 200: + payload.update(r.json()) + except Exception as exc: + payload["error"] = str(exc)[:160] + + try: + rows = fetch_all( + """ + SELECT event_type, COUNT(*)::int AS count + FROM agent_events + GROUP BY event_type + ORDER BY COUNT(*) DESC + LIMIT 12 + """ + ) + payload["herman_cockpit"] = [ + {"event_type": r.get("event_type") or "unknown", "count": int(r.get("count") or 0)} + for r in (rows or []) + ] + except Exception: + payload["herman_cockpit"] = [] + + return payload + +@admin_router.get("/hermes/bucket/{key}") +async def hermes_bucket_detail_proxy(key: str, limit: int = 60, tool: str | None = None) -> dict[str, Any]: + """Bucket detail: recent tool events + touched files for explorer popup.""" + import httpx + params: dict[str, Any] = {"limit": limit} + if tool: + params["tool"] = tool + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(f"{_hermes_feed_base()}/bucket/{key}", params=params) + if r.status_code == 404: + return {"error": "unknown bucket", "key": key, "events": [], "files": []} + r.raise_for_status() + return r.json() + + +@admin_router.get("/hermes/explorer") +async def hermes_explorer_proxy(path: str | None = None) -> dict[str, Any]: + """Browse Hermes Agent filesystem (read-only, restricted roots).""" + import httpx + params = {} + if path: + params["path"] = path + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.get(f"{_hermes_feed_base()}/explorer", params=params) + r.raise_for_status() + return r.json() + + +@admin_router.get("/hermes/explorer/file") +async def hermes_explorer_file_proxy(path: str) -> dict[str, Any]: + """Preview a file from Hermes Agent filesystem.""" + import httpx + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(f"{_hermes_feed_base()}/explorer/file", params={"path": path}) + if r.status_code in (403, 404, 400): + try: + err = (r.json() or {}).get("error") or r.text + except Exception: + err = r.text + return {"error": err, "path": path, "content": None} + r.raise_for_status() + return r.json() + + @admin_router.get("/hermes/conversations") async def hermes_conversations_proxy(limit: int = 50) -> dict[str, Any]: + """Live conversations from Hermes Agent (not June Postgres archive).""" import httpx + try: + async with httpx.AsyncClient(timeout=15.0) as client: + r = await client.get(f"{_hermes_feed_base()}/conversations", params={"limit": limit}) + if r.status_code == 200: + return r.json() + except Exception: + pass async with httpx.AsyncClient(timeout=30.0) as client: r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/conversations", params={"limit": limit}) r.raise_for_status() @@ -2393,10 +2503,18 @@ async def hermes_feed_proxy( limit: int = 80, offset: int = 0, ) -> dict[str, Any]: + """Live Telegram feed from Hermes Agent state.db.""" import httpx - params = {"limit": limit, "offset": offset} + params: dict[str, Any] = {"limit": limit, "offset": offset} if chat_id is not None: params["chat_id"] = chat_id + try: + async with httpx.AsyncClient(timeout=20.0) as client: + r = await client.get(f"{_hermes_feed_base()}/feed", params=params) + if r.status_code == 200: + return r.json() + except Exception: + pass async with httpx.AsyncClient(timeout=30.0) as client: r = await client.get(f"{settings.TOOLS_API_URL.rstrip('/')}/brain/feed", params=params) r.raise_for_status() @@ -2469,6 +2587,13 @@ async def hermes_pa_slot_screenshot(label: str): @admin_router.get("/hermes/users") async def hermes_users_proxy() -> dict[str, Any]: import httpx + try: + async with httpx.AsyncClient(timeout=10.0) as client: + r = await client.get(f"{_hermes_feed_base()}/users") + if r.status_code == 200 and (r.json().get("users") is not None): + return r.json() + except Exception: + pass users: list[dict[str, Any]] = [] try: async with httpx.AsyncClient(timeout=8.0) as client: diff --git a/cockpit/app/routes/analytics.py b/cockpit/app/routes/analytics.py index 042541b..ff6fab2 100644 --- a/cockpit/app/routes/analytics.py +++ b/cockpit/app/routes/analytics.py @@ -1,17 +1,28 @@ from pathlib import Path -from typing import Optional +from typing import Any, Optional -from fastapi import APIRouter, Query, Request +from fastapi import APIRouter, Body, Query, Request from fastapi.responses import JSONResponse from fastapi.templating import Jinja2Templates +from pydantic import BaseModel, Field -from app.services.analytics_data import collect_analytics +from app.services.analytics_data import add_finding, collect_analytics, list_hermes_findings router = APIRouter(prefix="/analytics", tags=["analytics"]) BASE_DIR = Path(__file__).resolve().parent.parent.parent templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) +class FindingIn(BaseModel): + kind: str = Field(default="insight", description="win|opportunity|attention|insight") + title: str + body: str = "" + score: float | None = None + source: str = "hermes" + href: str | None = None + meta: dict[str, Any] | None = None + + @router.get("") async def analytics_page(request: Request): initial = collect_analytics({}) @@ -30,5 +41,24 @@ async def analytics_api( days: int = Query(90, ge=7, le=365), ): filters = {"chain": chain, "province": province, "stage": stage, "agent": agent, "days": days} - filters = {k: v for k, v in filters.items() if v is not None and v != ""} return JSONResponse(collect_analytics(filters)) + + +@router.get("/api/findings") +async def analytics_findings(limit: int = Query(20, ge=1, le=100)): + return {"items": list_hermes_findings(limit)} + + +@router.post("/api/findings") +async def analytics_findings_create(payload: FindingIn = Body(...)): + """Hermes (of admin) kan bevindingen pushen naar het intelligence dashboard.""" + row = add_finding( + kind=payload.kind, + title=payload.title, + body=payload.body, + score=payload.score, + source=payload.source, + href=payload.href, + meta=payload.meta, + ) + return {"ok": True, "finding": row} diff --git a/cockpit/app/routes/dashboard.py b/cockpit/app/routes/dashboard.py index e3d4d1b..c0fc758 100644 --- a/cockpit/app/routes/dashboard.py +++ b/cockpit/app/routes/dashboard.py @@ -225,3 +225,8 @@ async def cto_dashboard_legacy(request: Request): @router.get("/marketing-redirect") async def marketing_redirect(): return RedirectResponse(url="/marketing", status_code=302) + + +@router.get("/trust") +async def trust_page(request: Request): + return templates.TemplateResponse("trust.html", {"request": request, "page_title": "Certificaat"}) diff --git a/cockpit/app/routes/settings.py b/cockpit/app/routes/settings.py index bdb02c3..9b0603d 100644 --- a/cockpit/app/routes/settings.py +++ b/cockpit/app/routes/settings.py @@ -10,12 +10,15 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) @router.get("/settings") async def settings_page(request: Request, tab: str = "email"): - allowed = ("email", "general", "permissions", "social", "ai") + allowed = ("email", "general", "permissions", "social") + active = tab if tab in allowed else "email" + if tab == "ai": + active = "email" return templates.TemplateResponse( "settings.html", { "request": request, "page_title": "Settings", - "active_tab": tab if tab in allowed else "email", + "active_tab": active, }, ) diff --git a/cockpit/app/services/analytics_data.py b/cockpit/app/services/analytics_data.py index d9d99b2..22eaed1 100644 --- a/cockpit/app/services/analytics_data.py +++ b/cockpit/app/services/analytics_data.py @@ -1,10 +1,10 @@ -"""Comprehensive analytics data from all DB tables with optional filters.""" +"""Intelligence analytics: wins, kansen, pulse score + Hermes findings hooks.""" from __future__ import annotations from datetime import datetime, timezone from typing import Any, Optional -from app.db import fetch_all, fetch_one +from app.db import execute, fetch_all, fetch_one def _safe_count(table: str, where: str = "", params: tuple = ()) -> int: @@ -26,6 +26,343 @@ def _iso_rows(rows: list) -> list: return rows +def ensure_findings_table() -> None: + try: + execute( + """ + CREATE TABLE IF NOT EXISTS analytics_findings ( + id SERIAL PRIMARY KEY, + kind TEXT NOT NULL DEFAULT 'insight', + title TEXT NOT NULL, + body TEXT, + score DOUBLE PRECISION, + source TEXT DEFAULT 'hermes', + href TEXT, + meta JSONB DEFAULT '{}'::jsonb, + active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ) + """ + ) + except Exception: + pass + + +def list_hermes_findings(limit: int = 20) -> list[dict[str, Any]]: + ensure_findings_table() + try: + rows = fetch_all( + """ + SELECT id, kind, title, body, score, source, href, meta, created_at + FROM analytics_findings + WHERE active = TRUE + ORDER BY created_at DESC + LIMIT %s + """, + (limit,), + ) + return _iso_rows(rows or []) + except Exception: + return [] + + +def add_finding( + *, + kind: str, + title: str, + body: str = "", + score: float | None = None, + source: str = "hermes", + href: str | None = None, + meta: dict | None = None, +) -> dict[str, Any]: + ensure_findings_table() + import json + + row = fetch_one( + """ + INSERT INTO analytics_findings (kind, title, body, score, source, href, meta) + VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb) + RETURNING id, kind, title, body, score, source, href, meta, created_at + """, + ( + kind or "insight", + title, + body or "", + score, + source or "hermes", + href, + json.dumps(meta or {}), + ), + ) + return _iso_rows([row])[0] if row else {} + + +def _revenue_kpis() -> dict[str, Any]: + out = { + "active_projects": 0, + "margin_month": 0.0, + "margin_year": 0.0, + "vision_text": "", + "horizon_text": "", + "tagline": "", + } + try: + row = fetch_one( + """ + SELECT COUNT(*)::int AS c, + COALESCE(SUM(margin_month),0) AS mm, + COALESCE(SUM(margin_year),0) AS my + FROM revenue_projects + WHERE COALESCE(status,'') NOT IN ('lost','cancelled','done') + """ + ) + if row: + out["active_projects"] = int(row.get("c") or 0) + out["margin_month"] = float(row.get("mm") or 0) + out["margin_year"] = float(row.get("my") or 0) + except Exception: + pass + try: + g = fetch_one( + """ + SELECT vision_text, horizon_text, tagline, mid_text + FROM revenue_cockpit_goals + WHERE COALESCE(is_active, TRUE) = TRUE + ORDER BY updated_at DESC NULLS LAST + LIMIT 1 + """ + ) + if g: + out["vision_text"] = g.get("vision_text") or "" + out["horizon_text"] = g.get("horizon_text") or "" + out["tagline"] = g.get("tagline") or "" + out["mid_text"] = g.get("mid_text") or "" + except Exception: + pass + return out + + +def _build_pulse(data: dict[str, Any]) -> dict[str, Any]: + k = data.get("kpis") or {} + partners = {r.get("status"): int(r.get("cnt") or 0) for r in (data.get("partnership_breakdown") or [])} + active_p = int(partners.get("active") or k.get("crm_partnerships") or 0) + none_p = int(partners.get("none") or 0) + proposal_p = int(partners.get("proposal") or 0) + stores = int(k.get("supermarkets") or 0) + cover_pct = round((active_p / stores) * 100, 2) if stores else 0.0 + gap = max(stores - active_p - proposal_p, 0) + + wins: list[dict[str, Any]] = [] + opportunities: list[dict[str, Any]] = [] + attention: list[dict[str, Any]] = [] + + if stores >= 1000: + wins.append({ + "id": "win-coverage", + "title": "Retail footprint is massief", + "body": f"{stores:,} supermarkten in de database — sterke basis voor targeting.".replace(",", "."), + "score": 92, + "metric": f"{stores} stores", + "href": "/retail", + }) + if active_p >= 10: + wins.append({ + "id": "win-crm", + "title": "Actieve partnerships draaien", + "body": f"{active_p} winkels met actieve CRM-status. Voorstellen: {proposal_p}.", + "score": 78, + "metric": f"{active_p} actief", + "href": "/retail", + }) + if int(k.get("rss_items") or 0) >= 200: + wins.append({ + "id": "win-rss", + "title": "Markt-signalen stromen binnen", + "body": f"{int(k['rss_items'])} RSS-items gevoed — concurrentie & trends blijven zichtbaar.", + "score": 74, + "metric": f"{int(k['rss_items'])} items", + "href": "/marketing", + }) + if int(k.get("promo_campaigns") or 0) >= 10: + wins.append({ + "id": "win-promo", + "title": "Promo-intelligence actief", + "body": f"{int(k['promo_campaigns'])} actieve folders/campagnes gemonitord.", + "score": 70, + "metric": f"{int(k['promo_campaigns'])} campagnes", + "href": "/marketing", + }) + if float(k.get("margin_month") or 0) > 0: + wins.append({ + "id": "win-margin", + "title": "Revenue engine levert marge", + "body": f"Cockpit-marge ≈ €{float(k['margin_month']):,.0f}/maand over {int(k.get('active_projects') or 0)} deals.".replace(",", "."), + "score": 88, + "metric": f"€{float(k['margin_month']):,.0f}".replace(",", "."), + "href": "/revenue-cockpit", + }) + if int(k.get("wholesalers") or 0) >= 100: + wins.append({ + "id": "win-wholesale", + "title": "Groothandel-netwerk in kaart", + "body": f"{int(k['wholesalers'])} groothandels beschikbaar voor distributie-routes.", + "score": 68, + "metric": f"{int(k['wholesalers'])} GH", + "href": "/retail", + }) + if int(k.get("agent_events") or 0) >= 500: + wins.append({ + "id": "win-agents", + "title": "Agent-mesh is productief", + "body": f"{int(k['agent_events'])} agent-events gelogd — Herman & crew blijven draaien.", + "score": 66, + "metric": f"{int(k['agent_events'])} events", + "href": "/agents", + }) + + if gap >= 100: + opportunities.append({ + "id": "opp-white-space", + "title": "Enorm witruimte in retail CRM", + "body": f"{gap:,} winkels zonder partnership. Focus eerst op top-halal scores & grote ketens.".replace(",", "."), + "score": min(98, 40 + int(gap / 100)), + "metric": f"{gap} open", + "href": "/retail", + "tag": "distributie", + }) + if cover_pct < 5: + opportunities.append({ + "id": "opp-cover", + "title": "Partnership-dekking is nog dun", + "body": f"Slechts {cover_pct}% van de winkels is ‘active’. Doelbeeld Cucina vraagt schaal.", + "score": 90, + "metric": f"{cover_pct}% dekking", + "href": "/retail", + "tag": "groei", + }) + if int(k.get("clients_active") or 0) == 0 and int(k.get("clients_total") or 0) > 0: + opportunities.append({ + "id": "opp-clients", + "title": "Klanten in intake → nog niet actief", + "body": f"{int(k['clients_total'])} klanten in CRM, 0 gemarkeerd als active. Conversie = snelle win.", + "score": 82, + "metric": "0 active", + "href": "/clients", + "tag": "crm", + }) + if float(k.get("pipeline_eur") or 0) <= 0 and int(k.get("deals_total") or 0) > 0: + opportunities.append({ + "id": "opp-pipeline", + "title": "Deals zonder pipeline-waarde", + "body": "Er staan deals, maar €0 open pipeline. Waardes & stages aanscherpen geeft sturing.", + "score": 76, + "metric": "€0 pipeline", + "href": "/deals", + "tag": "sales", + }) + + top_opp = data.get("top_opportunities") or [] + if top_opp: + best = top_opp[0] + opportunities.append({ + "id": "opp-halal-top", + "title": f"Halal-kans: {best.get('chain') or '?'} · {best.get('city') or '?'}", + "body": f"Score {round(float(best.get('halal_opportunity_score') or 0), 1)}/100 — sterkste retail-signaal nu.", + "score": round(float(best.get("halal_opportunity_score") or 0)), + "metric": f"{round(float(best.get('halal_opportunity_score') or 0), 1)}", + "href": "/retail", + "tag": "halal", + }) + # cluster cities + cities: dict[str, int] = {} + for o in top_opp: + cities[o.get("city") or "?"] = cities.get(o.get("city") or "?", 0) + 1 + top_city = sorted(cities.items(), key=lambda x: -x[1])[0] + if top_city[1] >= 2: + opportunities.append({ + "id": "opp-city-cluster", + "title": f"Cluster-kans in {top_city[0]}", + "body": f"{top_city[1]} top-scores in dezelfde stad — bundel outreach / bezorgroute.", + "score": 72, + "metric": top_city[0], + "href": "/retail", + "tag": "geo", + }) + + if int(k.get("contacts_wholesaler") or 0) == 0 and int(k.get("wholesalers") or 0) > 0: + opportunities.append({ + "id": "opp-gh-contacts", + "title": "Groothandel-contacten ontbreken", + "body": "550+ groothandels, 0 contactpersonen. Relaties = hefboom voor listingen.", + "score": 69, + "metric": "0 GH-contacten", + "href": "/retail", + "tag": "netwerk", + }) + + if int(k.get("pending_approvals") or 0) > 0: + attention.append({ + "id": "att-approvals", + "title": "Goedkeuringen wachten", + "body": f"{int(k['pending_approvals'])} agent-items op needs_approval.", + "score": 55, + "metric": str(int(k["pending_approvals"])), + "href": "/agents", + }) + if int(k.get("rss_bookmarks") or 0) == 0 and int(k.get("rss_items") or 0) > 100: + attention.append({ + "id": "att-bookmarks", + "title": "Geen RSS-bookmarks", + "body": "Veel signalen, weinig vastgepind. Markeer wat telt voor de weekbriefing.", + "score": 48, + "metric": "0 bookmarks", + "href": "/marketing", + }) + + # Pulse score 0-100 + score = 35 + score += min(18, active_p) # up to +18 + score += 12 if stores >= 2000 else (6 if stores >= 500 else 0) + score += 8 if int(k.get("rss_items") or 0) >= 500 else 0 + score += 8 if float(k.get("margin_month") or 0) > 100000 else (4 if float(k.get("margin_month") or 0) > 0 else 0) + score += 6 if int(k.get("promo_campaigns") or 0) >= 20 else 0 + score += 5 if int(k.get("nas_docs") or 0) >= 40 else 0 + if cover_pct < 1: + score -= 12 + elif cover_pct < 3: + score -= 6 + if int(k.get("pending_approvals") or 0) >= 5: + score -= 5 + if int(k.get("clients_active") or 0) == 0 and int(k.get("clients_total") or 0) > 0: + score -= 4 + score = max(0, min(100, score)) + + if score >= 75: + label, tone = "Sterk momentum", "good" + elif score >= 55: + label, tone = "Stabiel · kansen open", "mid" + else: + label, tone = "Opbouw-fase · focus nodig", "low" + + wins.sort(key=lambda x: -int(x.get("score") or 0)) + opportunities.sort(key=lambda x: -int(x.get("score") or 0)) + + return { + "score": score, + "label": label, + "tone": tone, + "cover_pct": cover_pct, + "white_space": gap, + "wins": wins[:8], + "opportunities": opportunities[:8], + "attention": attention[:6], + "headline": "Wat goed gaat vs waar de kansen liggen", + "sub": "Live uit CRM, retail, revenue & agent-mesh — Hermes vult diepere bevindingen aan.", + } + + def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any]: f = filters or {} chain = f.get("chain") or None @@ -39,12 +376,14 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any "filters": f, } + rev = _revenue_kpis() data["kpis"] = { "clients_total": _safe_count("clients"), "clients_active": _safe_count("clients", "stage = 'active'"), "deals_total": _safe_count("deals"), "pipeline_eur": float( (fetch_one("SELECT COALESCE(SUM(value),0) AS t FROM deals WHERE stage NOT IN ('won','lost')") or {}).get("t", 0) + or 0 ), "supermarkets": _safe_count("supermarkets"), "wholesalers": _safe_count("wholesalers"), @@ -57,6 +396,15 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any "contacts_supermarket": _safe_count("supermarket_contacts"), "contacts_wholesaler": _safe_count("wholesaler_contacts"), "nas_docs": _safe_count("document_analytics"), + "active_projects": rev.get("active_projects") or 0, + "margin_month": rev.get("margin_month") or 0, + "margin_year": rev.get("margin_year") or 0, + } + data["goals"] = { + "vision_text": rev.get("vision_text") or "", + "horizon_text": rev.get("horizon_text") or "", + "tagline": rev.get("tagline") or "", + "mid_text": rev.get("mid_text") or "", } try: @@ -76,8 +424,9 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any try: data["events_by_agent"] = fetch_all( """SELECT agent_name, COUNT(*) AS cnt FROM agent_events - WHERE created_at >= NOW() - INTERVAL '%s days' - GROUP BY agent_name ORDER BY cnt DESC LIMIT 20""" % days + WHERE created_at >= NOW() - make_interval(days => %s) + GROUP BY agent_name ORDER BY cnt DESC LIMIT 20""", + (days,), ) except Exception: data["events_by_agent"] = [] @@ -100,11 +449,15 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any data["supermarkets_by_chain"] = [] try: - data["supermarkets_by_province"] = fetch_all( - f"SELECT province, COUNT(*) AS cnt FROM supermarkets{sw} AND province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12" - if store_where - else "SELECT province, COUNT(*) AS cnt FROM supermarkets WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12" - ) + if store_where: + data["supermarkets_by_province"] = fetch_all( + f"SELECT province, COUNT(*) AS cnt FROM supermarkets{sw} AND province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12", + tuple(store_params), + ) + else: + data["supermarkets_by_province"] = fetch_all( + "SELECT province, COUNT(*) AS cnt FROM supermarkets WHERE province IS NOT NULL GROUP BY province ORDER BY cnt DESC LIMIT 12" + ) except Exception: data["supermarkets_by_province"] = [] @@ -133,8 +486,9 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any try: data["events_timeline"] = fetch_all( """SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM agent_events - WHERE created_at >= NOW() - INTERVAL '%s days' - GROUP BY DATE(created_at) ORDER BY day ASC""" % days + WHERE created_at >= NOW() - make_interval(days => %s) + GROUP BY DATE(created_at) ORDER BY day ASC""", + (days,), ) except Exception: data["events_timeline"] = [] @@ -150,7 +504,7 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any data["top_opportunities"] = fetch_all( """SELECT s.chain, s.city, ros.halal_opportunity_score FROM retail_opportunity_scores ros JOIN supermarkets s ON s.id = ros.supermarket_id - ORDER BY ros.halal_opportunity_score DESC LIMIT 10""" + ORDER BY ros.halal_opportunity_score DESC LIMIT 12""" ) except Exception: data["top_opportunities"] = [] @@ -191,25 +545,23 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any except Exception: data["recent_deals"] = [] - agent_where = f" WHERE created_at >= NOW() - INTERVAL '{days} days'" - if agent: - agent_where += " AND agent_name = %s" - try: + try: + if agent: data["recent_events"] = fetch_all( - f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events - {agent_where} ORDER BY created_at DESC LIMIT 25""", - (agent,), + """SELECT agent_name, event_type, title, status, created_at FROM agent_events + WHERE created_at >= NOW() - make_interval(days => %s) AND agent_name = %s + ORDER BY created_at DESC LIMIT 25""", + (days, agent), ) - except Exception: - data["recent_events"] = [] - else: - try: + else: data["recent_events"] = fetch_all( - f"""SELECT agent_name, event_type, title, status, created_at FROM agent_events - {agent_where} ORDER BY created_at DESC LIMIT 25""" + """SELECT agent_name, event_type, title, status, created_at FROM agent_events + WHERE created_at >= NOW() - make_interval(days => %s) + ORDER BY created_at DESC LIMIT 25""", + (days,), ) - except Exception: - data["recent_events"] = [] + except Exception: + data["recent_events"] = [] try: data["filter_meta"] = { @@ -230,4 +582,7 @@ def collect_analytics(filters: Optional[dict[str, Any]] = None) -> dict[str, Any ): if isinstance(data.get(key), list): data[key] = _iso_rows(data[key]) + + data["pulse"] = _build_pulse(data) + data["hermes_findings"] = list_hermes_findings(20) return data diff --git a/cockpit/app/services/herman.py b/cockpit/app/services/herman.py index 7560a29..3b10082 100644 --- a/cockpit/app/services/herman.py +++ b/cockpit/app/services/herman.py @@ -379,16 +379,23 @@ async def chat( } backend = (settings.HERMAN_LLM_BACKEND or "auto").strip().lower() - if backend in ("router", "openrouter", "llm"): + # Direct LLM backends (Hermes Agent API, OpenRouter, …) — skip offline orchestrator. + if backend in ("hermes", "router", "openrouter", "llm"): try: return await _chat_via_llm_router(message, channel) except Exception as exc: + hint = ( + "Controleer HERMES_API_KEY / Hermes Agent (:8642)." + if backend == "hermes" + else "Vul OpenRouter key in via Instellingen → AI / LLM." + ) return { "agent": "herman", "agent_label": "Herman", - "reply": f"LLM provider niet beschikbaar: {exc}. Vul OpenRouter key in via Instellingen → AI / LLM.", + "reply": f"LLM provider niet beschikbaar: {exc}. {hint}", } + # orchestrator | auto — try Herman orchestrator first try: async with httpx.AsyncClient(timeout=620.0) as client: r = await client.post( @@ -427,8 +434,8 @@ async def chat( "agent_label": "Herman", "reply": ( f"Orchestrator offline ({exc}). " - f"Cloud LLM ook niet beschikbaar ({llm_exc}). " - "Voeg OpenRouter API key toe: Instellingen → AI / LLM." + f"Cloud/Hermes LLM ook niet beschikbaar ({llm_exc}). " + "Check Hermes (:8642) of Instellingen → AI / LLM." ), } return { diff --git a/cockpit/caddy/Caddyfile b/cockpit/caddy/Caddyfile new file mode 100644 index 0000000..6a5e173 --- /dev/null +++ b/cockpit/caddy/Caddyfile @@ -0,0 +1,10 @@ +{ + auto_https off + admin off +} + +:8643 { + tls /certs/cert.pem /certs/key.pem + encode gzip + reverse_proxy cockpit:8600 +} diff --git a/cockpit/caddy/certs/ca.crt b/cockpit/caddy/certs/ca.crt new file mode 100644 index 0000000..b9cecd8 --- /dev/null +++ b/cockpit/caddy/certs/ca.crt @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFQzCCAyugAwIBAgIUJ6uaOJkACcf7vpmK8MXaS3fvsWUwDQYJKoZIhvcNAQEL +BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k +bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMzYwNzI5MTI0NzQxWjAxMRswGQYDVQQD +DBJGb29kbGlua2sgTG9jYWwgQ0ExEjAQBgNVBAoMCUZvb2RsaW5razCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBANrTeWKvnyvmwb3DRkm6ny2hu6vhxpoL +q3t06nwFqmfXDQDsLVXU9f+QL1tqKnUUMNF5DXJImbraa0fouH1Tn/iVIENrThrJ +BdJs+KgX8Ie8iVppzgFZw5/N5kmbsEeAAtaibyulCtvLqyC2hfV04I6TxEPBzTpp +a8jjGqPtVlGnzfBWyK10sZ/X28CQ1SORRKNCYWRVIfUL2S41/jBPrpAWfciKXefO +1azAXRo5C9Ava2L9w+LhK1FSA+SMJ14YG+SXbX+3gmBYuJ0v18pbnMicpznWDoqu +8e67NufkhDjTbOTKX4Jb8jVrIAVlmIUXLDLE3wRKuymhzEAXiY9H28ipWTuPG3jK +tVXzIxHLMr8nvnba5RMA0vsPo3fidRf8fRSbt3HgwCz4/7royAJjq853ydoWQiIZ +2sn9l8xBNzLSz7U+AaR12p+lgQuHdTNqNWrKD6f3q9kMioF3C23/22bSVVGAvqaa +/LHGZSnN8KhqXHcDDztN9X1BjpKxxDkSmmmi78C1wVib26Gu17X8RZl+XAE0QRfA +W61XF5it9ww7YlYG44kFCUO62bTnRVcE8suNHh2ShXqRZrHAxOkKCq2KXFKQ31L6 +jOt0cM7dCMFhu5l623PRHLnK/5SjBm/htC5Yah9kKQ66B6djbRx+zuvILAOgdLRa +cR/at+Cv0mfZAgMBAAGjUzBRMB0GA1UdDgQWBBT6s+9dKpDnca/oePjCHz3V2NbY +qTAfBgNVHSMEGDAWgBT6s+9dKpDnca/oePjCHz3V2NbYqTAPBgNVHRMBAf8EBTAD +AQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBsSzJCJHw5+Ko8NrseNhgbs7MROKZ1INR7 +7nQgFD1yWS1VdDcKG64W6VSFb7aiJIIlLlkwB4AK8nJ/pcJumgcfOYDVHFVwydXW +A2NGsvs2pDFmNtGkwBFmCUwkYym/VaA1V4vwwM4EmhgFMeKsFy44mFi6DyDQkviV +r/xa5Yqu1ar6o3iZfqtopoeg25g0UG/8qPsbwgNBAPmt6kPCkBBtHh8DXEJA56Bp +33kfJexLfXESEeO2civRzZd3SHDAH0/RuN89jm+BwLzYar6PlFMw3ZNhD33mOdUn +Q9h1c5LoPAXJBbI/6zn9lOYzI4jC0zjcf5UXPnarAODcrwH+P6ya/W42V2HryY3A +ad6QlQy5kzKKowhezPvgUFyT6gEg7Ueg1FijmeYRvyToP0cgT8vrtlEkgZ6xiUa5 +ENUBmbHSxrZAqFLffFlvEWMTsXtU442D/aO0BWFTokLElfNMT7OoDmaQU6uwDmXe +A1IyiqVGPQlEy4D/OkU74dbVirjoyv4Ub4QysQn2KckC7h8x1ZyLyiTRbmr1+S5W +dzd4VKck45SG/gWUByskEq8Z/BPwNSh8v0stoCYoT2QLKCTtyoM9m8SD2GyvytRa +UTe6x2R4Fwf+Hm+B+M/SclYsAOf8lUUto046gHs8V3kcu4zlyP5tF5J6maLoAmt0 +c9ibkVARig== +-----END CERTIFICATE----- diff --git a/cockpit/caddy/certs/cert.pem b/cockpit/caddy/certs/cert.pem new file mode 100644 index 0000000..8de649f --- /dev/null +++ b/cockpit/caddy/certs/cert.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEkDCCAnigAwIBAgIUZvhhkY0TiDOHWzrtjaX9/wc07+owDQYJKoZIhvcNAQEL +BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k +bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMjgxMTAzMTI0NzQxWjAwMRIwEAYDVQQD +DAkxMC40LjcuMTgxGjAYBgNVBAoMEUZvb2RsaW5rayBDb2NrcGl0MIIBIjANBgkq +hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzHQzBJNpldfV+1voe6Ca22p4zL0TQ4BJ +MV5rBlYqjVfgfNv4vo5l97yJ4WQL9cQkmLxUREkAj3Mesqa8BDgPkbpPg2VILqqP +0FpoZTE7mvYRur0fuTfAIeuDGpvelFbFCxjw4AdBZBLIo3h+hipsWmYzaPdximeL +45TSehgbv2rNpTi0BprGaNVQro9shzj+PRreRWcTXPs359sL5FPxbg+v6w/la8/f +stgyvWpv9SCotfQAyCKrsUm9eQqHs0nxzsT8ReouWNYeF9+bI4ENqvlHGCDxCXf3 +By+YY5eIiW0puIbtYgPa8rCYVPdunp3HsrwQ59RJqLvLeL59AXpSCwIDAQABo4Gg +MIGdMAsGA1UdDwQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATA5BgNVHREEMjAw +hwQKBAcShwR/AAABgglsb2NhbGhvc3SCF2NvY2twaXQuZm9vZGxpbmtrLmxvY2Fs +MB0GA1UdDgQWBBTkq3lZdHcVWo6gNo+NiuRmHCWDiDAfBgNVHSMEGDAWgBT6s+9d +KpDnca/oePjCHz3V2NbYqTANBgkqhkiG9w0BAQsFAAOCAgEAdFESQuLpHXT21XqN +L0zQxGMvvffLC5fO5hN3qWrAdVJul2nImkIdLa0t36//QDr6qEkBn/sbyXxvK2R2 +kZbd+zANWoHURA1nSBe5WU4X2jxnpWZsV5IcpcskGWqSMftJSU+e9OAbVxLfubFC +9pC1JCs3VtnHv5o3m4Ypi8ljousgiuwD7NgXaOGAHfnXFHwmjTNNhtNA6do/5IGV +KY8zPzyHIuufkwGAXWZeOs7W2AzUAKZsV7ZkE+C4SWNYuLcDj2k4SW46I1NyDe6x +p3QcbNq/9c4G7Ud1S/vxfaQ/jnBEVRDasjVJ8197yCSzjM9PHwMLfRStxqrUGhS/ +BcTeCaDwZIbScjcbF1LBeU5G4gc9CoyWUFKGYbJ+Dk+j+GOi0+2i5xss4daQn8xV +BzeHDn6W4YMeVFOETozEWqotUi8ZI+N3ObSM5H141qHc78WWyG8WY/FgV2lvYuag +3MawO8YYM95/VN0GIUxQXdBPrZrFlyL+8Vx1V7vI0sXTrdaVeB7B3lTgYwV64bVg +8Pac8zgd3vwYI5u71K8JIB4rCujY4SW2YF712/tKG8jsNEf2D14fFRoGtX1yg41u +k0LZkLzo/ldaKFiVYRTY6qwKgN7jBxO4/89Fm9W4w3dTDE2mOX7laX6oDTZWGh64 +HJOEFqjE6LCTsSnqN2da2GGm5AY= +-----END CERTIFICATE----- diff --git a/cockpit/caddy/certs/san.cnf b/cockpit/caddy/certs/san.cnf new file mode 100644 index 0000000..d43971c --- /dev/null +++ b/cockpit/caddy/certs/san.cnf @@ -0,0 +1,16 @@ +[req] +distinguished_name = req_distinguished_name +req_extensions = v3_req +prompt = no +[req_distinguished_name] +CN = 10.4.7.18 +O = Foodlinkk Cockpit +[v3_req] +keyUsage = digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = @alt_names +[alt_names] +IP.1 = 10.4.7.18 +IP.2 = 127.0.0.1 +DNS.1 = localhost +DNS.2 = cockpit.foodlinkk.local diff --git a/cockpit/static/cockpit-local-ca.crt b/cockpit/static/cockpit-local-ca.crt new file mode 100644 index 0000000..b9cecd8 --- /dev/null +++ b/cockpit/static/cockpit-local-ca.crt @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFQzCCAyugAwIBAgIUJ6uaOJkACcf7vpmK8MXaS3fvsWUwDQYJKoZIhvcNAQEL +BQAwMTEbMBkGA1UEAwwSRm9vZGxpbmtrIExvY2FsIENBMRIwEAYDVQQKDAlGb29k +bGlua2swHhcNMjYwODAxMTI0NzQxWhcNMzYwNzI5MTI0NzQxWjAxMRswGQYDVQQD +DBJGb29kbGlua2sgTG9jYWwgQ0ExEjAQBgNVBAoMCUZvb2RsaW5razCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBANrTeWKvnyvmwb3DRkm6ny2hu6vhxpoL +q3t06nwFqmfXDQDsLVXU9f+QL1tqKnUUMNF5DXJImbraa0fouH1Tn/iVIENrThrJ +BdJs+KgX8Ie8iVppzgFZw5/N5kmbsEeAAtaibyulCtvLqyC2hfV04I6TxEPBzTpp +a8jjGqPtVlGnzfBWyK10sZ/X28CQ1SORRKNCYWRVIfUL2S41/jBPrpAWfciKXefO +1azAXRo5C9Ava2L9w+LhK1FSA+SMJ14YG+SXbX+3gmBYuJ0v18pbnMicpznWDoqu +8e67NufkhDjTbOTKX4Jb8jVrIAVlmIUXLDLE3wRKuymhzEAXiY9H28ipWTuPG3jK +tVXzIxHLMr8nvnba5RMA0vsPo3fidRf8fRSbt3HgwCz4/7royAJjq853ydoWQiIZ +2sn9l8xBNzLSz7U+AaR12p+lgQuHdTNqNWrKD6f3q9kMioF3C23/22bSVVGAvqaa +/LHGZSnN8KhqXHcDDztN9X1BjpKxxDkSmmmi78C1wVib26Gu17X8RZl+XAE0QRfA +W61XF5it9ww7YlYG44kFCUO62bTnRVcE8suNHh2ShXqRZrHAxOkKCq2KXFKQ31L6 +jOt0cM7dCMFhu5l623PRHLnK/5SjBm/htC5Yah9kKQ66B6djbRx+zuvILAOgdLRa +cR/at+Cv0mfZAgMBAAGjUzBRMB0GA1UdDgQWBBT6s+9dKpDnca/oePjCHz3V2NbY +qTAfBgNVHSMEGDAWgBT6s+9dKpDnca/oePjCHz3V2NbYqTAPBgNVHRMBAf8EBTAD +AQH/MA0GCSqGSIb3DQEBCwUAA4ICAQBsSzJCJHw5+Ko8NrseNhgbs7MROKZ1INR7 +7nQgFD1yWS1VdDcKG64W6VSFb7aiJIIlLlkwB4AK8nJ/pcJumgcfOYDVHFVwydXW +A2NGsvs2pDFmNtGkwBFmCUwkYym/VaA1V4vwwM4EmhgFMeKsFy44mFi6DyDQkviV +r/xa5Yqu1ar6o3iZfqtopoeg25g0UG/8qPsbwgNBAPmt6kPCkBBtHh8DXEJA56Bp +33kfJexLfXESEeO2civRzZd3SHDAH0/RuN89jm+BwLzYar6PlFMw3ZNhD33mOdUn +Q9h1c5LoPAXJBbI/6zn9lOYzI4jC0zjcf5UXPnarAODcrwH+P6ya/W42V2HryY3A +ad6QlQy5kzKKowhezPvgUFyT6gEg7Ueg1FijmeYRvyToP0cgT8vrtlEkgZ6xiUa5 +ENUBmbHSxrZAqFLffFlvEWMTsXtU442D/aO0BWFTokLElfNMT7OoDmaQU6uwDmXe +A1IyiqVGPQlEy4D/OkU74dbVirjoyv4Ub4QysQn2KckC7h8x1ZyLyiTRbmr1+S5W +dzd4VKck45SG/gWUByskEq8Z/BPwNSh8v0stoCYoT2QLKCTtyoM9m8SD2GyvytRa +UTe6x2R4Fwf+Hm+B+M/SclYsAOf8lUUto046gHs8V3kcu4zlyP5tF5J6maLoAmt0 +c9ibkVARig== +-----END CERTIFICATE----- diff --git a/cockpit/static/css/analytics.css b/cockpit/static/css/analytics.css index 87c7fd2..4821a9c 100644 --- a/cockpit/static/css/analytics.css +++ b/cockpit/static/css/analytics.css @@ -1,50 +1,333 @@ -.analytics-shell { - display: grid; - grid-template-columns: 240px 1fr; - gap: 1rem; - align-items: start; +/* Foodlinkk Intelligence Analytics — lush neo dashboard */ +.ax-shell { + --ax-bg: #070b12; + --ax-panel: rgba(12, 18, 28, 0.88); + --ax-line: rgba(125, 211, 252, 0.16); + --ax-cyan: #38bdf8; + --ax-lime: #b8ff3c; + --ax-amber: #fbbf24; + --ax-rose: #fb7185; + --ax-text: #e8eef7; + --ax-muted: #8fa3b8; + --ax-font: "Outfit", "Segoe UI", sans-serif; + --ax-mono: "JetBrains Mono", ui-monospace, monospace; + font-family: var(--ax-font); + color: var(--ax-text); + margin: -0.25rem -0.5rem 0; + padding: 0 0 2.5rem; + position: relative; } -.analytics-filters label { - display: block; - margin-bottom: 0.75rem; - font-size: 0.85rem; - color: #94a3b8; -} -.analytics-filters select, -.analytics-filters input { - width: 100%; - margin-top: 0.25rem; -} -.analytics-main { min-width: 0; } -.analytics-kpis { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 0.75rem; +.ax-shell * { box-sizing: border-box; } +.ax-mono { font-family: var(--ax-mono); font-variant-numeric: tabular-nums; } +.ax-muted { color: var(--ax-muted); font-size: 0.82rem; } +.ax-shell code { font-family: var(--ax-mono); font-size: 0.72rem; color: #7dd3fc; } + +.ax-hero { + position: relative; + overflow: hidden; + border-radius: 22px; + border: 1px solid var(--ax-line); + background: + radial-gradient(1200px 420px at 12% -10%, rgba(56,189,248,0.22), transparent 55%), + radial-gradient(900px 380px at 90% 0%, rgba(184,255,60,0.12), transparent 50%), + linear-gradient(160deg, #0b1422 0%, #0a101a 55%, #0d1520 100%); + padding: 1.4rem 1.5rem 1.2rem; margin-bottom: 1rem; + animation: ax-fade-up .55s ease both; } -.analytics-kpis .kpi-card { - padding: 0.75rem 1rem; - border-radius: 10px; - background: rgba(15, 23, 42, 0.8); - border: 1px solid rgba(148, 163, 184, 0.15); +.ax-hero-glow { + position: absolute; inset: auto -20% -40% 40%; height: 70%; + background: radial-gradient(circle, rgba(56,189,248,0.18), transparent 65%); + pointer-events: none; filter: blur(8px); } -.analytics-kpis .kpi-card strong { - display: block; - font-size: 1.35rem; - color: #e2e8f0; -} -.analytics-kpis .kpi-card span { - font-size: 0.75rem; - color: #64748b; -} -.analytics-charts { +.ax-hero-grid { + position: relative; display: grid; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - gap: 1rem; - margin-bottom: 1.5rem; + grid-template-columns: 1.4fr auto 1fr; + gap: 1.25rem; + align-items: center; } -.chart-panel canvas { max-height: 220px; } -.chart-panel h4 { margin: 0 0 0.75rem; font-size: 0.9rem; color: #cbd5e1; } -@media (max-width: 900px) { - .analytics-shell { grid-template-columns: 1fr; } +.ax-eyebrow { + margin: 0 0 0.35rem; + font-size: 0.68rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: #64748b; + font-weight: 600; +} +.ax-hero h1 { + margin: 0; + font-size: clamp(2rem, 4vw, 2.75rem); + font-weight: 800; + letter-spacing: -0.03em; + line-height: 1.05; +} +.ax-live { + display: inline-flex; align-items: center; + margin-left: 0.55rem; vertical-align: middle; + font-size: 0.62rem; font-weight: 700; letter-spacing: 0.08em; + padding: 0.22rem 0.55rem; border-radius: 999px; + background: rgba(184,255,60,0.12); border: 1px solid rgba(184,255,60,0.35); + color: #d9ff8b; animation: ax-pulse 2.4s ease-in-out infinite; +} +.ax-lead { margin: 0.65rem 0 0; max-width: 36rem; color: #b6c7d8; font-size: 1rem; line-height: 1.5; } +.ax-goal { margin: 0.55rem 0 0; color: #7dd3fc; font-size: 0.82rem; opacity: 0.9; } + +.ax-pulse-orb { text-align: center; position: relative; width: 150px; justify-self: center; } +.ax-ring { width: 150px; height: 150px; transform: rotate(-90deg); } +.ax-ring-track { fill: none; stroke: rgba(148,163,184,0.15); stroke-width: 8; } +.ax-ring-value { + fill: none; stroke: var(--ax-cyan); stroke-width: 8; stroke-linecap: round; + transition: stroke-dasharray .8s ease; + filter: drop-shadow(0 0 8px rgba(56,189,248,0.45)); +} +.tone-good .ax-ring-value { stroke: var(--ax-lime); filter: drop-shadow(0 0 8px rgba(184,255,60,0.4)); } +.tone-mid .ax-ring-value { stroke: var(--ax-amber); } +.tone-low .ax-ring-value { stroke: var(--ax-rose); } +.ax-pulse-core { + position: absolute; inset: 0; display: grid; place-content: center; text-align: center; + transform: translateY(-6px); +} +.ax-pulse-core strong { font-size: 2.1rem; font-weight: 700; line-height: 1; } +.ax-pulse-core span { font-size: 0.68rem; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ax-muted); } +.ax-pulse-label { margin: 0.15rem 0 0; font-size: 0.75rem; color: #cbd5e1; } + +.ax-hero-actions { display: flex; flex-wrap: wrap; gap: 0.45rem; justify-content: flex-end; align-items: center; } +.ax-btn { + appearance: none; border: 1px solid rgba(148,163,184,0.28); + background: rgba(8,12,20,0.55); color: #e2e8f0; + border-radius: 999px; padding: 0.45rem 0.85rem; font: inherit; font-size: 0.78rem; + cursor: pointer; text-decoration: none; transition: border-color .15s, background .15s, transform .12s; +} +.ax-btn:hover { border-color: #38bdf8; background: rgba(14,165,233,0.12); transform: translateY(-1px); } +.ax-btn-primary { + border-color: rgba(184,255,60,0.45); + background: linear-gradient(135deg, rgba(184,255,60,0.18), rgba(56,189,248,0.12)); + color: #f8ffe8; +} +.ax-updated { width: 100%; text-align: right; color: #64748b; font-size: 0.7rem; } + +.ax-filters { + margin-top: 1rem; padding-top: 0.9rem; border-top: 1px solid rgba(148,163,184,0.12); + display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.65rem; align-items: end; +} +.ax-filters label { font-size: 0.72rem; color: #94a3b8; display: grid; gap: 0.25rem; } +.ax-filters select, .ax-filters input { + width: 100%; border-radius: 8px; border: 1px solid rgba(148,163,184,0.25); + background: #0a1018; color: #e2e8f0; padding: 0.4rem 0.5rem; font: inherit; +} + +.ax-signals { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 0.65rem; + margin-bottom: 1rem; + animation: ax-fade-up .6s ease both .08s; +} +.ax-signal { + position: relative; overflow: hidden; + background: var(--ax-panel); + border: 1px solid var(--ax-line); + border-radius: 16px; + padding: 0.85rem 0.9rem 0.75rem; + min-height: 96px; +} +.ax-signal::after { + content: ""; position: absolute; inset: auto 0 0 0; height: 2px; + background: linear-gradient(90deg, transparent, var(--ax-cyan), transparent); + opacity: 0.55; +} +.ax-signal.good::after { background: linear-gradient(90deg, transparent, var(--ax-lime), transparent); } +.ax-signal.warn::after { background: linear-gradient(90deg, transparent, var(--ax-amber), transparent); } +.ax-signal-label { display: block; font-size: 0.68rem; color: #94a3b8; letter-spacing: 0.04em; text-transform: uppercase; } +.ax-signal strong { display: block; margin-top: 0.35rem; font-size: 1.35rem; font-weight: 700; letter-spacing: -0.02em; } +.ax-signal em { display: block; margin-top: 0.25rem; font-style: normal; font-size: 0.72rem; color: #64748b; } + +.ax-split { + display: grid; grid-template-columns: 1fr 1fr; gap: 0.85rem; margin-bottom: 1rem; + animation: ax-fade-up .65s ease both .12s; +} +.ax-col { + background: var(--ax-panel); + border: 1px solid var(--ax-line); + border-radius: 18px; + padding: 1rem; + min-height: 320px; +} +.ax-col-win { box-shadow: inset 3px 0 0 rgba(184,255,60,0.55); } +.ax-col-opp { box-shadow: inset 3px 0 0 rgba(251,191,36,0.55); } +.ax-col-head { + display: flex; justify-content: space-between; align-items: baseline; gap: 0.75rem; + margin-bottom: 0.85rem; +} +.ax-col-head h2, .ax-hermes h2, .ax-radar h2 { + margin: 0; font-size: 1.15rem; font-weight: 700; letter-spacing: -0.02em; +} +.ax-col-head span, .ax-pill { + font-size: 0.72rem; color: #94a3b8; + padding: 0.2rem 0.55rem; border-radius: 999px; + border: 1px solid rgba(148,163,184,0.2); +} + +.ax-insight { + display: block; text-decoration: none; color: inherit; + border: 1px solid rgba(148,163,184,0.12); + background: rgba(6, 10, 16, 0.55); + border-radius: 14px; + padding: 0.8rem 0.85rem; + margin-bottom: 0.55rem; + transition: transform .14s, border-color .14s, background .14s; +} +.ax-insight:hover { + transform: translateY(-2px); + border-color: rgba(56,189,248,0.35); + background: rgba(14,165,233,0.08); +} +.ax-insight-top { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; } +.ax-insight-top strong { font-size: 0.95rem; line-height: 1.3; } +.ax-score { + font-size: 0.85rem; color: #d9ff8b; min-width: 2rem; text-align: right; +} +.ax-insight.opp .ax-score { color: #fcd34d; } +.ax-insight p { margin: 0.4rem 0 0.55rem; font-size: 0.82rem; color: #a7b7c8; line-height: 1.45; } +.ax-insight-foot { display: grid; gap: 0.35rem; } +.ax-insight-foot > span:first-child { font-size: 0.72rem; color: #7dd3fc; } +.ax-bar, .ax-mini-bar { + display: block; height: 4px; border-radius: 999px; background: rgba(148,163,184,0.15); overflow: hidden; +} +.ax-bar i, .ax-mini-bar i { + display: block; height: 100%; border-radius: inherit; + background: linear-gradient(90deg, #84cc16, #b8ff3c); +} +.ax-bar.opp i { background: linear-gradient(90deg, #f59e0b, #fbbf24); } +.ax-tag { + display: inline-block; font-size: 0.62rem; letter-spacing: 0.06em; text-transform: uppercase; + padding: 0.12rem 0.4rem; border-radius: 999px; margin-right: 0.25rem; + background: rgba(251,191,36,0.12); color: #fcd34d; border: 1px solid rgba(251,191,36,0.25); +} +.ax-empty { color: #64748b; font-size: 0.85rem; padding: 1rem 0; } + +.ax-band { margin-bottom: 1rem; animation: ax-fade-up .7s ease both .16s; } +.ax-att-row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0 1rem; } +.ax-att-chip { + flex: 1 1 220px; text-decoration: none; color: inherit; + border: 1px solid rgba(251,113,133,0.28); + background: rgba(251,113,133,0.08); + border-radius: 12px; padding: 0.7rem 0.8rem; +} +.ax-att-chip strong { display: block; font-size: 0.85rem; margin-bottom: 0.2rem; } +.ax-att-chip span { font-size: 0.75rem; color: #cbd5e1; } +.ax-attention h3 { margin: 0 0 0.35rem; font-size: 0.85rem; color: #fda4af; letter-spacing: 0.04em; text-transform: uppercase; } + +.ax-hermes { + border: 1px solid var(--ax-line); + border-radius: 18px; + background: + radial-gradient(700px 220px at 0% 0%, rgba(56,189,248,0.12), transparent 55%), + var(--ax-panel); + padding: 1rem 1.1rem 1.1rem; +} +.ax-findings-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.65rem; margin-top: 0.75rem; +} +.ax-finding { + border-radius: 14px; border: 1px solid rgba(148,163,184,0.16); + background: rgba(5,9,14,0.65); padding: 0.85rem; +} +.ax-finding.kind-win { border-color: rgba(184,255,60,0.3); } +.ax-finding.kind-opportunity { border-color: rgba(251,191,36,0.35); } +.ax-finding-top { display: flex; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.35rem; } +.ax-finding-top time { font-size: 0.68rem; color: #64748b; } +.ax-finding h4 { margin: 0; font-size: 0.95rem; } +.ax-finding p { margin: 0.4rem 0 0.55rem; font-size: 0.8rem; color: #a7b7c8; line-height: 1.45; } +.ax-finding-foot { display: flex; justify-content: space-between; font-size: 0.7rem; color: #64748b; } +.ax-findings-empty { margin-top: 0.75rem; } +.ax-findings-placeholder { + border: 1px dashed rgba(56,189,248,0.35); + border-radius: 14px; padding: 1.1rem 1.2rem; + background: rgba(14,165,233,0.05); +} +.ax-findings-placeholder strong { display: block; margin-bottom: 0.3rem; } +.ax-findings-placeholder p { margin: 0; color: #94a3b8; font-size: 0.85rem; line-height: 1.5; } + +.ax-radar { + border: 1px solid var(--ax-line); + border-radius: 18px; + background: var(--ax-panel); + padding: 1rem 1.1rem 1.15rem; + margin-bottom: 1rem; + animation: ax-fade-up .75s ease both .2s; +} +.ax-charts { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 0.75rem; +} +.ax-chart-card { + grid-column: span 2; + border: 1px solid rgba(148,163,184,0.12); + border-radius: 14px; + background: rgba(5,9,14,0.55); + padding: 0.75rem 0.8rem 0.6rem; +} +.ax-chart-card.wide { grid-column: span 6; } +.ax-chart-card h3 { margin: 0 0 0.45rem; font-size: 0.82rem; color: #cbd5e1; font-weight: 600; } +.ax-chart-wrap { position: relative; height: 210px; } +.ax-chart-card.wide .ax-chart-wrap { height: 180px; } + +.ax-lists { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.85rem; + animation: ax-fade-up .8s ease both .24s; +} +.ax-list-card { + border: 1px solid var(--ax-line); + border-radius: 16px; + background: var(--ax-panel); + padding: 0.9rem 1rem; +} +.ax-list-card h3 { margin: 0 0 0.75rem; font-size: 0.92rem; } +.ax-list-card ul { list-style: none; margin: 0; padding: 0; } +.ax-list-card li { + display: grid; grid-template-columns: auto 1fr auto; gap: 0.65rem; align-items: center; + padding: 0.55rem 0; border-bottom: 1px solid rgba(148,163,184,0.1); +} +.ax-rank { + width: 1.5rem; height: 1.5rem; border-radius: 8px; + display: grid; place-items: center; font-size: 0.72rem; + background: rgba(56,189,248,0.12); color: #7dd3fc; +} +.ax-list-card li strong { display: block; font-size: 0.82rem; } +.ax-list-card li em { font-style: normal; color: #fbbf24; font-size: 0.85rem; } +.ax-events li { grid-template-columns: 1fr auto; } +.ax-events li span { display: block; font-size: 0.7rem; color: #64748b; margin-top: 0.15rem; } +.ax-events time { font-size: 0.68rem; color: #64748b; } +.ax-dl { margin: 0; display: grid; gap: 0.55rem; } +.ax-dl > div { + display: flex; justify-content: space-between; gap: 0.75rem; + padding: 0.45rem 0; border-bottom: 1px solid rgba(148,163,184,0.1); +} +.ax-dl dt { color: #94a3b8; font-size: 0.8rem; } +.ax-dl dd { margin: 0; font-size: 0.9rem; color: #e2e8f0; } + +@keyframes ax-fade-up { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes ax-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(184,255,60,0.0); } + 50% { box-shadow: 0 0 0 6px rgba(184,255,60,0.08); } +} + +@media (max-width: 1100px) { + .ax-hero-grid { grid-template-columns: 1fr; } + .ax-hero-actions { justify-content: flex-start; } + .ax-signals { grid-template-columns: repeat(3, 1fr); } + .ax-split, .ax-lists { grid-template-columns: 1fr; } + .ax-charts { grid-template-columns: 1fr 1fr; } + .ax-chart-card, .ax-chart-card.wide { grid-column: span 2; } +} +@media (max-width: 700px) { + .ax-signals { grid-template-columns: 1fr 1fr; } + .ax-charts { grid-template-columns: 1fr; } + .ax-chart-card, .ax-chart-card.wide { grid-column: span 1; } } diff --git a/cockpit/static/css/herman-assistant.css b/cockpit/static/css/herman-assistant.css index 93974c2..b99cd6f 100644 --- a/cockpit/static/css/herman-assistant.css +++ b/cockpit/static/css/herman-assistant.css @@ -1,34 +1,72 @@ -/* Herman browser assistant — floating widget */ -.ha-fab { - position: fixed; - right: 1.25rem; - bottom: 1.25rem; - z-index: 1500; - width: 56px; - height: 56px; - border-radius: 50%; - border: 2px solid rgba(167, 139, 250, 0.55); - background: linear-gradient(145deg, #1e293b, #0f1419); - color: #f8fafc; - font-size: 1.5rem; - cursor: pointer; - box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); - transition: transform 0.15s, box-shadow 0.15s; +/* Herman / Hermes chat — always-on floating dock */ +.ha-dock { + /* wrapper only; FAB/panel are fixed to viewport */ +} +.ha-dock-hidden { + display: none !important; +} + +.ha-fab { + position: fixed !important; + right: max(1.25rem, env(safe-area-inset-right, 0px)); + bottom: max(1.25rem, env(safe-area-inset-bottom, 0px)); + z-index: 10050 !important; + height: 52px; + width: auto; + min-width: 52px; + padding: 0 1.15rem 0 0.9rem; + border-radius: 999px; + border: 1px solid rgba(56, 189, 248, 0.65); + background: linear-gradient(145deg, #0ea5e9 0%, #0284c7 55%, #0369a1 100%); + color: #f8fafc; + font-size: 1.15rem; + cursor: pointer; + display: inline-flex !important; + align-items: center; + justify-content: center; + gap: 0.45rem; + box-shadow: + 0 0 0 4px rgba(14, 165, 233, 0.18), + 0 10px 28px rgba(0, 0, 0, 0.5); + transition: transform 0.15s, box-shadow 0.15s, background 0.15s; + pointer-events: auto; +} +.ha-fab-icon { line-height: 1; font-size: 1.2rem; } +.ha-fab-label { + font-size: 0.88rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: none; +} +.ha-fab:hover { + transform: translateY(-2px) scale(1.03); + box-shadow: + 0 0 0 5px rgba(14, 165, 233, 0.28), + 0 14px 34px rgba(0, 0, 0, 0.55); +} +.ha-fab.is-open { + background: linear-gradient(145deg, #4c1d95, #1e1b4b); + border-color: rgba(167, 139, 250, 0.7); + box-shadow: + 0 0 0 4px rgba(167, 139, 250, 0.2), + 0 10px 28px rgba(0, 0, 0, 0.5); +} +.ha-fab.is-busy { + border-color: #38bdf8; + animation: ha-pulse 1.2s infinite; } -.ha-fab:hover { transform: scale(1.06); box-shadow: 0 10px 32px rgba(167, 139, 250, 0.25); } -.ha-fab.is-open { background: linear-gradient(145deg, #4c1d95, #1e1b4b); } .ha-panel { - position: fixed; - right: 1rem; - bottom: 5rem; - z-index: 1499; - width: min(400px, calc(100vw - 2rem)); - max-height: min(560px, calc(100vh - 7rem)); + position: fixed !important; + right: max(1rem, env(safe-area-inset-right, 0px)); + bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px)); + z-index: 10049 !important; + width: min(420px, calc(100vw - 2rem)); + max-height: min(620px, calc(100vh - 8rem)); display: flex; flex-direction: column; border-radius: 16px; - border: 1px solid rgba(167, 139, 250, 0.35); + border: 1px solid rgba(56, 189, 248, 0.35); background: rgba(10, 14, 20, 0.97); box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55); overflow: hidden; @@ -41,11 +79,33 @@ padding: 0.65rem 0.85rem; border-bottom: 1px solid rgba(148, 163, 184, 0.12); background: rgba(167, 139, 250, 0.08); + gap: 0.5rem; } -.ha-head h2 { margin: 0; font-size: 0.9rem; color: #f8fafc; } -.ha-head-actions { display: flex; gap: 0.35rem; align-items: center; } +.ha-head-title { min-width: 0; } +.ha-head h2 { margin: 0; font-size: 0.9rem; color: #f8fafc; display: flex; align-items: center; gap: 0.4rem; } +.ha-badge { + font-size: 0.62rem; + font-weight: 600; + color: #ddd6fe; + background: rgba(124, 58, 237, 0.35); + border: 1px solid rgba(167, 139, 250, 0.45); + border-radius: 999px; + padding: 0.12rem 0.45rem; + letter-spacing: 0.02em; +} +.ha-conn { + margin: 0.15rem 0 0; + font-size: 0.62rem; + color: #64748b; +} +.ha-conn.is-ok { color: #34d399; } +.ha-conn.is-err { color: #f87171; } +.ha-head-actions { display: flex; gap: 0.35rem; align-items: center; flex-shrink: 0; } .ha-head-actions a { font-size: 0.72rem; color: #a78bfa; text-decoration: none; } .ha-head-actions button { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 1.1rem; padding: 0.2rem; } +.ha-text-btn { font-size: 0.68rem !important; color: #94a3b8 !important; padding: 0.2rem 0.35rem !important; } +.ha-text-btn:hover { color: #e2e8f0 !important; } +.ha-text-btn:disabled { opacity: 0.4; cursor: not-allowed; } .ha-confirm { padding: 0.5rem 0.75rem; @@ -63,8 +123,8 @@ display: flex; flex-direction: column; gap: 0.5rem; - min-height: 200px; - max-height: 320px; + min-height: 220px; + max-height: 360px; } .ha-bubble { max-width: 92%; @@ -103,6 +163,7 @@ padding: 0.55rem 0.65rem; border-top: 1px solid rgba(148, 163, 184, 0.12); align-items: center; + margin: 0; } .ha-input-row .form-input { flex: 1; font-size: 0.8rem; padding: 0.45rem 0.6rem; } .ha-mic { @@ -122,7 +183,20 @@ 50% { box-shadow: 0 0 0 8px rgba(248, 113, 113, 0.28); } } -.ha-status { font-size: 0.65rem; color: #64748b; padding: 0 0.65rem 0.35rem; } +.ha-status { font-size: 0.72rem; color: #cbd5e1; padding: 0.25rem 0.65rem 0.4rem; line-height: 1.35; } +.ha-status.is-error { color: #fecaca; background: rgba(127, 29, 29, 0.35); border-radius: 6px; margin: 0 0.5rem 0.35rem; padding: 0.4rem 0.55rem; } +.ha-secure-hint { + margin: 0.25rem 0.55rem 0.45rem; + padding: 0.55rem 0.65rem; + border-radius: 8px; + background: rgba(14, 165, 233, 0.12); + border: 1px solid rgba(56, 189, 248, 0.45); + color: #e2e8f0; + font-size: 0.78rem; + line-height: 1.4; +} +.ha-secure-hint strong { color: #f8fafc; display: block; margin-bottom: 0.25rem; } +.ha-secure-hint a { display: inline-block; margin-top: 0.4rem; } .ha-results-modal { z-index: 1600; } .ha-results-card { width: min(640px, 96vw); max-height: 80vh; } @@ -143,6 +217,88 @@ .voice-results-table a { color: #38bdf8; } @media (max-width: 480px) { - .ha-panel { right: 0.5rem; left: 0.5rem; width: auto; bottom: 4.5rem; } - .ha-fab { right: 1rem; bottom: 1rem; } + .ha-fab { + right: 1rem; + bottom: calc(4.25rem + env(safe-area-inset-bottom, 0px)); /* above mobile bottom nav */ + } + .ha-panel { + right: 0.5rem; + left: 0.5rem; + width: auto; + bottom: calc(7.5rem + env(safe-area-inset-bottom, 0px)); + } +} + +.ha-toggles { + display: flex; + flex-wrap: wrap; + gap: 0.55rem 0.9rem; + padding: 0.15rem 0.65rem 0.35rem; + font-size: 0.68rem; + color: #94a3b8; +} +.ha-toggles label { + display: inline-flex; + align-items: center; + gap: 0.3rem; + cursor: pointer; + user-select: none; +} +.ha-toggles input { + accent-color: #38bdf8; + margin: 0; +} +.ha-mic.is-listening { + box-shadow: 0 0 0 2px rgba(248, 113, 113, 0.35); +} + +/* Toasts boven de Herman FAB, niet erachter */ +.toast-container { + bottom: 5.75rem !important; + right: 1rem !important; + left: auto !important; + z-index: 10120 !important; + max-width: min(360px, calc(100vw - 2rem)); +} +.toast { + color: #f1f5f9 !important; + background: #0f172a !important; + box-shadow: 0 8px 24px rgba(0,0,0,0.45); +} +.toast-error { border-color: #f87171 !important; color: #fecaca !important; } + +.ha-https-prompt { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + margin: 0 0.55rem 0.4rem; + padding: 0.45rem 0.55rem; + border-radius: 8px; + background: rgba(14, 165, 233, 0.12); + border: 1px solid rgba(56, 189, 248, 0.4); + color: #e2e8f0; + font-size: 0.75rem; +} +.ha-https-prompt .btn { flex-shrink: 0; } +.ha-https-prompt .ha-text-btn { margin-left: auto; color: #94a3b8; } + +.ha-fab.has-custom-pos { + right: auto !important; + bottom: auto !important; +} +.ha-fab.is-dragging { + cursor: grabbing !important; + transition: none !important; + transform: none !important; + user-select: none; + touch-action: none; +} +.ha-fab { + touch-action: none; + user-select: none; +} +.ha-panel.has-custom-pos { + right: auto !important; + bottom: auto !important; } diff --git a/cockpit/static/css/hermes.css b/cockpit/static/css/hermes.css index a4a98ba..5f9f78d 100644 --- a/cockpit/static/css/hermes.css +++ b/cockpit/static/css/hermes.css @@ -216,3 +216,162 @@ .dot-in { background: var(--hm-cyan); } .dot-out { background: var(--hm-purple); } .dot-edge { background: var(--hm-orange); } + + +.hm-section-title { + font-size: 0.85rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #94a3b8; + margin: 1.25rem 0 0.75rem; +} +.hm-bucket-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 0.75rem; +} +.hm-bucket-card { + background: rgba(15, 23, 42, 0.85); + border: 1px solid rgba(56, 189, 248, 0.2); + border-radius: 12px; + padding: 0.85rem 0.9rem; +} +.hm-bucket-label { font-size: 0.72rem; color: #94a3b8; margin-bottom: 0.35rem; } +.hm-bucket-count { font-size: 1.6rem; font-weight: 700; color: #f1f5f9; line-height: 1.1; } +.hm-bucket-parts { font-size: 0.68rem; color: #64748b; margin-top: 0.4rem; line-height: 1.35; } +.hm-tool-list { list-style: none; margin: 0.75rem 0 0; padding: 0; } +.hm-tool-list li { + display: flex; justify-content: space-between; gap: 0.75rem; + padding: 0.35rem 0; border-bottom: 1px solid rgba(148,163,184,0.1); + font-size: 0.78rem; color: #cbd5e1; +} +.hm-tool-list li strong { color: #e2e8f0; font-variant-numeric: tabular-nums; } +.hm-tool-list-tall { max-height: 280px; overflow: auto; } +.status-badge.on { color: #b8ff3c; } +.status-badge.off { color: #f87171; } + +/* Clickable buckets + explorer modal */ +.hm-section-hint { font-size: 0.7rem; font-weight: 500; color: #64748b; text-transform: none; letter-spacing: 0; margin-left: 0.5rem; } +.hm-bucket-click { + display: block; width: 100%; text-align: left; cursor: pointer; + transition: border-color .15s, transform .12s, background .15s; + font: inherit; color: inherit; +} +.hm-bucket-click:hover { + border-color: rgba(0, 229, 255, 0.55); + background: rgba(14, 165, 233, 0.08); + transform: translateY(-1px); +} +.hm-bucket-open { + margin-top: 0.55rem; font-size: 0.68rem; color: #38bdf8; opacity: 0.75; +} +.hm-bucket-click:hover .hm-bucket-open { opacity: 1; } + +.hm-modal-backdrop { + position: fixed; inset: 0; z-index: 80; + background: rgba(2, 6, 14, 0.72); + backdrop-filter: blur(4px); + display: flex; align-items: center; justify-content: center; + padding: 1rem; +} +.hm-modal { + width: min(1100px, 96vw); + height: min(820px, 92vh); + background: #0b1220; + border: 1px solid rgba(56, 189, 248, 0.28); + border-radius: 14px; + box-shadow: 0 24px 80px rgba(0,0,0,0.55); + display: flex; flex-direction: column; + overflow: hidden; +} +.hm-modal-head { + display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; + padding: 1rem 1.1rem 0.75rem; + border-bottom: 1px solid rgba(148,163,184,0.12); +} +.hm-modal-head h2 { margin: 0; font-size: 1.15rem; color: #f1f5f9; } +.hm-modal-sub { margin: 0.35rem 0 0; font-size: 0.72rem; color: #94a3b8; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; } +.hm-modal-close { + background: transparent; border: 0; color: #94a3b8; font-size: 1.6rem; line-height: 1; cursor: pointer; padding: 0 0.25rem; +} +.hm-modal-close:hover { color: #fff; } +.hm-chip { + border: 1px solid rgba(148,163,184,0.25); + background: rgba(15,23,42,0.7); + color: #cbd5e1; border-radius: 999px; + padding: 0.15rem 0.55rem; font-size: 0.65rem; cursor: pointer; +} +.hm-chip.active, .hm-chip:hover { border-color: #38bdf8; color: #e0f2fe; background: rgba(14,165,233,0.12); } + +.hm-modal-tabs { + display: flex; gap: 0.25rem; padding: 0.55rem 1rem 0; + border-bottom: 1px solid rgba(148,163,184,0.1); +} +.hm-modal-tabs button { + background: transparent; border: 0; color: #94a3b8; padding: 0.45rem 0.75rem; + font-size: 0.78rem; cursor: pointer; border-bottom: 2px solid transparent; +} +.hm-modal-tabs button.active { color: #e0f2fe; border-bottom-color: #38bdf8; } + +.hm-modal-body { flex: 1; min-height: 0; } +.hm-modal-split { display: grid; grid-template-columns: minmax(260px, 38%) 1fr; } +.hm-modal-side { + border-right: 1px solid rgba(148,163,184,0.12); + overflow: auto; background: rgba(8, 12, 20, 0.9); + position: relative; +} +.hm-modal-preview { overflow: auto; padding: 1rem 1.1rem; } +.hm-side-loading { + position: sticky; top: 0; z-index: 2; + padding: 0.4rem 0.75rem; font-size: 0.72rem; color: #38bdf8; + background: rgba(14,165,233,0.08); +} +.hm-event-list, .hm-file-list { list-style: none; margin: 0; padding: 0.35rem; } +.hm-event-item, .hm-file-item { + width: 100%; text-align: left; cursor: pointer; + background: transparent; border: 1px solid transparent; border-radius: 8px; + padding: 0.55rem 0.6rem; color: inherit; font: inherit; margin-bottom: 0.25rem; +} +.hm-event-item:hover, .hm-file-item:hover, +.hm-event-item.active, .hm-file-item.active { + background: rgba(14,165,233,0.1); + border-color: rgba(56,189,248,0.25); +} +.hm-event-top { display: flex; justify-content: space-between; gap: 0.5rem; } +.hm-event-top strong { font-size: 0.8rem; color: #f1f5f9; } +.hm-event-top time, .hm-file-meta time { font-size: 0.65rem; color: #64748b; white-space: nowrap; } +.hm-event-tool { font-size: 0.65rem; color: #38bdf8; margin-top: 0.15rem; } +.hm-event-preview, .hm-file-path { + font-size: 0.68rem; color: #94a3b8; margin-top: 0.25rem; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.hm-file-name { display: flex; gap: 0.4rem; align-items: center; } +.hm-file-name strong { font-size: 0.8rem; color: #e2e8f0; } +.hm-file-meta { display: flex; justify-content: space-between; gap: 0.5rem; margin-top: 0.25rem; font-size: 0.65rem; color: #64748b; } +.hm-explorer-bar { + display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; + padding: 0.55rem 0.65rem; border-bottom: 1px solid rgba(148,163,184,0.1); + position: sticky; top: 0; background: #0b1220; z-index: 1; +} +.hm-explorer-path { + font-size: 0.65rem; color: #7dd3fc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; +} +.hm-preview-head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; margin-bottom: 0.75rem; } +.hm-preview-head h3 { margin: 0; font-size: 1rem; color: #f8fafc; } +.hm-preview-head code { display: block; margin-top: 0.25rem; font-size: 0.68rem; color: #64748b; word-break: break-all; } +.hm-preview-meta { font-size: 0.68rem; color: #94a3b8; display: flex; flex-direction: column; align-items: flex-end; gap: 0.15rem; } +.hm-preview-code { + margin: 0; padding: 0.85rem; border-radius: 10px; + background: #020617; border: 1px solid rgba(148,163,184,0.15); + color: #e2e8f0; font-size: 0.72rem; line-height: 1.45; + white-space: pre-wrap; word-break: break-word; max-height: none; +} +.hm-preview-note { font-size: 0.68rem; color: #f59e0b; margin-top: 0.5rem; } +.hm-preview-paths { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.75rem; } +.hm-empty, .hm-empty-pane { padding: 1.25rem; color: #64748b; font-size: 0.8rem; } + +@media (max-width: 820px) { + .hm-modal { height: 94vh; } + .hm-modal-split { grid-template-columns: 1fr; grid-template-rows: 42% 58%; } + .hm-modal-side { border-right: 0; border-bottom: 1px solid rgba(148,163,184,0.12); } +} diff --git a/cockpit/static/js/analytics.js b/cockpit/static/js/analytics.js index 8e7342e..acae39b 100644 --- a/cockpit/static/js/analytics.js +++ b/cockpit/static/js/analytics.js @@ -1,199 +1,229 @@ -window.AnalyticsCharts = (function () { - var charts = {}; - var colors = { - gold: 'rgba(252, 211, 77, 0.85)', cyan: 'rgba(56, 189, 248, 0.85)', - green: 'rgba(74, 222, 128, 0.85)', red: 'rgba(251, 113, 133, 0.85)', - purple: 'rgba(168, 85, 247, 0.85)', orange: 'rgba(255, 159, 67, 0.85)', - gray: 'rgba(159, 176, 196, 0.85)', grid: 'rgba(159, 176, 196, 0.12)', text: '#c8d4e0', - }; - var palette = [colors.gold, colors.cyan, colors.green, colors.purple, colors.orange, colors.red, colors.gray]; - - function destroyAll() { - Object.keys(charts).forEach(function (k) { - if (charts[k]) { charts[k].destroy(); charts[k] = null; } - }); - } - - function barChart(id, labels, values, label, horizontal) { - var canvas = document.getElementById(id); - if (!canvas || typeof Chart === 'undefined') return; - if (charts[id]) charts[id].destroy(); - charts[id] = new Chart(canvas, { - type: 'bar', - data: { - labels: labels, - datasets: [{ label: label, data: values, backgroundColor: palette.slice(0, labels.length), borderRadius: 6 }], - }, - options: { - indexAxis: horizontal ? 'y' : 'x', - responsive: true, - plugins: { legend: { display: false } }, - scales: { - x: { ticks: { color: colors.text, maxRotation: 45 }, grid: { color: colors.grid } }, - y: { ticks: { color: colors.text }, grid: { color: colors.grid } }, - }, - }, - }); - } - - function doughnut(id, labels, values) { - var canvas = document.getElementById(id); - if (!canvas || typeof Chart === 'undefined') return; - if (charts[id]) charts[id].destroy(); - charts[id] = new Chart(canvas, { - type: 'doughnut', - data: { labels: labels, datasets: [{ data: values, backgroundColor: palette, borderWidth: 0 }] }, - options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: colors.text, boxWidth: 12 } } } }, - }); - } - - function lineChart(id, labels, values) { - var canvas = document.getElementById(id); - if (!canvas || typeof Chart === 'undefined') return; - if (charts[id]) charts[id].destroy(); - charts[id] = new Chart(canvas, { - type: 'line', - data: { - labels: labels, - datasets: [{ data: values, borderColor: colors.cyan, backgroundColor: 'rgba(56,189,248,0.15)', fill: true, tension: 0.3 }], - }, - options: { - responsive: true, - plugins: { legend: { display: false } }, - scales: { - x: { ticks: { color: colors.text }, grid: { color: colors.grid } }, - y: { ticks: { color: colors.text }, grid: { color: colors.grid } }, - }, - }, - }); - } - - function renderKpis(container, kpis) { - if (!container || !kpis) return; - var items = [ - ['Klanten totaal', kpis.clients_total], ['Actieve klanten', kpis.clients_active], - ['Pipeline €', '€' + Math.round(kpis.pipeline_eur || 0).toLocaleString('nl-NL')], - ['Supermarkten', kpis.supermarkets], ['Groothandels', kpis.wholesalers], - ['CRM actief', kpis.crm_partnerships], ['RSS items', kpis.rss_items], - ['Bookmarks', kpis.rss_bookmarks], ['Promo's', kpis.promo_campaigns], - ['Agent events', kpis.agent_events], ['Goedkeuringen', kpis.pending_approvals], - ['Contacten SM', kpis.contacts_supermarket], ['NAS docs', kpis.nas_docs], - ]; - container.innerHTML = items.map(function (it) { - return '
' + it[0] + '' + it[1] + '
'; - }).join(''); - } - - function renderTables(data) { - var dealsEl = document.getElementById('analytics-deals-table'); - var eventsEl = document.getElementById('analytics-events-table'); - if (dealsEl) { - dealsEl.innerHTML = (data.recent_deals || []).map(function (d) { - return '' + (d.title || '—') + '' + (d.stage || '') + '€' + Math.round(Number(d.value || 0)).toLocaleString('nl-NL') + ''; - }).join('') || 'Geen deals'; - } - if (eventsEl) { - eventsEl.innerHTML = (data.recent_events || []).map(function (e) { - var t = (e.created_at || '').substring(0, 16).replace('T', ' '); - return '' + (e.agent_name || '') + '' + (e.title || e.event_type || '') + '' + t + ''; - }).join('') || 'Geen events'; - } - } - - function render(data, mode) { - if (!data) return; - mode = mode || window.__vizMode || 'neo-bars'; - window.__analyticsLastData = data; - renderKpis(document.getElementById('analytics-kpis'), data.kpis); - var useRing = mode === 'neo-rings'; - var bar = function (id, labels, values, label, horizontal) { - if (useRing) doughnut(id, labels, values); - else barChart(id, labels, values, label, horizontal); - }; - bar('chart-deals', (data.deals_by_stage || []).map(function (r) { return r.stage; }), - (data.deals_by_stage || []).map(function (r) { return Number(r.total || r.cnt || 0); }), 'EUR'); - bar('chart-clients', (data.clients_by_stage || []).map(function (r) { return r.stage; }), - (data.clients_by_stage || []).map(function (r) { return Number(r.cnt || 0); }), 'Klanten'); - bar('chart-chains', (data.supermarkets_by_chain || []).map(function (r) { return r.chain; }), - (data.supermarkets_by_chain || []).map(function (r) { return Number(r.cnt || 0); }), 'Filialen'); - if (useRing) { - doughnut('chart-provinces', (data.supermarkets_by_province || []).map(function (r) { return r.province; }), - (data.supermarkets_by_province || []).map(function (r) { return Number(r.cnt || 0); })); - } else barChart('chart-provinces', (data.supermarkets_by_province || []).map(function (r) { return r.province; }), - (data.supermarkets_by_province || []).map(function (r) { return Number(r.cnt || 0); }), 'Filialen'); - doughnut('chart-partnerships', (data.partnership_breakdown || []).map(function (r) { return r.status; }), - (data.partnership_breakdown || []).map(function (r) { return Number(r.cnt || 0); })); - bar('chart-agents', (data.events_by_agent || []).map(function (r) { return r.agent_name; }), - (data.events_by_agent || []).map(function (r) { return Number(r.cnt || 0); }), 'Events', true); - if (mode === 'neo-table') { - lineChart('chart-timeline', [], []); - } else { - lineChart('chart-timeline', (data.events_timeline || []).map(function (r) { return String(r.day || '').substring(5); }), - (data.events_timeline || []).map(function (r) { return Number(r.cnt || 0); })); - } - doughnut('chart-rss', (data.rss_by_category || []).map(function (r) { return r.category || 'other'; }), - (data.rss_by_category || []).map(function (r) { return Number(r.cnt || 0); })); - bar('chart-wholesale', (data.wholesalers_by_province || []).map(function (r) { return r.province; }), - (data.wholesalers_by_province || []).map(function (r) { return Number(r.cnt || 0); }), 'GH'); - doughnut('chart-sentiment', (data.sentiment_distribution || []).map(function (r) { return r.sentiment_label || 'neutral'; }), - (data.sentiment_distribution || []).map(function (r) { return Number(r.cnt || 0); })); - bar('chart-words', (data.top_words || []).slice(0, 10).map(function (r) { return r.lemma; }), - (data.top_words || []).slice(0, 10).map(function (r) { return Number(r.total || 0); }), 'Count', true); - bar('chart-opportunities', (data.top_opportunities || []).map(function (r) { return (r.chain || '') + ' ' + (r.city || ''); }), - (data.top_opportunities || []).map(function (r) { return Math.round(Number(r.halal_opportunity_score || 0)); }), 'Score', true); - bar('chart-promo', (data.promo_by_chain || []).map(function (r) { return r.chain || '?'; }), - (data.promo_by_chain || []).map(function (r) { return Number(r.cnt || 0); }), 'Promo'); - doughnut('chart-milestones', (data.milestones_by_status || []).map(function (r) { return r.status; }), - (data.milestones_by_status || []).map(function (r) { return Number(r.cnt || 0); })); - renderTables(data); - } - - document.addEventListener('foodlinkk:vizmode', function (ev) { - if (window.__analyticsLastData) render(window.__analyticsLastData, ev.detail.mode); - }); - - return { render: render, destroyAll: destroyAll }; -})(); - +/** + * Foodlinkk Intelligence Analytics hub + */ function analyticsHub() { + const charts = {}; + + function destroyCharts() { + Object.keys(charts).forEach((k) => { + try { charts[k].destroy(); } catch (e) {} + delete charts[k]; + }); + } + + function euro(n) { + const v = Math.round(Number(n || 0)); + return '€' + v.toLocaleString('nl-NL'); + } + return { loading: false, + showFilters: false, updatedAt: '—', liveLabel: 'Live', f: { chain: '', province: '', stage: '', agent: '', days: 90 }, meta: {}, + data: {}, + kpis: {}, + pulse: { wins: [], opportunities: [], attention: [], score: 0 }, + findings: [], + goals: {}, + signals: [], _pollStop: null, - init() { - var initial = window.ANALYTICS_INITIAL || {}; - this.meta = initial.filter_meta || {}; - AnalyticsCharts.render(initial); - this.updatedAt = (initial.generated_at || '').substring(0, 19).replace('T', ' '); - this._pollStop = CockpitLive.startPolling(function () { return this.refresh(false); }.bind(this), 30000); + + fmt(n) { + return Number(n || 0).toLocaleString('nl-NL'); }, + + formatTime(iso) { + if (!iso) return ''; + try { + return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }); + } catch (e) { + return String(iso).slice(0, 16).replace('T', ' '); + } + }, + + shortGoal(t) { + const s = String(t || '').replace(/\s+/g, ' ').trim(); + return s.length > 90 ? s.slice(0, 87) + '…' : s; + }, + + ringDash(score) { + const s = Math.max(0, Math.min(100, Number(score || 0))); + const c = 2 * Math.PI * 52; + return ((s / 100) * c).toFixed(1); + }, + + applyData(payload) { + this.data = payload || {}; + this.kpis = this.data.kpis || {}; + this.pulse = this.data.pulse || { wins: [], opportunities: [], attention: [], score: 0 }; + this.findings = this.data.hermes_findings || []; + this.goals = this.data.goals || {}; + this.meta = this.data.filter_meta || this.meta; + this.updatedAt = (this.data.generated_at || '').substring(0, 19).replace('T', ' ') || '—'; + this.signals = [ + { key: 'margin', label: 'Marge / mnd', value: euro(this.kpis.margin_month), hint: (this.kpis.active_projects || 0) + ' deals', tone: 'good' }, + { key: 'partners', label: 'CRM actief', value: this.fmt(this.kpis.crm_partnerships), hint: (this.pulse.cover_pct || 0) + '% dekking', tone: 'good' }, + { key: 'gap', label: 'White space', value: this.fmt(this.pulse.white_space), hint: 'winkels zonder partnership', tone: 'warn' }, + { key: 'stores', label: 'Supermarkten', value: this.fmt(this.kpis.supermarkets), hint: this.fmt(this.kpis.wholesalers) + ' groothandels' }, + { key: 'rss', label: 'Markt signalen', value: this.fmt(this.kpis.rss_items), hint: this.fmt(this.kpis.promo_campaigns) + ' promo’s' }, + { key: 'agents', label: 'Agent events', value: this.fmt(this.kpis.agent_events), hint: this.fmt(this.kpis.pending_approvals) + ' approvals', tone: this.kpis.pending_approvals ? 'warn' : '' }, + ]; + this.$nextTick(() => this.renderCharts()); + }, + + init() { + this.applyData(window.ANALYTICS_INITIAL || {}); + if (window.CockpitLive && CockpitLive.startPolling) { + this._pollStop = CockpitLive.startPolling(() => this.refresh(false), 45000); + } else { + this._pollStop = setInterval(() => this.refresh(false), 45000); + } + }, + params() { - var p = new URLSearchParams(); - Object.entries(this.f).forEach(function (e) { - if (e[1] !== null && e[1] !== '' && e[1] !== undefined) p.set(e[0], e[1]); + const p = new URLSearchParams(); + Object.entries(this.f).forEach(([k, v]) => { + if (v !== null && v !== '' && v !== undefined) p.set(k, v); }); return p.toString(); }, + async refresh(toast) { this.loading = true; try { - var data = await fetch('/analytics/api/data?' + this.params()).then(function (r) { return r.json(); }); - this.meta = data.filter_meta || this.meta; - AnalyticsCharts.render(data); - this.updatedAt = (data.generated_at || '').substring(0, 19).replace('T', ' '); - if (toast !== false) Cockpit.toast('Analytics bijgewerkt', 'success'); + const data = await fetch('/analytics/api/data?' + this.params()).then((r) => r.json()); + this.applyData(data); + if (toast !== false && window.Cockpit) Cockpit.toast('Intelligence bijgewerkt', 'success'); } catch (e) { - if (toast !== false) Cockpit.toast(e.message, 'error'); + if (toast !== false && window.Cockpit) Cockpit.toast(e.message || 'Laden mislukt', 'error'); } finally { this.loading = false; } }, + resetFilters() { this.f = { chain: '', province: '', stage: '', agent: '', days: 90 }; this.refresh(); }, + + renderCharts() { + if (typeof Chart === 'undefined') return; + destroyCharts(); + const grid = 'rgba(148,163,184,0.12)'; + const tick = '#94a3b8'; + const d = this.data; + + const mk = (id, cfg) => { + const el = document.getElementById(id); + if (!el) return; + charts[id] = new Chart(el, cfg); + }; + + mk('ax-chart-timeline', { + type: 'line', + data: { + labels: (d.events_timeline || []).map((r) => String(r.day || '').substring(5)), + datasets: [{ + data: (d.events_timeline || []).map((r) => Number(r.cnt || 0)), + borderColor: '#38bdf8', + backgroundColor: 'rgba(56,189,248,0.15)', + fill: true, + tension: 0.35, + pointRadius: 0, + borderWidth: 2, + }], + }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { ticks: { color: tick, maxTicksLimit: 10 }, grid: { color: grid } }, + y: { ticks: { color: tick }, grid: { color: grid } }, + }, + }, + }); + + mk('ax-chart-partners', { + type: 'doughnut', + data: { + labels: (d.partnership_breakdown || []).map((r) => r.status), + datasets: [{ + data: (d.partnership_breakdown || []).map((r) => Number(r.cnt || 0)), + backgroundColor: ['#64748b', '#b8ff3c', '#fbbf24', '#38bdf8', '#fb7185'], + borderWidth: 0, + }], + }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { legend: { position: 'bottom', labels: { color: tick, boxWidth: 10, font: { size: 10 } } } }, + }, + }); + + mk('ax-chart-opps', { + type: 'bar', + data: { + labels: (d.top_opportunities || []).slice(0, 8).map((r) => (r.chain || '') + ' ' + (r.city || '')), + datasets: [{ + data: (d.top_opportunities || []).slice(0, 8).map((r) => Math.round(Number(r.halal_opportunity_score || 0))), + backgroundColor: 'rgba(251,191,36,0.55)', + borderColor: '#fbbf24', + borderWidth: 1, + }], + }, + options: { + indexAxis: 'y', responsive: true, maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { ticks: { color: tick }, grid: { color: grid } }, + y: { ticks: { color: '#cbd5e1', font: { size: 10 } }, grid: { display: false } }, + }, + }, + }); + + mk('ax-chart-chains', { + type: 'bar', + data: { + labels: (d.supermarkets_by_chain || []).slice(0, 8).map((r) => r.chain), + datasets: [{ + data: (d.supermarkets_by_chain || []).slice(0, 8).map((r) => Number(r.cnt || 0)), + backgroundColor: 'rgba(56,189,248,0.45)', + borderColor: '#38bdf8', + borderWidth: 1, + }], + }, + options: { + indexAxis: 'y', responsive: true, maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { ticks: { color: tick }, grid: { color: grid } }, + y: { ticks: { color: '#cbd5e1', font: { size: 10 } }, grid: { display: false } }, + }, + }, + }); + + mk('ax-chart-prov', { + type: 'bar', + data: { + labels: (d.supermarkets_by_province || []).slice(0, 8).map((r) => r.province), + datasets: [{ + data: (d.supermarkets_by_province || []).slice(0, 8).map((r) => Number(r.cnt || 0)), + backgroundColor: 'rgba(184,255,60,0.4)', + borderColor: '#b8ff3c', + borderWidth: 1, + }], + }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { ticks: { color: tick, maxRotation: 45 }, grid: { display: false } }, + y: { ticks: { color: tick }, grid: { color: grid } }, + }, + }, + }); + }, }; } diff --git a/cockpit/static/js/herman-assistant.js b/cockpit/static/js/herman-assistant.js index ac9b362..4eba916 100644 --- a/cockpit/static/js/herman-assistant.js +++ b/cockpit/static/js/herman-assistant.js @@ -21,6 +21,87 @@ window.HermanAssistant = (function () { return (prefix || 'br') + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9); } + var STORAGE_OPEN = 'herman_assistant_open'; + var STORAGE_CHAT = 'herman_assistant_chat_v1'; + var STORAGE_PREFS = 'herman_assistant_prefs_v1'; + var STORAGE_FAB = 'herman_assistant_fab_pos_v1'; + var MAX_STORED_TURNS = 40; + var _cachedVoice = null; + + function loadPrefs() { + try { + var raw = localStorage.getItem(STORAGE_PREFS); + if (!raw) return {}; + var data = JSON.parse(raw); + return data && typeof data === 'object' ? data : {}; + } catch (e) { + return {}; + } + } + + function pickMaartenVoice() { + if (!window.speechSynthesis) return null; + if (_cachedVoice) return _cachedVoice; + var voices = window.speechSynthesis.getVoices() || []; + if (!voices.length) return null; + var prefer = [ + /maarten/i, + /microsoft.*nederlands/i, + /google.*nederlands/i, + /nl[-_]NL/i, + /dutch/i, + ]; + for (var p = 0; p < prefer.length; p++) { + for (var i = 0; i < voices.length; i++) { + var v = voices[i]; + var hay = ((v.name || '') + ' ' + (v.lang || '') + ' ' + (v.voiceURI || '')); + if (prefer[p].test(hay)) { + _cachedVoice = v; + return v; + } + } + } + for (var j = 0; j < voices.length; j++) { + if (/^nl/i.test(voices[j].lang || '')) { + _cachedVoice = voices[j]; + return voices[j]; + } + } + return null; + } + + if (typeof window !== 'undefined' && window.speechSynthesis) { + window.speechSynthesis.addEventListener('voiceschanged', function () { + _cachedVoice = null; + pickMaartenVoice(); + }); + } + + function loadStoredChat(sessionPrefix) { + try { + var raw = localStorage.getItem(STORAGE_CHAT); + if (!raw) return null; + var data = JSON.parse(raw); + if (!data || !Array.isArray(data.turns)) return null; + if (sessionPrefix && data.sessionPrefix && data.sessionPrefix !== sessionPrefix) return null; + return data; + } catch (e) { + return null; + } + } + + function saveStoredChat(state) { + try { + var turns = (state.turns || []).slice(-MAX_STORED_TURNS); + localStorage.setItem(STORAGE_CHAT, JSON.stringify({ + sessionPrefix: state.sessionPrefix, + sessionId: state.sessionId, + turns: turns, + savedAt: Date.now(), + })); + } catch (e) { /* empty */ } + } + function baseState(opts) { opts = opts || {}; return { @@ -30,12 +111,20 @@ window.HermanAssistant = (function () { hidden: false, status: 'idle', statusDetail: '', + connOk: null, + connLabel: 'Herman', livePreview: '', liveInterim: false, recording: false, busy: false, - speakReply: true, + speakReply: false, handsFreeMode: false, + httpsAudioPrompt: false, + fabPos: null, + fabDragging: false, + _fabMoved: false, + _fabPointerId: null, + _fabOrigin: null, useBrowserPreview: !!SpeechRecognition, turns: [], manualText: '', @@ -65,7 +154,209 @@ window.HermanAssistant = (function () { toggleOpen: function () { this.open = !this.open; - try { localStorage.setItem('herman_assistant_open', this.open ? '1' : '0'); } catch (e) { /* empty */ } + try { localStorage.setItem(STORAGE_OPEN, this.open ? '1' : '0'); } catch (e) { /* empty */ } + if (this.open) this.scrollLog(); + }, + + loadFabPos: function () { + try { + var raw = localStorage.getItem(STORAGE_FAB); + if (!raw) return; + var p = JSON.parse(raw); + if (p && typeof p.x === 'number' && typeof p.y === 'number') this.fabPos = p; + } catch (e) { /* empty */ } + }, + + saveFabPos: function () { + try { + if (!this.fabPos) localStorage.removeItem(STORAGE_FAB); + else localStorage.setItem(STORAGE_FAB, JSON.stringify(this.fabPos)); + } catch (e) { /* empty */ } + }, + + resetFabPos: function () { + this.fabPos = null; + this.saveFabPos(); + }, + + fabStyle: function () { + if (!this.fabPos) return {}; + return { + left: this.fabPos.x + 'px', + top: this.fabPos.y + 'px', + right: 'auto', + bottom: 'auto', + transform: this.fabDragging ? 'none' : undefined, + }; + }, + + panelStyle: function () { + if (!this.fabPos) return {}; + var vw = window.innerWidth || 800; + var vh = window.innerHeight || 600; + var panelW = Math.min(420, vw - 24); + var panelH = Math.min(620, vh - 96); + var fabX = this.fabPos.x; + var fabY = this.fabPos.y; + var fabW = 130; + var fabH = 52; + var left = Math.min(Math.max(8, fabX + fabW / 2 - panelW / 2), vw - panelW - 8); + var top = fabY - panelH - 12; + if (top < 8) top = fabY + fabH + 12; + if (top + panelH > vh - 8) top = Math.max(8, vh - panelH - 8); + return { + left: left + 'px', + top: top + 'px', + right: 'auto', + bottom: 'auto', + }; + }, + + _clampFab: function (x, y) { + var vw = window.innerWidth || 800; + var vh = window.innerHeight || 600; + var w = 56; + var h = 52; + return { + x: Math.min(Math.max(8, x), Math.max(8, vw - w - 8)), + y: Math.min(Math.max(8, y), Math.max(8, vh - h - 8)), + }; + }, + + onFabPointerDown: function (e) { + if (e.button != null && e.button !== 0) return; + var point = e.touches && e.touches[0] ? e.touches[0] : e; + var el = e.currentTarget; + var rect = el.getBoundingClientRect(); + this._fabMoved = false; + this.fabDragging = true; + this._fabOrigin = { + px: point.clientX, + py: point.clientY, + left: this.fabPos ? this.fabPos.x : rect.left, + top: this.fabPos ? this.fabPos.y : rect.top, + }; + if (el.setPointerCapture && e.pointerId != null) { + try { el.setPointerCapture(e.pointerId); } catch (err) { /* empty */ } + } + }, + + onFabPointerMove: function (e) { + if (!this.fabDragging || !this._fabOrigin) return; + var point = e.touches && e.touches[0] ? e.touches[0] : e; + var dx = point.clientX - this._fabOrigin.px; + var dy = point.clientY - this._fabOrigin.py; + if (Math.abs(dx) > 4 || Math.abs(dy) > 4) this._fabMoved = true; + if (!this._fabMoved) return; + e.preventDefault(); + this.fabPos = this._clampFab(this._fabOrigin.left + dx, this._fabOrigin.top + dy); + }, + + onFabPointerUp: function (e) { + if (!this.fabDragging) return; + var moved = this._fabMoved; + this.fabDragging = false; + this._fabOrigin = null; + if (moved) { + this.saveFabPos(); + e.preventDefault(); + e.stopPropagation(); + return; + } + this.toggleOpen(); + }, + + onFabClick: function (e) { + // click handled in pointerup; prevent double-toggle + e.preventDefault(); + e.stopPropagation(); + }, + + onFabDblClick: function (e) { + e.preventDefault(); + this.resetFabPos(); + Cockpit.toast('Herman-knop teruggezet', 'info'); + }, + + + persistChat: function () { + saveStoredChat(this); + }, + + persistPrefs: function () { + try { + localStorage.setItem(STORAGE_PREFS, JSON.stringify({ + speakReply: !!this.speakReply, + handsFreeMode: !!this.handsFreeMode, + })); + } catch (e) { /* empty */ } + if (!this.speakReply && window.speechSynthesis) { + try { window.speechSynthesis.cancel(); } catch (e2) { /* empty */ } + } + }, + + applyPrefs: function () { + var prefs = loadPrefs(); + if (typeof prefs.speakReply === 'boolean') this.speakReply = prefs.speakReply; + if (typeof prefs.handsFreeMode === 'boolean') this.handsFreeMode = prefs.handsFreeMode; + }, + + onHandsFreeChange: function () { + this.persistPrefs(); + if (this.handsFreeMode && !this.micSecureOk()) { + this.offerHttpsAudio(); + return; + } + if (this.handsFreeMode && !this.recording && !this.busy) { + this.statusDetail = 'Hands-free — luisteren…'; + this.startListen(); + } else if (!this.handsFreeMode && this.recording) { + this.stopListen(); + this.statusDetail = ''; + } + }, + + + offerHttpsAudio: function () { + this.httpsAudioPrompt = true; + this.status = 'idle'; + this.statusDetail = ''; + if (this.handsFreeMode) { + this.handsFreeMode = false; + this.persistPrefs(); + } + }, + + goHttpsAudio: function () { + var url = this.httpsChatUrl(); + this.httpsAudioPrompt = false; + window.open(url, '_blank', 'noopener'); + }, + + micSecureOk: function () { + return !!(window.isSecureContext || location.hostname === 'localhost' || location.hostname === '127.0.0.1'); + }, + + httpsChatUrl: function () { + var host = location.hostname || '10.4.7.18'; + var path = location.pathname || '/'; + var q = location.search || ''; + return 'https://' + host + ':8643' + path + q; + }, + + clearChat: function () { + this.turns = []; + this.manualText = ''; + this.pendingAction = null; + this.sessionId = newSessionId(this.sessionPrefix); + this.statusDetail = ''; + this.status = 'idle'; + this.persistChat(); + }, + + markConn: function (ok, label) { + this.connOk = ok; + this.connLabel = label || (ok ? 'Verbonden met Herman' : 'Herman niet bereikbaar'); }, scrollLog: function () { @@ -78,6 +369,7 @@ window.HermanAssistant = (function () { addTurn: function (role, text, meta) { this.turns.push({ role: role, text: text, meta: meta || {}, at: nowTime() }); + this.persistChat(); this.scrollLog(); }, @@ -86,8 +378,10 @@ window.HermanAssistant = (function () { this.pendingAction = h.pending_action || (h.needs_confirmation ? this.pendingAction : null); if (!h.needs_confirmation && !h.pending_action) this.pendingAction = null; if (h.reply) { + var label = (h.agent_label || 'Herman').replace(/\s*[·•].*$/, '').trim() || 'Herman'; + if (/hermes/i.test(label)) label = 'Herman'; this.addTurn('agent', h.reply, { - agent_label: h.agent_label, + agent_label: label, delegated: h.delegated_agents, routing_reason: h.routing_reason, }); @@ -111,6 +405,8 @@ window.HermanAssistant = (function () { window.speechSynthesis.cancel(); var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800)); u.lang = 'nl-NL'; + var voice = pickMaartenVoice(); + if (voice) u.voice = voice; u.onend = function () { self.maybeResumeListening(300); }; window.speechSynthesis.speak(u); } else { @@ -208,7 +504,12 @@ window.HermanAssistant = (function () { async startListen() { if (this.recording || this.busy) return; + if (!this.micSecureOk()) { + this.offerHttpsAudio(); + return; + } var self = this; + this.httpsAudioPrompt = false; this.livePreview = ''; this.status = 'listening'; this.statusDetail = 'Luisteren…'; @@ -240,8 +541,12 @@ window.HermanAssistant = (function () { } } catch (e) { this.status = 'error'; - this.statusDetail = e.message; - Cockpit.toast('Microfoon: ' + e.message, 'error'); + var msg = e.message || String(e); + if (!this.micSecureOk()) { + msg = 'Mic alleen via HTTPS — gebruik de knop hierboven.'; + } + this.statusDetail = msg; + Cockpit.toast('Microfoon: ' + msg, 'error'); } }, @@ -291,6 +596,14 @@ window.HermanAssistant = (function () { this.status = 'idle'; this.statusDetail = ''; } catch (e) { + var preview = (this.livePreview || '').trim(); + if (preview) { + this.manualText = preview; + this.busy = false; + this.statusDetail = 'Browser-spraak gebruikt (Whisper offline)'; + Cockpit.toast('Whisper offline — tekst via browser-spraak', 'info'); + return this.sendText(); + } this.status = 'error'; this.statusDetail = e.message; Cockpit.toast(e.message, 'error'); @@ -301,19 +614,26 @@ window.HermanAssistant = (function () { async sendText(confirmActionId) { var text = (this.manualText || '').trim(); if (!text) return; + this.addTurn('user', text, { source: 'text' }); + this.manualText = ''; this.busy = true; this.status = 'processing'; + this.statusDetail = 'Herman denkt…'; try { var body = { message: text, channel: this.channel, session_id: this.sessionId }; if (confirmActionId) body.confirm_action_id = confirmActionId; var r = await Cockpit.api('/api/herman/chat', { method: 'POST', body: JSON.stringify(body) }); - this.addTurn('user', text, { source: 'text' }); - this.manualText = ''; this.handleHermanResponse(r); - this.status = 'idle'; + var reply = (r && r.reply) || ''; + var failed = /niet bereikbaar|niet beschikbaar|offline/i.test(reply); + this.markConn(!failed, failed ? 'Herman niet bereikbaar' : 'Verbonden met Herman'); + this.status = failed ? 'error' : 'idle'; + this.statusDetail = failed ? reply.slice(0, 120) : ''; } catch (e) { Cockpit.toast(e.message, 'error'); this.status = 'error'; + this.statusDetail = e.message; + this.markConn(false, 'Herman offline'); } this.busy = false; }, @@ -331,10 +651,21 @@ window.HermanAssistant = (function () { s.initWidget = function () { if (window.location.pathname.indexOf('/voice') === 0) this.hidden = true; if (window.location.pathname.indexOf('/herman') === 0) this.hidden = true; + this.applyPrefs(); + this.loadFabPos(); + pickMaartenVoice(); try { - var raw = localStorage.getItem('herman_assistant_open'); + var raw = localStorage.getItem(STORAGE_OPEN); if (raw === '1') this.open = true; } catch (e) { /* empty */ } + var stored = loadStoredChat(this.sessionPrefix); + if (stored) { + if (stored.sessionId) this.sessionId = stored.sessionId; + if (stored.turns && stored.turns.length) this.turns = stored.turns; + } + this.connOk = null; + this.connLabel = 'Herman'; + if (this.open) this.scrollLog(); }; return s; }, @@ -345,6 +676,8 @@ window.HermanAssistant = (function () { s.lastImage = ''; s.initChat = function () { this.open = true; + this.applyPrefs(); + pickMaartenVoice(); }; s.generateImage = async function () { if (!this.imagePrompt.trim()) return; @@ -371,3 +704,11 @@ window.HermanAssistant = (function () { function hermanBrowserWidget() { return HermanAssistant.browserWidget(); } + +document.addEventListener('alpine:init', function () { + if (window.Alpine && typeof Alpine.data === 'function') { + Alpine.data('hermanBrowserWidget', function () { + return HermanAssistant.browserWidget(); + }); + } +}); diff --git a/cockpit/static/js/hermes-ui.js b/cockpit/static/js/hermes-ui.js index 1febe97..83a19d5 100644 --- a/cockpit/static/js/hermes-ui.js +++ b/cockpit/static/js/hermes-ui.js @@ -1,346 +1,233 @@ /** - * Hermes Neo Command Center + * Hermes technical KPI dashboard + bucket explorer popup */ -function hermesControl() { - const PA_SITES = ['Airbnb', 'Booking.com', 'DuckDuckGo', 'HolidayCheck']; - - /** Canonical team — never duplicate role as name */ - const ROSTER = [ - { chat_id: 8859782446, role: 'CEO', name: 'Aïssa' }, - { chat_id: 789036463, role: 'CTO', name: 'Mo' }, - ]; +function hermesKpiDashboard() { + const SOURCE_LABELS = { + telegram: 'Telegram', + api_server: 'Cockpit API', + tui: 'TUI', + cron: 'Cron', + subagent: 'Subagent', + }; return { - tab: 'feed', - live: true, - stats: { conversations: 0, messages: 0, edges: 0, embeddings: 0, inbound: 0, outbound: 0 }, - conversations: [], - messages: [], - users: [], - agentEvents: [], - selectedChatId: null, - graphChatFilter: '', - searchQuery: '', - searchResults: [], - searchRan: false, - graphNetwork: null, - pollTimer: null, - paPollTimer: null, - paPolling: false, - paLive: { status: 'idle', query: '', slots: [] }, - paScreenshotTs: Date.now(), - hermesOnline: false, - hermesStatus: {}, - actionBusy: false, - actionMsg: '', - chartDirection: null, - chartAgents: null, - - get teamRoster() { - const byId = {}; - (this.users || []).forEach(u => { byId[u.chat_id] = u; }); - (this.conversations || []).forEach(c => { - if (!byId[c.chat_id]) byId[c.chat_id] = c; - else { - byId[c.chat_id].message_count = c.message_count || byId[c.chat_id].message_count; - } - }); - return ROSTER.map(r => ({ - ...r, - online: !!(byId[r.chat_id]?.online), - pa_mode: !!(byId[r.chat_id]?.pa_mode), - message_count: byId[r.chat_id]?.message_count || 0, - })); + loading: false, + kpis: { online: false, overview: {}, buckets: [], tools_top: [], sources: [], herman_cockpit: [] }, + chartTools: null, + chartSources: null, + _timer: null, + modal: { + open: false, + loading: false, + previewLoading: false, + key: null, + title: '', + tab: 'activity', + toolFilter: null, + detail: { events: [], files: [], parts: {}, count: 0 }, + explorer: { path: '', parent: null, entries: [] }, + selectedEvent: null, + preview: null, }, - get paLiveUser() { - const cid = this.paLive.chat_id; - if (!cid) return ''; - const m = ROSTER.find(r => r.chat_id === cid); - return m ? `${m.role} — ${m.name}` : this.paLive.user_name || ''; + get ov() { return this.kpis.overview || {}; }, + + fmt(n) { + const v = Number(n || 0); + return v.toLocaleString('nl-NL'); }, - get paSlots() { - const slots = this.paLive.slots || []; - if (slots.length >= 4) return slots; - const byLabel = {}; - slots.forEach(s => { byLabel[s.label] = s; }); - return PA_SITES.map(label => byLabel[label] || { - label, status: 'idle', has_screenshot: false, - }); - }, - - rosterPerson(chatId) { - return ROSTER.find(r => r.chat_id === Number(chatId)) || null; - }, - - msgLabel(msg) { - const p = this.rosterPerson(msg.chat_id); - if (p) return `${p.role} (${p.name})`; - if (msg.user_role && msg.user_name) return `${msg.user_role} (${msg.user_name})`; - return msg.user_role || msg.chat_id || '?'; - }, - - async init() { - const hash = (window.location.hash || '').replace('#', ''); - if (['feed', 'pa', 'graph', 'search', 'control'].includes(hash)) this.tab = hash; - await this.refreshStats(); - await this.refreshConversations(); - await this.refreshUsers(); - await this.refreshFeed(); - await this.refreshPaLive(); - setTimeout(() => this.initCharts(), 200); - this.pollTimer = setInterval(() => { - if (this.tab === 'feed' && this.live) this.refreshFeed(true); - }, 5000); - this.paPollTimer = setInterval(() => { - if (this.tab === 'pa' || this.paLive.status === 'running') this.refreshPaLive(true); - }, 2000); - window.addEventListener('hashchange', () => { - const h = (window.location.hash || '').replace('#', ''); - if (['feed', 'pa', 'graph', 'search', 'control'].includes(h)) this.tab = h; - }); - }, - - initCharts() { - if (typeof Chart === 'undefined') return; - const neoColors = ['#00e5ff', '#ff2d95', '#b8ff3c', '#ff9f43', '#a855f7', '#ffd700']; - Chart.defaults.color = '#64748b'; - Chart.defaults.borderColor = 'rgba(0,229,255,0.08)'; - - const dirEl = document.getElementById('hm-chart-direction'); - if (dirEl && !this.chartDirection) { - this.chartDirection = new Chart(dirEl, { - type: 'doughnut', - data: { - labels: ['Inbound', 'Outbound', 'Vectors'], - datasets: [{ - data: [1, 1, 1], - backgroundColor: ['#00e5ff', '#a855f7', '#b8ff3c'], - borderWidth: 0, - hoverOffset: 8, - }], - }, - options: { - responsive: true, - maintainAspectRatio: false, - cutout: '62%', - plugins: { - legend: { position: 'right', labels: { boxWidth: 12, padding: 10, color: '#94a3b8' } }, - }, - }, - }); - } - - const agEl = document.getElementById('hm-chart-agents'); - if (agEl && !this.chartAgents) { - this.chartAgents = new Chart(agEl, { - type: 'bar', - data: { - labels: ['herman', 'browser', 'marketing'], - datasets: [{ - label: 'events', - data: [0, 0, 0], - backgroundColor: 'rgba(0, 229, 255, 0.55)', - borderColor: '#00e5ff', - borderWidth: 1, - borderRadius: 4, - }], - }, - options: { - responsive: true, - maintainAspectRatio: false, - scales: { - y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { stepSize: 1 } }, - x: { grid: { display: false } }, - }, - plugins: { legend: { display: false } }, - }, - }); - } - this.updateCharts(); - }, - - updateCharts() { - const s = this.stats; - if (this.chartDirection) { - this.chartDirection.data.datasets[0].data = [ - Math.max(s.inbound || 0, 0), - Math.max(s.outbound || 0, 0), - Math.max(s.embeddings || 0, 0), - ]; - this.chartDirection.update('none'); - } - if (this.chartAgents) { - const counts = {}; - (this.agentEvents || []).forEach(ev => { - const n = (ev.agent_name || 'other').toLowerCase(); - counts[n] = (counts[n] || 0) + 1; - }); - const labels = Object.keys(counts).slice(0, 8); - if (!labels.length) labels.push('herman'); - this.chartAgents.data.labels = labels; - this.chartAgents.data.datasets[0].data = labels.map(l => counts[l] || 0); - this.chartAgents.update('none'); - } - }, - - setTab(t) { - this.tab = t; - history.replaceState(null, '', t === 'feed' ? '/hermes' : '/hermes#' + t); - if (t === 'graph') setTimeout(() => this.loadGraph(), 100); - if (t === 'control') { this.refreshStats(); this.refreshUsers(); } - if (t === 'pa') { this.paPolling = true; this.refreshPaLive(); } + fmtBytes(n) { + if (n == null || n === '') return ''; + const v = Number(n); + if (v < 1024) return v + ' B'; + if (v < 1024 * 1024) return (v / 1024).toFixed(1) + ' KB'; + return (v / (1024 * 1024)).toFixed(1) + ' MB'; }, formatTime(iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }); - } catch { return iso; } + } catch (e) { return String(iso).slice(0, 19); } }, - paStatusLabel() { - const m = { idle: 'Idle', running: 'Zoekt…', comparing: 'Vergelijkt…', done: 'Klaar', failed: 'Mislukt' }; - return m[this.paLive.status] || this.paLive.status || 'Idle'; + partsLine(parts) { + if (!parts) return ''; + return Object.keys(parts) + .filter((k) => parts[k] > 0) + .map((k) => parts[k] + ' ' + k.replace(/_/g, ' ')) + .join(' · '); }, - slotStatusLabel(s) { - const m = { idle: 'Idle', waiting: 'Wacht', loading: 'Bezig', completed: 'Klaar', failed: 'Fout' }; - return m[s] || s || 'Idle'; + sourceLabel(s) { return SOURCE_LABELS[s] || s || '—'; }, + + async init() { + await this.refresh(); + this._timer = setInterval(() => this.refresh(true), 15000); }, - slotScreenshotUrl(label) { - if (!label) return ''; - return `/api/admin/hermes/pa/live/${encodeURIComponent(label)}/screenshot.jpg?t=${this.paScreenshotTs}`; - }, - - async selectChat(chatId) { - this.selectedChatId = chatId; - await this.refreshFeed(); - }, - - async refreshStats() { + async refresh(quiet) { + if (!quiet) this.loading = true; try { - const data = await Cockpit.api('/hermes/stats'); - this.stats = data.stats || this.stats; - this.agentEvents = data.agent_events || []; - this.updateCharts(); - } catch (e) { console.warn('stats', e); } + const data = await Cockpit.api('/hermes/kpis'); + this.kpis = data || this.kpis; + this.$nextTick(() => this.renderCharts()); + } catch (e) { + if (!quiet && window.Cockpit) Cockpit.toast(e.message || 'KPI laden mislukt', 'error'); + this.kpis.online = false; + } + this.loading = false; }, - async refreshUsers() { - try { - const data = await Cockpit.api('/hermes/users'); - this.users = data.users || []; - } catch (e) { console.warn('users', e); } + async openBucket(b) { + this.modal.open = true; + this.modal.key = b.key; + this.modal.title = b.label; + this.modal.tab = b.key === 'files' ? 'touched' : 'activity'; + this.modal.toolFilter = null; + this.modal.selectedEvent = null; + this.modal.preview = null; + this.modal.detail = { events: [], files: [], parts: b.parts || {}, count: b.count || 0 }; + await this.loadBucketDetail(); + if (b.key === 'files') { + // preload roots quietly + this.loadExplorer('').catch(() => {}); + } }, - async refreshConversations() { - try { - const data = await Cockpit.api('/hermes/conversations'); - this.conversations = data.items || []; - } catch (e) { console.warn('conversations', e); } + closeModal() { + this.modal.open = false; + this.modal.preview = null; + this.modal.selectedEvent = null; }, - async refreshFeed(quiet) { - try { - let path = '/hermes/feed?limit=80'; - if (this.selectedChatId) path += '&chat_id=' + this.selectedChatId; - const data = await Cockpit.api(path); - this.messages = data.items || []; - if (!quiet) await this.refreshStats(); - } catch (e) { console.warn('feed', e); } + async setToolFilter(tool) { + this.modal.toolFilter = tool; + await this.loadBucketDetail(); }, - async refreshPaLive(quiet) { + async loadBucketDetail() { + if (!this.modal.key) return; + this.modal.loading = true; try { - const data = await Cockpit.api('/hermes/pa/live'); - this.paLive = data; - this.paScreenshotTs = Date.now(); - if (data.hermes) { - this.hermesOnline = data.hermes.online !== false; - this.hermesStatus = data.hermes; + const q = new URLSearchParams({ limit: '80' }); + if (this.modal.toolFilter) q.set('tool', this.modal.toolFilter); + const data = await Cockpit.api('/hermes/bucket/' + encodeURIComponent(this.modal.key) + '?' + q.toString()); + this.modal.detail = data || this.modal.detail; + if (!(this.modal.detail.events || []).length) { + this.modal.selectedEvent = null; } } catch (e) { - if (!quiet) console.warn('pa live', e); + if (window.Cockpit) Cockpit.toast(e.message || 'Detail laden mislukt', 'error'); + } + this.modal.loading = false; + }, + + selectEvent(ev) { + this.modal.selectedEvent = ev; + this.modal.preview = null; + if (ev.paths && ev.paths.length === 1 && this.modal.key === 'files') { + this.openFile(ev.paths[0]); } }, - async loadGraph() { + openExplorerTab() { + this.modal.tab = 'explorer'; + if (!(this.modal.explorer.entries || []).length) { + this.loadExplorer(this.modal.explorer.path || ''); + } + }, + + async loadExplorer(path) { + this.modal.loading = true; try { - let data; - if (this.graphChatFilter) { - data = await Cockpit.api('/hermes/graph/' + this.graphChatFilter + '?limit=80'); - } else { - data = await Cockpit.api('/hermes/graph?limit=100'); - } - this.renderGraph(data); + const q = path ? ('?path=' + encodeURIComponent(path)) : ''; + const data = await Cockpit.api('/hermes/explorer' + q); + this.modal.explorer = data || { path: '', parent: null, entries: [] }; } catch (e) { - Cockpit.toast('Graph laden mislukt: ' + e.message, 'error'); + if (window.Cockpit) Cockpit.toast(e.message || 'Verkenner laden mislukt', 'error'); } + this.modal.loading = false; }, - renderGraph(data) { - const el = document.getElementById('hermes-graph'); - if (!el || typeof vis === 'undefined') return; - const nodes = new vis.DataSet((data.nodes || []).map(n => ({ - id: n.id, - label: this.truncate(n.label || n.id, 40), - color: n.direction === 'in' - ? { background: '#164e63', border: '#00e5ff' } - : { background: '#4c1d95', border: '#a855f7' }, - font: { color: '#e8edf5', size: 11 }, - }))); - const edges = new vis.DataSet((data.edges || []).map(e => ({ - id: 'e' + e.id, from: e.from, to: e.to, - label: e.type || '', arrows: 'to', - color: { color: '#ff9f43' }, - })).filter(e => e.to)); - if (this.graphNetwork) this.graphNetwork.destroy(); - this.graphNetwork = new vis.Network(el, { nodes, edges }, { - physics: { stabilization: true }, - interaction: { hover: true }, - }); + explorerUp() { + if (this.modal.explorer.parent) this.loadExplorer(this.modal.explorer.parent); + else this.loadExplorer(''); }, - truncate(s, n) { - s = String(s || ''); - return s.length > n ? s.slice(0, n) + '…' : s; - }, - - async runSearch() { - if (!this.searchQuery || this.searchQuery.length < 2) { - Cockpit.toast('Minimaal 2 tekens', 'warn'); - return; - } - this.searchRan = true; + async openFile(path) { + if (!path) return; + this.modal.previewLoading = true; + this.modal.selectedEvent = null; try { - const data = await Cockpit.api('/hermes/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query: this.searchQuery, chat_id: this.selectedChatId || null, limit: 15 }), + const data = await Cockpit.api('/hermes/explorer/file?path=' + encodeURIComponent(path)); + this.modal.preview = data || { path, error: 'Geen data', name: path.split('/').pop() }; + if (this.modal.key === 'files' && this.modal.tab === 'activity') { + // keep tab; preview still shows + } + } catch (e) { + this.modal.preview = { path, name: path.split('/').pop(), error: e.message || 'Laden mislukt', content: null }; + } + this.modal.previewLoading = false; + }, + + renderCharts() { + if (typeof Chart === 'undefined') return; + const tools = (this.kpis.tools_top || []).slice(0, 8); + const sources = this.kpis.sources || []; + + const toolsCtx = document.getElementById('hm-chart-tools'); + if (toolsCtx) { + if (this.chartTools) this.chartTools.destroy(); + this.chartTools = new Chart(toolsCtx, { + type: 'bar', + data: { + labels: tools.map((t) => t.name), + datasets: [{ + label: 'Calls', + data: tools.map((t) => t.count), + backgroundColor: 'rgba(14, 165, 233, 0.55)', + borderColor: 'rgba(56, 189, 248, 0.9)', + borderWidth: 1, + }], + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { ticks: { color: '#94a3b8' }, grid: { color: 'rgba(148,163,184,0.12)' } }, + y: { ticks: { color: '#cbd5e1', font: { size: 10 } }, grid: { display: false } }, + }, + }, }); - this.searchResults = data.results || []; - } catch (e) { - Cockpit.toast('Zoeken mislukt: ' + e.message, 'error'); } - }, - async runAction(action) { - this.actionBusy = true; - this.actionMsg = ''; - try { - const data = await Cockpit.api('/hermes/actions/' + action, { method: 'POST' }); - this.actionMsg = data.output || '✅ Actie uitgevoerd'; - Cockpit.toast(this.actionMsg.slice(0, 80), 'success'); - await this.refreshStats(); - } catch (e) { - this.actionMsg = '❌ ' + e.message; - Cockpit.toast(this.actionMsg, 'error'); - } finally { - this.actionBusy = false; + const srcCtx = document.getElementById('hm-chart-sources'); + if (srcCtx) { + if (this.chartSources) this.chartSources.destroy(); + this.chartSources = new Chart(srcCtx, { + type: 'doughnut', + data: { + labels: sources.map((s) => this.sourceLabel(s.source)), + datasets: [{ + data: sources.map((s) => s.tools || 0), + backgroundColor: ['#0ea5e9', '#a855f7', '#f59e0b', '#22c55e', '#f43f5e', '#64748b'], + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { position: 'bottom', labels: { color: '#94a3b8', boxWidth: 10, font: { size: 10 } } }, + }, + }, + }); } }, }; } + +function hermesControl() { return hermesKpiDashboard(); } diff --git a/cockpit/static/js/voice-live.js b/cockpit/static/js/voice-live.js index 6df0c93..ea9ba1f 100644 --- a/cockpit/static/js/voice-live.js +++ b/cockpit/static/js/voice-live.js @@ -33,7 +33,7 @@ window.VoiceLive = (function () { busy: false, conversationMode: true, handsFreeMode: false, - speakReply: true, + speakReply: false, useBrowserPreview: !!SpeechRecognition, pipeline: [], feed: [], @@ -162,6 +162,18 @@ window.VoiceLive = (function () { window.speechSynthesis.cancel(); var u = new SpeechSynthesisUtterance(h.reply.replace(/\*\*/g, '').slice(0, 800)); u.lang = 'nl-NL'; + var voices = window.speechSynthesis.getVoices() || []; + var voice = null; + for (var vi = 0; vi < voices.length; vi++) { + var hay = ((voices[vi].name || '') + ' ' + (voices[vi].lang || '')); + if (/maarten/i.test(hay)) { voice = voices[vi]; break; } + } + if (!voice) { + for (var vj = 0; vj < voices.length; vj++) { + if (/^nl/i.test(voices[vj].lang || '')) { voice = voices[vj]; break; } + } + } + if (voice) u.voice = voice; u.onend = function () { self.maybeResumeListening(300); }; window.speechSynthesis.speak(u); } else { diff --git a/cockpit/templates/analytics.html b/cockpit/templates/analytics.html index 66d69e9..ea2f325 100644 --- a/cockpit/templates/analytics.html +++ b/cockpit/templates/analytics.html @@ -1,75 +1,250 @@ {% extends "base.html" %} +{% block title %}Intelligence · Analytics{% endblock %} {% block extra_head %} + + + - + {% endblock %} {% block content %} -
- - - - -
-
- -
-

Pipeline per stage

-

Klanten per stage

-

Supermarkten per keten

-

Provincie verdeling

-

CRM partnerships

-

Agent activiteit

-

Events timeline

-

RSS per categorie

-

Groothandels provincie

-

NAS sentiment

-

Top woorden NAS

-

Halal kansen top 10

-

Actieve promo folders

-

Milestones status

+
+ +
+ +
+
+

Foodlinkk · Intelligence

+

SpeelveldLive

+

+

+ + +

+
+
+ + + + +
+ + pulse +
+

+
+
+ + + Retail 360 + Revenue + +
-
-

Recente deals

- -
TitelStageWaarde
-
-

Agent events

- -
AgentEventTijd
-
+
+ + + + +
-
+
+ + +
+ +
+ + +
+
+
+

Wat goed gaat

+ +
+ +

Nog geen wins berekend.

+
+ +
+
+

Kansen

+ +
+ +

Geen kansen gevonden.

+
+
+ + +
+
+

Aandacht

+
+ +
+
+ +
+
+
+

Hermes bevindingen

+

Diepere analyse die Hermes hier later aanvult via /analytics/api/findings

+
+ +
+
+ +
+
+
+ Nog geen Hermes-findings +

Automatische wins & kansen staan hierboven. Zodra Hermes diepere markt- of concurrentie-inzichten heeft, landen ze hier.

+
+
+
+
+ + +
+
+

Market radar

+ live charts +
+
+
+

Agent pulse

+
+
+
+

Partnership mix

+
+
+
+

Top halal-kansen

+
+
+
+

Supermarkten per keten

+
+
+
+

Provincie footprint

+
+
+
+
+ + +
+
+

Halal opportunity board

+
    + +
+
+
+

Recente agent events

+
    + +
+
+
+

Coverage snapshot

+
+
Partnership dekking
+
White space
+
NAS docs
+
SM contacten
+
GH contacten
+
Pending approvals
+
+
+
{% endblock %} {% block scripts %} - + {% endblock %} diff --git a/cockpit/templates/base.html b/cockpit/templates/base.html index e803079..bd09f92 100644 --- a/cockpit/templates/base.html +++ b/cockpit/templates/base.html @@ -21,12 +21,13 @@ + {% block extra_head %}{% endblock %} - + @@ -111,14 +111,32 @@
- -
- -
+ +
+ +
-

Herman browser

+
+

Herman

+

+
- Voice live + Audio + + Volledig
@@ -132,23 +150,39 @@
-

Typ of spreek — bv. “zoek distributeurs in Dubai”

+

Chat met Herman op de achtergrond — bv. “zoek distributeurs in Dubai”

-

-
+
+ + +
+

+
+ Microfoon werkt via HTTPS + + +
+
- - -
+ +
@@ -189,8 +223,6 @@
- - @@ -232,7 +264,7 @@ _offset: 0, async initApp() { this.brand = window.FoodlinkkBrand.detect(location.pathname); - if (!localStorage.getItem('flk-cache-v3')) { + if (!localStorage.getItem('flk-cache-v4')) { if ('serviceWorker' in navigator) { const regs = await navigator.serviceWorker.getRegistrations(); await Promise.all(regs.map((r) => r.unregister())); @@ -241,7 +273,7 @@ const keys = await caches.keys(); await Promise.all(keys.map((k) => caches.delete(k))); } - localStorage.setItem('flk-cache-v3', '1'); + localStorage.setItem('flk-cache-v4', '1'); } if (window.I18n) await window.I18n.init(); this.locale = window.I18n ? window.I18n.locale() : 'nl'; diff --git a/cockpit/templates/herman_chat.html b/cockpit/templates/herman_chat.html index 8b40901..c60263e 100644 --- a/cockpit/templates/herman_chat.html +++ b/cockpit/templates/herman_chat.html @@ -64,8 +64,8 @@
- - + +
@@ -107,6 +107,6 @@
{% endblock %} {% block scripts %} - + {% endblock %} diff --git a/cockpit/templates/hermes.html b/cockpit/templates/hermes.html index 6753a1e..c977df7 100644 --- a/cockpit/templates/hermes.html +++ b/cockpit/templates/hermes.html @@ -1,244 +1,268 @@ {% extends "base.html" %} -{% block title %}Hermes · Telegram Command Center{% endblock %} - +{% block title %}Hermes · KPI's{% endblock %} {% block extra_head %} - + - {% endblock %} {% block content %} -
- +
- +
-
+
-
💬
-
Conversaties
Telegram chats
+
+
Hermes
Agent status
-
0
-
+
-
📨
-
Berichten
in + out
+
✈️
+
Telegram
actieve chats
-
0
-
+
-
🕸
-
Relaties
graph edges
+
🔧
+
Tool-calls
totaal werk
-
0
-
+
-
🧠
-
Vectors
pgvector
+
💬
+
Tekst in / uit
user · agent
-
0
-
+
+
+
+
+
🧩
+
Skills
geïnstalleerd
+
+
+
+
+
+
⏱
+
Sync
laatste snapshot
+
+
- -
+ +

Wat doet hij? klik een blok

+
+ +
+ + +
-
-

Berichten per richting

- klik voor detail -
-
-
-
-
-

Agent activiteit

- laatste events -
-
-
-
- -
- - - - - -
- - -
-