feat: admin UX overhaul — status filters, retry/cancel, mobile cards, i18n, shelf pre-populate
Some checks failed
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Failing after 32s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped

- Admin layout: SVG icons, active highlight, divider between nav sections
- Scrape page: status filter pills with counts, text + status combined search
- Audio page: status filter pills, cancel jobs, retry failed jobs, mobile cards for cache tab
- Translation page: status filter pills (incl. cancelled), cancel + retry jobs, mobile cancel/retry cards, i18n for all labels
- AI Jobs page: fix concurrent cancel (Set instead of single slot), per-job cancel errors inline, full mobile card layout, i18n title/heading
- Text-gen page: tagline editable input + copy, warnings copy, i18n title/heading
- Book page: chapter cover Save button, audio monitor link, currentShelf pre-populated from server
- pocketbase.ts: add getBookShelf(), shelf field on UserLibraryEntry
- New API route: POST /api/admin/translation/bulk (proxy for translation retry)
- i18n: 15 new admin_translation_*, admin_ai_jobs_*, admin_text_gen_* keys across all 5 locales
This commit is contained in:
root
2026-04-08 18:30:35 +05:00
parent 320f9fc76b
commit e399b1ce01
33 changed files with 2112 additions and 545 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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]

View File

@@ -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": ""
}
]

View File

@@ -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": ""
}
]

View File

@@ -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": ""
}
]

View File

@@ -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": ""
}
]
}
]
}
}