diff --git a/AGENTS.md b/AGENTS.md index a33c7f4..dd411c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,7 @@ doppler run --project libnovel --config prd_homelab -- docker compose -f homelab - 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` +- **Caddyfile is NOT in git** — lives at `/opt/libnovel/Caddyfile` on prod server only. Edit directly on the server and restart the `caddy` container. --- @@ -117,11 +118,43 @@ doppler run --project libnovel --config prd_homelab -- docker compose -f homelab | 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 Faro | RUM / Web Vitals (collector at `faro.libnovel.cc/collect`) → Alloy (port 12347) | +| OpenTelemetry | Distributed tracing (OTLP → cloudflared → OTel collector → Tempo) | +| Grafana | Dashboards at `https://grafana.libnovel.cc` | -Grafana dashboards: `homelab/otel/grafana/provisioning/dashboards/` +### Grafana dashboards: `homelab/otel/grafana/provisioning/dashboards/` + +Key dashboards: +- `backend.json` — Backend logs (Loki: `{service_name="backend"}`, plain text) +- `runner.json` — Runner logs (Loki: `{service_name="runner"}`) + Asynq Prometheus metrics +- `web-vitals.json` — Web Vitals (Loki: `{service_name="unknown_service"} kind=measurement` + pattern parser) +- `catalogue.json` — Scrape progress (Loki: `{service_name="runner"} | json | body="..."`) + +### Data pipeline (2026-04-07 working state) + +**Browser → Grafana Faro:** +Browser sends RUM data → `https://faro.libnovel.cc/collect` → **Alloy** `faro.receiver` (port 12347) → Loki (logs/exceptions) + OTel collector → **Tempo** (traces) + +**Backend/Runner → OTel:** +Backend/Runner Go SDK → `https://otel.libnovel.cc` (cloudflared tunnel) → **OTel collector** (port 4318) → Tempo (traces) + Loki (logs via `otlphttp/loki` exporter) +Runner also sends to **Alloy** `otelcol.receiver.otlp` (port 4318) → `otelcol.exporter.loki` → Loki + +### Loki log format per service + +- `service_name="backend"`: Plain text (e.g. `backend: asynq task dispatch enabled`) +- `service_name="runner"`: JSON with `body`, `attributes{slug,chapters,page}`, `severity` +- `service_name="unknown_service"`: Faro RUM text format (e.g. `kind=measurement lcp=5428.0 ...`) + +### OTel Collector ports (homelab) + +- gRPC: `4317` — receives from cloudflared (`otel.libnovel.cc`) +- HTTP: `4318` — receives from cloudflared + Alloy +- Metrics: `8888` + +### Known issues / pending fixes + +- Web Vitals use `service_name="unknown_service"` (Faro SDK doesn't set service.name in browser) — works with `unknown_service` label +- Runner logs go to both Alloy→Loki AND OTel collector→Loki (dual pipeline — intentional for resilience) --- diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 0d6fec9..c2b9290 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -179,24 +179,25 @@ func run() error { Commit: commit, }, backend.Dependencies{ - BookReader: store, - RankingStore: store, - AudioStore: store, - TranslationStore: store, - PresignStore: store, - ProgressStore: store, - CoverStore: store, - Producer: producer, - TaskReader: store, - SearchIndex: searchIndex, - Kokoro: kokoroClient, - PocketTTS: pocketTTSClient, - CFAI: cfaiClient, - ImageGen: imageGenClient, - TextGen: textGenClient, - BookWriter: store, - AIJobStore: store, - Log: log, + BookReader: store, + RankingStore: store, + AudioStore: store, + TranslationStore: store, + PresignStore: store, + ProgressStore: store, + CoverStore: store, + ChapterImageStore: store, + Producer: producer, + TaskReader: store, + SearchIndex: searchIndex, + Kokoro: kokoroClient, + PocketTTS: pocketTTSClient, + CFAI: cfaiClient, + ImageGen: imageGenClient, + TextGen: textGenClient, + BookWriter: store, + AIJobStore: store, + Log: log, }, ) diff --git a/backend/internal/backend/handlers_image.go b/backend/internal/backend/handlers_image.go index 49de025..f6ba684 100644 --- a/backend/internal/backend/handlers_image.go +++ b/backend/internal/backend/handlers_image.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "time" @@ -292,6 +293,129 @@ func sniffImageContentType(data []byte) string { return "image/png" } +// saveChapterImageRequest is the JSON body for POST /api/admin/image-gen/save-chapter-image. +type saveChapterImageRequest struct { + // Slug is the book slug. + Slug string `json:"slug"` + // Chapter is the 1-based chapter number. + Chapter int `json:"chapter"` + // ImageB64 is the base64-encoded image bytes (PNG or JPEG). + ImageB64 string `json:"image_b64"` +} + +// handleAdminImageGenSaveChapterImage handles POST /api/admin/image-gen/save-chapter-image. +// +// Accepts a pre-generated image as base64 and stores it as the chapter illustration +// in MinIO, replacing the existing one if present. Does not call Cloudflare AI. +func (s *Server) handleAdminImageGenSaveChapterImage(w http.ResponseWriter, r *http.Request) { + if s.deps.ChapterImageStore == nil { + jsonError(w, http.StatusServiceUnavailable, "chapter image store not configured") + return + } + + var req saveChapterImageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "parse body: "+err.Error()) + return + } + if req.Slug == "" { + jsonError(w, http.StatusBadRequest, "slug is required") + return + } + if req.Chapter <= 0 { + jsonError(w, http.StatusBadRequest, "chapter must be > 0") + return + } + if req.ImageB64 == "" { + jsonError(w, http.StatusBadRequest, "image_b64 is required") + return + } + + imgData, err := base64.StdEncoding.DecodeString(req.ImageB64) + if err != nil { + imgData, err = base64.RawStdEncoding.DecodeString(req.ImageB64) + if err != nil { + jsonError(w, http.StatusBadRequest, "decode image_b64: "+err.Error()) + return + } + } + + contentType := sniffImageContentType(imgData) + if err := s.deps.ChapterImageStore.PutChapterImage(r.Context(), req.Slug, req.Chapter, imgData, contentType); err != nil { + s.deps.Log.Error("admin: save-chapter-image failed", "slug", req.Slug, "chapter", req.Chapter, "err", err) + jsonError(w, http.StatusInternalServerError, "save chapter image: "+err.Error()) + return + } + + s.deps.Log.Info("admin: chapter image saved", "slug", req.Slug, "chapter", req.Chapter, "bytes", len(imgData)) + writeJSON(w, 0, map[string]any{ + "saved": true, + "image_url": fmt.Sprintf("/api/chapter-image/novelfire.net/%s/%d", req.Slug, req.Chapter), + "bytes": len(imgData), + }) +} + +// handleHeadChapterImage handles HEAD /api/chapter-image/{domain}/{slug}/{n}. +// +// Returns 200 when an image exists for this chapter, 404 otherwise. +// Used by the SSR loader to check existence without downloading the full image. +func (s *Server) handleHeadChapterImage(w http.ResponseWriter, r *http.Request) { + if s.deps.ChapterImageStore == nil { + w.WriteHeader(http.StatusNotFound) + return + } + + slug := r.PathValue("slug") + nStr := r.PathValue("n") + n, err := strconv.Atoi(nStr) + if err != nil || n <= 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + + if s.deps.ChapterImageStore.ChapterImageExists(r.Context(), slug, n) { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusNotFound) + } +} + +// handleGetChapterImage handles GET /api/chapter-image/{domain}/{slug}/{n}. +// +// Serves the stored chapter illustration directly from MinIO. +// Returns 404 when no image has been saved for this chapter. +func (s *Server) handleGetChapterImage(w http.ResponseWriter, r *http.Request) { + if s.deps.ChapterImageStore == nil { + http.NotFound(w, r) + return + } + + slug := r.PathValue("slug") + nStr := r.PathValue("n") + n, err := strconv.Atoi(nStr) + if err != nil || n <= 0 { + jsonError(w, http.StatusBadRequest, "invalid chapter number") + return + } + + data, contentType, ok, err := s.deps.ChapterImageStore.GetChapterImage(r.Context(), slug, n) + if err != nil { + s.deps.Log.Error("chapter-image: get failed", "slug", slug, "n", n, "err", err) + jsonError(w, http.StatusInternalServerError, "could not retrieve chapter image") + return + } + if !ok { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + // handleAdminImageGenAsync handles POST /api/admin/image-gen/async. // // Fire-and-forget variant: validates the request, creates an ai_job record of diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index 83f940a..8ecef3f 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -57,6 +57,9 @@ type Dependencies struct { // CoverStore reads and writes book cover images from MinIO. // If nil, the cover endpoint falls back to a CDN redirect. CoverStore bookstore.CoverStore + // ChapterImageStore reads and writes per-chapter illustration images from MinIO. + // If nil, chapter image endpoints return 404/503. + ChapterImageStore bookstore.ChapterImageStore // Producer creates scrape/audio tasks in PocketBase. Producer taskqueue.Producer // TaskReader reads scrape/audio task records from PocketBase. @@ -205,6 +208,11 @@ func (s *Server) ListenAndServe(ctx context.Context) error { mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen) mux.HandleFunc("POST /api/admin/image-gen/async", s.handleAdminImageGenAsync) mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover) + mux.HandleFunc("POST /api/admin/image-gen/save-chapter-image", s.handleAdminImageGenSaveChapterImage) + + // Chapter image serving + mux.HandleFunc("GET /api/chapter-image/{domain}/{slug}/{n}", s.handleGetChapterImage) + mux.HandleFunc("HEAD /api/chapter-image/{domain}/{slug}/{n}", s.handleHeadChapterImage) // Admin text generation endpoints (chapter names + book description) mux.HandleFunc("GET /api/admin/text-gen/models", s.handleAdminTextGenModels) diff --git a/backend/internal/bookstore/bookstore.go b/backend/internal/bookstore/bookstore.go index 79a790a..3fee99a 100644 --- a/backend/internal/bookstore/bookstore.go +++ b/backend/internal/bookstore/bookstore.go @@ -171,6 +171,20 @@ type AIJobStore interface { ListAIJobs(ctx context.Context) ([]domain.AIJob, error) } +// ChapterImageStore covers per-chapter illustration images stored in MinIO. +// The backend admin writes them; the backend serves them. +type ChapterImageStore interface { + // PutChapterImage stores a raw image for chapter n of slug in MinIO. + PutChapterImage(ctx context.Context, slug string, n int, data []byte, contentType string) error + + // GetChapterImage retrieves the image for chapter n of slug. + // Returns (nil, "", false, nil) when no image exists. + GetChapterImage(ctx context.Context, slug string, n int) ([]byte, string, bool, error) + + // ChapterImageExists returns true when an image is stored for slug/n. + ChapterImageExists(ctx context.Context, slug string, n int) bool +} + // TranslationStore covers machine-translated chapter storage in MinIO. // The runner writes translations; the backend reads them. type TranslationStore interface { diff --git a/backend/internal/storage/minio.go b/backend/internal/storage/minio.go index 0898c9e..e2c9bd9 100644 --- a/backend/internal/storage/minio.go +++ b/backend/internal/storage/minio.go @@ -134,6 +134,12 @@ func CoverObjectKey(slug string) string { return fmt.Sprintf("covers/%s.jpg", slug) } +// ChapterImageObjectKey returns the MinIO object key for a chapter illustration. +// Format: chapter-images/{slug}/{n:06d}.jpg +func ChapterImageObjectKey(slug string, n int) string { + return fmt.Sprintf("chapter-images/%s/%06d.jpg", slug, n) +} + // TranslationObjectKey returns the MinIO object key for a translated chapter. // Format: {lang}/{slug}/{n:06d}.md func TranslationObjectKey(lang, slug string, n int) string { @@ -265,3 +271,28 @@ func coverContentType(data []byte) string { } return "image/jpeg" } + +// ── Chapter image operations ─────────────────────────────────────────────────── + +// putChapterImage stores a chapter illustration in the browse bucket. +func (m *minioClient) putChapterImage(ctx context.Context, key, contentType string, data []byte) error { + return m.putObject(ctx, m.bucketBrowse, key, contentType, data) +} + +// getChapterImage retrieves a chapter illustration. Returns (nil, false, nil) +// when the object does not exist. +func (m *minioClient) getChapterImage(ctx context.Context, key string) ([]byte, bool, error) { + if !m.objectExists(ctx, m.bucketBrowse, key) { + return nil, false, nil + } + data, err := m.getObject(ctx, m.bucketBrowse, key) + if err != nil { + return nil, false, err + } + return data, true, nil +} + +// chapterImageExists returns true when the chapter image object exists. +func (m *minioClient) chapterImageExists(ctx context.Context, key string) bool { + return m.objectExists(ctx, m.bucketBrowse, key) +} diff --git a/backend/internal/storage/store.go b/backend/internal/storage/store.go index d90ed38..c5a7908 100644 --- a/backend/internal/storage/store.go +++ b/backend/internal/storage/store.go @@ -54,6 +54,7 @@ var _ bookstore.ProgressStore = (*Store)(nil) var _ bookstore.CoverStore = (*Store)(nil) var _ bookstore.TranslationStore = (*Store)(nil) var _ bookstore.AIJobStore = (*Store)(nil) +var _ bookstore.ChapterImageStore = (*Store)(nil) var _ taskqueue.Producer = (*Store)(nil) var _ taskqueue.Consumer = (*Store)(nil) var _ taskqueue.Reader = (*Store)(nil) @@ -1043,6 +1044,36 @@ func (s *Store) CoverExists(ctx context.Context, slug string) bool { return s.mc.coverExists(ctx, CoverObjectKey(slug)) } +// ── ChapterImageStore ────────────────────────────────────────────────────────── + +func (s *Store) PutChapterImage(ctx context.Context, slug string, n int, data []byte, contentType string) error { + key := ChapterImageObjectKey(slug, n) + if contentType == "" { + contentType = coverContentType(data) + } + if err := s.mc.putChapterImage(ctx, key, contentType, data); err != nil { + return fmt.Errorf("PutChapterImage: %w", err) + } + return nil +} + +func (s *Store) GetChapterImage(ctx context.Context, slug string, n int) ([]byte, string, bool, error) { + key := ChapterImageObjectKey(slug, n) + data, ok, err := s.mc.getChapterImage(ctx, key) + if err != nil { + return nil, "", false, fmt.Errorf("GetChapterImage: %w", err) + } + if !ok { + return nil, "", false, nil + } + ct := coverContentType(data) + return data, ct, true, nil +} + +func (s *Store) ChapterImageExists(ctx context.Context, slug string, n int) bool { + return s.mc.chapterImageExists(ctx, ChapterImageObjectKey(slug, n)) +} + // ── TranslationStore ─────────────────────────────────────────────────────────── func (s *Store) TranslationObjectKey(lang, slug string, n int) string { diff --git a/homelab/docker-compose.yml b/homelab/docker-compose.yml index 63d439c..c71a3a4 100644 --- a/homelab/docker-compose.yml +++ b/homelab/docker-compose.yml @@ -84,8 +84,10 @@ services: LOG_LEVEL: "${LOG_LEVEL}" GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}" - # OTel — send runner traces/metrics to the local collector (HTTP) - OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4318" + # OTel — send runner traces/logs to Alloy (HTTP) + # Alloy forwards traces → OTel collector → Tempo + # logs → Loki + OTEL_EXPORTER_OTLP_ENDPOINT: "http://alloy:4318" OTEL_SERVICE_NAME: "runner" healthcheck: @@ -370,6 +372,7 @@ services: expose: - "12347" # Faro HTTP receiver (POST /collect) - "12348" # Alloy UI / health endpoint + - "4318" # OTLP receiver (HTTP) for backend/runner logs depends_on: - otel-collector - loki @@ -382,9 +385,9 @@ services: volumes: - ./otel/collector.yaml:/etc/otelcol-contrib/config.yaml:ro expose: - - "4317" # OTLP gRPC - - "4318" # OTLP HTTP - - "8888" # Collector self-metrics (scraped by Prometheus) + - "14317" # OTLP gRPC (Alloy forwards traces here) + - "14318" # OTLP HTTP (Alloy forwards traces here) + - "8888" # Collector self-metrics (scraped by Prometheus) depends_on: - tempo - prometheus diff --git a/homelab/otel/alloy.river b/homelab/otel/alloy.river index e068070..ed37e26 100644 --- a/homelab/otel/alloy.river +++ b/homelab/otel/alloy.river @@ -1,15 +1,19 @@ -// Grafana Alloy — Faro RUM receiver +// Grafana Alloy — Faro RUM receiver + OTel log bridge // // Receives browser telemetry (Web Vitals, traces, logs, exceptions) from the // LibNovel SvelteKit frontend via the @grafana/faro-web-sdk. // +// Also receives OTLP logs from the backend and runner services, and forwards +// them to Loki in the native push format (solving the OTLP→Loki gap). +// // Pipeline: -// faro.receiver → receives HTTP POST /collect from browsers +// faro.receiver → receives HTTP POST /collect from browsers +// otelcol.receiver.otlp → receives OTLP logs from backend/runner (HTTP :4318) // otelcol.exporter.otlphttp → forwards traces to OTel Collector → Tempo -// loki.write → forwards logs/exceptions to Loki +// loki.write → forwards Faro logs/exceptions to Loki +// otelcol.exporter.loki → forwards OTel logs to Loki (native format) // // 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 { @@ -25,6 +29,40 @@ faro.receiver "faro" { } } +// Receive OTLP traces and logs from backend/runner +otelcol.receiver.otlp "otel_logs" { + http { + endpoint = "0.0.0.0:4318" + } + + output { + logs = [otelcol.exporter.loki.otel_logs.input] + traces = [otelcol.exporter.otlphttp.otel_logs.input] + } +} + +// Convert OTel logs to Loki format and forward to loki.write +otelcol.exporter.loki "otel_logs" { + forward_to = [loki.write.otel_logs.receiver] +} + +// Send backend/runner traces to the OTel Collector → Tempo +otelcol.exporter.otlphttp "otel_logs" { + client { + endpoint = "http://otel-collector:4318" + tls { + insecure = true + } + } +} + +// Push backend/runner logs to Loki (native push format) +loki.write "otel_logs" { + endpoint { + url = "http://loki:3100/loki/api/v1/push" + } +} + // Forward Faro traces to the OTel Collector (which routes to Tempo) otelcol.exporter.otlphttp "faro" { client { diff --git a/homelab/otel/collector.yaml b/homelab/otel/collector.yaml index f311017..9d6ac6b 100644 --- a/homelab/otel/collector.yaml +++ b/homelab/otel/collector.yaml @@ -17,7 +17,7 @@ processors: timeout: 5s send_batch_size: 512 - # Attach host metadata to all telemetry + # Attach host metadata to traces/metrics resourcedetection: detectors: [env, system] timeout: 5s @@ -73,5 +73,7 @@ service: exporters: [prometheusremotewrite] logs: receivers: [otlp] - processors: [resourcedetection, batch] + # No resourcedetection — preserve service.name from OTel resource attributes + # (backend=backend, runner=runner, Alloy/Faro=no service.name → unknown_service) + processors: [batch] exporters: [otlphttp/loki] diff --git a/homelab/otel/grafana/provisioning/dashboards/backend.json b/homelab/otel/grafana/provisioning/dashboards/backend.json index 54da685..ef00fd7 100644 --- a/homelab/otel/grafana/provisioning/dashboards/backend.json +++ b/homelab/otel/grafana/provisioning/dashboards/backend.json @@ -298,7 +298,7 @@ "targets": [ { "datasource": { "type": "loki", "uid": "loki" }, - "expr": "{service_name=\"backend\"} | json | level =~ `(WARN|ERROR|error|warn)`", + "expr": "{service_name=\"backend\"}", "legendFormat": "" } ] diff --git a/homelab/otel/grafana/provisioning/dashboards/catalogue.json b/homelab/otel/grafana/provisioning/dashboards/catalogue.json index d17d972..5d2b73c 100644 --- a/homelab/otel/grafana/provisioning/dashboards/catalogue.json +++ b/homelab/otel/grafana/provisioning/dashboards/catalogue.json @@ -1,7 +1,7 @@ { "uid": "libnovel-catalogue", "title": "Catalogue & Content Progress", - "description": "Scraping progress, audio generation coverage, and catalogue health derived from runner structured logs.", + "description": "Scraping progress from runner OTel logs in Loki. Logs are JSON: body=message, attributes.slug/chapters/page=fields.", "tags": ["libnovel", "catalogue", "content"], "timezone": "browser", "refresh": "1m", @@ -12,9 +12,9 @@ "id": 1, "type": "stat", "title": "Books Scraped (last 24h)", - "description": "Count of unique book slugs appearing in successful scrape task completions.", + "description": "Count of unique slugs from chapter list fetched messages.", "gridPos": { "x": 0, "y": 0, "w": 4, "h": 4 }, - "options": { "reduceOptions": { "calcs": ["sum"] }, "colorMode": "value", "graphMode": "none" }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" }, "fieldConfig": { "defaults": { "color": { "fixedColor": "blue", "mode": "fixed" }, @@ -24,7 +24,7 @@ "targets": [ { "datasource": { "type": "loki", "uid": "loki" }, - "expr": "sum_over_time({service_name=\"runner\"} | json | msg=`scrape task done` [24h])", + "expr": "count(count_over_time({service_name=\"runner\"} | json | body=\"chapter list fetched\" [24h])) by (attributes_slug)", "legendFormat": "books scraped" } ] @@ -33,8 +33,9 @@ "id": 2, "type": "stat", "title": "Chapters Scraped (last 24h)", + "description": "Count of 'chapter list fetched' events.", "gridPos": { "x": 4, "y": 0, "w": 4, "h": 4 }, - "options": { "reduceOptions": { "calcs": ["sum"] }, "colorMode": "value", "graphMode": "none" }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" }, "fieldConfig": { "defaults": { "color": { "fixedColor": "blue", "mode": "fixed" }, @@ -44,17 +45,18 @@ "targets": [ { "datasource": { "type": "loki", "uid": "loki" }, - "expr": "sum_over_time({service_name=\"runner\"} | json | unwrap scraped [24h])", - "legendFormat": "chapters scraped" + "expr": "sum(count_over_time({service_name=\"runner\"} | json | body=\"chapter list fetched\" [24h]))", + "legendFormat": "chapter lists fetched" } ] }, { "id": 3, "type": "stat", - "title": "Audio Jobs Completed (last 24h)", + "title": "Metadata Saved (last 24h)", + "description": "Count of 'metadata saved' events.", "gridPos": { "x": 8, "y": 0, "w": 4, "h": 4 }, - "options": { "reduceOptions": { "calcs": ["sum"] }, "colorMode": "value", "graphMode": "none" }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" }, "fieldConfig": { "defaults": { "color": { "fixedColor": "green", "mode": "fixed" }, @@ -64,43 +66,18 @@ "targets": [ { "datasource": { "type": "loki", "uid": "loki" }, - "expr": "sum_over_time({service_name=\"runner\"} | json | msg=`audio task done` [24h])", - "legendFormat": "audio done" + "expr": "sum(count_over_time({service_name=\"runner\"} | json | body=\"metadata saved\" [24h]))", + "legendFormat": "metadata saved" } ] }, { "id": 4, "type": "stat", - "title": "Audio Jobs Failed (last 24h)", - "gridPos": { "x": 12, "y": 0, "w": 4, "h": 4 }, - "options": { "reduceOptions": { "calcs": ["sum"] }, "colorMode": "background", "graphMode": "none" }, - "fieldConfig": { - "defaults": { - "thresholds": { - "mode": "absolute", - "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 1 }, - { "color": "red", "value": 5 } - ] - } - } - }, - "targets": [ - { - "datasource": { "type": "loki", "uid": "loki" }, - "expr": "sum_over_time({service_name=\"runner\"} | json | msg=`audio task failed` [24h])", - "legendFormat": "audio failed" - } - ] - }, - { - "id": 5, - "type": "stat", "title": "Scrape Errors (last 24h)", - "gridPos": { "x": 16, "y": 0, "w": 4, "h": 4 }, - "options": { "reduceOptions": { "calcs": ["sum"] }, "colorMode": "background", "graphMode": "none" }, + "description": "Count of error severity logs from the runner.", + "gridPos": { "x": 12, "y": 0, "w": 4, "h": 4 }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" }, "fieldConfig": { "defaults": { "thresholds": { @@ -116,97 +93,93 @@ "targets": [ { "datasource": { "type": "loki", "uid": "loki" }, - "expr": "sum_over_time({service_name=\"runner\"} | json | msg=`scrape task failed` [24h])", - "legendFormat": "scrape errors" + "expr": "sum(count_over_time({service_name=\"runner\"} | json | severity=\"ERROR\" [24h]))", + "legendFormat": "errors" } ] }, { - "id": 6, + "id": 5, "type": "stat", - "title": "Catalogue Refresh — Books Indexed", - "description": "Total books indexed in the last catalogue refresh cycle (from the ok field in the summary log).", - "gridPos": { "x": 20, "y": 0, "w": 4, "h": 4 }, - "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "none" }, + "title": "Rate Limited (last 24h)", + "description": "Count of rate limiting events from Novelfire.", + "gridPos": { "x": 16, "y": 0, "w": 4, "h": 4 }, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" }, "fieldConfig": { "defaults": { - "color": { "fixedColor": "purple", "mode": "fixed" }, - "thresholds": { "mode": "absolute", "steps": [] } + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 50 } + ] + } } }, "targets": [ { "datasource": { "type": "loki", "uid": "loki" }, - "expr": "last_over_time({service_name=\"runner\"} | json | op=`catalogue_refresh` | msg=`catalogue refresh done` | unwrap ok [7d])", - "legendFormat": "indexed" + "expr": "sum(count_over_time({service_name=\"runner\"} | json | body=~\"rate limit\" [24h]))", + "legendFormat": "rate limited" } ] }, { "id": 10, "type": "timeseries", - "title": "Audio Generation Rate (tasks/min)", + "title": "Scrape Rate (books/min)", + "description": "Rate of events per minute.", "gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 }, - "description": "Rate of audio task completions and failures over time.", - "options": { - "tooltip": { "mode": "multi" }, - "legend": { "displayMode": "list", "placement": "bottom" } - }, + "options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } }, "fieldConfig": { - "defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 10 } }, - "overrides": [ - { "matcher": { "id": "byName", "options": "failed" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "completed" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] } - ] + "defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 10 } } }, "targets": [ { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(rate(traces_spanmetrics_calls_total{service=\"runner\", span_name=\"runner.audio_task\", status_code!=\"STATUS_CODE_ERROR\"}[5m])) * 60", - "legendFormat": "completed" + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "sum(rate({service_name=\"runner\"} | json | body=\"chapter list fetched\" [5m])) * 60", + "legendFormat": "books/min" }, { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(rate(traces_spanmetrics_calls_total{service=\"runner\", span_name=\"runner.audio_task\", status_code=\"STATUS_CODE_ERROR\"}[5m])) * 60", - "legendFormat": "failed" + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "sum(rate({service_name=\"runner\"} | json | body=\"metadata saved\" [5m])) * 60", + "legendFormat": "metadata/min" } ] }, { "id": 11, "type": "timeseries", - "title": "Scraping Rate (tasks/min)", + "title": "Error Rate (errors/min)", + "description": "Rate of error and rate-limit messages over time.", "gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 }, - "description": "Rate of scrape task completions and failures over time.", - "options": { - "tooltip": { "mode": "multi" }, - "legend": { "displayMode": "list", "placement": "bottom" } - }, + "options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } }, "fieldConfig": { "defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 10 } }, "overrides": [ - { "matcher": { "id": "byName", "options": "failed" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }, - { "matcher": { "id": "byName", "options": "completed" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] } + { "matcher": { "id": "byName", "options": "errors/min" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }, + { "matcher": { "id": "byName", "options": "rate-limit/min" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] } ] }, "targets": [ { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(rate(traces_spanmetrics_calls_total{service=\"runner\", span_name=\"runner.scrape_task\", status_code!=\"STATUS_CODE_ERROR\"}[5m])) * 60", - "legendFormat": "completed" + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "sum(rate({service_name=\"runner\"} | json | severity=\"ERROR\" [5m])) * 60", + "legendFormat": "errors/min" }, { - "datasource": { "type": "prometheus", "uid": "prometheus" }, - "expr": "sum(rate(traces_spanmetrics_calls_total{service=\"runner\", span_name=\"runner.scrape_task\", status_code=\"STATUS_CODE_ERROR\"}[5m])) * 60", - "legendFormat": "failed" + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "sum(rate({service_name=\"runner\"} | json | body=~\"rate limit\" [5m])) * 60", + "legendFormat": "rate-limit/min" } ] }, { "id": 20, "type": "logs", - "title": "Scrape Task Events", - "description": "One log line per completed or failed scrape task. Fields: task_id, kind, url, scraped, skipped, errors.", + "title": "Runner Logs (errors & warnings)", + "description": "Runner log lines containing errors or warnings.", "gridPos": { "x": 0, "y": 12, "w": 24, "h": 10 }, "options": { "showTime": true, @@ -220,7 +193,7 @@ "targets": [ { "datasource": { "type": "loki", "uid": "loki" }, - "expr": "{service_name=\"runner\"} | json | msg =~ `scrape task (done|failed|starting)`", + "expr": "{service_name=\"runner\"} | json | severity=~\"ERROR|WARN\"", "legendFormat": "" } ] @@ -228,12 +201,12 @@ { "id": 21, "type": "logs", - "title": "Audio Task Events", - "description": "One log line per completed or failed audio task. Fields: task_id, slug, chapter, voice, key (on success), reason (on failure).", + "title": "Runner Logs (all)", + "description": "All runner log entries.", "gridPos": { "x": 0, "y": 22, "w": 24, "h": 10 }, "options": { "showTime": true, - "showLabels": false, + "showLabels": true, "wrapLogMessage": false, "prettifyLogMessage": true, "enableLogDetails": true, @@ -243,30 +216,7 @@ "targets": [ { "datasource": { "type": "loki", "uid": "loki" }, - "expr": "{service_name=\"runner\"} | json | msg =~ `audio task (done|failed|starting)`", - "legendFormat": "" - } - ] - }, - { - "id": 22, - "type": "logs", - "title": "Catalogue Refresh Progress", - "description": "Progress logs from the background catalogue refresh (every 24h). Fields: op=catalogue_refresh, scraped, ok, skipped, errors.", - "gridPos": { "x": 0, "y": 32, "w": 24, "h": 8 }, - "options": { - "showTime": true, - "showLabels": false, - "wrapLogMessage": false, - "prettifyLogMessage": true, - "enableLogDetails": true, - "sortOrder": "Descending", - "dedupStrategy": "none" - }, - "targets": [ - { - "datasource": { "type": "loki", "uid": "loki" }, - "expr": "{service_name=\"runner\"} | json | op=`catalogue_refresh`", + "expr": "{service_name=\"runner\"}", "legendFormat": "" } ] diff --git a/homelab/otel/grafana/provisioning/dashboards/runner.json b/homelab/otel/grafana/provisioning/dashboards/runner.json index 5a9f125..950e92e 100644 --- a/homelab/otel/grafana/provisioning/dashboards/runner.json +++ b/homelab/otel/grafana/provisioning/dashboards/runner.json @@ -345,7 +345,7 @@ "targets": [ { "datasource": { "type": "loki", "uid": "loki" }, - "expr": "{service_name=\"runner\"} | json | level =~ `(WARN|ERROR|error|warn)`", + "expr": "{service_name=\"runner\"}", "legendFormat": "" } ] @@ -368,7 +368,7 @@ "targets": [ { "datasource": { "type": "loki", "uid": "loki" }, - "expr": "{service_name=\"runner\"} | json", + "expr": "{service_name=\"runner\"}", "legendFormat": "" } ] diff --git a/homelab/otel/grafana/provisioning/dashboards/web-vitals.json b/homelab/otel/grafana/provisioning/dashboards/web-vitals.json index a9918da..005174f 100644 --- a/homelab/otel/grafana/provisioning/dashboards/web-vitals.json +++ b/homelab/otel/grafana/provisioning/dashboards/web-vitals.json @@ -1,37 +1,71 @@ { "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"], + "description": "Core Web Vitals from @grafana/faro-web-sdk. Data: browser \u2192 Alloy faro.receiver \u2192 Loki ({service_name=unknown_service}). Log format: key=value pairs, e.g. lcp=767.000000 fcp=767.000000. Use | regexp to extract.", + "tags": [ + "libnovel", + "frontend", + "rum", + "web-vitals" + ], "timezone": "browser", "refresh": "1m", - "time": { "from": "now-24h", "to": "now" }, + "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" }, + "title": "LCP \u2014 p75 (Largest Contentful Paint)", + "description": "Good < 2.5s, needs improvement < 4s, poor >= 4s. Source: Loki {service_name=unknown_service} Faro measurements.", + "gridPos": { + "x": 0, + "y": 0, + "w": 4, + "h": 4 + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "colorMode": "background", + "graphMode": "none" + }, "fieldConfig": { "defaults": { "unit": "ms", + "decimals": 0, "thresholds": { "mode": "absolute", "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 2500 }, - { "color": "red", "value": 4000 } + { + "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", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "quantile_over_time(0.75, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `lcp=(?P\\d+\\.?\\d*)` | unwrap lcp [1h])", "legendFormat": "LCP p75", "instant": true } @@ -40,27 +74,53 @@ { "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" }, + "title": "INP \u2014 p75 (Interaction to Next Paint)", + "description": "Good < 200ms, needs improvement < 500ms, poor >= 500ms.", + "gridPos": { + "x": 4, + "y": 0, + "w": 4, + "h": 4 + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "colorMode": "background", + "graphMode": "none" + }, "fieldConfig": { "defaults": { "unit": "ms", + "decimals": 0, "thresholds": { "mode": "absolute", "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 200 }, - { "color": "red", "value": 500 } + { + "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", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "quantile_over_time(0.75, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `inp=(?P\\d+\\.?\\d*)` | unwrap inp [1h])", "legendFormat": "INP p75", "instant": true } @@ -69,10 +129,23 @@ { "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" }, + "title": "CLS \u2014 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", @@ -80,17 +153,29 @@ "thresholds": { "mode": "absolute", "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 0.1 }, - { "color": "red", "value": 0.25 } + { + "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))", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "quantile_over_time(0.75, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `cls=(?P\\d+\\.?\\d*)` | unwrap cls [1h])", "legendFormat": "CLS p75", "instant": true } @@ -99,27 +184,53 @@ { "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" }, + "title": "TTFB \u2014 p75 (Time to First Byte)", + "description": "Good < 800ms, needs improvement < 1800ms, poor >= 1800ms.", + "gridPos": { + "x": 12, + "y": 0, + "w": 4, + "h": 4 + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "colorMode": "background", + "graphMode": "none" + }, "fieldConfig": { "defaults": { "unit": "ms", + "decimals": 0, "thresholds": { "mode": "absolute", "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 800 }, - { "color": "red", "value": 1800 } + { + "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", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "quantile_over_time(0.75, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `ttfb=(?P\\d+\\.?\\d*)` | unwrap ttfb [1h])", "legendFormat": "TTFB p75", "instant": true } @@ -128,27 +239,53 @@ { "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" }, + "title": "FCP \u2014 p75 (First Contentful Paint)", + "description": "Good < 1.8s, needs improvement < 3s, poor >= 3s.", + "gridPos": { + "x": 16, + "y": 0, + "w": 4, + "h": 4 + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + }, + "colorMode": "background", + "graphMode": "none" + }, "fieldConfig": { "defaults": { "unit": "ms", + "decimals": 0, "thresholds": { "mode": "absolute", "steps": [ - { "color": "green", "value": null }, - { "color": "yellow", "value": 1800 }, - { "color": "red", "value": 3000 } + { + "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", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "quantile_over_time(0.75, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `fcp=(?P\\d+\\.?\\d*)` | unwrap fcp [1h])", "legendFormat": "FCP p75", "instant": true } @@ -157,20 +294,45 @@ { "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" }, + "title": "Measurements / min", + "description": "Number of Faro measurement events in the last 5 minutes (activity indicator).", + "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 }] } + "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", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "sum(count_over_time({service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" [5m]))", + "legendFormat": "measurements", "instant": true } ] @@ -179,58 +341,176 @@ "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" } }, + "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 } }, + "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] } }] } + { + "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", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `lcp=(?P\\d+\\.?\\d*)` | unwrap lcp [5m])", "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", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "quantile_over_time(0.75, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `lcp=(?P\\d+\\.?\\d*)` | unwrap lcp [5m])", "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", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `lcp=(?P\\d+\\.?\\d*)` | unwrap lcp [5m])", "legendFormat": "p95" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "2500", "legendFormat": "Good (2.5s)" }, - { "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "4000", "legendFormat": "Poor (4s)" } + { + "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" } }, + "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 } } + "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", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `ttfb=(?P\\d+\\.?\\d*)` | unwrap ttfb [5m])", "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", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "quantile_over_time(0.75, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `ttfb=(?P\\d+\\.?\\d*)` | unwrap ttfb [5m])", "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", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"kind=measurement\" |= \"type=web-vitals\" | regexp `ttfb=(?P\\d+\\.?\\d*)` | unwrap ttfb [5m])", "legendFormat": "p95" } ] @@ -239,8 +519,13 @@ "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 }, + "description": "JS exceptions captured by Faro. kind=exception events.", + "gridPos": { + "x": 0, + "y": 12, + "w": 24, + "h": 10 + }, "options": { "showTime": true, "showLabels": true, @@ -252,8 +537,11 @@ }, "targets": [ { - "datasource": { "type": "loki", "uid": "loki" }, - "expr": "{service_name=\"libnovel-ui\"} | json | kind =~ `(exception|error)`", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "{service_name=\"unknown_service\"} | regexp `(?P\\w+)` | kind = \"exception\"", "legendFormat": "" } ] @@ -261,24 +549,256 @@ { "id": 21, "type": "logs", - "title": "Frontend Logs (all Faro events)", - "gridPos": { "x": 0, "y": 22, "w": 24, "h": 10 }, + "title": "Web Vitals Measurements", + "description": "All Faro measurement events.", + "gridPos": { + "x": 0, + "y": 22, + "w": 24, + "h": 10 + }, "options": { "showTime": true, - "showLabels": false, - "wrapLogMessage": true, + "showLabels": true, + "wrapLogMessage": false, "prettifyLogMessage": true, "enableLogDetails": true, "sortOrder": "Descending", "dedupStrategy": "none" }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "{service_name=\"unknown_service\"} | regexp `(?P\\w+)` | kind = \"measurement\"", + "legendFormat": "" + } + ] + }, + { + "id": 30, + "type": "row", + "title": "API Performance (Upstream Requests)", + "gridPos": { "x": 0, "y": 32, "w": 24, "h": 1 }, + "collapsed": false + }, + { + "id": 31, + "type": "timeseries", + "title": "API Request Duration — p50 / p75 / p95 by endpoint", + "description": "Duration of all libnovel.cc/api/* fetch requests captured by Faro faro.performance.resource events. Values in ms.", + "gridPos": { "x": 0, "y": 33, "w": 24, "h": 10 }, + "options": { + "tooltip": { "mode": "multi" }, + "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max", "lastNotNull"] } + }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "custom": { "lineWidth": 2, "fillOpacity": 5 } + } + }, "targets": [ { "datasource": { "type": "loki", "uid": "loki" }, - "expr": "{service_name=\"libnovel-ui\"}", + "expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/progress/audio-time\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [5m])", + "legendFormat": "p50 /api/progress/audio-time" + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/progress/audio-time\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [5m])", + "legendFormat": "p95 /api/progress/audio-time" + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/presign/audio\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [5m])", + "legendFormat": "p50 /api/presign/audio" + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/presign/audio\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [5m])", + "legendFormat": "p95 /api/presign/audio" + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/progress\" !~ \"audio-time\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [5m])", + "legendFormat": "p50 /api/progress" + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/progress\" !~ \"audio-time\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [5m])", + "legendFormat": "p95 /api/progress" + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/comments\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [5m])", + "legendFormat": "p50 /api/comments" + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/comments\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [5m])", + "legendFormat": "p95 /api/comments" + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/settings\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [5m])", + "legendFormat": "p50 /api/settings" + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/settings\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [5m])", + "legendFormat": "p95 /api/settings" + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "quantile_over_time(0.50, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/catalogue-page\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [5m])", + "legendFormat": "p50 /api/catalogue-page" + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/catalogue-page\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [5m])", + "legendFormat": "p95 /api/catalogue-page" + } + ] + }, + { + "id": 32, + "type": "barchart", + "title": "API Avg Duration — last 1h", + "description": "Average duration per endpoint over the last hour. Useful for spotting the slowest APIs at a glance.", + "gridPos": { "x": 0, "y": 43, "w": 12, "h": 8 }, + "options": { + "orientation": "horizontal", + "legend": { "displayMode": "list", "placement": "bottom" }, + "tooltip": { "mode": "single" }, + "xTickLabelRotation": 0 + }, + "fieldConfig": { + "defaults": { "unit": "ms", "color": { "mode": "palette-classic" } } + }, + "targets": [ + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "avg_over_time({service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/progress/audio-time\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [1h])", + "legendFormat": "/api/progress/audio-time", + "instant": true + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "avg_over_time({service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/presign/audio\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [1h])", + "legendFormat": "/api/presign/audio", + "instant": true + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "avg_over_time({service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/progress\" !~ \"audio-time\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [1h])", + "legendFormat": "/api/progress", + "instant": true + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "avg_over_time({service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/comments\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [1h])", + "legendFormat": "/api/comments", + "instant": true + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "avg_over_time({service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/settings\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [1h])", + "legendFormat": "/api/settings", + "instant": true + }, + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "avg_over_time({service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api/catalogue-page\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [1h])", + "legendFormat": "/api/catalogue-page", + "instant": true + } + ] + }, + { + "id": 33, + "type": "stat", + "title": "Slowest API call — p95 last 1h", + "description": "p95 duration of the single slowest endpoint in the last hour.", + "gridPos": { "x": 12, "y": 43, "w": 6, "h": 4 }, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "background", + "graphMode": "none" + }, + "fieldConfig": { + "defaults": { + "unit": "ms", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 500 }, + { "color": "red", "value": 1000 } + ] + } + } + }, + "targets": [ + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "max(quantile_over_time(0.95, {service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur [1h]))", + "legendFormat": "p95 max", + "instant": true + } + ] + }, + { + "id": 34, + "type": "stat", + "title": "API Requests / min", + "description": "Rate of libnovel.cc API requests captured by Faro in the last 5 minutes.", + "gridPos": { "x": 18, "y": 43, "w": 6, "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": "loki", "uid": "loki" }, + "expr": "sum(count_over_time({service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api\" [5m])) / 5", + "legendFormat": "req/min", + "instant": true + } + ] + }, + { + "id": 35, + "type": "logs", + "title": "Slow API Requests (>500ms)", + "description": "Individual faro.performance.resource events where duration > 500ms. Useful for debugging outliers.", + "gridPos": { "x": 0, "y": 47, "w": 24, "h": 8 }, + "options": { + "showTime": true, + "showLabels": false, + "wrapLogMessage": false, + "prettifyLogMessage": false, + "enableLogDetails": true, + "sortOrder": "Descending", + "dedupStrategy": "none" + }, + "targets": [ + { + "datasource": { "type": "loki", "uid": "loki" }, + "expr": "{service_name=\"unknown_service\"} |= \"faro.performance.resource\" |= \"libnovel.cc/api\" | regexp `event_data_duration=(?P[0-9.]+)` | unwrap dur | dur > 500", "legendFormat": "" } ] } ] -} +} \ No newline at end of file diff --git a/ui/messages/en.json b/ui/messages/en.json index 6b1b24d..3e7ded2 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -478,5 +478,20 @@ "feed_reader_label": "reading", "feed_chapters_label": "{n} chapters", "feed_browse_cta": "Browse catalogue", - "feed_find_users_cta": "Discover readers" + "feed_find_users_cta": "Discover readers", + + "admin_translation_page_title": "Translation \u2014 Admin", + "admin_translation_heading": "Machine Translation", + "admin_translation_tab_enqueue": "Enqueue", + "admin_translation_tab_jobs": "Jobs", + "admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026", + "admin_translation_no_matching": "No matching jobs.", + "admin_translation_no_jobs": "No translation jobs yet.", + + "admin_ai_jobs_page_title": "AI Jobs \u2014 Admin", + "admin_ai_jobs_heading": "AI Jobs", + "admin_ai_jobs_subheading": "Background AI generation tasks", + + "admin_text_gen_page_title": "Text Gen \u2014 Admin", + "admin_text_gen_heading": "Text Generation" } diff --git a/ui/messages/fr.json b/ui/messages/fr.json index d3289d7..9bc301b 100644 --- a/ui/messages/fr.json +++ b/ui/messages/fr.json @@ -476,5 +476,20 @@ "feed_browse_cta": "Parcourir le catalogue", "feed_find_users_cta": "Trouver des lecteurs", "admin_nav_gitea": "Gitea", - "admin_nav_grafana": "Grafana" + "admin_nav_grafana": "Grafana", + + "admin_translation_page_title": "Translation \u2014 Admin", + "admin_translation_heading": "Machine Translation", + "admin_translation_tab_enqueue": "Enqueue", + "admin_translation_tab_jobs": "Jobs", + "admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026", + "admin_translation_no_matching": "No matching jobs.", + "admin_translation_no_jobs": "No translation jobs yet.", + + "admin_ai_jobs_page_title": "AI Jobs \u2014 Admin", + "admin_ai_jobs_heading": "AI Jobs", + "admin_ai_jobs_subheading": "Background AI generation tasks", + + "admin_text_gen_page_title": "Text Gen \u2014 Admin", + "admin_text_gen_heading": "Text Generation" } diff --git a/ui/messages/id.json b/ui/messages/id.json index e843e20..f822a19 100644 --- a/ui/messages/id.json +++ b/ui/messages/id.json @@ -476,5 +476,20 @@ "feed_browse_cta": "Jelajahi katalog", "feed_find_users_cta": "Temukan pembaca", "admin_nav_gitea": "Gitea", - "admin_nav_grafana": "Grafana" + "admin_nav_grafana": "Grafana", + + "admin_translation_page_title": "Translation \u2014 Admin", + "admin_translation_heading": "Machine Translation", + "admin_translation_tab_enqueue": "Enqueue", + "admin_translation_tab_jobs": "Jobs", + "admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026", + "admin_translation_no_matching": "No matching jobs.", + "admin_translation_no_jobs": "No translation jobs yet.", + + "admin_ai_jobs_page_title": "AI Jobs \u2014 Admin", + "admin_ai_jobs_heading": "AI Jobs", + "admin_ai_jobs_subheading": "Background AI generation tasks", + + "admin_text_gen_page_title": "Text Gen \u2014 Admin", + "admin_text_gen_heading": "Text Generation" } diff --git a/ui/messages/pt.json b/ui/messages/pt.json index 2a33bdb..0965e52 100644 --- a/ui/messages/pt.json +++ b/ui/messages/pt.json @@ -476,5 +476,20 @@ "feed_browse_cta": "Ver catálogo", "feed_find_users_cta": "Encontrar leitores", "admin_nav_gitea": "Gitea", - "admin_nav_grafana": "Grafana" + "admin_nav_grafana": "Grafana", + + "admin_translation_page_title": "Translation \u2014 Admin", + "admin_translation_heading": "Machine Translation", + "admin_translation_tab_enqueue": "Enqueue", + "admin_translation_tab_jobs": "Jobs", + "admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026", + "admin_translation_no_matching": "No matching jobs.", + "admin_translation_no_jobs": "No translation jobs yet.", + + "admin_ai_jobs_page_title": "AI Jobs \u2014 Admin", + "admin_ai_jobs_heading": "AI Jobs", + "admin_ai_jobs_subheading": "Background AI generation tasks", + + "admin_text_gen_page_title": "Text Gen \u2014 Admin", + "admin_text_gen_heading": "Text Generation" } diff --git a/ui/messages/ru.json b/ui/messages/ru.json index f78272b..89876fc 100644 --- a/ui/messages/ru.json +++ b/ui/messages/ru.json @@ -476,5 +476,20 @@ "feed_browse_cta": "Каталог", "feed_find_users_cta": "Найти читателей", "admin_nav_gitea": "Gitea", - "admin_nav_grafana": "Grafana" + "admin_nav_grafana": "Grafana", + + "admin_translation_page_title": "Translation \u2014 Admin", + "admin_translation_heading": "Machine Translation", + "admin_translation_tab_enqueue": "Enqueue", + "admin_translation_tab_jobs": "Jobs", + "admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026", + "admin_translation_no_matching": "No matching jobs.", + "admin_translation_no_jobs": "No translation jobs yet.", + + "admin_ai_jobs_page_title": "AI Jobs \u2014 Admin", + "admin_ai_jobs_heading": "AI Jobs", + "admin_ai_jobs_subheading": "Background AI generation tasks", + + "admin_text_gen_page_title": "Text Gen \u2014 Admin", + "admin_text_gen_heading": "Text Generation" } diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 3088b63..2b3f50c 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -646,6 +646,7 @@ export interface UserLibraryEntry { user_id?: string; slug: string; saved_at: string; + shelf?: string; } function libraryFilter(sessionId: string, userId?: string): string { @@ -675,6 +676,19 @@ export async function isBookSaved( return row !== null; } +/** Returns the shelf the user has placed this book on, or '' if not saved / no shelf set. */ +export async function getBookShelf( + sessionId: string, + slug: string, + userId?: string +): Promise { + const filter = userId + ? `user_id="${userId}"&&slug="${slug}"` + : `session_id="${sessionId}"&&slug="${slug}"`; + const row = await listOne('user_library', filter).catch(() => null); + return (row?.shelf as ShelfName) || ''; +} + /** Save a book to the user's library. No-op if already saved. */ export async function saveBook( sessionId: string, diff --git a/ui/src/routes/admin/+layout.svelte b/ui/src/routes/admin/+layout.svelte index a04138f..4ca2e0d 100644 --- a/ui/src/routes/admin/+layout.svelte +++ b/ui/src/routes/admin/+layout.svelte @@ -3,25 +3,89 @@ import * as m from '$lib/paraglide/messages.js'; const internalLinks = [ - { href: '/admin/scrape', label: () => m.admin_nav_scrape() }, - { href: '/admin/audio', label: () => m.admin_nav_audio() }, - { href: '/admin/translation', label: () => m.admin_nav_translation() }, - { href: '/admin/changelog', label: () => m.admin_nav_changelog() }, - { href: '/admin/image-gen', label: () => m.admin_nav_image_gen() }, - { href: '/admin/text-gen', label: () => m.admin_nav_text_gen() }, - { href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() }, - { href: '/admin/ai-jobs', label: () => m.admin_nav_ai_jobs() } + { + href: '/admin/scrape', + label: () => m.admin_nav_scrape(), + icon: `` + }, + { + href: '/admin/audio', + label: () => m.admin_nav_audio(), + icon: `` + }, + { + href: '/admin/translation', + label: () => m.admin_nav_translation(), + icon: `` + }, + { + href: '/admin/image-gen', + label: () => m.admin_nav_image_gen(), + icon: `` + }, + { + href: '/admin/text-gen', + label: () => m.admin_nav_text_gen(), + icon: `` + }, + { + href: '/admin/ai-jobs', + label: () => m.admin_nav_ai_jobs(), + icon: `` + }, + { + href: '/admin/catalogue-tools', + label: () => m.admin_nav_catalogue_tools(), + icon: `` + }, + { + href: '/admin/changelog', + label: () => m.admin_nav_changelog(), + icon: `` + } ]; const externalLinks = [ - { href: 'https://feedback.libnovel.cc', label: () => m.admin_nav_feedback() }, - { href: 'https://errors.libnovel.cc', label: () => m.admin_nav_errors() }, - { href: 'https://analytics.libnovel.cc', label: () => m.admin_nav_analytics() }, - { 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() } + { + href: 'https://feedback.libnovel.cc', + label: () => m.admin_nav_feedback(), + icon: `` + }, + { + href: 'https://errors.libnovel.cc', + label: () => m.admin_nav_errors(), + icon: `` + }, + { + href: 'https://analytics.libnovel.cc', + label: () => m.admin_nav_analytics(), + icon: `` + }, + { + href: 'https://logs.libnovel.cc', + label: () => m.admin_nav_logs(), + icon: `` + }, + { + href: 'https://grafana.libnovel.cc', + label: () => m.admin_nav_grafana(), + icon: `` + }, + { + href: 'https://uptime.libnovel.cc', + label: () => m.admin_nav_uptime(), + icon: `` + }, + { + href: 'https://push.libnovel.cc', + label: () => m.admin_nav_push(), + icon: `` + }, + { + href: 'https://gitea.kalekber.cc/kamil/libnovel', + label: () => m.admin_nav_gitea(), + icon: `` + } ]; interface Props { @@ -45,44 +109,53 @@