feat: expand Doris homelab services directory
This commit is contained in:
2
home/doris-dashboard/.gitignore
vendored
2
home/doris-dashboard/.gitignore
vendored
@@ -9,6 +9,8 @@ data/homelab.json
|
||||
data/sky.json
|
||||
data/todos.json
|
||||
data/weather.json
|
||||
data/wind_alert.json
|
||||
data/paperless_review.json
|
||||
data/paperless_review_state.json
|
||||
data/hermes_usage.json
|
||||
public/services.html
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib, html, json, math, os, re, shutil, sqlite3, subprocess, time, urllib.error, urllib.request, yaml
|
||||
import hashlib, html, json, math, os, re, shutil, socket, sqlite3, subprocess, time, urllib.error, urllib.request, yaml
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -35,37 +35,100 @@ SERVICE_DIRECTORY = [
|
||||
'items': [
|
||||
{'name': 'Doris Dashboard', 'url': 'index.html', 'health_url': 'http://127.0.0.1:8787/', 'purpose': 'Morning briefing, alerts, and operator homepage.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8787'},
|
||||
{'name': 'Services Directory', 'url': 'services.html', 'health_url': 'http://127.0.0.1:8787/services.html', 'purpose': 'Front door into the rest of the stack.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8787'},
|
||||
{'name': 'Homepage', 'url': 'http://10.5.1.6:3300', 'health_url': 'http://10.5.1.6:3300', 'purpose': 'Legacy PD portal and bookmarks page for the broader stack.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3300'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'group': 'Shared Datastores',
|
||||
'items': [
|
||||
{'name': 'PostgreSQL', 'url': '', 'purpose': 'Primary shared Postgres backing Paperless, n8n, and other stateful stacks.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': '5432/tcp', 'tcp_host': '10.5.1.6', 'tcp_port': 5432, 'badge': 'Healthy'},
|
||||
{'name': 'MariaDB', 'url': '', 'purpose': 'Shared MariaDB service for stacks that still need MySQL-compatible storage.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': '3306/tcp', 'tcp_host': '10.5.1.6', 'tcp_port': 3306, 'badge': 'Healthy'},
|
||||
{'name': 'Redis', 'url': '', 'purpose': 'Shared cache and queue backend used across the homelab.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': '6379/tcp', 'tcp_host': '10.5.1.6', 'tcp_port': 6379, 'badge': 'Healthy'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'group': 'Productivity / Workflow',
|
||||
'items': [
|
||||
{'name': 'Donetick', 'url': 'https://donetick.paccoco.com', 'purpose': 'Chores, recurring work, and household task coordination.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '2021'},
|
||||
{'name': 'Paperless', 'url': 'https://paperless.paccoco.com', 'purpose': 'Document archive, OCR intake, and household paperwork triage.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8083'},
|
||||
{'name': 'n8n', 'url': 'https://n8n.paccoco.com', 'purpose': 'Automation bus for webhooks, alerts, and cross-service glue.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '5678'},
|
||||
{'name': 'Doris Schoolhouse', 'url': 'https://schoolhouse.paccoco.com', 'purpose': 'Class workflows, uploads, recordings, and school intake.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '8091'},
|
||||
{'name': 'DoneTick', 'url': 'https://donetick.paccoco.com', 'health_url': 'https://donetick.paccoco.com', 'purpose': 'Chores, recurring work, and household task coordination.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '2021'},
|
||||
{'name': 'Paperless-NGX', 'url': 'https://paperless.paccoco.com', 'health_url': 'https://paperless.paccoco.com', 'purpose': 'Document archive, OCR intake, and household paperwork triage.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8083'},
|
||||
{'name': 'n8n', 'url': 'https://n8n.paccoco.com', 'health_url': 'https://n8n.paccoco.com', 'purpose': 'Automation bus for webhooks, alerts, and cross-service glue.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '5678'},
|
||||
{'name': 'Immich', 'url': 'http://10.5.1.6:2283', 'health_url': 'http://10.5.1.6:2283', 'purpose': 'Photo backup, search, and household media timeline.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '2283'},
|
||||
{'name': 'Karakeep', 'url': 'http://10.5.1.6:3100', 'health_url': 'http://10.5.1.6:3100/signin', 'purpose': 'Self-hosted bookmark capture and later-read archive.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3100'},
|
||||
{'name': 'KitchenOwl', 'url': 'http://10.5.1.6:8086', 'health_url': 'http://10.5.1.6:8086', 'purpose': 'Shared shopping lists and meal planning workflows.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8086'},
|
||||
{'name': 'Qui', 'url': 'http://10.5.1.6:7476', 'health_url': 'http://10.5.1.6:7476', 'purpose': 'Household utility app and quick operator tooling surface.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '7476'},
|
||||
{'name': 'Dakboard Bridge', 'url': '', 'health_url': 'http://10.5.1.6:5087', 'purpose': 'Authenticated bridge feeding Dakboard or display-side workflows.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': '5087'},
|
||||
{'name': 'Doris Schoolhouse', 'url': 'https://schoolhouse.paccoco.com', 'health_url': 'https://schoolhouse.paccoco.com', 'purpose': 'Class workflows, uploads, recordings, and school intake.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '8091'},
|
||||
{'name': 'Doris Kitchen', 'url': 'http://10.5.1.16:8092', 'health_url': 'http://10.5.1.16:8092', 'purpose': 'Meal planning, grocery workflows, and kitchen-side operator tasks.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8092'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'group': 'AI / Search',
|
||||
'items': [
|
||||
{'name': 'LiteLLM API', 'url': 'http://10.5.1.6:4000', 'health_url': 'http://10.5.1.6:4000/v1/models', 'purpose': 'Shared model gateway for automations, Schoolhouse, and local tools.', 'exposure': 'LAN', 'state': 'attention', 'host': 'PD', 'port': '4000'},
|
||||
{'name': 'LiteLLM', 'url': 'http://10.5.1.6:4000', 'health_url': 'http://10.5.1.6:4000/v1/models', 'purpose': 'Shared model gateway for automations, Schoolhouse, and local tools.', 'exposure': 'LAN', 'state': 'attention', 'host': 'PD', 'port': '4000'},
|
||||
{'name': 'OpenWebUI', 'url': 'https://openwebui.paccoco.com', 'health_url': 'http://10.5.1.6:8282', 'purpose': 'Chat UI for the lab models, docs, and retrieval workflows.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8282'},
|
||||
{'name': 'Qdrant', 'url': 'http://10.5.1.16:6333/dashboard', 'health_url': 'http://10.5.1.16:6333/healthz', 'purpose': 'Vector store for Project NOMAD and local retrieval systems.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '6333/6334'},
|
||||
{'name': 'Whisper API', 'url': 'http://10.5.1.16:8786/docs', 'health_url': 'http://10.5.1.16:8786/health', 'purpose': 'Fast local transcription endpoint for Schoolhouse and ad-hoc uploads.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8786'},
|
||||
{'name': 'Ollama (NOMAD)', 'url': 'http://10.5.1.16:11434/api/tags', 'health_url': 'http://10.5.1.16:11434/api/tags', 'purpose': 'Project NOMAD local model runtime and admin surface.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '11434'},
|
||||
{'name': 'Qdrant', 'url': 'http://10.5.1.6:6333/dashboard', 'health_url': 'http://10.5.1.6:6333/healthz', 'purpose': 'Vector store for Project NOMAD and local retrieval systems.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '6333/6334'},
|
||||
{'name': 'Whisper (CPU)', 'url': 'http://10.5.1.16:8786/docs', 'health_url': 'http://10.5.1.16:8786/health', 'purpose': 'Fast local CPU transcription endpoint on NOMAD.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8786'},
|
||||
{'name': 'Whisper (CUDA)', 'url': 'http://10.5.1.112:8787/docs', 'health_url': 'http://10.5.1.112:8787/health', 'purpose': 'Heavy GPU transcription endpoint on Rocinante.', 'exposure': 'LAN', 'state': 'ok', 'host': 'ROCINANTE', 'port': '8787'},
|
||||
{'name': 'OpenClaw', 'url': 'http://10.5.1.6:18789', 'health_url': 'http://10.5.1.6:18789', 'purpose': 'Legacy local helper/web surface documented in the AI stack.', 'exposure': 'LAN', 'state': 'attention', 'host': 'PD', 'port': '18789'},
|
||||
{'name': 'SearXNG', 'url': 'http://10.5.1.6:8888', 'health_url': 'http://10.5.1.6:8888', 'purpose': 'Private metasearch box for research and agent-side browsing.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8888'},
|
||||
{'name': 'Ollama — light tier', 'url': 'http://10.5.1.6:11434/api/tags', 'health_url': 'http://10.5.1.6:11434/api/tags', 'purpose': 'PD light-tier Ollama runtime backing cheaper/default model paths.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '11434'},
|
||||
{'name': 'Ollama — heavy tier', 'url': 'http://10.5.1.112:11434/api/tags', 'health_url': 'http://10.5.1.112:11434/api/tags', 'purpose': 'Rocinante heavy-tier Ollama runtime for larger model requests.', 'exposure': 'LAN', 'state': 'ok', 'host': 'ROCINANTE', 'port': '11434'},
|
||||
{'name': 'Reranker (TEI)', 'url': 'http://10.5.1.5:9787', 'health_url': 'http://10.5.1.5:9787', 'purpose': 'Serenity-hosted reranker used by retrieval flows.', 'exposure': 'LAN', 'state': 'ok', 'host': 'SERENITY', 'port': '9787'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'group': 'Homelab / Control Plane',
|
||||
'items': [
|
||||
{'name': 'Grafana', 'url': 'https://grafana.paccoco.com', 'purpose': 'Metrics, dashboards, and alert drill-down.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '3000'},
|
||||
{'name': 'Gitea', 'url': 'https://gitea.paccoco.com', 'purpose': 'Repos, issues, and source control.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '3002 / 2222'},
|
||||
{'name': 'Grafana', 'url': 'https://grafana.paccoco.com', 'health_url': 'https://grafana.paccoco.com', 'purpose': 'Metrics, dashboards, and alert drill-down.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '3000'},
|
||||
{'name': 'Prometheus', 'url': 'http://10.5.1.6:9090', 'health_url': 'http://10.5.1.6:9090/-/healthy', 'purpose': 'Metrics scrape and alert evaluation backend.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '9090'},
|
||||
{'name': 'Gitea', 'url': 'https://gitea.paccoco.com', 'health_url': 'https://gitea.paccoco.com', 'purpose': 'Repos, issues, and source control.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '3002 / 2222'},
|
||||
{'name': 'Dockhand', 'url': 'http://10.5.1.6:3230', 'health_url': 'http://10.5.1.6:3230', 'purpose': 'Container housekeeping and image/update operations.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3230'},
|
||||
{'name': 'Uptime Kuma', 'url': 'http://10.5.1.6:3001', 'health_url': 'http://10.5.1.6:3001', 'purpose': 'Availability checks and service-watch status board.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3001'},
|
||||
{'name': 'Gotify', 'url': 'https://gotify.paccoco.com', 'health_url': 'https://gotify.paccoco.com', 'purpose': 'Push notification hub for alerts and workflow fan-out.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8443'},
|
||||
{'name': 'RackPeek', 'url': 'http://10.5.1.6:8283', 'health_url': 'http://10.5.1.6:8283', 'purpose': 'Rack/device inventory and operator reference panel.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8283'},
|
||||
{'name': 'Shlink', 'url': 'http://10.5.1.6:8087', 'health_url': 'http://10.5.1.6:8087/rest/health', 'purpose': 'Short-link API and redirect backend.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8087'},
|
||||
{'name': 'Shlink Web Client', 'url': 'http://10.5.1.6:8088', 'health_url': 'http://10.5.1.6:8088', 'purpose': 'Short-link management UI for the Shlink backend.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8088'},
|
||||
{'name': 'Loki', 'url': '', 'purpose': 'Log aggregation backend for Grafana and the monitoring stack.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': 'internal', 'probe': False, 'badge': 'Manual', 'detail': 'documented internal monitoring component; verify from Grafana/compose when needed'},
|
||||
{'name': 'Promtail', 'url': '', 'purpose': 'Log shipping agent feeding Loki from the homelab nodes.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': 'internal', 'probe': False, 'badge': 'Manual', 'detail': 'documented internal monitoring component; verify from Grafana/compose when needed'},
|
||||
{'name': 'cAdvisor', 'url': '', 'purpose': 'Container metrics exporter feeding Prometheus.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'PD', 'port': 'internal', 'probe': False, 'badge': 'Manual', 'detail': 'documented internal monitoring component; verify from Prometheus targets when needed'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'group': 'Media / Library',
|
||||
'items': [
|
||||
{'name': 'Plex', 'url': 'http://10.5.1.6:32400/web/index.html', 'health_url': 'http://10.5.1.6:32400/identity', 'purpose': 'Primary media server, playback hub, and GPU-backed transcoding surface.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '32400'},
|
||||
{'name': 'Tautulli', 'url': 'http://10.5.1.6:8181', 'health_url': 'http://10.5.1.6:8181', 'purpose': 'Plex activity analytics, user history, and watch-session drill-down.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8181'},
|
||||
{'name': 'Audiobookshelf', 'url': 'http://10.5.1.6:13358', 'health_url': 'http://10.5.1.6:13358', 'purpose': 'Audiobook and podcast library with self-hosted playback and progress sync.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '13358'},
|
||||
{'name': 'Calibre-Web', 'url': 'http://10.5.1.6:8183', 'health_url': 'http://10.5.1.6:8183', 'purpose': 'Ebook library browser and metadata management front-end.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8183'},
|
||||
{'name': 'Seerr (Overseerr)', 'url': 'http://10.5.1.6:5055', 'health_url': 'http://10.5.1.6:5055', 'purpose': 'Request intake for media additions and library demand tracking.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '5055'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'group': 'Smart Home / Access',
|
||||
'items': [
|
||||
{'name': 'Home Assistant', 'url': '', 'purpose': 'Smart-home control hub documented on PD, but Doris does not yet have a stable operator-safe route.', 'exposure': 'PRIVATE', 'state': 'attention', 'host': 'PD', 'port': '8123', 'probe': False, 'badge': 'Manual', 'detail': 'documented active on PD; local port currently refuses from NOMAD, so keep this manual until routed cleanly'},
|
||||
{'name': 'Kima Hub', 'url': 'http://10.5.1.6:3333', 'health_url': 'http://10.5.1.6:3333', 'purpose': 'Custom IoT gateway and smart-home integration surface.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '3333'},
|
||||
{'name': 'Pi-hole VIP', 'url': 'http://10.5.1.53/ui/', 'health_url': 'http://10.5.1.53/admin/', 'purpose': 'Floating DNS/admin surface for the HA Pi-hole pair your clients should target.', 'exposure': 'LAN', 'state': 'ok', 'host': 'VIP', 'port': '53 / 80'},
|
||||
{'name': 'Pi-hole PD', 'url': 'http://10.5.1.6/ui/', 'health_url': 'http://10.5.1.6/admin/', 'purpose': 'Primary PD Pi-hole node behind the floating DNS VIP.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '53 / 80'},
|
||||
{'name': 'Pi-hole NOMAD', 'url': 'https://10.5.1.16/login', 'purpose': 'NOMAD backup Pi-hole replica for the HA DNS pair.', 'exposure': 'LAN', 'state': 'attention', 'host': 'NOMAD', 'port': '53 / 80/443', 'probe': False, 'badge': 'Manual', 'detail': 'documented active; local admin currently presents a self-signed TLS flow from NOMAD so Doris leaves this manual'},
|
||||
{'name': 'Authelia', 'url': 'http://10.5.1.6:9091', 'health_url': 'http://10.5.1.6:9091', 'purpose': 'Identity and auth gate backing protected public routes.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '9091'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'group': 'Mapping / Radio / Mesh',
|
||||
'items': [
|
||||
{'name': 'MeshMonitor', 'url': 'https://meshmonitor.paccoco.com', 'health_url': 'http://10.5.1.6:8081', 'purpose': 'Meshtastic monitoring, node views, and map/radio telemetry.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'PD', 'port': '8081'},
|
||||
{'name': 'TileServer GL', 'url': 'http://10.5.1.6:8082', 'health_url': 'http://10.5.1.6:8082', 'purpose': 'Map tile serving for local geo and radio/mapping workflows.', 'exposure': 'LAN', 'state': 'ok', 'host': 'PD', 'port': '8082'},
|
||||
{'name': 'MeshCore to MQTT relay', 'url': '', 'purpose': 'Containerized relay moving MeshCore data into MQTT for the radio stack.', 'exposure': 'PRIVATE', 'state': 'ok', 'host': 'NOMAD', 'port': 'outbound MQTT only', 'probe': False, 'badge': 'Manual', 'detail': 'documented running as mctomqtt; verify via container/runtime logs when needed'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'group': 'Game Servers',
|
||||
'items': [
|
||||
{'name': 'Pelican Panel', 'url': 'https://panel.paccoco.com', 'health_url': 'https://panel.paccoco.com', 'purpose': 'Game-server management panel for the NOMAD Wings stack.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '443'},
|
||||
{'name': 'Wings Node API', 'url': 'https://node1.paccoco.com', 'health_url': 'https://node1.paccoco.com', 'purpose': 'Public-facing Wings node endpoint; 401 means the daemon is reachable and auth-protected.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '8443 / 443'},
|
||||
{'name': 'Minecraft Server 25565', 'url': '', 'purpose': 'First Wings-managed Java server on NOMAD.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '25565/tcp+udp', 'tcp_host': '10.5.1.16', 'tcp_port': 25565, 'badge': 'Healthy'},
|
||||
{'name': 'Minecraft Server 25566', 'url': '', 'purpose': 'Second Wings-managed Java/NeoForge server on NOMAD.', 'exposure': 'PUBLIC', 'state': 'ok', 'host': 'NOMAD', 'port': '25566/tcp+udp', 'tcp_host': '10.5.1.16', 'tcp_port': 25566, 'badge': 'Healthy'},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -74,7 +137,6 @@ SERVICE_DIRECTORY = [
|
||||
{'name': 'Dozzle', 'url': 'http://10.5.1.16:9999', 'health_url': 'http://10.5.1.16:9999', 'purpose': 'Live Docker logs on NOMAD without opening a shell.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '9999'},
|
||||
{'name': 'Flatnotes', 'url': 'http://10.5.1.16:8200', 'health_url': 'http://10.5.1.16:8200', 'purpose': 'Quick notes and lightweight knowledge capture.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8200'},
|
||||
{'name': 'Kiwix', 'url': 'http://10.5.1.16:8090', 'health_url': 'http://10.5.1.16:8090', 'purpose': 'Offline library / knowledge mirror for disconnected use.', 'exposure': 'LAN', 'state': 'ok', 'host': 'NOMAD', 'port': '8090'},
|
||||
{'name': 'Home Assistant', 'url': '', 'purpose': 'Smart-home control hub documented on PD, but no operator-safe route is exposed from NOMAD yet.', 'exposure': 'PRIVATE', 'state': 'attention', 'host': 'PD', 'port': '8123', 'probe': False, 'badge': 'Manual', 'detail': 'documented active on PD; add a stable route before probing from Doris'},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1085,6 +1147,7 @@ def fetch_hermes_usage(days:int=30)->dict[str,Any]:
|
||||
'last_24h_tokens':0,
|
||||
'last_24h_sessions':0,
|
||||
'last_24h_tool_calls':0,
|
||||
'previous_24h_tokens':0,
|
||||
'last_7d_tokens':0,
|
||||
'avg_tokens_per_session':0,
|
||||
'avg_tool_calls_per_session':0,
|
||||
@@ -1095,6 +1158,7 @@ def fetch_hermes_usage(days:int=30)->dict[str,Any]:
|
||||
'recent_sessions':[],
|
||||
'latest_session':None,
|
||||
'account_windows':[],
|
||||
'daily_tokens':[],
|
||||
'generated_at':datetime.now(LOCAL_TZ).isoformat(),
|
||||
}
|
||||
if not HERMES_STATE_DB.exists():
|
||||
@@ -1103,6 +1167,7 @@ def fetch_hermes_usage(days:int=30)->dict[str,Any]:
|
||||
now_ts=time.time()
|
||||
cutoff=now_ts-(days*86400)
|
||||
day_cutoff=now_ts-86400
|
||||
prev_day_cutoff=now_ts-(2*86400)
|
||||
week_cutoff=now_ts-(7*86400)
|
||||
try:
|
||||
conn=sqlite3.connect(f"file:{HERMES_STATE_DB}?mode=ro", uri=True)
|
||||
@@ -1150,6 +1215,12 @@ def fetch_hermes_usage(days:int=30)->dict[str,Any]:
|
||||
stats['last_24h_tokens']=int((row['total_tokens'] or 0) if row else 0)
|
||||
row=cur.execute("SELECT count(*) AS c FROM messages WHERE role='tool' AND timestamp >= ?",(day_cutoff,)).fetchone()
|
||||
stats['last_24h_tool_calls']=int((row['c'] or 0) if row else 0)
|
||||
row=cur.execute("""
|
||||
SELECT sum(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens
|
||||
FROM sessions
|
||||
WHERE started_at >= ? AND started_at < ?
|
||||
""",(prev_day_cutoff, day_cutoff)).fetchone()
|
||||
stats['previous_24h_tokens']=int((row['total_tokens'] or 0) if row else 0)
|
||||
row=cur.execute("""
|
||||
SELECT sum(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens
|
||||
FROM sessions
|
||||
@@ -1191,6 +1262,28 @@ def fetch_hermes_usage(days:int=30)->dict[str,Any]:
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 5
|
||||
""",(cutoff,))]
|
||||
day_buckets={}
|
||||
for day_index in range(6,-1,-1):
|
||||
day=(datetime.now(LOCAL_TZ)-timedelta(days=day_index)).date()
|
||||
day_buckets[day.isoformat()]={'label':day.strftime('%a'),'tokens':0,'sessions':0}
|
||||
day_rows=cur.execute("""
|
||||
SELECT started_at,
|
||||
(coalesce(input_tokens,0)+coalesce(output_tokens,0)+coalesce(cache_read_tokens,0)+coalesce(cache_write_tokens,0)+coalesce(reasoning_tokens,0)) AS total_tokens
|
||||
FROM sessions
|
||||
WHERE started_at >= ?
|
||||
ORDER BY started_at ASC
|
||||
""",(week_cutoff,)).fetchall()
|
||||
for row in day_rows:
|
||||
try:
|
||||
day_key=datetime.fromtimestamp(float(row['started_at']), LOCAL_TZ).date().isoformat()
|
||||
except Exception:
|
||||
continue
|
||||
bucket=day_buckets.get(day_key)
|
||||
if not bucket:
|
||||
continue
|
||||
bucket['tokens'] += int(row['total_tokens'] or 0)
|
||||
bucket['sessions'] += 1
|
||||
stats['daily_tokens']=list(day_buckets.values())
|
||||
for item in stats['recent_sessions']:
|
||||
started=item.get('started_at')
|
||||
try: item['started_local']=datetime.fromtimestamp(float(started), LOCAL_TZ).strftime('%a %-I:%M %p')
|
||||
@@ -1306,6 +1399,88 @@ def build_focus_cards(local_items:list[dict[str,Any]], todos:list[dict[str,Any]]
|
||||
return ("<section class='focus-grid'>" + ''.join(cards) + "</section>", section_state)
|
||||
|
||||
|
||||
def hermes_delta_text(current:int|float|None, previous:int|float|None)->str:
|
||||
try:
|
||||
current_value=float(current or 0)
|
||||
previous_value=float(previous or 0)
|
||||
except Exception:
|
||||
return 'No comparison window yet'
|
||||
if previous_value <= 0 and current_value <= 0:
|
||||
return 'No Hermes usage in either 24h window'
|
||||
if previous_value <= 0:
|
||||
return 'Up from a cold previous 24h window'
|
||||
delta=((current_value-previous_value)/previous_value)*100.0
|
||||
direction='up' if delta >= 0 else 'down'
|
||||
return f"{abs(delta):.0f}% {direction} vs previous 24h"
|
||||
|
||||
|
||||
def choose_feature_story(local_items:list[dict[str,Any]], homelab_items:list[dict[str,Any]], space_items:list[dict[str,Any]], signal_items:list[dict[str,Any]])->dict[str,Any] | None:
|
||||
for pool in (local_items, homelab_items, space_items, signal_items):
|
||||
if pool:
|
||||
return pool[0]
|
||||
return None
|
||||
|
||||
|
||||
def build_feature_story(feature_item:dict[str,Any] | None)->str:
|
||||
if not feature_item:
|
||||
return ''
|
||||
title=str(feature_item.get('title') or 'Lead item')
|
||||
summary=(feature_item.get('summary') or '').strip()[:260]
|
||||
source=str(feature_item.get('source') or 'Source')
|
||||
when=(feature_item.get('published_at','')[:16].replace('T',' '))
|
||||
category=str(feature_item.get('category') or 'other')
|
||||
url=str(feature_item.get('url') or '')
|
||||
feature_label='Lead local item' if category=='local' else ('Lead homelab item' if category in {'homelab','smart_home','radio'} else 'Lead signal')
|
||||
why_text=why(feature_item)
|
||||
return f"""
|
||||
<section class='feature-story'>
|
||||
<article class='feature-card {esc(category)}'>
|
||||
<div class='feature-meta'><span>{esc(feature_label)}</span><span>{esc(source)}</span><span>{esc(when)} UTC</span></div>
|
||||
<div class='feature-body'>
|
||||
<div class='feature-copy'>
|
||||
<span class='feature-kicker'>{esc(label(category))}</span>
|
||||
<h2><a href='{esc(url)}' target='_blank' rel='noreferrer'>{esc(title)}</a></h2>
|
||||
<p>{esc(summary)}</p>
|
||||
<div class='feature-why'>Why it made lead: {esc(why_text)}</div>
|
||||
</div>
|
||||
<div class='feature-actions'>
|
||||
<a href='{esc(url)}' target='_blank' rel='noreferrer' class='feature-open'>Open story</a>
|
||||
<span class='feature-note'>Promoted so you get one strong item instead of four equal tiles.</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>"""
|
||||
|
||||
|
||||
def build_operator_snapshot(todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any], hermes:dict[str,Any])->str:
|
||||
urgent_todos=[item for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard']
|
||||
warn_items=[item for item in pulse if item.get('state')=='warn']
|
||||
session_window=find_usage_window(hermes.get('account_windows',[]), 'session') if hermes.get('ok') else None
|
||||
session_remaining=session_window.get('remaining_percent') if session_window else None
|
||||
metrics=[
|
||||
metric_card('Urgent queue', compact_number(len(urgent_todos)), 'Donetick chores due today / overdue', 'warn' if urgent_todos else 'ok'),
|
||||
metric_card('Hermes burn', compact_number(hermes.get('last_24h_tokens',0)) if hermes.get('ok') else '—', hermes_delta_text(hermes.get('last_24h_tokens',0), hermes.get('previous_24h_tokens',0)) if hermes.get('ok') else 'Telemetry unavailable', usage_window_state(session_remaining) if hermes.get('ok') else 'attention'),
|
||||
metric_card('Session headroom', pct_text(session_remaining) if hermes.get('ok') else '—', f"Resets {session_window.get('reset_local') or 'unknown'}" if session_window else 'No live budget window available', usage_window_state(session_remaining) if hermes.get('ok') else 'attention'),
|
||||
metric_card('Operator inbox', f"{paperless.get('active_count',0)} docs / {len(warn_items)} lab warns", 'Paperless + homelab friction to clear next', 'warn' if paperless.get('flagged_count',0) or warn_items else ('attention' if paperless.get('active_count',0) else 'ok')),
|
||||
]
|
||||
return "<section class='snapshot-strip'>" + ''.join(metrics) + "</section>"
|
||||
|
||||
|
||||
def render_hermes_trend_card(hermes:dict[str,Any])->str:
|
||||
buckets=hermes.get('daily_tokens') or []
|
||||
if not buckets:
|
||||
return "<article class='summary-card'><h3>Usage trend</h3><p>No 7-day history available yet.</p></article>"
|
||||
max_tokens=max((int(item.get('tokens') or 0) for item in buckets), default=0) or 1
|
||||
peak=max(buckets, key=lambda item: int(item.get('tokens') or 0))
|
||||
bars=[]
|
||||
for item in buckets:
|
||||
tokens=int(item.get('tokens') or 0)
|
||||
height=max(14, round((tokens/max_tokens)*84)) if tokens else 10
|
||||
bars.append(f"<div class='trend-bar-wrap'><span class='trend-value'>{esc(compact_number(tokens))}</span><div class='trend-bar' style='height:{height}px'></div><span class='trend-label'>{esc(item.get('label') or '')}</span></div>")
|
||||
summary=f"Peak {peak.get('label')} at {compact_number(peak.get('tokens',0))} tokens; now {hermes_delta_text(hermes.get('last_24h_tokens',0), hermes.get('previous_24h_tokens',0))}."
|
||||
return f"<article class='summary-card'><h3>Usage trend</h3><div class='trend-chart'>{''.join(bars)}</div><p>{esc(summary)}</p></article>"
|
||||
|
||||
|
||||
def build_hero_summary(local_items:list[dict[str,Any]], todos:list[dict[str,Any]], pulse:list[dict[str,str]], paperless:dict[str,Any], hermes:dict[str,Any])->str:
|
||||
urgent_todos=len([item for item in todos if (item.get('state') or '') in {'overdue','today'} and (item.get('source') or '') != 'Dashboard'])
|
||||
warn_count=len([item for item in pulse if item.get('state')=='warn'])
|
||||
@@ -1393,6 +1568,7 @@ def build_hermes_section(hermes:dict[str,Any])->str:
|
||||
recent_rows.append(f"{esc(source)} · {esc(item.get('started_local') or 'recent')} · {esc(model)} · {compact_number(item.get('total_tokens',0))} tokens")
|
||||
operator_rows=[
|
||||
f"Last 24h burn: {compact_number(hermes.get('last_24h_tokens',0))} tokens across {compact_number(hermes.get('last_24h_sessions',0))} sessions",
|
||||
f"24h change: {hermes_delta_text(hermes.get('last_24h_tokens',0), hermes.get('previous_24h_tokens',0))}",
|
||||
f"Rolling 7-day burn: {compact_number(hermes.get('last_7d_tokens',0))} tokens",
|
||||
f"Cache reuse: {pct_text(hermes.get('cache_share_percent'))}",
|
||||
f"Average session size: {compact_number(hermes.get('avg_tokens_per_session',0))} tokens",
|
||||
@@ -1403,6 +1579,7 @@ def build_hermes_section(hermes:dict[str,Any])->str:
|
||||
<h3>Operator read</h3>
|
||||
<ul>{list_rows(operator_rows)}</ul>
|
||||
</article>
|
||||
{render_hermes_trend_card(hermes)}
|
||||
<article class='summary-card'>
|
||||
<h3>Model mix</h3>
|
||||
<ul>{list_rows(model_rows)}</ul>
|
||||
@@ -1432,6 +1609,21 @@ def check_service_health(item:dict[str,Any])->dict[str,str]:
|
||||
badge=str(item.get('badge') or ('Healthy' if state=='ok' else 'Manual'))
|
||||
detail=str(item.get('detail') or 'manual operator check only')
|
||||
return {'state':state,'badge':badge,'detail':detail}
|
||||
tcp_host=str(item.get('tcp_host') or '').strip()
|
||||
tcp_port=item.get('tcp_port')
|
||||
if tcp_host and tcp_port:
|
||||
started=time.time()
|
||||
sock=socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(3)
|
||||
try:
|
||||
sock.connect((tcp_host, int(tcp_port)))
|
||||
elapsed=max(1,int((time.time()-started)*1000))
|
||||
return {'state':'ok','badge':str(item.get('badge') or 'Healthy'),'detail':f'tcp {int(tcp_port)} open · {elapsed} ms'}
|
||||
except Exception as e:
|
||||
elapsed=max(1,int((time.time()-started)*1000))
|
||||
return {'state':'warn','badge':'Down','detail':f'tcp {int(tcp_port)} {type(e).__name__} · {elapsed} ms'}
|
||||
finally:
|
||||
sock.close()
|
||||
target=str(item.get('health_url') or item.get('url') or '').strip()
|
||||
if not target:
|
||||
return {'state':'warn','badge':'Down','detail':'no health URL configured'}
|
||||
@@ -1445,7 +1637,7 @@ def check_service_health(item:dict[str,Any])->dict[str,str]:
|
||||
elapsed=max(1,int((time.time()-started)*1000))
|
||||
detail_parts=[str(code), f'{elapsed} ms']
|
||||
lower=final_url.lower()
|
||||
if '/auth/resource/' in lower or '/accounts/login/' in lower:
|
||||
if '/auth/resource/' in lower or '/accounts/login/' in lower or '/login' in lower:
|
||||
detail_parts.append('auth gate')
|
||||
elif final_url.rstrip('/') != target.rstrip('/'):
|
||||
detail_parts.append('redirected')
|
||||
@@ -1598,8 +1790,13 @@ def render()->str:
|
||||
hermes_html=build_hermes_section(hermes)
|
||||
system_html=build_system_section(system)
|
||||
hero_tokens=compact_number(hermes.get('total_tokens',0)) if hermes.get('ok') else '—'
|
||||
snapshot_html=build_operator_snapshot(todos,pulse,paperless,hermes)
|
||||
feature_item=choose_feature_story(groups['local'], groups['homelab'], groups['space'], top_briefing or groups['signals'])
|
||||
feature_html=build_feature_story(feature_item)
|
||||
session_window=find_usage_window(hermes.get('account_windows',[]),'session') if hermes.get('ok') else None
|
||||
primary_html=''.join([
|
||||
section('Top briefing','Compact scan of what is worth your time.',top_briefing,'Nothing worth bothering you with.',compact=True),
|
||||
feature_html,
|
||||
section('Top briefing','Everything else worth a glance after the lead item.',top_briefing,'Nothing worth bothering you with.',compact=True),
|
||||
weather_html,
|
||||
forecast_html,
|
||||
section('Homelab / Smart Home / Radio','Infrastructure, self-hosting, smart home, and ham radio.',groups['homelab'][:4],'No homelab signal right now.',compact=True),
|
||||
@@ -1618,7 +1815,8 @@ def render()->str:
|
||||
collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"<div class='metrics metrics-compact'>{pulse_metrics}</div>",'homelab-pulse',open_by_default=False),
|
||||
])
|
||||
return f"""<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><meta http-equiv='refresh' content='1800'><title>Doris Dashboard</title><link rel='stylesheet' href='style.css'></head><body data-donetick-focus-signature='{esc(focus_signature)}'><main>{top_nav('home')}
|
||||
<header class='hero'><div><p class='eyebrow'>PC Doris Thatcher</p><h1>Doris Dashboard</h1><p class='sub'>{esc(hero_summary)}</p><div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden stories</button><span class='muted'>Hidden stories: <strong data-hidden-topics-count>0</strong></span></div></div><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Central time · refreshes every 30m</span></div><div><strong>{len(candidates)}</strong><span>Candidates</span></div><div><strong>{len(set(i.get('source','') for i in candidates))}</strong><span>Sources</span></div><div><strong>{esc(hero_tokens)}</strong><span>30-day Hermes tokens</span></div></div></header>
|
||||
<header class='hero'><div class='hero-main'><p class='eyebrow'>PC Doris Thatcher</p><div class='hero-title-row'><h1>Doris Dashboard</h1><div class='hero-chips'><span class='hero-chip'>Hermes {esc((hermes.get('models') or [{}])[0].get('model') or hermes.get('default_model') or 'unknown')}</span><span class='hero-chip'>Session {esc(pct_text((session_window or {}).get('remaining_percent')) if hermes.get('ok') else '—')}</span><span class='hero-chip'>24h {esc(compact_number(hermes.get('last_24h_tokens',0)) if hermes.get('ok') else '—')} tokens</span></div></div><p class='sub'>{esc(hero_summary)}</p><div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden stories</button><span class='muted'>Hidden stories: <strong data-hidden-topics-count>0</strong></span></div></div><div class='hero-rail'><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Central time · refreshes every 30m</span></div><div><strong>{len(candidates)}</strong><span>Candidates</span></div><div><strong>{len(set(i.get('source','') for i in candidates))}</strong><span>Sources</span></div><div><strong>{esc(hero_tokens)}</strong><span>30-day Hermes tokens</span></div></div></div></header>
|
||||
<section class='snapshot-block'><div class='section-heading-inline'><div><p class='eyebrow'>Today's operator snapshot</p><h2>What matters before the scroll</h2></div><span class='muted'>Glanceable heat across chores, Hermes, and the lab.</span></div>{snapshot_html}</section>
|
||||
<div class='dashboard-shell'><section class='primary-column'>{primary_html}</section><aside class='secondary-column'>{secondary_html}</aside></div>
|
||||
<footer><p>Category mix: {esc(', '.join(f'{k}: {v}' for k,v in counts.most_common(10)))}</p><p>Local-only on NOMAD unless John says otherwise. Weather from Open-Meteo; moon phase calculated locally. Hermes stats come from ~/.hermes/state.db.</p></footer></main>{feedback_bootstrap()}</body></html>"""
|
||||
|
||||
|
||||
@@ -55,12 +55,47 @@ main{max-width:1380px;margin:0 auto;padding:18px}
|
||||
|
||||
.hero{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1.25fr) 430px;
|
||||
grid-template-columns:minmax(0,1.4fr) minmax(320px,.85fr);
|
||||
gap:14px;
|
||||
align-items:stretch;
|
||||
border-radius:24px;
|
||||
padding:16px 18px;
|
||||
margin-bottom:16px;
|
||||
margin-bottom:14px;
|
||||
}
|
||||
|
||||
.hero-main,
|
||||
.hero-rail{min-width:0}
|
||||
|
||||
.hero-main{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
justify-content:space-between;
|
||||
gap:10px;
|
||||
}
|
||||
|
||||
.hero-title-row{
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
justify-content:space-between;
|
||||
gap:14px;
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
|
||||
.hero-chips{
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
gap:8px;
|
||||
justify-content:flex-end;
|
||||
}
|
||||
|
||||
.hero-chip{
|
||||
border:1px solid rgba(124,140,255,.28);
|
||||
background:rgba(124,140,255,.10);
|
||||
color:#d9deff;
|
||||
border-radius:999px;
|
||||
padding:7px 10px;
|
||||
font-size:.8rem;
|
||||
white-space:nowrap;
|
||||
}
|
||||
|
||||
.eyebrow{
|
||||
@@ -113,6 +148,30 @@ h1{
|
||||
|
||||
.status,
|
||||
.metrics{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
gap:10px;
|
||||
}
|
||||
|
||||
.snapshot-block{
|
||||
margin:0 0 16px;
|
||||
}
|
||||
|
||||
.section-heading-inline{
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
align-items:flex-end;
|
||||
gap:12px;
|
||||
margin:0 0 10px;
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
|
||||
.section-heading-inline h2{
|
||||
margin:2px 0 0;
|
||||
font-size:1.08rem;
|
||||
}
|
||||
|
||||
.snapshot-strip{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(4,minmax(0,1fr));
|
||||
gap:10px;
|
||||
@@ -641,3 +700,135 @@ footer p{margin:.25rem 0}
|
||||
.nav-links{justify-content:flex-start}
|
||||
.services-grid{grid-template-columns:1fr}
|
||||
}
|
||||
|
||||
|
||||
.feature-story{margin:0 0 2px}
|
||||
|
||||
.feature-card{
|
||||
padding:16px;
|
||||
border-radius:20px;
|
||||
position:relative;
|
||||
overflow:hidden;
|
||||
background:linear-gradient(135deg,rgba(124,140,255,.18),rgba(79,209,165,.08)),rgba(17,21,29,.96);
|
||||
}
|
||||
|
||||
.feature-card::after{
|
||||
content:'';
|
||||
position:absolute;
|
||||
inset:auto -30px -30px auto;
|
||||
width:180px;
|
||||
height:180px;
|
||||
background:radial-gradient(circle,rgba(124,140,255,.16),transparent 68%);
|
||||
pointer-events:none;
|
||||
}
|
||||
|
||||
.feature-meta{
|
||||
display:flex;
|
||||
gap:10px;
|
||||
flex-wrap:wrap;
|
||||
color:var(--muted);
|
||||
font-size:.82rem;
|
||||
margin-bottom:10px;
|
||||
}
|
||||
|
||||
.feature-kicker{
|
||||
display:inline-flex;
|
||||
margin-bottom:8px;
|
||||
color:#c7d0ff;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:.14em;
|
||||
font-size:.73rem;
|
||||
}
|
||||
|
||||
.feature-body{
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1.35fr) 230px;
|
||||
gap:16px;
|
||||
align-items:end;
|
||||
}
|
||||
|
||||
.feature-copy h2{
|
||||
margin:0 0 8px;
|
||||
font-size:1.45rem;
|
||||
line-height:1.08;
|
||||
}
|
||||
|
||||
.feature-copy p{
|
||||
margin:0;
|
||||
color:#d7deea;
|
||||
line-height:1.5;
|
||||
}
|
||||
|
||||
.feature-why{
|
||||
margin-top:12px;
|
||||
color:var(--muted);
|
||||
font-size:.86rem;
|
||||
}
|
||||
|
||||
.feature-actions{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:10px;
|
||||
align-items:flex-start;
|
||||
}
|
||||
|
||||
.feature-open{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
text-decoration:none;
|
||||
border-radius:999px;
|
||||
padding:10px 14px;
|
||||
background:#eef2ff;
|
||||
color:#0f172a;
|
||||
font-weight:700;
|
||||
}
|
||||
|
||||
.feature-note{
|
||||
color:var(--muted);
|
||||
font-size:.83rem;
|
||||
line-height:1.4;
|
||||
}
|
||||
|
||||
.trend-chart{
|
||||
display:flex;
|
||||
align-items:flex-end;
|
||||
gap:8px;
|
||||
min-height:120px;
|
||||
margin:8px 0 10px;
|
||||
}
|
||||
|
||||
.trend-bar-wrap{
|
||||
flex:1;
|
||||
min-width:0;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
align-items:center;
|
||||
justify-content:flex-end;
|
||||
gap:6px;
|
||||
}
|
||||
|
||||
.trend-value,
|
||||
.trend-label{
|
||||
font-size:.73rem;
|
||||
color:var(--muted);
|
||||
}
|
||||
|
||||
.trend-bar{
|
||||
width:100%;
|
||||
max-width:28px;
|
||||
border-radius:999px 999px 10px 10px;
|
||||
background:linear-gradient(180deg,#8ea0ff 0%,#4fd1a5 100%);
|
||||
box-shadow:0 8px 20px rgba(79,209,165,.18);
|
||||
}
|
||||
|
||||
@media (max-width:980px){
|
||||
.focus-grid,.summary-grid,.forecast-grid,.metrics.metrics-compact,.snapshot-strip{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.feature-body{grid-template-columns:1fr}
|
||||
}
|
||||
|
||||
@media (max-width:920px){
|
||||
.hero-title-row,.section-heading-inline{align-items:flex-start}
|
||||
.hero-chips{justify-content:flex-start}
|
||||
.snapshot-strip{grid-template-columns:1fr}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user