Compare commits
2 Commits
v2.6.32
...
v3-cleanup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e399b1ce01 | ||
|
|
320f9fc76b |
41
AGENTS.md
41
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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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<lcp>\\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<inp>\\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<cls>\\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<ttfb>\\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<fcp>\\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<lcp>\\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<lcp>\\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<lcp>\\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<ttfb>\\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<ttfb>\\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<ttfb>\\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<kind>\\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<kind>\\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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[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<dur>[0-9.]+)` | unwrap dur | dur > 500",
|
||||
"legendFormat": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -247,5 +247,22 @@ html {
|
||||
100% { width: 100%; opacity: 0; }
|
||||
}
|
||||
.animate-progress-bar {
|
||||
animation: progress-bar 8s cubic-bezier(0.1, 0.05, 0.1, 1) forwards;
|
||||
animation: progress-bar 4s cubic-bezier(0.1, 0.05, 0.1, 1) forwards;
|
||||
}
|
||||
|
||||
/* ── Respect reduced motion — disable all decorative animations ─────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Footer content-visibility — skip paint for off-screen footer ───── */
|
||||
footer {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 80px;
|
||||
}
|
||||
|
||||
@@ -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<ShelfName> {
|
||||
const filter = userId
|
||||
? `user_id="${userId}"&&slug="${slug}"`
|
||||
: `session_id="${sessionId}"&&slug="${slug}"`;
|
||||
const row = await listOne<UserLibraryEntry>('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,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { locales, getLocale } from '$lib/paraglide/runtime.js';
|
||||
import ListeningMode from '$lib/components/ListeningMode.svelte';
|
||||
import SearchModal from '$lib/components/SearchModal.svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
|
||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||
|
||||
@@ -823,7 +823,9 @@
|
||||
|
||||
<main class="flex-1 max-w-6xl mx-auto w-full px-4 py-8">
|
||||
{#key page.url.pathname + page.url.search}
|
||||
{@render children()}
|
||||
<div in:fade={{ duration: 180, delay: 60 }} out:fade={{ duration: 100 }}>
|
||||
{@render children()}
|
||||
</div>
|
||||
{/key}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -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: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/audio',
|
||||
label: () => m.admin_nav_audio(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/translation',
|
||||
label: () => m.admin_nav_translation(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/image-gen',
|
||||
label: () => m.admin_nav_image_gen(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/text-gen',
|
||||
label: () => m.admin_nav_text_gen(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/ai-jobs',
|
||||
label: () => m.admin_nav_ai_jobs(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/catalogue-tools',
|
||||
label: () => m.admin_nav_catalogue_tools(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/changelog',
|
||||
label: () => m.admin_nav_changelog(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" />`
|
||||
}
|
||||
];
|
||||
|
||||
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: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />`
|
||||
},
|
||||
{
|
||||
href: 'https://errors.libnovel.cc',
|
||||
label: () => m.admin_nav_errors(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />`
|
||||
},
|
||||
{
|
||||
href: 'https://analytics.libnovel.cc',
|
||||
label: () => m.admin_nav_analytics(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />`
|
||||
},
|
||||
{
|
||||
href: 'https://logs.libnovel.cc',
|
||||
label: () => m.admin_nav_logs(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h10" />`
|
||||
},
|
||||
{
|
||||
href: 'https://grafana.libnovel.cc',
|
||||
label: () => m.admin_nav_grafana(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 8v8m-4-5v5m-4-2v2m-2 4h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />`
|
||||
},
|
||||
{
|
||||
href: 'https://uptime.libnovel.cc',
|
||||
label: () => m.admin_nav_uptime(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />`
|
||||
},
|
||||
{
|
||||
href: 'https://push.libnovel.cc',
|
||||
label: () => m.admin_nav_push(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />`
|
||||
},
|
||||
{
|
||||
href: 'https://gitea.kalekber.cc/kamil/libnovel',
|
||||
label: () => m.admin_nav_gitea(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />`
|
||||
}
|
||||
];
|
||||
|
||||
interface Props {
|
||||
@@ -45,44 +109,53 @@
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="
|
||||
fixed top-0 left-0 h-full z-50 w-56 shrink-0 border-r border-(--color-border) px-3 py-6 flex flex-col gap-6
|
||||
fixed top-0 left-0 h-full z-50 w-56 shrink-0 border-r border-(--color-border) px-2 py-5 flex flex-col gap-5
|
||||
bg-(--color-surface) transition-transform duration-200
|
||||
{sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
md:relative md:translate-x-0 md:w-48 md:z-auto md:top-auto md:h-auto
|
||||
md:relative md:translate-x-0 md:w-52 md:z-auto md:top-auto md:h-auto
|
||||
"
|
||||
>
|
||||
<!-- Internal pages -->
|
||||
<div>
|
||||
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_pages_label()}</p>
|
||||
<p class="px-3 mb-1.5 text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_pages_label()}</p>
|
||||
<nav class="flex flex-col gap-0.5">
|
||||
{#each internalLinks as link}
|
||||
{@const active = page.url.pathname.startsWith(link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
class="px-2 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{page.url.pathname.startsWith(link.href)
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-2.5
|
||||
{active
|
||||
? 'bg-(--color-surface-2) text-(--color-text)'
|
||||
: 'text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text)'}"
|
||||
>
|
||||
{link.label()}
|
||||
<svg class="w-3.5 h-3.5 shrink-0 {active ? 'text-(--color-brand)' : 'opacity-50'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{@html link.icon}
|
||||
</svg>
|
||||
{link.label()}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-(--color-border)"></div>
|
||||
|
||||
<!-- External tools -->
|
||||
<div>
|
||||
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_tools_label()}</p>
|
||||
<p class="px-3 mb-1.5 text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_tools_label()}</p>
|
||||
<nav class="flex flex-col gap-0.5">
|
||||
{#each externalLinks as link}
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-2 py-1.5 rounded-md text-sm font-medium text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text) transition-colors flex items-center justify-between"
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text) transition-colors flex items-center gap-2.5"
|
||||
>
|
||||
{link.label()}
|
||||
<svg class="w-3 h-3 shrink-0 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-3.5 h-3.5 shrink-0 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{@html link.icon}
|
||||
</svg>
|
||||
<span class="flex-1">{link.label()}</span>
|
||||
<svg class="w-2.5 h-2.5 shrink-0 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import type { AIJob } from '$lib/server/pocketbase';
|
||||
import { cn } from '$lib/utils';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -55,25 +56,25 @@
|
||||
});
|
||||
|
||||
// ── Cancel ────────────────────────────────────────────────────────────────────
|
||||
let cancellingId = $state<string | null>(null);
|
||||
let cancelError = $state('');
|
||||
let cancellingIds = $state(new Set<string>());
|
||||
let cancelErrors: Record<string, string> = $state({});
|
||||
|
||||
async function cancelJob(id: string) {
|
||||
if (cancellingId) return;
|
||||
cancellingId = id;
|
||||
cancelError = '';
|
||||
if (cancellingIds.has(id)) return;
|
||||
cancellingIds = new Set([...cancellingIds, id]);
|
||||
delete cancelErrors[id];
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${id}/cancel`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
cancelError = body.error ?? `Error ${res.status}`;
|
||||
cancelErrors = { ...cancelErrors, [id]: body.error ?? `Error ${res.status}` };
|
||||
} else {
|
||||
await invalidateAll();
|
||||
jobs = jobs.map((j) => j.id === id ? { ...j, status: 'cancelled' as AIJob['status'] } : j);
|
||||
}
|
||||
} catch {
|
||||
cancelError = 'Network error.';
|
||||
cancelErrors = { ...cancelErrors, [id]: 'Network error.' };
|
||||
} finally {
|
||||
cancellingId = null;
|
||||
cancellingIds = new Set([...cancellingIds].filter((x) => x !== id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +106,7 @@
|
||||
jobId: string;
|
||||
slug: string;
|
||||
imageType: string;
|
||||
chapter: number;
|
||||
prompt: string;
|
||||
imageSrc: string;
|
||||
contentType: string;
|
||||
@@ -175,6 +177,7 @@
|
||||
jobId: job.id,
|
||||
slug: job.slug,
|
||||
imageType: '',
|
||||
chapter: 0,
|
||||
prompt: '',
|
||||
imageSrc: '',
|
||||
contentType: 'image/png',
|
||||
@@ -195,6 +198,7 @@
|
||||
let payload: {
|
||||
prompt?: string;
|
||||
type?: string;
|
||||
chapter?: number;
|
||||
content_type?: string;
|
||||
image_b64?: string;
|
||||
bytes?: number;
|
||||
@@ -207,6 +211,7 @@
|
||||
return;
|
||||
}
|
||||
r.imageType = payload.type ?? 'cover';
|
||||
r.chapter = payload.chapter ?? 0;
|
||||
r.prompt = payload.prompt ?? '';
|
||||
r.contentType = payload.content_type ?? 'image/png';
|
||||
r.bytes = payload.bytes ?? 0;
|
||||
@@ -314,6 +319,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save image as chapter illustration ───────────────────────────────────────
|
||||
|
||||
async function saveImageAsChapterImage() {
|
||||
if (review?.kind !== 'image-gen' || review.saving) return;
|
||||
review.saving = true;
|
||||
review.saveError = '';
|
||||
|
||||
const b64 = review.imageSrc.split(',')[1];
|
||||
try {
|
||||
const res = await fetch('/api/admin/image-gen/save-chapter-image', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ slug: review.slug, chapter: review.chapter, image_b64: b64 })
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
review.saveError = body.error ?? `Error ${res.status}`;
|
||||
} else {
|
||||
review.savedUrl = body.image_url ?? `/api/chapter-image/novelfire.net/${review.slug}/${review.chapter}`;
|
||||
}
|
||||
} catch {
|
||||
review.saveError = 'Network error.';
|
||||
} finally {
|
||||
review.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadImage() {
|
||||
if (review?.kind !== 'image-gen') return;
|
||||
const a = document.createElement('a');
|
||||
@@ -406,12 +438,16 @@
|
||||
const REVIEWABLE_KINDS = new Set(['chapter-names', 'image-gen', 'description']);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.admin_ai_jobs_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-6xl mx-auto space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">AI Jobs</h1>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">Background AI generation tasks</p>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">{m.admin_ai_jobs_heading()}</h1>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">{m.admin_ai_jobs_subheading()}</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => invalidateAll()}
|
||||
@@ -466,11 +502,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel error -->
|
||||
{#if cancelError}
|
||||
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{cancelError}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Jobs table -->
|
||||
{#if filteredJobs.length === 0}
|
||||
<div class="rounded-lg border border-(--color-border) bg-(--color-surface-2) px-6 py-12 text-center">
|
||||
@@ -479,7 +510,8 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-(--color-border) overflow-hidden">
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden sm:block rounded-lg border border-(--color-border) overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-(--color-border) bg-(--color-surface-2)">
|
||||
@@ -508,19 +540,26 @@
|
||||
{kindLabel(job.kind)}
|
||||
</td>
|
||||
|
||||
<!-- Slug -->
|
||||
<td class="px-4 py-3 max-w-[12rem]">
|
||||
{#if job.slug}
|
||||
<!-- Slug -->
|
||||
<td class="px-4 py-3 max-w-[12rem]">
|
||||
{#if job.slug}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<a
|
||||
href="/admin/image-gen?slug={job.slug}"
|
||||
href="/books/{job.slug}"
|
||||
class="text-(--color-brand) hover:underline truncate block font-mono text-xs"
|
||||
>
|
||||
{job.slug}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-(--color-muted) text-xs">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
{#if job.kind === 'image-gen'}
|
||||
<a href="/admin/image-gen?slug={job.slug}" class="text-[10px] text-(--color-muted) hover:text-(--color-text) transition-colors">↗ image editor</a>
|
||||
{:else if job.kind === 'chapter-names' || job.kind === 'description'}
|
||||
<a href="/admin/text-gen" class="text-[10px] text-(--color-muted) hover:text-(--color-text) transition-colors">↗ text editor</a>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-(--color-muted) text-xs">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<!-- Model -->
|
||||
<td class="px-4 py-3 hidden sm:table-cell">
|
||||
@@ -569,13 +608,13 @@
|
||||
<!-- Actions -->
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
{#if job.status === 'pending' || job.status === 'running'}
|
||||
{#if job.status === 'pending' || job.status === 'running'}
|
||||
<button
|
||||
onclick={() => cancelJob(job.id)}
|
||||
disabled={cancellingId === job.id}
|
||||
disabled={cancellingIds.has(job.id)}
|
||||
class="px-2 py-1 rounded text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{cancellingId === job.id ? 'Cancelling…' : 'Cancel'}
|
||||
{cancellingIds.has(job.id) ? 'Cancelling…' : 'Cancel'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if REVIEWABLE_KINDS.has(job.kind) && job.status === 'done'}
|
||||
@@ -594,6 +633,11 @@
|
||||
{job.error_message}
|
||||
</span>
|
||||
{/if}
|
||||
{#if cancelErrors[job.id]}
|
||||
<span class="text-xs text-(--color-danger)" title={cancelErrors[job.id]}>
|
||||
{cancelErrors[job.id]}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -601,6 +645,74 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div class="sm:hidden space-y-3">
|
||||
{#each filteredJobs as job (job.id)}
|
||||
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-3">
|
||||
<!-- Row 1: status + kind -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class={cn('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', statusBg(job.status))}>
|
||||
{job.status}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-(--color-text)">{kindLabel(job.kind)}</span>
|
||||
</div>
|
||||
<!-- Slug + links -->
|
||||
{#if job.slug}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<a href="/books/{job.slug}" class="text-(--color-brand) hover:underline font-mono text-xs truncate">
|
||||
{job.slug}
|
||||
</a>
|
||||
{#if job.kind === 'image-gen'}
|
||||
<a href="/admin/image-gen?slug={job.slug}" class="text-[10px] text-(--color-muted) hover:text-(--color-text) transition-colors">↗ image editor</a>
|
||||
{:else if job.kind === 'chapter-names' || job.kind === 'description'}
|
||||
<a href="/admin/text-gen" class="text-[10px] text-(--color-muted) hover:text-(--color-text) transition-colors">↗ text editor</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Meta grid -->
|
||||
<div class="grid grid-cols-2 gap-1 text-xs">
|
||||
<span class="text-(--color-muted)">Model</span>
|
||||
<span class="text-(--color-muted) font-mono text-right truncate" title={job.model}>{job.model || '—'}</span>
|
||||
{#if job.items_total > 0}
|
||||
<span class="text-(--color-muted)">Progress</span>
|
||||
<span class="text-(--color-muted) text-right tabular-nums">{job.items_done}/{job.items_total}</span>
|
||||
{/if}
|
||||
<span class="text-(--color-muted)">Started</span>
|
||||
<span class="text-(--color-muted) text-right">{fmtDate(job.started)}</span>
|
||||
<span class="text-(--color-muted)">Duration</span>
|
||||
<span class="text-(--color-muted) text-right tabular-nums">{duration(job.started, job.finished)}</span>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if job.status === 'pending' || job.status === 'running'}
|
||||
<button
|
||||
onclick={() => cancelJob(job.id)}
|
||||
disabled={cancellingIds.has(job.id)}
|
||||
class="px-2.5 py-1 rounded text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{cancellingIds.has(job.id) ? 'Cancelling…' : 'Cancel'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if REVIEWABLE_KINDS.has(job.kind) && job.status === 'done'}
|
||||
<button
|
||||
onclick={() => openReview(job)}
|
||||
class="px-2.5 py-1 rounded text-xs font-medium bg-green-400/10 text-green-400 hover:bg-green-400/20 transition-colors"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if job.error_message}
|
||||
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
|
||||
{/if}
|
||||
{#if cancelErrors[job.id]}
|
||||
<p class="text-xs text-(--color-danger)">{cancelErrors[job.id]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
Showing {filteredJobs.length} of {jobs.length} jobs
|
||||
</p>
|
||||
@@ -744,45 +856,53 @@
|
||||
<p class="text-(--color-text)">{fmtBytes(review.bytes)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if review.savedUrl}
|
||||
<p class="text-xs text-green-400">
|
||||
Saved as cover →
|
||||
<a href={review.savedUrl} target="_blank" rel="noopener noreferrer" class="underline hover:text-green-300">{review.savedUrl}</a>
|
||||
</p>
|
||||
{/if}
|
||||
{#if review.saveError}
|
||||
<p class="text-xs text-(--color-danger)">{review.saveError}</p>
|
||||
{/if}
|
||||
{#if review.savedUrl}
|
||||
<p class="text-xs text-green-400">
|
||||
Saved →
|
||||
<a href={review.savedUrl} target="_blank" rel="noopener noreferrer" class="underline hover:text-green-300">{review.savedUrl}</a>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
{#if !review.loading && !review.error && review.imageSrc}
|
||||
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4 flex-wrap">
|
||||
<button onclick={closeReview} class="px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">Discard</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Footer -->
|
||||
{#if !review.loading && !review.error && review.imageSrc}
|
||||
<div class="px-5 py-4 border-t border-(--color-border) shrink-0 flex items-center justify-between gap-4 flex-wrap">
|
||||
<button onclick={closeReview} class="px-3 py-1.5 rounded-md text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">Discard</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onclick={downloadImage}
|
||||
class="px-3 py-1.5 rounded-md text-sm bg-(--color-surface-3) text-(--color-text) hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
{#if review.savedUrl}
|
||||
<span class="text-sm text-green-400 font-medium">Saved ✓</span>
|
||||
{:else if review.imageType === 'cover'}
|
||||
<button
|
||||
onclick={downloadImage}
|
||||
class="px-3 py-1.5 rounded-md text-sm bg-(--color-surface-3) text-(--color-text) hover:bg-zinc-600 transition-colors"
|
||||
onclick={saveImageAsCover}
|
||||
disabled={review.saving}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Download
|
||||
{review.saving ? 'Saving…' : 'Save as cover'}
|
||||
</button>
|
||||
{#if review.imageType === 'cover' && !review.savedUrl}
|
||||
<button
|
||||
onclick={saveImageAsCover}
|
||||
disabled={review.saving}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{review.saving ? 'Saving…' : 'Save as cover'}
|
||||
</button>
|
||||
{:else if review.savedUrl}
|
||||
<span class="text-sm text-green-400 font-medium">Saved ✓</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if review.imageType === 'chapter' && review.chapter > 0}
|
||||
<button
|
||||
onclick={saveImageAsChapterImage}
|
||||
disabled={review.saving}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium bg-(--color-brand) text-black hover:bg-amber-300 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{review.saving ? 'Saving…' : `Save as chapter ${review.chapter} image`}
|
||||
</button>
|
||||
{/if}
|
||||
{#if review.saveError}
|
||||
<p class="text-xs text-(--color-danger)">{review.saveError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Description review ──────────────────────────────────────────────── -->
|
||||
{:else if review.kind === 'description'}
|
||||
|
||||
@@ -73,24 +73,81 @@
|
||||
|
||||
// ── Audio jobs stats + filter ────────────────────────────────────────────────
|
||||
let jobsQ = $state('');
|
||||
let jobsStatusFilter = $state('all');
|
||||
const JOB_STATUS_OPTIONS = ['all', 'generating', 'pending', 'done', 'failed'] as const;
|
||||
|
||||
let filteredJobs = $derived(
|
||||
jobsQ.trim()
|
||||
? jobs.filter(
|
||||
(j) =>
|
||||
j.slug.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
|
||||
j.voice.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
|
||||
j.status.toLowerCase().includes(jobsQ.toLowerCase().trim())
|
||||
)
|
||||
: jobs
|
||||
jobs.filter((j: AudioJob) => {
|
||||
const qLower = jobsQ.trim().toLowerCase();
|
||||
const matchesQ =
|
||||
!qLower ||
|
||||
j.slug.toLowerCase().includes(qLower) ||
|
||||
j.voice.toLowerCase().includes(qLower) ||
|
||||
j.status.toLowerCase().includes(qLower);
|
||||
const matchesStatus = jobsStatusFilter === 'all' || j.status === jobsStatusFilter;
|
||||
return matchesQ && matchesStatus;
|
||||
})
|
||||
);
|
||||
|
||||
let stats = $derived({
|
||||
total: jobs.length,
|
||||
done: jobs.filter((j) => j.status === 'done').length,
|
||||
failed: jobs.filter((j) => j.status === 'failed').length,
|
||||
inFlight: jobs.filter((j) => j.status === 'pending' || j.status === 'generating').length
|
||||
done: jobs.filter((j: AudioJob) => j.status === 'done').length,
|
||||
failed: jobs.filter((j: AudioJob) => j.status === 'failed').length,
|
||||
pending: jobs.filter((j: AudioJob) => j.status === 'pending').length,
|
||||
generating: jobs.filter((j: AudioJob) => j.status === 'generating').length,
|
||||
inFlight: jobs.filter((j: AudioJob) => j.status === 'pending' || j.status === 'generating').length
|
||||
});
|
||||
|
||||
// ── Cancel single job ────────────────────────────────────────────────────────
|
||||
let cancellingJobIds = $state(new Set<string>());
|
||||
let cancelJobErrors: Record<string, string> = $state({});
|
||||
|
||||
async function cancelJob(id: string) {
|
||||
if (cancellingJobIds.has(id)) return;
|
||||
cancellingJobIds = new Set([...cancellingJobIds, id]);
|
||||
delete cancelJobErrors[id];
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${encodeURIComponent(id)}/cancel`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
cancelJobErrors = { ...cancelJobErrors, [id]: body.error ?? `Error ${res.status}` };
|
||||
} else {
|
||||
jobs = jobs.map((j: AudioJob) => j.id === id ? { ...j, status: 'cancelled' } : j);
|
||||
}
|
||||
} catch {
|
||||
cancelJobErrors = { ...cancelJobErrors, [id]: 'Network error.' };
|
||||
} finally {
|
||||
cancellingJobIds = new Set([...cancellingJobIds].filter((x) => x !== id));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Retry failed job ─────────────────────────────────────────────────────────
|
||||
let retryingJobIds = $state(new Set<string>());
|
||||
let retryJobErrors: Record<string, string> = $state({});
|
||||
|
||||
async function retryJob(job: AudioJob) {
|
||||
if (retryingJobIds.has(job.id)) return;
|
||||
retryingJobIds = new Set([...retryingJobIds, job.id]);
|
||||
delete retryJobErrors[job.id];
|
||||
try {
|
||||
const res = await fetch('/api/admin/audio/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ slug: job.slug, voice: job.voice, from: job.chapter, to: job.chapter, force: true })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
retryJobErrors = { ...retryJobErrors, [job.id]: body.error ?? `Error ${res.status}` };
|
||||
} else {
|
||||
jobs = jobs.map((j: AudioJob) => j.id === job.id ? { ...j, status: 'pending', error_message: '' } : j);
|
||||
}
|
||||
} catch {
|
||||
retryJobErrors = { ...retryJobErrors, [job.id]: 'Network error.' };
|
||||
} finally {
|
||||
retryingJobIds = new Set([...retryingJobIds].filter((x) => x !== job.id));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audio cache filter ───────────────────────────────────────────────────────
|
||||
function parseCacheKey(key: string) {
|
||||
const parts = key.split('/');
|
||||
@@ -154,12 +211,29 @@
|
||||
|
||||
<!-- ── Audio Jobs tab ─────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'jobs'}
|
||||
<input
|
||||
type="search"
|
||||
bind:value={jobsQ}
|
||||
placeholder={m.admin_audio_filter_jobs()}
|
||||
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<input
|
||||
type="search"
|
||||
bind:value={jobsQ}
|
||||
placeholder={m.admin_audio_filter_jobs()}
|
||||
class="flex-1 min-w-48 max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<!-- Status pills -->
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
{#each JOB_STATUS_OPTIONS as s}
|
||||
{@const count = s === 'all' ? stats.total : (s === 'generating' ? stats.generating : s === 'pending' ? stats.pending : s === 'done' ? stats.done : stats.failed)}
|
||||
<button
|
||||
onclick={() => (jobsStatusFilter = s)}
|
||||
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors capitalize
|
||||
{jobsStatusFilter === s
|
||||
? 'bg-(--color-brand) text-black'
|
||||
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
{s}{count > 0 ? ` ${count}` : ''}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if filteredJobs.length === 0}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">
|
||||
@@ -169,65 +243,116 @@
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Book</th>
|
||||
<th class="px-4 py-3 text-right">Ch.</th>
|
||||
<th class="px-4 py-3 text-left">Voice</th>
|
||||
<th class="px-4 py-3 text-left">Engine</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
<th class="px-4 py-3 text-left">Started</th>
|
||||
<th class="px-4 py-3 text-left">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-(--color-border)/50">
|
||||
{#each filteredJobs as job}
|
||||
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
|
||||
<td class="px-4 py-3 text-(--color-text) font-medium">
|
||||
<a href="/books/{job.slug}" class="hover:text-(--color-brand) transition-colors">{job.slug}</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{job.voice}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) text-xs">{engineLabel(job.voice)}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{duration(job.started, job.finished)}</td>
|
||||
</tr>
|
||||
{#if job.error_message}
|
||||
<tr class="bg-(--color-danger)/10">
|
||||
<td colspan="7" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{job.error_message}</td>
|
||||
</tr>
|
||||
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Book</th>
|
||||
<th class="px-4 py-3 text-right">Ch.</th>
|
||||
<th class="px-4 py-3 text-left">Voice</th>
|
||||
<th class="px-4 py-3 text-left">Engine</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
<th class="px-4 py-3 text-left">Started</th>
|
||||
<th class="px-4 py-3 text-left">Duration</th>
|
||||
<th class="px-4 py-3 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-(--color-border)/50">
|
||||
{#each filteredJobs as job}
|
||||
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
|
||||
<td class="px-4 py-3 text-(--color-text) font-medium">
|
||||
<a href="/books/{job.slug}" class="hover:text-(--color-brand) transition-colors">{job.slug}</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{job.voice}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) text-xs">{engineLabel(job.voice)}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{duration(job.started, job.finished)}</td>
|
||||
<td class="px-4 py-3">
|
||||
{#if job.status === 'pending' || job.status === 'generating'}
|
||||
<button
|
||||
onclick={() => cancelJob(job.id)}
|
||||
disabled={cancellingJobIds.has(job.id)}
|
||||
class="px-2 py-1 rounded text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{cancellingJobIds.has(job.id) ? 'Cancelling…' : 'Cancel'}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
{#if job.status === 'failed'}
|
||||
<button
|
||||
onclick={() => retryJob(job)}
|
||||
disabled={retryingJobIds.has(job.id)}
|
||||
class="px-2 py-1 rounded text-xs font-medium bg-sky-400/10 text-sky-400 hover:bg-sky-400/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{retryingJobIds.has(job.id) ? 'Retrying…' : 'Retry ↺'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if cancelJobErrors[job.id]}
|
||||
<p class="text-xs text-(--color-danger) mt-1">{cancelJobErrors[job.id]}</p>
|
||||
{/if}
|
||||
{#if retryJobErrors[job.id]}
|
||||
<p class="text-xs text-(--color-danger) mt-1">{retryJobErrors[job.id]}</p>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{#if job.error_message}
|
||||
<tr class="bg-(--color-danger)/10">
|
||||
<td colspan="8" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{job.error_message}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div class="sm:hidden space-y-3">
|
||||
{#each filteredJobs as job}
|
||||
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<a href="/books/{job.slug}" class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors truncate">
|
||||
{job.slug}
|
||||
</a>
|
||||
<span class="shrink-0 text-xs font-semibold {jobStatusColor(job.status)}">{job.status}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1 text-xs">
|
||||
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{job.chapter}</span>
|
||||
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{job.voice}</span>
|
||||
<span class="text-(--color-muted)">Engine</span><span class="text-(--color-muted) text-right">{engineLabel(job.voice)}</span>
|
||||
<span class="text-(--color-muted)">Started</span><span class="text-(--color-muted) text-right">{fmtDate(job.started)}</span>
|
||||
<span class="text-(--color-muted)">Duration</span><span class="text-(--color-muted) text-right">{duration(job.started, job.finished)}</span>
|
||||
</div>
|
||||
{#if job.error_message}
|
||||
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
|
||||
{/if}
|
||||
<!-- Mobile cards -->
|
||||
<div class="sm:hidden space-y-3">
|
||||
{#each filteredJobs as job}
|
||||
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<a href="/books/{job.slug}" class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors truncate">
|
||||
{job.slug}
|
||||
</a>
|
||||
<span class="shrink-0 text-xs font-semibold {jobStatusColor(job.status)}">{job.status}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1 text-xs">
|
||||
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{job.chapter}</span>
|
||||
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{job.voice}</span>
|
||||
<span class="text-(--color-muted)">Engine</span><span class="text-(--color-muted) text-right">{engineLabel(job.voice)}</span>
|
||||
<span class="text-(--color-muted)">Started</span><span class="text-(--color-muted) text-right">{fmtDate(job.started)}</span>
|
||||
<span class="text-(--color-muted)">Duration</span><span class="text-(--color-muted) text-right">{duration(job.started, job.finished)}</span>
|
||||
</div>
|
||||
{#if job.error_message}
|
||||
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
|
||||
{/if}
|
||||
{#if job.status === 'pending' || job.status === 'generating'}
|
||||
<button
|
||||
onclick={() => cancelJob(job.id)}
|
||||
disabled={cancellingJobIds.has(job.id)}
|
||||
class="w-full px-3 py-1.5 rounded-lg text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{cancellingJobIds.has(job.id) ? 'Cancelling…' : 'Cancel job'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if job.status === 'failed'}
|
||||
<button
|
||||
onclick={() => retryJob(job)}
|
||||
disabled={retryingJobIds.has(job.id)}
|
||||
class="w-full px-3 py-1.5 rounded-lg text-xs font-medium bg-sky-400/10 text-sky-400 hover:bg-sky-400/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{retryingJobIds.has(job.id) ? 'Retrying…' : 'Retry ↺'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if cancelJobErrors[job.id]}
|
||||
<p class="text-xs text-(--color-danger)">{cancelJobErrors[job.id]}</p>
|
||||
{/if}
|
||||
{#if retryJobErrors[job.id]}
|
||||
<p class="text-xs text-(--color-danger)">{retryJobErrors[job.id]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -178,17 +178,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats ────────────────────────────────────────────────────────────────────
|
||||
let stats = $derived({
|
||||
total: tasks.length,
|
||||
running: tasks.filter((t: ScrapingTask) => t.status === 'running').length,
|
||||
pending: tasks.filter((t: ScrapingTask) => t.status === 'pending').length,
|
||||
done: tasks.filter((t: ScrapingTask) => t.status === 'done').length,
|
||||
failed: tasks.filter((t: ScrapingTask) => t.status === 'failed').length,
|
||||
cancelled: tasks.filter((t: ScrapingTask) => t.status === 'cancelled').length,
|
||||
});
|
||||
|
||||
// ── Table filter ────────────────────────────────────────────────────────────
|
||||
let q = $state('');
|
||||
let statusFilter = $state('all');
|
||||
const STATUS_OPTIONS = ['all', 'running', 'pending', 'done', 'failed', 'cancelled'] as const;
|
||||
|
||||
let filtered = $derived(
|
||||
q.trim()
|
||||
? tasks.filter(
|
||||
(t: ScrapingTask) =>
|
||||
t.kind.toLowerCase().includes(q.toLowerCase()) ||
|
||||
t.status.toLowerCase().includes(q.toLowerCase()) ||
|
||||
(t.target_url ?? '').toLowerCase().includes(q.toLowerCase())
|
||||
)
|
||||
: tasks
|
||||
tasks.filter((t: ScrapingTask) => {
|
||||
const qLower = q.trim().toLowerCase();
|
||||
const matchesQ =
|
||||
!qLower ||
|
||||
t.kind.toLowerCase().includes(qLower) ||
|
||||
t.status.toLowerCase().includes(qLower) ||
|
||||
(t.target_url ?? '').toLowerCase().includes(qLower);
|
||||
const matchesStatus = statusFilter === 'all' || t.status === statusFilter;
|
||||
return matchesQ && matchesStatus;
|
||||
})
|
||||
);
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
@@ -226,6 +241,15 @@
|
||||
{ label: 'Isekai', url: 'https://novelfire.net/genre/isekai' },
|
||||
{ label: 'Martial Arts', url: 'https://novelfire.net/genre/martial-arts' },
|
||||
];
|
||||
|
||||
function statusPillColor(s: string) {
|
||||
if (s === 'running') return 'text-(--color-brand)';
|
||||
if (s === 'pending') return 'text-sky-400';
|
||||
if (s === 'done') return 'text-green-400';
|
||||
if (s === 'failed') return 'text-(--color-danger)';
|
||||
if (s === 'cancelled') return 'text-(--color-muted)';
|
||||
return 'text-(--color-text)';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -344,6 +368,27 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Status filter pills -->
|
||||
<div class="flex gap-1.5 flex-wrap">
|
||||
{#each STATUS_OPTIONS as s}
|
||||
{@const count = s === 'all' ? stats.total : stats[s as keyof typeof stats]}
|
||||
<button
|
||||
onclick={() => (statusFilter = s)}
|
||||
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors capitalize flex items-center gap-1.5
|
||||
{statusFilter === s
|
||||
? 'bg-(--color-brand) text-black'
|
||||
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
{s}
|
||||
{#if count > 0 && s !== 'all'}
|
||||
<span class="tabular-nums {statusFilter === s ? 'text-black/70' : statusPillColor(s)}">{count}</span>
|
||||
{:else if s === 'all'}
|
||||
<span class="tabular-nums opacity-60">{count}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">
|
||||
{q.trim() ? m.admin_scrape_no_matching() : m.admin_tasks_empty()}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import type { PageData } from './$types';
|
||||
import type { TextModelInfo, BookSummary } from './+page.server';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -345,7 +346,9 @@
|
||||
let tGenerating = $state(false);
|
||||
let tError = $state('');
|
||||
let tResult = $state('');
|
||||
let tEdited = $state('');
|
||||
let tUsedModel = $state('');
|
||||
let tCopied = $state(false);
|
||||
|
||||
let tCanGenerate = $derived(tSlug.trim().length > 0 && !tGenerating);
|
||||
|
||||
@@ -354,7 +357,7 @@
|
||||
|
||||
async function generateTagline() {
|
||||
if (!tCanGenerate) return;
|
||||
tGenerating = true; tError = ''; tResult = '';
|
||||
tGenerating = true; tError = ''; tResult = ''; tEdited = ''; tCopied = false;
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/tagline', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
@@ -363,10 +366,17 @@
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { tError = body.error ?? `Error ${res.status}`; return; }
|
||||
tResult = body.new_tagline ?? '';
|
||||
tEdited = tResult;
|
||||
tUsedModel = body.model ?? '';
|
||||
} catch { tError = 'Network error.'; } finally { tGenerating = false; }
|
||||
}
|
||||
|
||||
async function copyTagline() {
|
||||
await navigator.clipboard.writeText(tEdited || tResult);
|
||||
tCopied = true;
|
||||
setTimeout(() => { tCopied = false; }, 2000);
|
||||
}
|
||||
|
||||
// ── Genres state ──────────────────────────────────────────────────────────────
|
||||
let gAC = makeBookAC();
|
||||
let gSlug = $state('');
|
||||
@@ -424,6 +434,7 @@
|
||||
let wError = $state('');
|
||||
let wWarnings = $state<string[]>([]);
|
||||
let wUsedModel = $state('');
|
||||
let wCopied = $state(false);
|
||||
|
||||
let wCanGenerate = $derived(wSlug.trim().length > 0 && !wGenerating);
|
||||
|
||||
@@ -432,7 +443,7 @@
|
||||
|
||||
async function generateWarnings() {
|
||||
if (!wCanGenerate) return;
|
||||
wGenerating = true; wError = ''; wWarnings = [];
|
||||
wGenerating = true; wError = ''; wWarnings = []; wCopied = false;
|
||||
try {
|
||||
const res = await fetch('/api/admin/text-gen/content-warnings', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
@@ -445,6 +456,12 @@
|
||||
} catch { wError = 'Network error.'; } finally { wGenerating = false; }
|
||||
}
|
||||
|
||||
async function copyWarnings() {
|
||||
await navigator.clipboard.writeText(wWarnings.join(', '));
|
||||
wCopied = true;
|
||||
setTimeout(() => { wCopied = false; }, 2000);
|
||||
}
|
||||
|
||||
// ── Quality score state ───────────────────────────────────────────────────────
|
||||
let qAC = makeBookAC();
|
||||
let qSlug = $state('');
|
||||
@@ -477,13 +494,13 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Text Gen — Admin</title>
|
||||
<title>{m.admin_text_gen_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6 max-w-5xl">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">Text Generation</h1>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">{m.admin_text_gen_heading()}</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
Generate chapter titles and book descriptions using Cloudflare Workers AI.
|
||||
</p>
|
||||
@@ -920,32 +937,44 @@
|
||||
</button>
|
||||
{#if tError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{tError}</p>{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if tResult}
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
Tagline{#if tUsedModel}<span class="normal-case font-normal"> · {tUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<div class="bg-(--color-surface) border border-(--color-brand)/40 rounded-xl p-4">
|
||||
<p class="text-base italic text-(--color-text)">{tResult}</p>
|
||||
</div>
|
||||
<button onclick={() => { tResult = ''; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
|
||||
<div>
|
||||
{#if tResult}
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
Tagline{#if tUsedModel}<span class="normal-case font-normal"> · {tUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={tEdited}
|
||||
class="w-full bg-(--color-surface) border border-(--color-brand)/40 rounded-xl px-4 py-3 text-base italic text-(--color-text) focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<p class="text-xs text-(--color-muted)">Note: tagline field not yet in the data model — copy and use manually.</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={copyTagline}
|
||||
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors
|
||||
{tCopied ? 'bg-green-400/10 text-green-400 border border-green-400/30' : 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)'}"
|
||||
>
|
||||
{tCopied ? 'Copied ✓' : 'Copy to clipboard'}
|
||||
</button>
|
||||
<button onclick={() => { tResult = ''; tEdited = ''; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
|
||||
</div>
|
||||
{:else if tGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
|
||||
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
|
||||
<p class="text-sm text-(--color-muted)">Tagline will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if tGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
|
||||
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
|
||||
<p class="text-sm text-(--color-muted)">Tagline will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Genres panel ────────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'genres'}
|
||||
@@ -1077,38 +1106,48 @@
|
||||
</button>
|
||||
{#if wError}<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{wError}</p>{/if}
|
||||
</div>
|
||||
<div>
|
||||
{#if wWarnings.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
Detected warnings{#if wUsedModel}<span class="normal-case font-normal"> · {wUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each wWarnings as w}
|
||||
<span class="px-3 py-1 rounded-full text-sm bg-amber-400/10 text-amber-400 border border-amber-400/30">{w}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<button onclick={() => { wWarnings = []; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
|
||||
<div>
|
||||
{#if wWarnings.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
|
||||
Detected warnings{#if wUsedModel}<span class="normal-case font-normal"> · {wUsedModel.split('/').pop()}</span>{/if}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each wWarnings as w}
|
||||
<span class="px-3 py-1 rounded-full text-sm bg-amber-400/10 text-amber-400 border border-amber-400/30">{w}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if wWarnings.length === 0 && wUsedModel}
|
||||
<div class="bg-green-400/10 border border-green-400/30 rounded-xl p-4">
|
||||
<p class="text-sm text-green-400">No content warnings detected.</p>
|
||||
<p class="text-xs text-(--color-muted)">Note: content warnings field not yet in the data model — copy and use manually.</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={copyWarnings}
|
||||
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors
|
||||
{wCopied ? 'bg-green-400/10 text-green-400 border border-green-400/30' : 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)'}"
|
||||
>
|
||||
{wCopied ? 'Copied ✓' : 'Copy to clipboard'}
|
||||
</button>
|
||||
<button onclick={() => { wWarnings = []; wUsedModel = ''; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Clear</button>
|
||||
</div>
|
||||
{:else if wGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
|
||||
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
|
||||
<p class="text-sm text-(--color-muted)">Warnings will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if wWarnings.length === 0 && wUsedModel}
|
||||
<div class="bg-green-400/10 border border-green-400/30 rounded-xl p-4">
|
||||
<p class="text-sm text-green-400">No content warnings detected.</p>
|
||||
</div>
|
||||
{:else if wGenerating}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-28">
|
||||
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-28">
|
||||
<p class="text-sm text-(--color-muted)">Warnings will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Quality score panel ────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'quality'}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import type { TranslationJob } from '$lib/server/pocketbase';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
@@ -52,6 +53,7 @@
|
||||
if (status === 'running') return 'text-(--color-brand) animate-pulse';
|
||||
if (status === 'pending') return 'text-sky-400 animate-pulse';
|
||||
if (status === 'failed') return 'text-(--color-danger)';
|
||||
if (status === 'cancelled') return 'text-(--color-muted)';
|
||||
return 'text-(--color-text)';
|
||||
}
|
||||
|
||||
@@ -77,33 +79,91 @@
|
||||
}
|
||||
|
||||
let jobsQ = $state('');
|
||||
let jobsStatusFilter = $state('all');
|
||||
const JOB_STATUS_OPTIONS = ['all', 'running', 'pending', 'done', 'failed', 'cancelled'] as const;
|
||||
|
||||
let filteredJobs = $derived(
|
||||
jobsQ.trim()
|
||||
? jobs.filter(
|
||||
(j) =>
|
||||
j.slug.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
|
||||
j.lang.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
|
||||
j.status.toLowerCase().includes(jobsQ.toLowerCase().trim())
|
||||
)
|
||||
: jobs
|
||||
jobs.filter((j: TranslationJob) => {
|
||||
const qLower = jobsQ.trim().toLowerCase();
|
||||
const matchesQ =
|
||||
!qLower ||
|
||||
j.slug.toLowerCase().includes(qLower) ||
|
||||
j.lang.toLowerCase().includes(qLower) ||
|
||||
j.status.toLowerCase().includes(qLower);
|
||||
const matchesStatus = jobsStatusFilter === 'all' || j.status === jobsStatusFilter;
|
||||
return matchesQ && matchesStatus;
|
||||
})
|
||||
);
|
||||
|
||||
let stats = $derived({
|
||||
total: jobs.length,
|
||||
done: jobs.filter((j) => j.status === 'done').length,
|
||||
failed: jobs.filter((j) => j.status === 'failed').length,
|
||||
inFlight: jobs.filter((j) => j.status === 'pending' || j.status === 'running').length
|
||||
done: jobs.filter((j: TranslationJob) => j.status === 'done').length,
|
||||
failed: jobs.filter((j: TranslationJob) => j.status === 'failed').length,
|
||||
running: jobs.filter((j: TranslationJob) => j.status === 'running').length,
|
||||
pending: jobs.filter((j: TranslationJob) => j.status === 'pending').length,
|
||||
cancelled: jobs.filter((j: TranslationJob) => j.status === 'cancelled').length,
|
||||
inFlight: jobs.filter((j: TranslationJob) => j.status === 'pending' || j.status === 'running').length
|
||||
});
|
||||
|
||||
// ── Cancel single job ────────────────────────────────────────────────────────
|
||||
let cancellingJobIds = $state(new Set<string>());
|
||||
let cancelJobErrors: Record<string, string> = $state({});
|
||||
|
||||
async function cancelJob(id: string) {
|
||||
if (cancellingJobIds.has(id)) return;
|
||||
cancellingJobIds = new Set([...cancellingJobIds, id]);
|
||||
delete cancelJobErrors[id];
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${encodeURIComponent(id)}/cancel`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
cancelJobErrors = { ...cancelJobErrors, [id]: body.error ?? `Error ${res.status}` };
|
||||
} else {
|
||||
jobs = jobs.map((j: TranslationJob) => j.id === id ? { ...j, status: 'cancelled' } : j);
|
||||
}
|
||||
} catch {
|
||||
cancelJobErrors = { ...cancelJobErrors, [id]: 'Network error.' };
|
||||
} finally {
|
||||
cancellingJobIds = new Set([...cancellingJobIds].filter((x) => x !== id));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Retry failed job ─────────────────────────────────────────────────────────
|
||||
let retryingJobIds = $state(new Set<string>());
|
||||
let retryJobErrors: Record<string, string> = $state({});
|
||||
|
||||
async function retryJob(job: TranslationJob) {
|
||||
if (retryingJobIds.has(job.id)) return;
|
||||
retryingJobIds = new Set([...retryingJobIds, job.id]);
|
||||
delete retryJobErrors[job.id];
|
||||
try {
|
||||
const res = await fetch('/api/admin/translation/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ slug: job.slug, lang: job.lang, from: job.chapter, to: job.chapter })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
retryJobErrors = { ...retryJobErrors, [job.id]: body.error ?? `Error ${res.status}` };
|
||||
} else {
|
||||
jobs = jobs.map((j: TranslationJob) => j.id === job.id ? { ...j, status: 'pending', error_message: '' } : j);
|
||||
}
|
||||
} catch {
|
||||
retryJobErrors = { ...retryJobErrors, [job.id]: 'Network error.' };
|
||||
} finally {
|
||||
retryingJobIds = new Set([...retryingJobIds].filter((x) => x !== job.id));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Translation — Admin</title>
|
||||
<title>{m.admin_translation_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">Machine Translation</h1>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">{m.admin_translation_heading()}</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{stats.total} job{stats.total !== 1 ? 's' : ''} ·
|
||||
<span class="text-green-400">{stats.done} done</span>
|
||||
@@ -123,14 +183,14 @@
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{activeTab === 'enqueue' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Enqueue
|
||||
{m.admin_translation_tab_enqueue()}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'jobs')}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{activeTab === 'jobs' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Jobs
|
||||
{m.admin_translation_tab_jobs()}
|
||||
{#if stats.inFlight > 0}
|
||||
<span
|
||||
class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[10px] font-bold"
|
||||
@@ -248,58 +308,101 @@
|
||||
|
||||
<!-- ── Jobs tab ───────────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'jobs'}
|
||||
<input
|
||||
type="search"
|
||||
bind:value={jobsQ}
|
||||
placeholder="Filter by slug, lang, or status…"
|
||||
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<input
|
||||
type="search"
|
||||
bind:value={jobsQ}
|
||||
placeholder={m.admin_translation_filter_placeholder()}
|
||||
class="flex-1 min-w-48 max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
{#each JOB_STATUS_OPTIONS as s}
|
||||
{@const count = s === 'all' ? stats.total : (stats as Record<string, number>)[s] ?? 0}
|
||||
<button
|
||||
onclick={() => (jobsStatusFilter = s)}
|
||||
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors capitalize
|
||||
{jobsStatusFilter === s
|
||||
? 'bg-(--color-brand) text-black'
|
||||
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
{s}{count > 0 ? ` ${count}` : ''}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if filteredJobs.length === 0}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">
|
||||
{jobsQ.trim() ? 'No matching jobs.' : 'No translation jobs yet.'}
|
||||
{jobsQ.trim() ? m.admin_translation_no_matching() : m.admin_translation_no_jobs()}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Book</th>
|
||||
<th class="px-4 py-3 text-right">Ch.</th>
|
||||
<th class="px-4 py-3 text-left">Lang</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
<th class="px-4 py-3 text-left">Started</th>
|
||||
<th class="px-4 py-3 text-left">Duration</th>
|
||||
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Book</th>
|
||||
<th class="px-4 py-3 text-right">Ch.</th>
|
||||
<th class="px-4 py-3 text-left">Lang</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
<th class="px-4 py-3 text-left">Started</th>
|
||||
<th class="px-4 py-3 text-left">Duration</th>
|
||||
<th class="px-4 py-3 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-(--color-border)/50">
|
||||
{#each filteredJobs as job}
|
||||
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
|
||||
<td class="px-4 py-3 text-(--color-text) font-medium">
|
||||
<a href="/books/{job.slug}" class="hover:text-(--color-brand) transition-colors"
|
||||
>{job.slug}</a
|
||||
>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs uppercase">{job.lang}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap"
|
||||
>{duration(job.started, job.finished)}</td
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
{#if job.status === 'pending' || job.status === 'running'}
|
||||
<button
|
||||
onclick={() => cancelJob(job.id)}
|
||||
disabled={cancellingJobIds.has(job.id)}
|
||||
class="px-2 py-1 rounded text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{cancellingJobIds.has(job.id) ? 'Cancelling…' : 'Cancel'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if job.status === 'failed'}
|
||||
<button
|
||||
onclick={() => retryJob(job)}
|
||||
disabled={retryingJobIds.has(job.id)}
|
||||
class="px-2 py-1 rounded text-xs font-medium bg-sky-400/10 text-sky-400 hover:bg-sky-400/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{retryingJobIds.has(job.id) ? 'Retrying…' : 'Retry ↺'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if cancelJobErrors[job.id]}
|
||||
<p class="text-xs text-(--color-danger) mt-1">{cancelJobErrors[job.id]}</p>
|
||||
{/if}
|
||||
{#if retryJobErrors[job.id]}
|
||||
<p class="text-xs text-(--color-danger) mt-1">{retryJobErrors[job.id]}</p>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-(--color-border)/50">
|
||||
{#each filteredJobs as job}
|
||||
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
|
||||
<td class="px-4 py-3 text-(--color-text) font-medium">
|
||||
<a href="/books/{job.slug}" class="hover:text-(--color-brand) transition-colors"
|
||||
>{job.slug}</a
|
||||
>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs uppercase">{job.lang}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap"
|
||||
>{duration(job.started, job.finished)}</td
|
||||
{#if job.error_message}
|
||||
<tr class="bg-(--color-danger)/10">
|
||||
<td colspan="7" class="px-4 py-2 text-xs text-(--color-danger) font-mono"
|
||||
>{job.error_message}</td
|
||||
>
|
||||
</tr>
|
||||
{#if job.error_message}
|
||||
<tr class="bg-(--color-danger)/10">
|
||||
<td colspan="6" class="px-4 py-2 text-xs text-(--color-danger) font-mono"
|
||||
>{job.error_message}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -332,9 +435,33 @@
|
||||
class="text-(--color-muted) text-right">{duration(job.started, job.finished)}</span
|
||||
>
|
||||
</div>
|
||||
{#if job.error_message}
|
||||
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
|
||||
{/if}
|
||||
{#if job.error_message}
|
||||
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
|
||||
{/if}
|
||||
{#if job.status === 'pending' || job.status === 'running'}
|
||||
<button
|
||||
onclick={() => cancelJob(job.id)}
|
||||
disabled={cancellingJobIds.has(job.id)}
|
||||
class="w-full px-3 py-1.5 rounded-lg text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{cancellingJobIds.has(job.id) ? 'Cancelling…' : 'Cancel'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if job.status === 'failed'}
|
||||
<button
|
||||
onclick={() => retryJob(job)}
|
||||
disabled={retryingJobIds.has(job.id)}
|
||||
class="w-full px-3 py-1.5 rounded-lg text-xs font-medium bg-sky-400/10 text-sky-400 hover:bg-sky-400/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{retryingJobIds.has(job.id) ? 'Retrying…' : 'Retry ↺'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if cancelJobErrors[job.id]}
|
||||
<p class="text-xs text-(--color-danger)">{cancelJobErrors[job.id]}</p>
|
||||
{/if}
|
||||
{#if retryJobErrors[job.id]}
|
||||
<p class="text-xs text-(--color-danger)">{retryJobErrors[job.id]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* POST /api/admin/image-gen/save-chapter-image
|
||||
*
|
||||
* Admin-only proxy: persists a pre-generated base64 image as a chapter
|
||||
* illustration in MinIO without re-calling Cloudflare AI.
|
||||
*
|
||||
* Body: { slug: string, chapter: number, image_b64: string }
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/image-gen/save-chapter-image', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/image-gen/save-chapter-image', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
34
ui/src/routes/api/admin/translation/bulk/+server.ts
Normal file
34
ui/src/routes/api/admin/translation/bulk/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* POST /api/admin/translation/bulk
|
||||
*
|
||||
* Admin-only proxy to the Go backend's translation bulk-enqueue endpoint.
|
||||
* Body: { slug, lang, from, to }
|
||||
* Response 200: { enqueued }
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const body = await request.text();
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch('/api/admin/translation/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('api/admin/translation/bulk', 'backend proxy error', { err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* GET /api/chapter-image/[domain]/[slug]/[n]
|
||||
* HEAD /api/chapter-image/[domain]/[slug]/[n]
|
||||
*
|
||||
* Proxies chapter illustration images from the Go backend.
|
||||
* HEAD returns 200/404 to check existence; GET returns the full image bytes.
|
||||
* Returns 404 when no image has been saved for this chapter.
|
||||
*/
|
||||
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
async function proxyChapterImage({ params, method }: { params: { domain: string; slug: string; n: string }; method: string }) {
|
||||
const { domain, slug, n } = params;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch(
|
||||
`/api/chapter-image/${encodeURIComponent(domain)}/${encodeURIComponent(slug)}/${encodeURIComponent(n)}`,
|
||||
{ method }
|
||||
);
|
||||
} catch {
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
if (res.status === 404) {
|
||||
throw error(404, 'No chapter image found');
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw error(res.status, 'Could not fetch chapter image');
|
||||
}
|
||||
|
||||
if (method === 'HEAD') {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type') ?? 'image/jpeg';
|
||||
const data = await res.arrayBuffer();
|
||||
|
||||
return new Response(data, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Content-Length': String(data.byteLength)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = ({ params }) => proxyChapterImage({ params, method: 'GET' });
|
||||
export const HEAD: RequestHandler = ({ params }) => proxyChapterImage({ params, method: 'HEAD' });
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getBook, listChapterIdx, getProgress, isBookSaved, countReadersThisWeek, getBookRating, getBookAvgRating } from '$lib/server/pocketbase';
|
||||
import { getBook, listChapterIdx, getProgress, isBookSaved, getBookShelf, countReadersThisWeek, getBookRating, getBookAvgRating } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
|
||||
|
||||
@@ -15,15 +15,16 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
|
||||
if (book) {
|
||||
// Book is in the library — normal path
|
||||
let chapters, progress, saved, readersThisWeek, userRating, ratingAvg;
|
||||
let chapters, progress, saved, readersThisWeek, userRating, ratingAvg, currentShelf;
|
||||
try {
|
||||
[chapters, progress, saved, readersThisWeek, userRating, ratingAvg] = await Promise.all([
|
||||
[chapters, progress, saved, readersThisWeek, userRating, ratingAvg, currentShelf] = await Promise.all([
|
||||
listChapterIdx(slug),
|
||||
getProgress(locals.sessionId, slug, locals.user?.id),
|
||||
isBookSaved(locals.sessionId, slug, locals.user?.id),
|
||||
countReadersThisWeek(slug),
|
||||
getBookRating(locals.sessionId, slug, locals.user?.id),
|
||||
getBookAvgRating(slug)
|
||||
getBookAvgRating(slug),
|
||||
getBookShelf(locals.sessionId, slug, locals.user?.id)
|
||||
]);
|
||||
} catch (e) {
|
||||
log.error('books', 'failed to load book page data', { slug, err: String(e) });
|
||||
@@ -35,6 +36,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
chapters,
|
||||
inLib: true,
|
||||
saved,
|
||||
currentShelf: currentShelf ?? '',
|
||||
lastChapter: progress?.chapter ?? null,
|
||||
readersThisWeek,
|
||||
userRating: userRating ?? 0,
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
}
|
||||
|
||||
// ── Shelf ─────────────────────────────────────────────────────────────────
|
||||
let currentShelf = $state<ShelfName>('');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let currentShelf = $state<ShelfName>((data.currentShelf as ShelfName) ?? '');
|
||||
|
||||
async function setShelf(shelf: ShelfName) {
|
||||
currentShelf = shelf;
|
||||
@@ -256,7 +257,8 @@
|
||||
let chapterCoverN = $state('1');
|
||||
let chapterCoverGenerating = $state(false);
|
||||
let chapterCoverPreview = $state<string | null>(null);
|
||||
let chapterCoverResult = $state<'error' | ''>('');
|
||||
let chapterCoverResult = $state<'saved' | 'error' | ''>('');
|
||||
let chapterCoverSaving = $state(false);
|
||||
let chapterCoverPrompt = $state('');
|
||||
|
||||
async function generateChapterCover() {
|
||||
@@ -287,6 +289,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChapterCover() {
|
||||
const slug = data.book?.slug;
|
||||
if (chapterCoverSaving || !chapterCoverPreview || !slug) return;
|
||||
const n = parseInt(chapterCoverN, 10);
|
||||
if (!n || n < 1) return;
|
||||
chapterCoverSaving = true;
|
||||
chapterCoverResult = '';
|
||||
try {
|
||||
const b64 = chapterCoverPreview.replace(/^data:[^;]+;base64,/, '');
|
||||
const res = await fetch('/api/admin/image-gen/save-cover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, chapter: n, image_b64: b64 })
|
||||
});
|
||||
if (res.ok) {
|
||||
chapterCoverResult = 'saved';
|
||||
chapterCoverPreview = null;
|
||||
} else {
|
||||
chapterCoverResult = 'error';
|
||||
}
|
||||
} catch {
|
||||
chapterCoverResult = 'error';
|
||||
} finally {
|
||||
chapterCoverSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin: description generation ─────────────────────────────────────────
|
||||
let descGenerating = $state(false);
|
||||
let descPreview = $state('');
|
||||
@@ -1131,12 +1160,24 @@
|
||||
</button>
|
||||
{#if chapterCoverResult === 'error'}
|
||||
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
||||
{:else if chapterCoverResult === 'saved'}
|
||||
<span class="text-xs text-green-400">{m.book_detail_admin_saved()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if chapterCoverPreview}
|
||||
<div class="flex items-start gap-3 mt-1">
|
||||
<img src={chapterCoverPreview} alt="Chapter cover preview" class="w-24 rounded border border-(--color-border)" />
|
||||
<button onclick={() => (chapterCoverPreview = null)} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors pt-1">{m.book_detail_admin_discard()}</button>
|
||||
<div class="flex flex-col gap-2 pt-1">
|
||||
<button
|
||||
onclick={saveChapterCover}
|
||||
disabled={chapterCoverSaving}
|
||||
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{chapterCoverSaving ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-green-600/20 text-green-400 hover:bg-green-600/30 border border-green-600/30'}"
|
||||
>
|
||||
{chapterCoverSaving ? m.book_detail_admin_saving() : 'Save Ch. Cover'}
|
||||
</button>
|
||||
<button onclick={() => (chapterCoverPreview = null)} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1265,7 +1306,10 @@
|
||||
|
||||
<!-- Audio TTS bulk enqueue -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_audio_tts()}</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_audio_tts()}</p>
|
||||
<a href="/admin/audio" class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) transition-colors">Monitor jobs ↗</a>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Voice selector -->
|
||||
<div class="flex flex-col gap-1">
|
||||
|
||||
@@ -86,16 +86,19 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
isPreview: true,
|
||||
lang: '',
|
||||
translationStatus: 'unavailable' as string,
|
||||
isPro: locals.isPro
|
||||
isPro: locals.isPro,
|
||||
chapterImageUrl: null as string | null
|
||||
};
|
||||
}
|
||||
|
||||
// ── Normal path: fetch from PocketBase + MinIO ─────────────────────────
|
||||
// Fetch book metadata, chapter index, and voice list in parallel
|
||||
const [book, chapters, voicesRes] = await Promise.all([
|
||||
// Fetch book metadata, chapter index, voice list, and chapter image check in parallel.
|
||||
// HEAD /api/chapter-image checks existence cheaply without downloading the image.
|
||||
const [book, chapters, voicesRes, chapterImageRes] = await Promise.all([
|
||||
getBook(slug),
|
||||
listChapterIdx(slug),
|
||||
backendFetch('/api/voices').catch(() => null)
|
||||
backendFetch('/api/voices').catch(() => null),
|
||||
backendFetch(`/api/chapter-image/novelfire.net/${encodeURIComponent(slug)}/${n}`, { method: 'HEAD' }).catch(() => null)
|
||||
]);
|
||||
|
||||
if (!book) error(404, `Book "${slug}" not found`);
|
||||
@@ -103,6 +106,12 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
const chapterIdx = chapters.find((c) => c.number === n);
|
||||
if (!chapterIdx) error(404, `Chapter ${n} not found`);
|
||||
|
||||
// Chapter image URL — only set when a generated image exists for this chapter
|
||||
const chapterImageUrl =
|
||||
chapterImageRes?.ok
|
||||
? `/api/chapter-image/novelfire.net/${encodeURIComponent(slug)}/${n}`
|
||||
: null;
|
||||
|
||||
// Parse voices — fall back to empty list on error
|
||||
let voices: Voice[] = [];
|
||||
try {
|
||||
@@ -124,19 +133,20 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
const tData = (await tRes.json()) as { html: string; lang: string };
|
||||
const prevChapter = chapters.find((c) => c.number === n - 1) ?? null;
|
||||
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
|
||||
return {
|
||||
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
|
||||
chapter: chapterIdx,
|
||||
html: tData.html,
|
||||
voices,
|
||||
prev: prevChapter ? prevChapter.number : null,
|
||||
next: nextChapter ? nextChapter.number : null,
|
||||
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
|
||||
sessionId: locals.sessionId,
|
||||
isPreview: false,
|
||||
lang,
|
||||
translationStatus: 'done',
|
||||
isPro: locals.isPro
|
||||
return {
|
||||
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
|
||||
chapter: chapterIdx,
|
||||
html: tData.html,
|
||||
voices,
|
||||
prev: prevChapter ? prevChapter.number : null,
|
||||
next: nextChapter ? nextChapter.number : null,
|
||||
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
|
||||
sessionId: locals.sessionId,
|
||||
isPreview: false,
|
||||
lang,
|
||||
translationStatus: 'done',
|
||||
isPro: locals.isPro,
|
||||
chapterImageUrl
|
||||
};
|
||||
}
|
||||
// 404 = not generated yet — fall through to original, UI can trigger generation
|
||||
@@ -193,6 +203,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
isPreview: false,
|
||||
lang: useTranslation ? lang : '',
|
||||
translationStatus,
|
||||
isPro: locals.isPro
|
||||
isPro: locals.isPro,
|
||||
chapterImageUrl
|
||||
};
|
||||
};
|
||||
|
||||
@@ -553,7 +553,19 @@
|
||||
<div class="text-(--color-muted) text-center py-16">
|
||||
<p>{fetchError || m.reader_audio_error()}</p>
|
||||
</div>
|
||||
{:else if layout.readMode === 'paginated'}
|
||||
{:else}
|
||||
<!-- Chapter illustration hero (if generated, hidden in focus mode) -->
|
||||
{#if data.chapterImageUrl && !layout.focusMode}
|
||||
<div class="mt-4 mb-6 -mx-4 sm:mx-0 sm:rounded-xl overflow-hidden">
|
||||
<img
|
||||
src={data.chapterImageUrl}
|
||||
alt="Chapter {data.chapter.number} illustration"
|
||||
class="w-full object-cover max-h-72 sm:max-h-96"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if layout.readMode === 'paginated'}
|
||||
<!-- ── Paginated reader ───────────────────────────────────────────── -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
@@ -611,6 +623,7 @@
|
||||
{@html html}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- ── Bottom navigation + comments (hidden in focus mode) ───────────────── -->
|
||||
{#if !layout.focusMode}
|
||||
|
||||
Reference in New Issue
Block a user