Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aad42834f | ||
|
|
15a31a5c64 | ||
|
|
4d3b91af30 | ||
|
|
eb8a92f0c1 |
@@ -170,7 +170,15 @@ jobs:
|
||||
SENTRY_URL: https://errors.libnovel.cc/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
SENTRY_ORG: libnovel
|
||||
SENTRY_PROJECT: libnovel-ui
|
||||
SENTRY_PROJECT: ui
|
||||
|
||||
- name: Create GlitchTip release
|
||||
run: glitchtip-cli releases new ${{ gitea.ref_name }}
|
||||
env:
|
||||
SENTRY_URL: https://errors.libnovel.cc/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
SENTRY_ORG: libnovel
|
||||
SENTRY_PROJECT: ui
|
||||
|
||||
- name: Upload source maps to GlitchTip
|
||||
run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
|
||||
@@ -178,7 +186,15 @@ jobs:
|
||||
SENTRY_URL: https://errors.libnovel.cc/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
SENTRY_ORG: libnovel
|
||||
SENTRY_PROJECT: libnovel-ui
|
||||
SENTRY_PROJECT: ui
|
||||
|
||||
- name: Finalize GlitchTip release
|
||||
run: glitchtip-cli releases finalize ${{ gitea.ref_name }}
|
||||
env:
|
||||
SENTRY_URL: https://errors.libnovel.cc/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
SENTRY_ORG: libnovel
|
||||
SENTRY_PROJECT: ui
|
||||
|
||||
# ── docker: ui ────────────────────────────────────────────────────────────────
|
||||
docker-ui:
|
||||
|
||||
150
AGENTS.md
Normal file
150
AGENTS.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# LibNovel v2 — Agent Context
|
||||
|
||||
This file is the root-level knowledge base for LLM coding agents (OpenCode, Claude, Cursor, Copilot, etc.).
|
||||
Sub-directories have their own `AGENTS.md` with deeper context (e.g. `ios/AGENTS.md`).
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| UI | SvelteKit 2 + Svelte 5, TypeScript, TailwindCSS |
|
||||
| Backend / Runner | Go (single repo, two binaries: `backend`, `runner`) |
|
||||
| iOS app | SwiftUI, iOS 17+, Swift 5.9+ |
|
||||
| Database | PocketBase (SQLite) + MinIO (object storage) |
|
||||
| Search | Meilisearch |
|
||||
| Queue | Asynq over Redis (local) / Valkey (prod) |
|
||||
| Scraping | Novelfire scraper in `backend/novelfire/` |
|
||||
|
||||
---
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
.
|
||||
├── .gitea/workflows/ # CI/CD — Gitea Actions (NOT .github/)
|
||||
├── .opencode/ # OpenCode agent config (memory, skills)
|
||||
├── backend/ # Go backend + runner (single module)
|
||||
├── caddy/ # Caddy reverse proxy Dockerfile
|
||||
├── homelab/ # Homelab docker-compose + observability stack
|
||||
├── ios/ # SwiftUI iOS app (see ios/AGENTS.md)
|
||||
├── scripts/ # Utility scripts
|
||||
├── ui/ # SvelteKit UI
|
||||
├── docker-compose.yml # Prod compose (all services)
|
||||
├── AGENTS.md # This file
|
||||
└── opencode.json # OpenCode config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI/CD — Gitea Actions
|
||||
|
||||
- Workflows live in `.gitea/workflows/` — **not** `.github/workflows/`
|
||||
- Self-hosted Gitea instance; use `gitea.ref_name` / `gitea.sha` (not `github.*`)
|
||||
- Two workflows:
|
||||
- `ci.yaml` — runs on every push to `main` (test + type-check)
|
||||
- `release.yaml` — runs on `v*` tags (build Docker images, upload source maps, create Gitea release)
|
||||
- Secrets: `DOCKER_USER`, `DOCKER_TOKEN`, `GITEA_TOKEN`, `GLITCHTIP_AUTH_TOKEN`
|
||||
|
||||
### Releasing a new version
|
||||
|
||||
```bash
|
||||
git tag v2.5.X -m "Short title\n\nOptional longer body"
|
||||
git push origin v2.5.X
|
||||
```
|
||||
|
||||
CI will build all Docker images, upload source maps to GlitchTip, and create a Gitea release automatically.
|
||||
|
||||
---
|
||||
|
||||
## GlitchTip Error Tracking
|
||||
|
||||
- Instance: `https://errors.libnovel.cc/`
|
||||
- Org: `libnovel`
|
||||
- Projects: `ui` (id/1), `backend` (id/2), `runner` (id/3)
|
||||
- Tool: `glitchtip-cli` v0.1.0
|
||||
|
||||
### Per-service DSNs (stored in Doppler)
|
||||
|
||||
| Service | Doppler key | GlitchTip project |
|
||||
|---|---|---|
|
||||
| UI (SvelteKit) | `PUBLIC_GLITCHTIP_DSN` | ui (1) |
|
||||
| Backend (Go) | `GLITCHTIP_DSN_BACKEND` | backend (2) |
|
||||
| Runner (Go) | `GLITCHTIP_DSN_RUNNER` | runner (3) |
|
||||
|
||||
### Source map upload flow (release.yaml)
|
||||
|
||||
The correct order is **critical** — uploading before `releases new` results in 0 files shown in GlitchTip UI:
|
||||
|
||||
```
|
||||
glitchtip-cli sourcemaps inject ./build # inject debug IDs
|
||||
glitchtip-cli releases new <version> # MUST come before upload
|
||||
glitchtip-cli sourcemaps upload ./build \
|
||||
--release <version> # associate files with release
|
||||
glitchtip-cli releases finalize <version> # mark release complete
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
| Environment | Host | Path | Doppler config |
|
||||
|---|---|---|---|
|
||||
| Prod | `165.22.70.138` | `/opt/libnovel/` | `prd` |
|
||||
| Homelab runner | `192.168.0.109` | `/opt/libnovel-runner/` | `prd_homelab` |
|
||||
|
||||
### Docker Compose — always use Doppler
|
||||
|
||||
```bash
|
||||
# Prod
|
||||
doppler run --project libnovel --config prd -- docker compose <cmd>
|
||||
|
||||
# Homelab full-stack (runs from .bak file on server)
|
||||
doppler run --project libnovel --config prd_homelab -- docker compose -f homelab/docker-compose.yml.bak <cmd>
|
||||
|
||||
# Homelab runner only
|
||||
doppler run --project libnovel --config prd_homelab -- docker compose -f homelab/runner/docker-compose.yml <cmd>
|
||||
```
|
||||
|
||||
- Prod runner has `profiles: [runner]` — `docker compose up -d` will NOT accidentally start it
|
||||
- When deploying, always sync `docker-compose.yml` to the server before running `up -d`
|
||||
|
||||
---
|
||||
|
||||
## Observability
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| GlitchTip | Error tracking (UI + backend + runner) |
|
||||
| Grafana Faro | RUM / Web Vitals (collector at `faro.libnovel.cc/collect`) |
|
||||
| OpenTelemetry | Distributed tracing (OTLP → collector → Tempo) |
|
||||
| Grafana | Dashboards at `/admin/grafana` |
|
||||
|
||||
Grafana dashboards: `homelab/otel/grafana/provisioning/dashboards/`
|
||||
|
||||
---
|
||||
|
||||
## Go Backend
|
||||
|
||||
- Primary files: `orchestrator.go`, `server/handlers_*.go`, `novelfire/scraper.go`, `storage/hybrid.go`, `storage/pocketbase.go`
|
||||
- Store interface: `store.go` — never touch MinIO/PocketBase clients directly outside `storage/`
|
||||
- Two binaries built from the same module: `backend` (HTTP API) and `runner` (Asynq worker)
|
||||
|
||||
---
|
||||
|
||||
## SvelteKit UI
|
||||
|
||||
- Source: `ui/src/`
|
||||
- i18n: Paraglide — translation files in `ui/messages/*.json` (5 locales)
|
||||
- Auth debug bypass: `GET /api/auth/debug-login?token=<DEBUG_LOGIN_TOKEN>&username=<username>&next=<path>`
|
||||
|
||||
---
|
||||
|
||||
## iOS App
|
||||
|
||||
Full context in `ios/AGENTS.md`. Quick notes:
|
||||
- SwiftUI, iOS 17+, `@Observable` for new types
|
||||
- Download key separator: `::` (not `-`)
|
||||
- Voice fallback: book override → global default → `"af_bella"`
|
||||
- Offline pattern: `NetworkMonitor` env object + `OfflineBanner` + `ErrorAlertModifier`
|
||||
@@ -302,6 +302,8 @@ services:
|
||||
PUBLIC_UMAMI_SCRIPT_URL: "${PUBLIC_UMAMI_SCRIPT_URL}"
|
||||
# GlitchTip client + server-side error tracking
|
||||
PUBLIC_GLITCHTIP_DSN: "${PUBLIC_GLITCHTIP_DSN}"
|
||||
# Grafana Faro RUM (browser Web Vitals, traces, errors)
|
||||
PUBLIC_FARO_COLLECTOR_URL: "${PUBLIC_FARO_COLLECTOR_URL}"
|
||||
# OpenTelemetry tracing
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||
OTEL_SERVICE_NAME: "ui"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
# uptime.libnovel.cc → uptime-kuma:3001
|
||||
# push.libnovel.cc → gotify:80
|
||||
# grafana.libnovel.cc → grafana:3000
|
||||
# faro.libnovel.cc → alloy:12347
|
||||
|
||||
services:
|
||||
|
||||
@@ -346,6 +347,23 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ── Grafana Alloy (Faro RUM receiver) ───────────────────────────────────────
|
||||
# Receives browser telemetry from @grafana/faro-web-sdk (Web Vitals, traces,
|
||||
# errors). Exposes POST /collect at faro.libnovel.cc via cloudflared.
|
||||
# Forwards traces to otel-collector (→ Tempo) and logs to Loki directly.
|
||||
alloy:
|
||||
image: grafana/alloy:latest
|
||||
restart: unless-stopped
|
||||
command: ["run", "--server.http.listen-addr=0.0.0.0:12348", "/etc/alloy/alloy.river"]
|
||||
volumes:
|
||||
- ./otel/alloy.river:/etc/alloy/alloy.river:ro
|
||||
expose:
|
||||
- "12347" # Faro HTTP receiver (POST /collect)
|
||||
- "12348" # Alloy UI / health endpoint
|
||||
depends_on:
|
||||
- otel-collector
|
||||
- loki
|
||||
|
||||
# ── OTel Collector ──────────────────────────────────────────────────────────
|
||||
# Receives OTLP from backend/ui/runner, fans out to Tempo + Prometheus + Loki.
|
||||
otel-collector:
|
||||
|
||||
43
homelab/otel/alloy.river
Normal file
43
homelab/otel/alloy.river
Normal file
@@ -0,0 +1,43 @@
|
||||
// Grafana Alloy — Faro RUM receiver
|
||||
//
|
||||
// Receives browser telemetry (Web Vitals, traces, logs, exceptions) from the
|
||||
// LibNovel SvelteKit frontend via the @grafana/faro-web-sdk.
|
||||
//
|
||||
// Pipeline:
|
||||
// faro.receiver → receives HTTP POST /collect from browsers
|
||||
// otelcol.exporter.otlphttp → forwards traces to OTel Collector → Tempo
|
||||
// loki.write → forwards logs/exceptions to Loki
|
||||
//
|
||||
// The Faro endpoint is exposed publicly at faro.libnovel.cc via cloudflared.
|
||||
// CORS is configured to allow requests from libnovel.cc.
|
||||
|
||||
faro.receiver "faro" {
|
||||
server {
|
||||
listen_address = "0.0.0.0"
|
||||
listen_port = 12347
|
||||
|
||||
cors_allowed_origins = ["https://libnovel.cc", "https://www.libnovel.cc"]
|
||||
}
|
||||
|
||||
output {
|
||||
logs = [loki.write.faro.receiver]
|
||||
traces = [otelcol.exporter.otlphttp.faro.input]
|
||||
}
|
||||
}
|
||||
|
||||
// Forward Faro traces to the OTel Collector (which routes to Tempo)
|
||||
otelcol.exporter.otlphttp "faro" {
|
||||
client {
|
||||
endpoint = "http://otel-collector:4318"
|
||||
tls {
|
||||
insecure = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forward Faro logs/exceptions directly to Loki
|
||||
loki.write "faro" {
|
||||
endpoint {
|
||||
url = "http://loki:3100/loki/api/v1/push"
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,9 @@ extensions:
|
||||
|
||||
service:
|
||||
extensions: [health_check, pprof]
|
||||
telemetry:
|
||||
metrics:
|
||||
address: 0.0.0.0:8888
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"uid": "libnovel-backend",
|
||||
"title": "Backend API",
|
||||
"description": "Request rate, error rate, and latency for the LibNovel backend. Powered by Tempo span metrics and UI OTel instrumentation.",
|
||||
"description": "Request rate, error rate, and latency for the LibNovel backend. Powered by Tempo span metrics.",
|
||||
"tags": ["libnovel", "backend", "api"],
|
||||
"timezone": "browser",
|
||||
"refresh": "30s",
|
||||
@@ -173,7 +173,7 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"5..\"}[5m])) * 60",
|
||||
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\", status_code=\"STATUS_CODE_ERROR\"}[5m])) * 60",
|
||||
"legendFormat": "5xx/min",
|
||||
"instant": true
|
||||
}
|
||||
@@ -182,7 +182,7 @@
|
||||
{
|
||||
"id": 10,
|
||||
"type": "timeseries",
|
||||
"title": "Request Rate by Status",
|
||||
"title": "Request Rate (total vs errors)",
|
||||
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
|
||||
"options": {
|
||||
"tooltip": { "mode": "multi" },
|
||||
@@ -191,27 +191,21 @@
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "reqps", "custom": { "lineWidth": 2, "fillOpacity": 10 } },
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byFrameRefID", "options": "errors" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
|
||||
{ "matcher": { "id": "byName", "options": "errors" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
|
||||
]
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "success",
|
||||
"refId": "total",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"2..\"}[5m]))",
|
||||
"legendFormat": "2xx"
|
||||
},
|
||||
{
|
||||
"refId": "notfound",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"4..\"}[5m]))",
|
||||
"legendFormat": "4xx"
|
||||
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\"}[5m]))",
|
||||
"legendFormat": "total"
|
||||
},
|
||||
{
|
||||
"refId": "errors",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"5..\"}[5m]))",
|
||||
"legendFormat": "5xx"
|
||||
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\", status_code=\"STATUS_CODE_ERROR\"}[5m]))",
|
||||
"legendFormat": "errors"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -248,50 +242,30 @@
|
||||
{
|
||||
"id": 12,
|
||||
"type": "timeseries",
|
||||
"title": "Requests / min by HTTP method (UI → Backend)",
|
||||
"title": "Request Rate by Span Name (top operations)",
|
||||
"gridPos": { "x": 0, "y": 12, "w": 12, "h": 8 },
|
||||
"description": "Throughput broken down by HTTP route / span name from Tempo span metrics.",
|
||||
"options": {
|
||||
"tooltip": { "mode": "multi" },
|
||||
"legend": { "displayMode": "list", "placement": "bottom" }
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 5 } }
|
||||
"defaults": { "unit": "reqps", "custom": { "lineWidth": 2, "fillOpacity": 5 } }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\"}[5m])) by (http_request_method) * 60",
|
||||
"legendFormat": "{{http_request_method}}"
|
||||
"expr": "topk(10, sum(rate(traces_spanmetrics_calls_total{service=\"backend\"}[5m])) by (span_name))",
|
||||
"legendFormat": "{{span_name}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "timeseries",
|
||||
"title": "Requests / min — UI → PocketBase",
|
||||
"title": "Latency by Span Name (p95)",
|
||||
"gridPos": { "x": 12, "y": 12, "w": 12, "h": 8 },
|
||||
"description": "Traffic from SvelteKit server to PocketBase (auth, collections, etc.).",
|
||||
"options": {
|
||||
"tooltip": { "mode": "multi" },
|
||||
"legend": { "displayMode": "list", "placement": "bottom" }
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 5 } }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"pocketbase\"}[5m])) by (http_request_method, http_response_status_code) * 60",
|
||||
"legendFormat": "{{http_request_method}} {{http_response_status_code}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"type": "timeseries",
|
||||
"title": "UI → Backend Latency (p50 / p95)",
|
||||
"gridPos": { "x": 0, "y": 20, "w": 12, "h": 8 },
|
||||
"description": "HTTP client latency as seen from the SvelteKit SSR layer calling backend.",
|
||||
"description": "p95 latency per operation — helps identify slow endpoints.",
|
||||
"options": {
|
||||
"tooltip": { "mode": "multi" },
|
||||
"legend": { "displayMode": "list", "placement": "bottom" }
|
||||
@@ -302,13 +276,8 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.50, sum(rate(http_client_request_duration_seconds_bucket{job=\"ui\", server_address=\"backend\"}[5m])) by (le))",
|
||||
"legendFormat": "p50"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.95, sum(rate(http_client_request_duration_seconds_bucket{job=\"ui\", server_address=\"backend\"}[5m])) by (le))",
|
||||
"legendFormat": "p95"
|
||||
"expr": "topk(10, histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"backend\"}[5m])) by (le, span_name)))",
|
||||
"legendFormat": "{{span_name}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -316,7 +285,7 @@
|
||||
"id": 20,
|
||||
"type": "logs",
|
||||
"title": "Backend Errors",
|
||||
"gridPos": { "x": 0, "y": 28, "w": 24, "h": 10 },
|
||||
"gridPos": { "x": 0, "y": 20, "w": 24, "h": 10 },
|
||||
"options": {
|
||||
"showTime": true,
|
||||
"showLabels": false,
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "libnovel_runner_tasks_running",
|
||||
"expr": "runner_tasks_running",
|
||||
"legendFormat": "running",
|
||||
"instant": true
|
||||
}
|
||||
@@ -61,7 +61,7 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "libnovel_runner_tasks_completed_total",
|
||||
"expr": "runner_tasks_completed_total",
|
||||
"legendFormat": "completed",
|
||||
"instant": true
|
||||
}
|
||||
@@ -93,7 +93,7 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "libnovel_runner_tasks_failed_total",
|
||||
"expr": "runner_tasks_failed_total",
|
||||
"legendFormat": "failed",
|
||||
"instant": true
|
||||
}
|
||||
@@ -126,7 +126,7 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "libnovel_runner_uptime_seconds",
|
||||
"expr": "runner_uptime_seconds",
|
||||
"legendFormat": "uptime",
|
||||
"instant": true
|
||||
}
|
||||
@@ -159,7 +159,7 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "libnovel_runner_tasks_failed_total / clamp_min(libnovel_runner_tasks_completed_total + libnovel_runner_tasks_failed_total, 1)",
|
||||
"expr": "runner_tasks_failed_total / clamp_min(runner_tasks_completed_total + runner_tasks_failed_total, 1)",
|
||||
"legendFormat": "failure rate",
|
||||
"instant": true
|
||||
}
|
||||
@@ -215,17 +215,17 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "rate(libnovel_runner_tasks_completed_total[5m]) * 60",
|
||||
"expr": "rate(runner_tasks_completed_total[5m]) * 60",
|
||||
"legendFormat": "completed"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "rate(libnovel_runner_tasks_failed_total[5m]) * 60",
|
||||
"expr": "rate(runner_tasks_failed_total[5m]) * 60",
|
||||
"legendFormat": "failed"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "libnovel_runner_tasks_running",
|
||||
"expr": "runner_tasks_running",
|
||||
"legendFormat": "running"
|
||||
}
|
||||
]
|
||||
|
||||
284
homelab/otel/grafana/provisioning/dashboards/web-vitals.json
Normal file
284
homelab/otel/grafana/provisioning/dashboards/web-vitals.json
Normal file
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"uid": "libnovel-web-vitals",
|
||||
"title": "Web Vitals (RUM)",
|
||||
"description": "Real User Monitoring — Core Web Vitals (LCP, CLS, INP, TTFB, FCP) from @grafana/faro-web-sdk. Data flows: browser → Alloy faro.receiver → Tempo (traces) + Loki (logs).",
|
||||
"tags": ["libnovel", "frontend", "rum", "web-vitals"],
|
||||
"timezone": "browser",
|
||||
"refresh": "1m",
|
||||
"time": { "from": "now-24h", "to": "now" },
|
||||
"schemaVersion": 39,
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"title": "LCP — p75 (Largest Contentful Paint)",
|
||||
"description": "Good < 2.5 s, needs improvement < 4 s, poor ≥ 4 s.",
|
||||
"gridPos": { "x": 0, "y": 0, "w": 4, "h": 4 },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "ms",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 2500 },
|
||||
{ "color": "red", "value": 4000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[1h])) by (le)) * 1000",
|
||||
"legendFormat": "LCP p75",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "stat",
|
||||
"title": "INP — p75 (Interaction to Next Paint)",
|
||||
"description": "Good < 200 ms, needs improvement < 500 ms, poor ≥ 500 ms.",
|
||||
"gridPos": { "x": 4, "y": 0, "w": 4, "h": 4 },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "ms",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 200 },
|
||||
{ "color": "red", "value": 500 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*inp|INP\"}[1h])) by (le)) * 1000",
|
||||
"legendFormat": "INP p75",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "stat",
|
||||
"title": "CLS — p75 (Cumulative Layout Shift)",
|
||||
"description": "Good < 0.1, needs improvement < 0.25, poor ≥ 0.25.",
|
||||
"gridPos": { "x": 8, "y": 0, "w": 4, "h": 4 },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"decimals": 3,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 0.1 },
|
||||
{ "color": "red", "value": 0.25 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*cls|CLS\"}[1h])) by (le))",
|
||||
"legendFormat": "CLS p75",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "stat",
|
||||
"title": "TTFB — p75 (Time to First Byte)",
|
||||
"description": "Good < 800 ms, needs improvement < 1800 ms, poor ≥ 1800 ms.",
|
||||
"gridPos": { "x": 12, "y": 0, "w": 4, "h": 4 },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "ms",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 800 },
|
||||
{ "color": "red", "value": 1800 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[1h])) by (le)) * 1000",
|
||||
"legendFormat": "TTFB p75",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "stat",
|
||||
"title": "FCP — p75 (First Contentful Paint)",
|
||||
"description": "Good < 1.8 s, needs improvement < 3 s, poor ≥ 3 s.",
|
||||
"gridPos": { "x": 16, "y": 0, "w": 4, "h": 4 },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "ms",
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 1800 },
|
||||
{ "color": "red", "value": 3000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*fcp|FCP\"}[1h])) by (le)) * 1000",
|
||||
"legendFormat": "FCP p75",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "stat",
|
||||
"title": "Active Sessions (30 min)",
|
||||
"gridPos": { "x": 20, "y": 0, "w": 4, "h": 4 },
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"libnovel-ui\"}[30m]))",
|
||||
"legendFormat": "sessions",
|
||||
"instant": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "timeseries",
|
||||
"title": "LCP over time (p50 / p75 / p95)",
|
||||
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
|
||||
"options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "ms", "custom": { "lineWidth": 2, "fillOpacity": 10 } },
|
||||
"overrides": [
|
||||
{ "matcher": { "id": "byName", "options": "Good (2.5s)" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [4, 4] } }] },
|
||||
{ "matcher": { "id": "byName", "options": "Poor (4s)" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [4, 4] } }] }
|
||||
]
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000",
|
||||
"legendFormat": "p50"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000",
|
||||
"legendFormat": "p75"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000",
|
||||
"legendFormat": "p95"
|
||||
},
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "2500", "legendFormat": "Good (2.5s)" },
|
||||
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "4000", "legendFormat": "Poor (4s)" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "timeseries",
|
||||
"title": "TTFB over time (p50 / p75 / p95)",
|
||||
"gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 },
|
||||
"options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } },
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "ms", "custom": { "lineWidth": 2, "fillOpacity": 10 } }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000",
|
||||
"legendFormat": "p50"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000",
|
||||
"legendFormat": "p75"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000",
|
||||
"legendFormat": "p95"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"type": "logs",
|
||||
"title": "Frontend Errors & Exceptions",
|
||||
"description": "JS exceptions and console errors captured by Faro and shipped to Loki.",
|
||||
"gridPos": { "x": 0, "y": 12, "w": 24, "h": 10 },
|
||||
"options": {
|
||||
"showTime": true,
|
||||
"showLabels": true,
|
||||
"wrapLogMessage": true,
|
||||
"prettifyLogMessage": true,
|
||||
"enableLogDetails": true,
|
||||
"sortOrder": "Descending",
|
||||
"dedupStrategy": "none"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "{service_name=\"libnovel-ui\"} | json | kind =~ `(exception|error)`",
|
||||
"legendFormat": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"type": "logs",
|
||||
"title": "Frontend Logs (all Faro events)",
|
||||
"gridPos": { "x": 0, "y": 22, "w": 24, "h": 10 },
|
||||
"options": {
|
||||
"showTime": true,
|
||||
"showLabels": false,
|
||||
"wrapLogMessage": true,
|
||||
"prettifyLogMessage": true,
|
||||
"enableLogDetails": true,
|
||||
"sortOrder": "Descending",
|
||||
"dedupStrategy": "none"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "{service_name=\"libnovel-ui\"}",
|
||||
"legendFormat": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -410,6 +410,7 @@
|
||||
"admin_nav_uptime": "Uptime",
|
||||
"admin_nav_push": "Push",
|
||||
"admin_nav_gitea": "Gitea",
|
||||
"admin_nav_grafana": "Grafana",
|
||||
|
||||
"admin_scrape_status_idle": "Idle",
|
||||
"admin_scrape_status_running": "Running",
|
||||
|
||||
@@ -471,5 +471,6 @@
|
||||
"feed_chapters_label": "{n} chapitres",
|
||||
"feed_browse_cta": "Parcourir le catalogue",
|
||||
"feed_find_users_cta": "Trouver des lecteurs",
|
||||
"admin_nav_gitea": "Gitea"
|
||||
"admin_nav_gitea": "Gitea",
|
||||
"admin_nav_grafana": "Grafana"
|
||||
}
|
||||
|
||||
@@ -471,5 +471,6 @@
|
||||
"feed_chapters_label": "{n} bab",
|
||||
"feed_browse_cta": "Jelajahi katalog",
|
||||
"feed_find_users_cta": "Temukan pembaca",
|
||||
"admin_nav_gitea": "Gitea"
|
||||
"admin_nav_gitea": "Gitea",
|
||||
"admin_nav_grafana": "Grafana"
|
||||
}
|
||||
|
||||
@@ -471,5 +471,6 @@
|
||||
"feed_chapters_label": "{n} capítulos",
|
||||
"feed_browse_cta": "Ver catálogo",
|
||||
"feed_find_users_cta": "Encontrar leitores",
|
||||
"admin_nav_gitea": "Gitea"
|
||||
"admin_nav_gitea": "Gitea",
|
||||
"admin_nav_grafana": "Grafana"
|
||||
}
|
||||
|
||||
@@ -471,5 +471,6 @@
|
||||
"feed_chapters_label": "{n} глав",
|
||||
"feed_browse_cta": "Каталог",
|
||||
"feed_find_users_cta": "Найти читателей",
|
||||
"admin_nav_gitea": "Gitea"
|
||||
"admin_nav_gitea": "Gitea",
|
||||
"admin_nav_grafana": "Grafana"
|
||||
}
|
||||
|
||||
142
ui/package-lock.json
generated
142
ui/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1005.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||
"@grafana/faro-web-sdk": "^2.3.1",
|
||||
"@inlang/paraglide-js": "^2.15.1",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
@@ -1689,6 +1690,115 @@
|
||||
"module-details-from-path": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@grafana/faro-core": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@grafana/faro-core/-/faro-core-2.3.1.tgz",
|
||||
"integrity": "sha512-htDKO0YFKr0tfntrPoM151vOPSZzmP6oE0+0MDvbI1WDaBW4erXmYi3feGJLWDXt5/vZBg9iQRmZoRzTLTTcOA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/otlp-transformer": "^0.213.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/otlp-transformer": {
|
||||
"version": "0.213.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.213.0.tgz",
|
||||
"integrity": "sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.213.0",
|
||||
"@opentelemetry/core": "2.6.0",
|
||||
"@opentelemetry/resources": "2.6.0",
|
||||
"@opentelemetry/sdk-logs": "0.213.0",
|
||||
"@opentelemetry/sdk-metrics": "2.6.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.0",
|
||||
"protobufjs": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/resources": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz",
|
||||
"integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.6.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/sdk-logs": {
|
||||
"version": "0.213.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.213.0.tgz",
|
||||
"integrity": "sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.213.0",
|
||||
"@opentelemetry/core": "2.6.0",
|
||||
"@opentelemetry/resources": "2.6.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.4.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/sdk-metrics": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.0.tgz",
|
||||
"integrity": "sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.6.0",
|
||||
"@opentelemetry/resources": "2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.9.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/sdk-trace-base": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz",
|
||||
"integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.6.0",
|
||||
"@opentelemetry/resources": "2.6.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grafana/faro-web-sdk": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@grafana/faro-web-sdk/-/faro-web-sdk-2.3.1.tgz",
|
||||
"integrity": "sha512-WMfErl2YSP+CcfcobMpCdK6apX86hc8bymMXsvYLQpBBkQ0KJjIilEQS/YXd+g/cg6F1kwbeweisBKluNNy5sA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@grafana/faro-core": "^2.3.1",
|
||||
"ua-parser-js": "1.0.41",
|
||||
"web-vitals": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grpc/grpc-js": {
|
||||
"version": "1.14.3",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
|
||||
@@ -7377,6 +7487,32 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ua-parser-js": {
|
||||
"version": "1.0.41",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
|
||||
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/faisalman"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"ua-parser-js": "script/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
@@ -7540,6 +7676,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz",
|
||||
"integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1005.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||
"@grafana/faro-web-sdk": "^2.3.1",
|
||||
"@inlang/paraglide-js": "^2.15.1",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Sentry from '@sentry/sveltekit';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { initializeFaro, getWebInstrumentations } from '@grafana/faro-web-sdk';
|
||||
|
||||
// Sentry / GlitchTip client-side error tracking.
|
||||
// No-op when PUBLIC_GLITCHTIP_DSN is unset (e.g. local dev).
|
||||
@@ -13,4 +14,21 @@ if (env.PUBLIC_GLITCHTIP_DSN) {
|
||||
});
|
||||
}
|
||||
|
||||
// Grafana Faro RUM — browser performance monitoring (Web Vitals, traces, errors).
|
||||
// No-op when PUBLIC_FARO_COLLECTOR_URL is unset (e.g. local dev).
|
||||
if (env.PUBLIC_FARO_COLLECTOR_URL) {
|
||||
initializeFaro({
|
||||
url: env.PUBLIC_FARO_COLLECTOR_URL,
|
||||
app: {
|
||||
name: 'libnovel-ui',
|
||||
version: env.PUBLIC_BUILD_VERSION || 'dev',
|
||||
environment: 'production'
|
||||
},
|
||||
instrumentations: [
|
||||
// Core Web Vitals (LCP, CLS, INP, TTFB, FCP) + JS errors + console
|
||||
...getWebInstrumentations({ captureConsole: false })
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
export const handleError = Sentry.handleErrorWithSentry();
|
||||
|
||||
@@ -381,6 +381,7 @@ export * from './admin_nav_logs.js'
|
||||
export * from './admin_nav_uptime.js'
|
||||
export * from './admin_nav_push.js'
|
||||
export * from './admin_nav_gitea.js'
|
||||
export * from './admin_nav_grafana.js'
|
||||
export * from './admin_scrape_status_idle.js'
|
||||
export * from './admin_scrape_full_catalogue.js'
|
||||
export * from './admin_scrape_single_book.js'
|
||||
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_grafana.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_grafana.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_GrafanaInputs */
|
||||
|
||||
const en_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Grafana`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Grafana`)
|
||||
};
|
||||
|
||||
const id_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Grafana`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Grafana`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Grafana`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Grafana" |
|
||||
*
|
||||
* @param {Admin_Nav_GrafanaInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_grafana = /** @type {((inputs?: Admin_Nav_GrafanaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_GrafanaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_grafana(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_grafana(inputs)
|
||||
if (locale === "id") return id_admin_nav_grafana(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_grafana(inputs)
|
||||
return fr_admin_nav_grafana(inputs)
|
||||
});
|
||||
@@ -778,9 +778,10 @@
|
||||
|
||||
<!-- Chapter list drawer (slides up above the mini-bar) -->
|
||||
{#if chapterDrawerOpen && audioStore.chapters.length > 0}
|
||||
<div class="border-b border-(--color-border) bg-(--color-surface) max-h-[32rem] overflow-y-auto">
|
||||
<div class="max-w-6xl mx-auto px-4">
|
||||
<div class="flex items-center justify-between py-2 border-b border-(--color-border) sticky top-0 bg-(--color-surface)">
|
||||
<div class="border-b border-(--color-border) bg-(--color-surface) flex justify-center md:justify-end md:pr-4">
|
||||
<div class="w-full md:w-80 flex flex-col max-h-72">
|
||||
<!-- Sticky header -->
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-(--color-border) shrink-0">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.player_chapters()}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -794,26 +795,29 @@
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
{#each audioStore.chapters as ch (ch.number)}
|
||||
<a
|
||||
use:setIfActive={ch.number === audioStore.chapter}
|
||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
|
||||
? 'text-(--color-brand) font-semibold'
|
||||
: 'text-(--color-muted)'}"
|
||||
>
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
|
||||
{ch.number}
|
||||
</span>
|
||||
<span class="truncate">{ch.title || m.player_chapter_n({ n: String(ch.number) })}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
<!-- Scrollable list -->
|
||||
<div class="overflow-y-auto px-4">
|
||||
{#each audioStore.chapters as ch (ch.number)}
|
||||
<a
|
||||
use:setIfActive={ch.number === audioStore.chapter}
|
||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
|
||||
? 'text-(--color-brand) font-semibold'
|
||||
: 'text-(--color-muted)'}"
|
||||
>
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
|
||||
{ch.number}
|
||||
</span>
|
||||
<span class="truncate">{ch.title || m.player_chapter_n({ n: String(ch.number) })}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
{ href: 'https://logs.libnovel.cc', label: () => m.admin_nav_logs() },
|
||||
{ href: 'https://uptime.libnovel.cc', label: () => m.admin_nav_uptime() },
|
||||
{ href: 'https://push.libnovel.cc', label: () => m.admin_nav_push() },
|
||||
{ href: 'https://grafana.libnovel.cc', label: () => m.admin_nav_grafana() },
|
||||
{ href: 'https://gitea.kalekber.cc/kamil/libnovel', label: () => m.admin_nav_gitea() }
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user