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 '
Volledige database analyse · filter · charts · realtime
- -Foodlinkk · Intelligence
++ + +
+| Titel | Stage | Waarde |
|---|
| Agent | Event | Tijd |
|---|
Diepere analyse die Hermes hier later aanvult via /analytics/api/findings
Automatische wins & kansen staan hierboven. Zodra Hermes diepere markt- of concurrentie-inzichten heeft, landen ze hier.
+Typ of spreek — bv. “zoek distributeurs in Dubai”
+Chat met Herman op de achtergrond — bv. “zoek distributeurs in Dubai”
Live feed · 4 PA browsers · second brain · volledige controle
+Live processen van Hermes Agent + Herman — klik een blok voor detail · + Hermes UI + +
++ Delegaties: · + Delivery: +
++ + + + + +
+
+ Preview afgekapt (bestand te groot).
+Cloud LLM naast lokale Ollama. Standaard provider = Herman, briefing, documenten.
-
- Eén API key voor Herman, briefing en document-chat.
- Model: deepseek/deepseek-v4-flash
-
Klik een preset — daarna API key invullen in het formulier.
-Nog geen LLM provider — voeg DeepSeek, Gemini of Groq toe.
-Daarna geen “onveilige website”-melding meer, en de microfoon werkt direct.
+certutil -addstore -f Root \$env:USERPROFILE\\Downloads\\foodlinkk-cockpit-ca.crt
+