Compare commits

..

9 Commits

Author SHA1 Message Date
Marcel
3a0290ab35 ci(observability): inject GRAFANA_DB_PASSWORD from Gitea secrets
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m29s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m34s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
Wires the new GRAFANA_DB_PASSWORD secret through the deploy pipeline:

- docker-compose.prod.yml: backend env now passes GRAFANA_DB_PASSWORD
  through so Flyway V68 can resolve the ${grafanaDbPassword} placeholder
  in production and staging (it already worked in local dev via
  docker-compose.yml).
- release.yml + nightly.yml: declare GRAFANA_DB_PASSWORD as a required
  Gitea secret, write it into .env.production / .env.staging (consumed
  by archive-backend), and into /opt/familienarchiv/obs-secrets.env
  (consumed by obs-grafana's PostgreSQL datasource).

Operator action before the next deploy: add a GRAFANA_DB_PASSWORD value
to the Gitea repo secrets (openssl rand -hex 32).

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:44:19 +02:00
Marcel
5d191b2286 docs(architecture): show Grafana→PostgreSQL link for PO Overview dashboard
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m31s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m32s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
Adds the new read-only connection from Grafana to archive-db (via the
grafana_reader role) introduced by the PO Overview dashboard.

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:27:06 +02:00
Marcel
2fa1ce3eb8 docs(deployment): document GRAFANA_DB_PASSWORD across env tables
Adds GRAFANA_DB_PASSWORD to the observability-stack env-var table, the
Gitea secrets table, and the obs-secrets.env reference, so operators see
the variable wherever they look for related secrets.

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:26:34 +02:00
Marcel
99c9612a35 feat(observability): add PO Overview Grafana dashboard
Provisioned dashboard for the product owner's weekly check-in: system
health (Prometheus + Loki), user activity (PostgreSQL audit_log), archive
progress (PostgreSQL transcription_blocks + audit_log), and OCR quality
(Prometheus ocr-service metrics). Default range 7d, manual refresh,
thresholds per the issue spec.

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:25:41 +02:00
Marcel
93eed612bc chore(observability): document GRAFANA_DB_PASSWORD in env files
.env.example: declare GRAFANA_DB_PASSWORD with an openssl rand -hex 32 hint
so a missing value fails loudly (NFR-OPS-02). obs.env: add a comment
explaining that the real value comes from CI's obs-secrets.env, matching
the pattern used for other secrets in that file.

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:23:09 +02:00
Marcel
336ef20bd9 feat(observability): provision Grafana PostgreSQL datasource
Adds a read-only datasource pointing at archive-db using the grafana_reader
role (provisioned by Flyway V68). The password is interpolated from the
GRAFANA_DB_PASSWORD env var passed to obs-grafana, and the connection is
locked to editable: false so the credential cannot be inspected via the UI.

sslmode=disable is intentional: traffic stays inside archiv-net.

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:22:35 +02:00
Marcel
1564ffeaad feat(observability): pass GRAFANA_DB_PASSWORD to archive-backend
Flyway runs inside the backend container at startup; V68's
${grafanaDbPassword} placeholder is resolved from this env var.

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:22:02 +02:00
Marcel
f9d4d9a252 feat(observability): wire obs-grafana to archive-db and inject GRAFANA_DB_PASSWORD
obs-grafana now joins archiv-net so it can resolve archive-db:5432 for the
PO Overview dashboard's PostgreSQL datasource, and receives GRAFANA_DB_PASSWORD
so provisioning can interpolate it into the datasource config.

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:21:25 +02:00
Marcel
f0b801f171 feat(observability): create grafana_reader read-only DB role
Add Flyway V68 migration that provisions a read-only PostgreSQL role
scoped to audit_log, documents, and transcription_blocks. The role's
password is injected via the new ${grafanaDbPassword} Flyway placeholder,
which FlywayConfig reads from the GRAFANA_DB_PASSWORD env var. The
migration is idempotent: CREATE on first run, ALTER on re-run.

Adds a Testcontainers integration test asserting positive grants on the
three intended tables and a negative grant on app_users (NFR-SEC-01).

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:20:39 +02:00
29 changed files with 1106 additions and 713 deletions

View File

@@ -39,6 +39,12 @@ PORT_PROMETHEUS=9090
# Grafana admin password — change this before exposing Grafana beyond localhost # Grafana admin password — change this before exposing Grafana beyond localhost
GRAFANA_ADMIN_PASSWORD=changeme GRAFANA_ADMIN_PASSWORD=changeme
# Password for the read-only grafana_reader PostgreSQL role used by the PO
# Overview dashboard. Consumed by Flyway V68 (to set the role's password) and
# by Grafana's PostgreSQL datasource (to connect). REQUIRED in production —
# generate with: openssl rand -hex 32
GRAFANA_DB_PASSWORD=changeme-generate-with-openssl-rand-hex-32
# GlitchTip domain — production: use https://glitchtip.archiv.raddatz.cloud (must match Caddy vhost) # GlitchTip domain — production: use https://glitchtip.archiv.raddatz.cloud (must match Caddy vhost)
GLITCHTIP_DOMAIN=http://localhost:3002 GLITCHTIP_DOMAIN=http://localhost:3002

View File

@@ -31,6 +31,7 @@ name: nightly
# STAGING_APP_ADMIN_USERNAME # STAGING_APP_ADMIN_USERNAME
# STAGING_APP_ADMIN_PASSWORD # STAGING_APP_ADMIN_PASSWORD
# GRAFANA_ADMIN_PASSWORD # GRAFANA_ADMIN_PASSWORD
# GRAFANA_DB_PASSWORD (read-only grafana_reader DB role, issue #651)
# GLITCHTIP_SECRET_KEY # GLITCHTIP_SECRET_KEY
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled) # SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
@@ -79,6 +80,7 @@ jobs:
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
POSTGRES_USER=archiv POSTGRES_USER=archiv
SENTRY_DSN=${{ secrets.SENTRY_DSN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }}
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
EOF EOF
- name: Verify backend /import:ro mount is wired - name: Verify backend /import:ro mount is wired
@@ -142,6 +144,7 @@ jobs:
cp docker-compose.observability.yml /opt/familienarchiv/ cp docker-compose.observability.yml /opt/familienarchiv/
cat > /opt/familienarchiv/obs-secrets.env <<'EOF' cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }} GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }} POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
POSTGRES_HOST=archiv-staging-db-1 POSTGRES_HOST=archiv-staging-db-1

View File

@@ -35,6 +35,7 @@ name: release
# MAIL_USERNAME # MAIL_USERNAME
# MAIL_PASSWORD # MAIL_PASSWORD
# GRAFANA_ADMIN_PASSWORD # GRAFANA_ADMIN_PASSWORD
# GRAFANA_DB_PASSWORD (read-only grafana_reader DB role, issue #651)
# GLITCHTIP_SECRET_KEY # GLITCHTIP_SECRET_KEY
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled) # SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
@@ -77,6 +78,7 @@ jobs:
IMPORT_HOST_DIR=/srv/familienarchiv-production/import IMPORT_HOST_DIR=/srv/familienarchiv-production/import
POSTGRES_USER=archiv POSTGRES_USER=archiv
SENTRY_DSN=${{ secrets.SENTRY_DSN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }}
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
EOF EOF
- name: Build images - name: Build images
@@ -110,6 +112,7 @@ jobs:
cp docker-compose.observability.yml /opt/familienarchiv/ cp docker-compose.observability.yml /opt/familienarchiv/
cat > /opt/familienarchiv/obs-secrets.env <<'EOF' cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }} GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }} POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
POSTGRES_HOST=archiv-production-db-1 POSTGRES_HOST=archiv-production-db-1

View File

@@ -7,12 +7,15 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource; import javax.sql.DataSource;
import java.util.Map;
@Configuration @Configuration
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class FlywayConfig { public class FlywayConfig {
private static final String GRAFANA_DB_PASSWORD_FALLBACK = "changeme-grafana-db-password";
private final DataSource dataSource; private final DataSource dataSource;
@Bean(name = "flyway") @Bean(name = "flyway")
@@ -21,6 +24,7 @@ public class FlywayConfig {
Flyway flyway = Flyway.configure() Flyway flyway = Flyway.configure()
.dataSource(dataSource) .dataSource(dataSource)
.locations("classpath:db/migration") .locations("classpath:db/migration")
.placeholders(Map.of("grafanaDbPassword", resolveGrafanaDbPassword()))
.baselineOnMigrate(true) .baselineOnMigrate(true)
.baselineVersion("4") .baselineVersion("4")
.load(); .load();
@@ -28,4 +32,14 @@ public class FlywayConfig {
log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted); log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted);
return flyway; return flyway;
} }
private String resolveGrafanaDbPassword() {
String value = System.getenv("GRAFANA_DB_PASSWORD");
if (value == null || value.isBlank()) {
log.warn("GRAFANA_DB_PASSWORD is not set; the grafana_reader role will use a non-secret fallback. "
+ "Set GRAFANA_DB_PASSWORD in production to enable the Grafana PostgreSQL datasource.");
return GRAFANA_DB_PASSWORD_FALLBACK;
}
return value;
}
} }

View File

@@ -0,0 +1,17 @@
-- Read-only role used by the Grafana PostgreSQL datasource for the PO Overview
-- dashboard (issue #651). Password is injected at migration time via the Flyway
-- placeholder ${grafanaDbPassword}, supplied by FlywayConfig from the
-- GRAFANA_DB_PASSWORD environment variable.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'grafana_reader') THEN
EXECUTE format('CREATE ROLE grafana_reader WITH LOGIN PASSWORD %L', '${grafanaDbPassword}');
ELSE
EXECUTE format('ALTER ROLE grafana_reader WITH LOGIN PASSWORD %L', '${grafanaDbPassword}');
END IF;
END
$$;
GRANT CONNECT ON DATABASE ${flyway:database} TO grafana_reader;
GRANT USAGE ON SCHEMA public TO grafana_reader;
GRANT SELECT ON audit_log, documents, transcription_blocks TO grafana_reader;

View File

@@ -0,0 +1,47 @@
package org.raddatz.familienarchiv.config;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class GrafanaReaderRoleIntegrationTest {
@Autowired JdbcTemplate jdbc;
@Test
void grafana_reader_has_select_on_audit_log() {
assertThat(hasSelect("audit_log")).isTrue();
}
@Test
void grafana_reader_has_select_on_documents() {
assertThat(hasSelect("documents")).isTrue();
}
@Test
void grafana_reader_has_select_on_transcription_blocks() {
assertThat(hasSelect("transcription_blocks")).isTrue();
}
@Test
void grafana_reader_has_no_select_on_app_users() {
assertThat(hasSelect("app_users")).isFalse();
}
private boolean hasSelect(String table) {
Boolean result = jdbc.queryForObject(
"SELECT has_table_privilege('grafana_reader', ?, 'SELECT')",
Boolean.class,
table);
return Boolean.TRUE.equals(result);
}
}

View File

@@ -147,6 +147,9 @@ services:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme} GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme}
GF_USERS_ALLOW_SIGN_UP: "false" GF_USERS_ALLOW_SIGN_UP: "false"
GF_SERVER_ROOT_URL: ${GF_SERVER_ROOT_URL:-http://localhost:3003} GF_SERVER_ROOT_URL: ${GF_SERVER_ROOT_URL:-http://localhost:3003}
# Read-only password for the grafana_reader PostgreSQL role; interpolated
# into the provisioned PostgreSQL datasource (see datasources.yml).
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD}
volumes: volumes:
- grafana_data:/var/lib/grafana - grafana_data:/var/lib/grafana
- ./infra/observability/grafana/provisioning:/etc/grafana/provisioning:ro - ./infra/observability/grafana/provisioning:/etc/grafana/provisioning:ro
@@ -165,6 +168,7 @@ services:
condition: service_healthy condition: service_healthy
networks: networks:
- obs-net - obs-net
- archiv-net # PO Overview dashboard queries archive-db via the grafana_reader role
# --- Error Tracking: GlitchTip --- # --- Error Tracking: GlitchTip ---

View File

@@ -227,6 +227,9 @@ services:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv
SPRING_DATASOURCE_USERNAME: archiv SPRING_DATASOURCE_USERNAME: archiv
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
# Consumed by Flyway V68 via the ${grafanaDbPassword} placeholder to set
# the read-only grafana_reader role's password.
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD}
# Application uses the bucket-scoped service account, not MinIO root. # Application uses the bucket-scoped service account, not MinIO root.
S3_ENDPOINT: http://minio:9000 S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: archiv-app S3_ACCESS_KEY: archiv-app

View File

@@ -163,6 +163,9 @@ services:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB} SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER} SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
# Consumed by Flyway V68 via the ${grafanaDbPassword} placeholder to set
# the read-only grafana_reader role's password.
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD}
S3_ENDPOINT: http://minio:9000 S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: ${MINIO_ROOT_USER} S3_ACCESS_KEY: ${MINIO_ROOT_USER}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD} S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}

View File

@@ -152,6 +152,7 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
| `PORT_GRAFANA` | Host port for the Grafana UI (bound to `127.0.0.1` only) | `3003` | — | — | | `PORT_GRAFANA` | Host port for the Grafana UI (bound to `127.0.0.1` only) | `3003` | — | — |
| `POSTGRES_HOST` | PostgreSQL hostname for GlitchTip's db-init job and workers. Override when only the staging stack is running and `archive-db` is not resolvable by that name. | `archive-db` | — | — | | `POSTGRES_HOST` | PostgreSQL hostname for GlitchTip's db-init job and workers. Override when only the staging stack is running and `archive-db` is not resolvable by that name. | `archive-db` | — | — |
| `GRAFANA_ADMIN_PASSWORD` | Grafana `admin` user password | `changeme` | YES (prod) | YES | | `GRAFANA_ADMIN_PASSWORD` | Grafana `admin` user password | `changeme` | YES (prod) | YES |
| `GRAFANA_DB_PASSWORD` | Password for the read-only `grafana_reader` PostgreSQL role used by the PO Overview dashboard (issue #651). Consumed by Flyway V68 and the Grafana PostgreSQL datasource. Generate with `openssl rand -hex 32`. | — | YES (prod) | YES |
| `PORT_GLITCHTIP` | Host port for the GlitchTip UI (bound to `127.0.0.1` only) | `3002` | — | — | | `PORT_GLITCHTIP` | Host port for the GlitchTip UI (bound to `127.0.0.1` only) | `3002` | — | — |
| `GLITCHTIP_DOMAIN` | Public-facing base URL for GlitchTip (used in email links and CORS) | `http://localhost:3002` | YES (prod) | — | | `GLITCHTIP_DOMAIN` | Public-facing base URL for GlitchTip (used in email links and CORS) | `http://localhost:3002` | YES (prod) | — |
| `GLITCHTIP_SECRET_KEY` | Django secret key for GlitchTip — generate with `python3 -c "import secrets; print(secrets.token_hex(32))"` | — | YES | YES | | `GLITCHTIP_SECRET_KEY` | Django secret key for GlitchTip — generate with `python3 -c "import secrets; print(secrets.token_hex(32))"` | — | YES | YES |
@@ -256,6 +257,7 @@ git.raddatz.cloud A <server IP>
| `MAIL_USERNAME` | release.yml | SMTP user | | `MAIL_USERNAME` | release.yml | SMTP user |
| `MAIL_PASSWORD` | release.yml | SMTP password | | `MAIL_PASSWORD` | release.yml | SMTP password |
| `GRAFANA_ADMIN_PASSWORD` | both | Grafana `admin` login — generate a strong password | | `GRAFANA_ADMIN_PASSWORD` | both | Grafana `admin` login — generate a strong password |
| `GRAFANA_DB_PASSWORD` | both | Read-only `grafana_reader` role password — `openssl rand -hex 32` |
| `GLITCHTIP_SECRET_KEY` | both | Django secret key — `openssl rand -hex 32` | | `GLITCHTIP_SECRET_KEY` | both | Django secret key — `openssl rand -hex 32` |
| `SENTRY_DSN` | both | GlitchTip project DSN — set after first-run (§4); leave empty to keep Sentry disabled | | `SENTRY_DSN` | both | GlitchTip project DSN — set after first-run (§4); leave empty to keep Sentry disabled |
| `VITE_SENTRY_DSN` | both | GlitchTip frontend project DSN — set after first-run (§4); leave empty to keep Sentry disabled | | `VITE_SENTRY_DSN` | both | GlitchTip frontend project DSN — set after first-run (§4); leave empty to keep Sentry disabled |
@@ -357,6 +359,7 @@ Both files are passed explicitly via `--env-file` to the compose command, so the
| Gitea secret | Notes | | Gitea secret | Notes |
|---|---| |---|---|
| `GRAFANA_ADMIN_PASSWORD` | Strong unique password; shared by nightly and release | | `GRAFANA_ADMIN_PASSWORD` | Strong unique password; shared by nightly and release |
| `GRAFANA_DB_PASSWORD` | `openssl rand -hex 32`; shared by nightly and release — read-only DB role for the PO Overview dashboard |
| `GLITCHTIP_SECRET_KEY` | `openssl rand -hex 32`; shared by nightly and release | | `GLITCHTIP_SECRET_KEY` | `openssl rand -hex 32`; shared by nightly and release |
| `STAGING_POSTGRES_PASSWORD` / `PROD_POSTGRES_PASSWORD` | Must match the running PostgreSQL container | | `STAGING_POSTGRES_PASSWORD` / `PROD_POSTGRES_PASSWORD` | Must match the running PostgreSQL container |

View File

@@ -46,6 +46,7 @@ Rel(backend, tempo, "Sends distributed traces via OTLP", "HTTP / OTLP / port 431
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090") Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
Rel(grafana, loki, "Queries logs", "HTTP 3100") Rel(grafana, loki, "Queries logs", "HTTP 3100")
Rel(grafana, tempo, "Queries traces", "HTTP 3200") Rel(grafana, tempo, "Queries traces", "HTTP 3200")
Rel(grafana, db, "Read-only dashboard queries via grafana_reader role", "PostgreSQL / archiv-net")
Rel(glitchtip, db, "Stores error events in glitchtip DB", "PostgreSQL / archiv-net") Rel(glitchtip, db, "Stores error events in glitchtip DB", "PostgreSQL / archiv-net")
Rel(obs_glitchtip_worker, obs_redis, "Processes Celery tasks", "Redis / obs-net") Rel(obs_glitchtip_worker, obs_redis, "Processes Celery tasks", "Redis / obs-net")

View File

@@ -522,7 +522,6 @@
"notification_filter_unread": "Ungelesen", "notification_filter_unread": "Ungelesen",
"notification_filter_mention": "Erwähnung", "notification_filter_mention": "Erwähnung",
"notification_filter_reply": "Antwort", "notification_filter_reply": "Antwort",
"notification_error_generic": "Aktion fehlgeschlagen. Bitte versuche es erneut.",
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren", "notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
"notification_load_more": "Ältere laden", "notification_load_more": "Ältere laden",
"notification_empty_history": "Keine Benachrichtigungen", "notification_empty_history": "Keine Benachrichtigungen",

View File

@@ -522,7 +522,6 @@
"notification_filter_unread": "Unread", "notification_filter_unread": "Unread",
"notification_filter_mention": "Mention", "notification_filter_mention": "Mention",
"notification_filter_reply": "Reply", "notification_filter_reply": "Reply",
"notification_error_generic": "Action failed. Please try again.",
"notification_mark_all_read_aria": "Mark all notifications as read", "notification_mark_all_read_aria": "Mark all notifications as read",
"notification_load_more": "Load older", "notification_load_more": "Load older",
"notification_empty_history": "No notifications", "notification_empty_history": "No notifications",

View File

@@ -522,7 +522,6 @@
"notification_filter_unread": "No leídas", "notification_filter_unread": "No leídas",
"notification_filter_mention": "Mención", "notification_filter_mention": "Mención",
"notification_filter_reply": "Respuesta", "notification_filter_reply": "Respuesta",
"notification_error_generic": "La acción ha fallado. Por favor, inténtalo de nuevo.",
"notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas", "notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas",
"notification_load_more": "Cargar anteriores", "notification_load_more": "Cargar anteriores",
"notification_empty_history": "Sin notificaciones", "notification_empty_history": "Sin notificaciones",

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import * as m from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/shared/utils/time'; import { relativeTime } from '$lib/shared/utils/time';
import type { NotificationItem } from '$lib/notification/notifications.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte';
@@ -7,13 +6,11 @@ import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
interface Props { interface Props {
unread: NotificationItem[]; unread: NotificationItem[];
optimisticMarkRead: (id: string) => void; onMarkRead: (n: NotificationItem) => void;
optimisticMarkAllRead: () => void; onMarkAllRead: () => void;
} }
const { unread, optimisticMarkRead, optimisticMarkAllRead }: Props = $props(); const { unread, onMarkRead, onMarkAllRead }: Props = $props();
let errorMessage: string | null = $state(null);
function verb(type: NotificationItem['type'], actor: string): string { function verb(type: NotificationItem['type'], actor: string): string {
return type === 'REPLY' return type === 'REPLY'
@@ -27,9 +24,6 @@ function href(n: NotificationItem): string {
</script> </script>
<section class="rounded-sm border border-line bg-surface p-5"> <section class="rounded-sm border border-line bg-surface p-5">
{#if errorMessage}
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
{/if}
{#if unread.length === 0} {#if unread.length === 0}
<div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center"> <div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center">
<svg <svg
@@ -72,28 +66,14 @@ function href(n: NotificationItem): string {
{m.chronik_for_you_count({ count: unread.length })} {m.chronik_for_you_count({ count: unread.length })}
</span> </span>
</div> </div>
<form <button
action="/aktivitaeten?/mark-all-read" type="button"
method="POST" data-testid="chronik-mark-all-read"
use:enhance={() => { onclick={onMarkAllRead}
errorMessage = null; class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
optimisticMarkAllRead();
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
}
};
}}
> >
<button {m.chronik_mark_all_read()}
type="submit" </button>
data-testid="chronik-mark-all-read"
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.chronik_mark_all_read()}
</button>
</form>
</div> </div>
<ul role="list" class="flex flex-col gap-2"> <ul role="list" class="flex flex-col gap-2">
@@ -109,7 +89,7 @@ function href(n: NotificationItem): string {
aria-hidden="true" aria-hidden="true"
class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent" class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent"
> >
{n.type === 'MENTION' ? '@' : ''} {n.type === 'MENTION' ? '@' : '\u21A9'}
</span> </span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="font-sans text-sm leading-snug text-ink"> <p class="font-sans text-sm leading-snug text-ink">
@@ -120,40 +100,25 @@ function href(n: NotificationItem): string {
</p> </p>
</div> </div>
</a> </a>
<form <button
action="/aktivitaeten?/dismiss-notification" type="button"
method="POST" data-testid="chronik-fuerdich-dismiss"
use:enhance={() => { aria-label={m.chronik_mark_read_aria()}
errorMessage = null; onclick={() => onMarkRead(n)}
optimisticMarkRead(n.id); class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
}
};
}}
> >
<input type="hidden" name="notificationId" value={n.id} /> <svg
<button xmlns="http://www.w3.org/2000/svg"
type="submit" class="h-4 w-4"
data-testid="chronik-fuerdich-dismiss" fill="none"
aria-label={m.chronik_mark_read_aria()} viewBox="0 0 24 24"
class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none" stroke="currentColor"
stroke-width="2"
aria-hidden="true"
> >
<svg <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
xmlns="http://www.w3.org/2000/svg" </svg>
class="h-4 w-4" </button>
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</form>
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@@ -5,36 +5,7 @@ import { page, userEvent } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte';
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); afterEach(cleanup);
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await (
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
)({ result: mockFormResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => {
cleanup();
mockFormResult.type = 'success';
});
function notif(partial: Partial<NotificationItem>): NotificationItem { function notif(partial: Partial<NotificationItem>): NotificationItem {
return { return {
@@ -55,8 +26,8 @@ describe('ChronikFuerDichBox', () => {
it('renders inbox-zero state when there are no unread items', async () => { it('renders inbox-zero state when there are no unread items', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [], unread: [],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]'); const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
expect(zero).not.toBeNull(); expect(zero).not.toBeNull();
@@ -66,8 +37,8 @@ describe('ChronikFuerDichBox', () => {
it('links to the archived mentions in the inbox-zero state', async () => { it('links to the archived mentions in the inbox-zero state', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [], unread: [],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]'); const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
expect(link).not.toBeNull(); expect(link).not.toBeNull();
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
it('renders the count badge with correct total when unread exists', async () => { it('renders the count badge with correct total when unread exists', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' }), notif({ id: 'b' })], unread: [notif({ id: 'a' }), notif({ id: 'b' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
await expect.element(page.getByText('2 neu')).toBeInTheDocument(); await expect.element(page.getByText('2 neu')).toBeInTheDocument();
}); });
@@ -85,8 +56,8 @@ describe('ChronikFuerDichBox', () => {
it('count badge has aria-live=polite when unread exists', async () => { it('count badge has aria-live=polite when unread exists', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })], unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
// Wait for render // Wait for render
await expect.element(page.getByText('1 neu')).toBeInTheDocument(); await expect.element(page.getByText('1 neu')).toBeInTheDocument();
@@ -98,8 +69,8 @@ describe('ChronikFuerDichBox', () => {
it('does not render the "Alle gelesen" button when there are no unread items', async () => { it('does not render the "Alle gelesen" button when there are no unread items', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [], unread: [],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument(); await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
const all = document.querySelector('[data-testid="chronik-mark-all-read"]'); const all = document.querySelector('[data-testid="chronik-mark-all-read"]');
@@ -109,38 +80,38 @@ describe('ChronikFuerDichBox', () => {
it('renders the "Alle gelesen" button when unread exists', async () => { it('renders the "Alle gelesen" button when unread exists', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })], unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument(); await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
}); });
it('calls optimisticMarkAllRead when the "Alle gelesen" button is submitted', async () => { it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => {
const optimisticMarkAllRead = vi.fn(); const onMarkAllRead = vi.fn();
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })], unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead onMarkAllRead
}); });
await userEvent.click(page.getByText('Alle gelesen')); await userEvent.click(page.getByText('Alle gelesen'));
expect(optimisticMarkAllRead).toHaveBeenCalledTimes(1); expect(onMarkAllRead).toHaveBeenCalledTimes(1);
}); });
it('calls optimisticMarkRead with the notification id when its dismiss button is submitted', async () => { it('calls onMarkRead (and not navigation) when a per-item Dismiss button is clicked', async () => {
const optimisticMarkRead = vi.fn(); const onMarkRead = vi.fn();
const n = notif({ id: 'xyz' }); const n = notif({ id: 'xyz' });
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [n], unread: [n],
optimisticMarkRead, onMarkRead,
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const dismiss = document.querySelector( const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]' '[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLButtonElement | null; ) as HTMLButtonElement | null;
expect(dismiss).not.toBeNull(); expect(dismiss).not.toBeNull();
dismiss?.click(); dismiss?.click();
expect(optimisticMarkRead).toHaveBeenCalledTimes(1); expect(onMarkRead).toHaveBeenCalledTimes(1);
expect(optimisticMarkRead.mock.calls[0][0]).toBe('xyz'); expect(onMarkRead.mock.calls[0][0]).toEqual(n);
}); });
it('mention row href includes both commentId and annotationId when annotationId is present', async () => { it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
@@ -153,8 +124,8 @@ describe('ChronikFuerDichBox', () => {
annotationId: 'annot-9' annotationId: 'annot-9'
}) })
], ],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const link = document.querySelector( const link = document.querySelector(
'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]' 'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]'
@@ -165,8 +136,8 @@ describe('ChronikFuerDichBox', () => {
it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => { it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'x' })], unread: [notif({ id: 'x' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]'); const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
expect(dismiss).not.toBeNull(); expect(dismiss).not.toBeNull();
@@ -174,22 +145,4 @@ describe('ChronikFuerDichBox', () => {
// Prevents the senior-audience tap-drag bug flagged by Leonie. // Prevents the senior-audience tap-drag bug flagged by Leonie.
expect(dismiss?.closest('a')).toBeNull(); expect(dismiss?.closest('a')).toBeNull();
}); });
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
render(ChronikFuerDichBox, {
unread: [notif({ id: 'err-1' })],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
});
const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLButtonElement | null;
expect(dismiss).not.toBeNull();
dismiss?.click();
// Allow microtask queue to flush
await new Promise((r) => setTimeout(r, 0));
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
});
}); });

View File

@@ -4,36 +4,7 @@ import { page } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications'; import type { NotificationItem } from '$lib/notification/notifications';
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); afterEach(cleanup);
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await (
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
)({ result: mockFormResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => {
cleanup();
mockFormResult.type = 'success';
});
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
id: 'n-1', id: 'n-1',
@@ -51,7 +22,7 @@ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem =>
describe('ChronikFuerDichBox', () => { describe('ChronikFuerDichBox', () => {
it('renders the inbox-zero state when there are no unread', async () => { it('renders the inbox-zero state when there are no unread', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { unread: [], optimisticMarkRead: () => {}, optimisticMarkAllRead: () => {} } props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} }
}); });
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible(); await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
@@ -63,8 +34,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })], unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })], unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -91,8 +62,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ actorName: 'Bertha' })], unread: [mention({ actorName: 'Bertha' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -105,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ type: 'REPLY', actorName: 'Carl' })], unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -115,11 +86,11 @@ describe('ChronikFuerDichBox', () => {
.toBeVisible(); .toBeVisible();
}); });
it('calls optimisticMarkRead with the notification id when its dismiss button is clicked', async () => { it('calls onMarkRead with the notification when its dismiss button is clicked', async () => {
const optimisticMarkRead = vi.fn(); const onMarkRead = vi.fn();
const item = mention({ id: 'n-7' }); const item = mention({ id: 'n-7' });
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { unread: [item], optimisticMarkRead, optimisticMarkAllRead: () => {} } props: { unread: [item], onMarkRead, onMarkAllRead: () => {} }
}); });
const dismiss = document.querySelector( const dismiss = document.querySelector(
@@ -127,55 +98,35 @@ describe('ChronikFuerDichBox', () => {
) as HTMLElement; ) as HTMLElement;
dismiss.click(); dismiss.click();
expect(optimisticMarkRead).toHaveBeenCalledWith('n-7'); expect(onMarkRead).toHaveBeenCalledWith(item);
}); });
it('calls optimisticMarkAllRead when the mark-all-read button is clicked', async () => { it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
const optimisticMarkAllRead = vi.fn(); const onMarkAllRead = vi.fn();
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention()], unread: [mention()],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead onMarkAllRead
} }
}); });
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement; const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
btn.click(); btn.click();
expect(optimisticMarkAllRead).toHaveBeenCalledOnce(); expect(onMarkAllRead).toHaveBeenCalledOnce();
}); });
it('builds a deep-link href to the comment for each notification', async () => { it('builds a deep-link href to the comment for each notification', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })], unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement; const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
expect(link.getAttribute('href')).toContain('doc-x'); expect(link.getAttribute('href')).toContain('doc-x');
}); });
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
render(ChronikFuerDichBox, {
props: {
unread: [mention({ id: 'err-1' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {}
}
});
const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLElement;
dismiss.click();
// Allow microtask queue to flush
await new Promise((r) => setTimeout(r, 0));
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
});
}); });

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside'; import { clickOutside } from '$lib/shared/actions/clickOutside';
import { notificationStore } from '$lib/notification/notifications.svelte'; import { notificationStore } from '$lib/notification/notifications.svelte';
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
import NotificationDropdown from './NotificationDropdown.svelte'; import NotificationDropdown from './NotificationDropdown.svelte';
let open = $state(false); let open = $state(false);
@@ -28,6 +30,17 @@ function closeDropdown() {
bellButtonEl?.focus(); bellButtonEl?.focus();
} }
async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
await stream.markRead(notification);
const url = buildCommentHref(
notification.documentId,
notification.referenceId,
notification.annotationId
);
closeDropdown();
goto(url);
}
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && open) { if (event.key === 'Escape' && open) {
event.stopPropagation(); event.stopPropagation();
@@ -100,8 +113,8 @@ onDestroy(() => {
{#if open} {#if open}
<NotificationDropdown <NotificationDropdown
notifications={stream.notifications} notifications={stream.notifications}
optimisticMarkRead={stream.optimisticMarkRead} onMarkRead={handleMarkRead}
optimisticMarkAllRead={stream.optimisticMarkAllRead} onMarkAllRead={stream.markAllRead}
onClose={closeDropdown} onClose={closeDropdown}
/> />
{/if} {/if}

View File

@@ -3,18 +3,10 @@ import { cleanup, render } from 'vitest-browser-svelte';
import type { NotificationItem } from '$lib/notification/notifications'; import type { NotificationItem } from '$lib/notification/notifications';
import NotificationBell from './NotificationBell.svelte'; import NotificationBell from './NotificationBell.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() })); const gotoMock = vi.hoisted(() => vi.fn());
vi.mock('$app/forms', () => ({ vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() }));
enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) {
const handler = (e: Event) => {
e.preventDefault();
submit?.({ formData: new FormData(node) } as never);
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
const mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] })); const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
vi.mock('$lib/notification/notifications.svelte', () => ({ vi.mock('$lib/notification/notifications.svelte', () => ({
@@ -25,17 +17,18 @@ vi.mock('$lib/notification/notifications.svelte', () => ({
get unreadCount() { get unreadCount() {
return mockNotificationList.value.length; return mockNotificationList.value.length;
}, },
optimisticMarkRead: vi.fn(), markRead: mockMarkRead,
optimisticMarkAllRead: vi.fn(),
fetchNotifications: vi.fn().mockResolvedValue(undefined), fetchNotifications: vi.fn().mockResolvedValue(undefined),
init: vi.fn(), init: vi.fn(),
destroy: vi.fn() destroy: vi.fn(),
markAllRead: vi.fn()
} }
})); }));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); gotoMock.mockClear();
mockMarkRead.mockClear();
mockNotificationList.value = []; mockNotificationList.value = [];
}); });
@@ -52,6 +45,16 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
...overrides ...overrides
}); });
async function openDropdownAndClickFirstNotification() {
const bellButton = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
bellButton.click();
await vi.waitFor(() => {
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
});
const notifButton = document.querySelector<HTMLButtonElement>('[role="list"] button')!;
notifButton.click();
}
describe('NotificationBell — cursor and tooltip', () => { describe('NotificationBell — cursor and tooltip', () => {
it('bell button has cursor-pointer class', async () => { it('bell button has cursor-pointer class', async () => {
render(NotificationBell); render(NotificationBell);
@@ -79,3 +82,29 @@ describe('NotificationBell — cursor and tooltip', () => {
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title')); expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
}); });
}); });
describe('NotificationBell', () => {
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
render(NotificationBell);
await openDropdownAndClickFirstNotification();
await vi.waitFor(() => {
expect(gotoMock).toHaveBeenCalledWith(
'/documents/doc-1?commentId=ref-1&annotationId=annot-1'
);
});
});
it('handleMarkRead navigates to commentId-only URL when annotationId is absent', async () => {
mockNotificationList.value = [makeNotification({ annotationId: null })];
render(NotificationBell);
await openDropdownAndClickFirstNotification();
await vi.waitFor(() => {
expect(gotoMock).toHaveBeenCalledWith('/documents/doc-1?commentId=ref-1');
});
});
});

View File

@@ -1,21 +1,17 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/shared/utils/time'; import { relativeTime } from '$lib/shared/utils/time';
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
import type { NotificationItem } from '$lib/notification/notifications.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte';
type Props = { type Props = {
notifications: NotificationItem[]; notifications: NotificationItem[];
optimisticMarkRead: (id: string) => void; onMarkRead: (notification: NotificationItem) => void;
optimisticMarkAllRead: () => void; onMarkAllRead: () => void;
onClose: () => void; onClose: () => void;
}; };
let { notifications, optimisticMarkRead, optimisticMarkAllRead, onClose }: Props = $props(); let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
let errorMessage = $state<string | null>(null);
function handleViewAll() { function handleViewAll() {
onClose(); // close first — avoids stale dropdown during navigation transition onClose(); // close first — avoids stale dropdown during navigation transition
@@ -35,35 +31,16 @@ function handleViewAll() {
{m.notification_bell_label()} {m.notification_bell_label()}
</span> </span>
{#if notifications.length > 0} {#if notifications.length > 0}
<form <button
action="/aktivitaeten?/mark-all-read" type="button"
method="POST" onclick={onMarkAllRead}
use:enhance={() => { class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
errorMessage = null;
optimisticMarkAllRead();
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
}
};
}}
> >
<button {m.notification_mark_all_read()}
type="submit" </button>
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.notification_mark_all_read()}
</button>
</form>
{/if} {/if}
</div> </div>
<!-- Error banner (shown when a dismiss or mark-all action fails) -->
{#if errorMessage}
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
{/if}
<!-- Notification list --> <!-- Notification list -->
{#if notifications.length === 0} {#if notifications.length === 0}
<!-- Empty state --> <!-- Empty state -->
@@ -89,93 +66,67 @@ function handleViewAll() {
<ul role="list" class="max-h-[24rem] overflow-y-auto"> <ul role="list" class="max-h-[24rem] overflow-y-auto">
{#each notifications as notification (notification.id)} {#each notifications as notification (notification.id)}
<li> <li>
<form <button
action="/aktivitaeten?/dismiss-notification" type="button"
method="POST" onclick={() => onMarkRead(notification)}
class="contents" class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
use:enhance={() => { {!notification.read ? 'bg-accent-bg/20' : ''}"
errorMessage = null;
optimisticMarkRead(notification.id);
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
} else {
// Navigate away — no need to update the store since we're leaving the page
onClose();
goto(
buildCommentHref(
notification.documentId,
notification.referenceId,
notification.annotationId
)
);
}
};
}}
> >
<input type="hidden" name="notificationId" value={notification.id} /> <!-- Type icon -->
<button <span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
type="submit" {#if notification.type === 'REPLY'}
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3.5 text-left last:border-b-0 hover:bg-canvas <!-- Reply icon -->
{!notification.read ? 'bg-accent-bg/20' : ''}" <svg
> xmlns="http://www.w3.org/2000/svg"
<!-- Type icon --> class="h-4 w-4"
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true"> fill="none"
{#if notification.type === 'REPLY'} viewBox="0 0 24 24"
<!-- Reply icon --> stroke="currentColor"
<svg stroke-width="2"
xmlns="http://www.w3.org/2000/svg" >
class="h-4 w-4" <path
fill="none" stroke-linecap="round"
viewBox="0 0 24 24" stroke-linejoin="round"
stroke="currentColor" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
stroke-width="2" />
> </svg>
<path {:else}
stroke-linecap="round" <!-- Mention icon -->
stroke-linejoin="round" <svg
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" xmlns="http://www.w3.org/2000/svg"
/> class="h-4 w-4"
</svg> fill="none"
{:else} viewBox="0 0 24 24"
<!-- Mention icon --> stroke="currentColor"
<svg stroke-width="2"
xmlns="http://www.w3.org/2000/svg" >
class="h-4 w-4" <path
fill="none" stroke-linecap="round"
viewBox="0 0 24 24" stroke-linejoin="round"
stroke="currentColor" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
stroke-width="2" />
> </svg>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
/>
</svg>
{/if}
</span>
<!-- Text + time -->
<div class="min-w-0 flex-1">
<p class="text-sm leading-snug text-ink">
{notification.type === 'REPLY'
? m.notification_type_reply({ actor: notification.actorName })
: m.notification_type_mention({ actor: notification.actorName })}
</p>
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
</div>
<!-- Unread dot -->
{#if !notification.read}
<span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
aria-label={m.notification_unread()}
></span>
{/if} {/if}
</button> </span>
</form>
<!-- Text + time -->
<div class="min-w-0 flex-1">
<p class="text-sm leading-snug text-ink">
{notification.type === 'REPLY'
? m.notification_type_reply({ actor: notification.actorName })
: m.notification_type_mention({ actor: notification.actorName })}
</p>
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
</div>
<!-- Unread dot -->
{#if !notification.read}
<span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
aria-label={m.notification_unread()}
></span>
{/if}
</button>
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@@ -6,38 +6,9 @@ import NotificationDropdown from './NotificationDropdown.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
// Configurable result for the enhance mock — tests that need failure set
// mockFormResult.type = 'failure' before clicking.
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
// Invoke the SubmitFunction and always call the returned result callback with
// mockFormResult so tests can exercise both success and failure branches.
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await cb({ result: mockFormResult, update: async () => {} } as never);
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
mockFormResult.type = 'success'; // reset to default after each test
}); });
const makeNotification = (overrides: Record<string, unknown> = {}) => ({ const makeNotification = (overrides: Record<string, unknown> = {}) => ({
@@ -58,8 +29,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -71,8 +42,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -84,8 +55,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -99,8 +70,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification()], notifications: [makeNotification()],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -112,8 +83,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })], notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -127,8 +98,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })], notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -145,8 +116,8 @@ describe('NotificationDropdown', () => {
makeNotification({ id: 'n1', read: false }), makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: true }) makeNotification({ id: 'n2', read: true })
], ],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -155,100 +126,37 @@ describe('NotificationDropdown', () => {
expect(unreadDots.length).toBe(1); expect(unreadDots.length).toBe(1);
}); });
it('each notification row is wrapped in a form posting to the dismiss action', async () => { it('calls onMarkRead with the notification when an item is clicked', async () => {
render(NotificationDropdown, { const onMarkRead = vi.fn();
props: {
notifications: [makeNotification({ id: 'n42' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
const form = document.querySelector('form[action="/aktivitaeten?/dismiss-notification"]');
expect(form).not.toBeNull();
expect(form?.getAttribute('method')).toBe('POST');
});
it('the dismiss form has a hidden notificationId input with the notification id', async () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ id: 'n42' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
const input = document.querySelector<HTMLInputElement>(
'form[action="/aktivitaeten?/dismiss-notification"] input[name="notificationId"]'
);
expect(input?.value).toBe('n42');
});
it('calls optimisticMarkRead with the notification id when a row is submitted', async () => {
const optimisticMarkRead = vi.fn();
const n = makeNotification({ id: 'n42', actorName: 'Anna' }); const n = makeNotification({ id: 'n42', actorName: 'Anna' });
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [n], notifications: [n],
optimisticMarkRead, onMarkRead,
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click(); await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
expect(optimisticMarkRead).toHaveBeenCalledWith('n42'); expect(onMarkRead).toHaveBeenCalledWith(n);
}); });
it('the mark-all-read control is a form posting to the mark-all-read action', async () => { it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
const onMarkAllRead = vi.fn();
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification()], notifications: [makeNotification()],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead,
onClose: () => {}
}
});
const form = document.querySelector('form[action="/aktivitaeten?/mark-all-read"]');
expect(form).not.toBeNull();
expect(form?.getAttribute('method')).toBe('POST');
});
it('calls optimisticMarkAllRead when the mark-all-read button is submitted', async () => {
const optimisticMarkAllRead = vi.fn();
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
optimisticMarkRead: () => {},
optimisticMarkAllRead,
onClose: () => {} onClose: () => {}
} }
}); });
await page.getByRole('button', { name: /alle gelesen/i }).click(); await page.getByRole('button', { name: /alle gelesen/i }).click();
expect(optimisticMarkAllRead).toHaveBeenCalledOnce(); expect(onMarkAllRead).toHaveBeenCalledOnce();
});
it('shows a role=alert error banner when mark-all-read returns a failure', async () => {
mockFormResult.type = 'failure';
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
await page.getByRole('button', { name: /alle gelesen/i }).click();
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
}); });
it('calls onClose when the view-all button is clicked', async () => { it('calls onClose when the view-all button is clicked', async () => {
@@ -256,8 +164,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose onClose
} }
}); });
@@ -271,8 +179,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -285,15 +193,12 @@ describe('NotificationDropdown', () => {
it('calls onClose before navigating to /aktivitaeten', async () => { it('calls onClose before navigating to /aktivitaeten', async () => {
const callOrder: string[] = []; const callOrder: string[] = [];
const onClose = vi.fn(() => callOrder.push('close')); const onClose = vi.fn(() => callOrder.push('close'));
vi.mocked(goto).mockImplementation(() => { vi.mocked(goto).mockImplementation(() => callOrder.push('goto'));
callOrder.push('goto');
return Promise.resolve();
});
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose onClose
} }
}); });
@@ -307,8 +212,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })], notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -320,8 +225,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })], notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -337,78 +242,14 @@ describe('NotificationDropdown', () => {
makeNotification({ id: 'n1', actorName: 'First' }), makeNotification({ id: 'n1', actorName: 'First' }),
makeNotification({ id: 'n2', actorName: 'Second' }) makeNotification({ id: 'n2', actorName: 'Second' })
], ],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
const forms = document.querySelectorAll('form[action="/aktivitaeten?/dismiss-notification"]'); const items = document.querySelectorAll('button[type="button"]');
expect(forms.length).toBe(2); // At least 2 items + mark-all button
}); expect(items.length).toBeGreaterThanOrEqual(2);
it('calls onClose and goto with the deep-link URL after a successful dismiss', async () => {
const onClose = vi.fn();
const n = makeNotification({
id: 'n42',
documentId: 'd1',
referenceId: 'c1',
annotationId: null,
actorName: 'Anna'
});
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose
}
});
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
expect(onClose).toHaveBeenCalledOnce();
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1');
});
it('does NOT call onClose or goto when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
const onClose = vi.fn();
const n = makeNotification({ id: 'n99', actorName: 'Bob' });
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose
}
});
await page.getByRole('button', { name: /Bob hat auf deinen/i }).click();
expect(onClose).not.toHaveBeenCalled();
expect(goto).not.toHaveBeenCalled();
});
it('calls goto with annotationId appended when the notification has an annotationId', async () => {
const n = makeNotification({
id: 'n55',
documentId: 'd1',
referenceId: 'c1',
annotationId: 'a1',
actorName: 'Eva'
});
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
await page.getByRole('button', { name: /Eva hat auf deinen/i }).click();
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1&annotationId=a1');
}); });
}); });

View File

@@ -108,46 +108,12 @@ describe('notificationStore (singleton)', () => {
expect(notificationStore.unreadCount).toBe(1); expect(notificationStore.unreadCount).toBe(1);
}); });
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => { it('markAllRead resets unreadCount', async () => {
notificationStore.init(); mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
const notification = makeNotification({ id: 'sse-1', read: false }); await notificationStore.markAllRead();
lastEventSource!.simulate('notification', JSON.stringify(notification));
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
notificationStore.optimisticMarkRead('sse-1'); expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
expect(notificationStore.notifications[0].read).toBe(true);
expect(notificationStore.unreadCount).toBe(0); expect(notificationStore.unreadCount).toBe(0);
expect(mockFetch).not.toHaveBeenCalled();
});
it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => {
notificationStore.init();
const notification = makeNotification({ id: 'sse-1', read: true });
lastEventSource!.simulate('notification', JSON.stringify(notification));
notificationStore.optimisticMarkRead('sse-1');
expect(notificationStore.unreadCount).toBe(0);
});
it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => {
notificationStore.init();
lastEventSource!.simulate(
'notification',
JSON.stringify(makeNotification({ id: 'n1', read: false }))
);
lastEventSource!.simulate(
'notification',
JSON.stringify(makeNotification({ id: 'n2', read: false }))
);
mockFetch.mockReset();
notificationStore.optimisticMarkAllRead();
expect(notificationStore.unreadCount).toBe(0);
expect(notificationStore.notifications.every((n) => n.read)).toBe(true);
expect(mockFetch).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -35,19 +35,28 @@ async function fetchUnreadCount(): Promise<void> {
} }
} }
function optimisticMarkRead(id: string): void { async function markRead(notification: NotificationItem): Promise<void> {
const notification = notifications.find((n) => n.id === id); if (!notification.read) {
if (notification && !notification.read) { try {
notification.read = true; await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
unreadCount = Math.max(0, unreadCount - 1); notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
} }
} }
function optimisticMarkAllRead(): void { async function markAllRead(): Promise<void> {
for (const n of notifications) { try {
n.read = true; await fetch('/api/notifications/read-all', { method: 'POST' });
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
} catch (e) {
console.error('Failed to mark all notifications as read', e);
} }
unreadCount = 0;
} }
function init(): void { function init(): void {
@@ -114,8 +123,8 @@ export const notificationStore = {
}, },
fetchNotifications, fetchNotifications,
fetchUnreadCount, fetchUnreadCount,
optimisticMarkRead, markRead,
optimisticMarkAllRead, markAllRead,
init, init,
destroy destroy
}; };

View File

@@ -1,6 +1,4 @@
import { fail } from '@sveltejs/kit';
import { createApiClient } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { components, operations } from '$lib/generated/api'; import type { components, operations } from '$lib/generated/api';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
@@ -67,31 +65,3 @@ export async function load({ fetch, url }) {
loadError loadError
}; };
} }
export const actions = {
'dismiss-notification': async ({ request, fetch }) => {
const data = await request.formData();
const raw = data.get('notificationId');
const notificationId = typeof raw === 'string' ? raw : null;
if (!notificationId) return fail(400, { error: getErrorMessage(undefined) });
const api = createApiClient(fetch);
const result = await api.PATCH('/api/notifications/{id}/read', {
params: { path: { id: notificationId } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
return { success: true };
},
'mark-all-read': async ({ fetch }) => {
const api = createApiClient(fetch);
const result = await api.POST('/api/notifications/read-all');
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
return { success: true };
}
};

View File

@@ -76,6 +76,14 @@ async function onFilterChange(v: FilterValue) {
}); });
} }
async function onMarkRead(n: NotificationItem) {
await notificationStore.markRead(n);
}
async function onMarkAllRead() {
await notificationStore.markAllRead();
}
const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter)); const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter));
const isEmpty = $derived(displayFeed.length === 0); const isEmpty = $derived(displayFeed.length === 0);
@@ -100,11 +108,7 @@ function retry() {
{#if data.loadError === 'activity'} {#if data.loadError === 'activity'}
<ChronikErrorCard onRetry={retry} /> <ChronikErrorCard onRetry={retry} />
{:else} {:else}
<ChronikFuerDichBox <ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
unread={unread}
optimisticMarkRead={notificationStore.optimisticMarkRead}
optimisticMarkAllRead={notificationStore.optimisticMarkAllRead}
/>
<div class="mt-6"> <div class="mt-6">
<ChronikFilterPills value={data.filter} onChange={onFilterChange} /> <ChronikFilterPills value={data.filter} onChange={onFilterChange} />

View File

@@ -1,10 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { load, actions } from './+page.server'; import { load } from './+page.server';
const mockApi = { const mockApi = {
GET: vi.fn(), GET: vi.fn()
PATCH: vi.fn(),
POST: vi.fn()
}; };
vi.mock('$lib/shared/api.server', () => ({ vi.mock('$lib/shared/api.server', () => ({
@@ -175,84 +173,3 @@ describe('aktivitaeten/load — kinds param per filter', () => {
expect(call[1].params.query.kinds).toHaveLength(2); expect(call[1].params.query.kinds).toHaveLength(2);
}); });
}); });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function makeActionEvent(formData: FormData): any {
return {
request: new Request('http://localhost/aktivitaeten', { method: 'POST', body: formData }),
fetch
};
}
describe('aktivitaeten/actions — dismiss-notification', () => {
it('returns fail(400, { error }) and does NOT call PATCH when notificationId is missing', async () => {
const result = await actions['dismiss-notification'](makeActionEvent(new FormData()));
expect(result).toMatchObject({ status: 400 });
expect(mockApi.PATCH).not.toHaveBeenCalled();
});
it('calls PATCH /api/notifications/{id}/read with the form-supplied notificationId', async () => {
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
const fd = new FormData();
fd.set('notificationId', 'n-abc');
await actions['dismiss-notification'](makeActionEvent(fd));
expect(mockApi.PATCH).toHaveBeenCalledWith('/api/notifications/{id}/read', {
params: { path: { id: 'n-abc' } }
});
});
it('returns { success: true } when the API responds ok', async () => {
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
const fd = new FormData();
fd.set('notificationId', 'n-abc');
const result = await actions['dismiss-notification'](makeActionEvent(fd));
expect(result).toEqual({ success: true });
});
it('returns fail(status, { error }) when the API responds non-ok', async () => {
mockApi.PATCH.mockResolvedValue({
response: { ok: false, status: 403 },
error: { code: 'NOTIFICATION_NOT_FOUND' }
});
const fd = new FormData();
fd.set('notificationId', 'n-abc');
const result = await actions['dismiss-notification'](makeActionEvent(fd));
expect(result).toMatchObject({ status: 403 });
});
});
describe('aktivitaeten/actions — mark-all-read', () => {
it('calls POST /api/notifications/read-all', async () => {
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
await actions['mark-all-read'](makeActionEvent(new FormData()));
expect(mockApi.POST).toHaveBeenCalledWith('/api/notifications/read-all');
});
it('returns { success: true } when the API responds ok', async () => {
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
expect(result).toEqual({ success: true });
});
it('returns fail(status, { error }) when the API responds non-ok', async () => {
mockApi.POST.mockResolvedValue({
response: { ok: false, status: 500 },
error: { code: 'INTERNAL_ERROR' }
});
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
expect(result).toMatchObject({ status: 500 });
});
});

View File

@@ -0,0 +1,702 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "grafana" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "Product owner overview — system health, user activity, archive progress, and OCR quality at a weekly glance.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
"id": 100,
"title": "System Health",
"type": "row",
"panels": []
},
{
"id": 1,
"title": "Backend Status",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 },
"targets": [
{
"expr": "up{job=\"spring-boot\"}",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" } } },
{ "type": "value", "options": { "1": { "text": "UP", "color": "green" } } }
],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "value"
}
},
{
"id": 2,
"title": "Server Errors (5xx)",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 },
"targets": [
{
"expr": "sum(increase(http_server_requests_seconds_count{status=~\"5..\"}[$__range]))",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 6 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 3,
"title": "Response Time (p95)",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 },
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[$__range])) by (le))",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"decimals": 2,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.5 },
{ "color": "red", "value": 2 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 4,
"title": "Error Log Count",
"type": "stat",
"datasource": { "type": "loki", "uid": "loki" },
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 },
"targets": [
{
"expr": "sum(count_over_time({compose_service=\"backend\"} | json | level=\"ERROR\" [$__range]))",
"queryType": "instant",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 10 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 5,
"title": "CPU Usage",
"type": "bargauge",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 8, "x": 0, "y": 5 },
"targets": [
{
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 85 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showUnfilled": true
}
},
{
"id": 6,
"title": "Memory Usage",
"type": "bargauge",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 8, "x": 8, "y": 5 },
"targets": [
{
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 85 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showUnfilled": true
}
},
{
"id": 7,
"title": "Disk Usage",
"type": "bargauge",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 8, "x": 16, "y": 5 },
"targets": [
{
"expr": "(1 - (node_filesystem_avail_bytes{mountpoint=\"/\"} / node_filesystem_size_bytes{mountpoint=\"/\"})) * 100",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 80 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showUnfilled": true
}
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 10 },
"id": 101,
"title": "User Activity",
"type": "row",
"panels": []
},
{
"id": 8,
"title": "Active Users",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 11 },
"targets": [
{
"rawSql": "SELECT COUNT(DISTINCT actor_id) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'LOGIN_SUCCESS'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 9,
"title": "Total Logins",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 11 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'LOGIN_SUCCESS'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 10,
"title": "Failed Login Attempts",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 11 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind IN ('LOGIN_FAILED', 'LOGIN_RATE_LIMITED')",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 4 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 11,
"title": "Daily Logins (last 7 days)",
"type": "barchart",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 7, "w": 24, "x": 0, "y": 15 },
"targets": [
{
"rawSql": "SELECT DATE_TRUNC('day', happened_at) AS time, COUNT(*) AS logins FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'LOGIN_SUCCESS' GROUP BY 1 ORDER BY 1",
"format": "time_series",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"legend": { "displayMode": "hidden" },
"orientation": "auto",
"showValue": "auto",
"stacking": "none",
"xTickLabelRotation": 0,
"xTickLabelSpacing": 0
}
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 22 },
"id": 102,
"title": "Archive Progress",
"type": "row",
"panels": []
},
{
"id": 12,
"title": "Transcription Coverage",
"type": "bargauge",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 5, "w": 24, "x": 0, "y": 23 },
"targets": [
{
"rawSql": "SELECT (COUNT(*) FILTER (WHERE text IS NOT NULL AND text <> ''))::float * 100.0 / NULLIF(COUNT(*), 0) AS percent_complete FROM transcription_blocks",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"decimals": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 25 },
{ "color": "green", "value": 75 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showUnfilled": true
}
},
{
"id": 13,
"title": "Total Documents",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 28 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM documents WHERE status <> 'PLACEHOLDER'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 14,
"title": "Uploads This Week",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 28 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'FILE_UPLOADED'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 15,
"title": "Blocks Transcribed This Week",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 28 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'TEXT_SAVED'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 16,
"title": "Blocks Reviewed This Week",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 28 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'BLOCK_REVIEWED'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 32 },
"id": 103,
"title": "OCR Health",
"type": "row",
"panels": []
},
{
"id": 17,
"title": "OCR Jobs",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 33 },
"targets": [
{
"expr": "sum(increase(ocr_jobs_total[$__range]))",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 18,
"title": "OCR Page Error Rate",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 33 },
"targets": [
{
"expr": "sum(increase(ocr_skipped_pages_total[$__range])) / clamp_min(sum(increase(ocr_pages_total[$__range])), 1)",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"decimals": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.01 },
{ "color": "red", "value": 0.05 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 19,
"title": "Illegible Word Rate",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 33 },
"targets": [
{
"expr": "sum(increase(ocr_illegible_words_total[$__range])) / clamp_min(sum(increase(ocr_words_total[$__range])), 1)",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"decimals": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.1 },
{ "color": "red", "value": 0.25 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 20,
"title": "OCR Service Status",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 33 },
"targets": [
{
"expr": "ocr_models_ready",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "0": { "text": "NOT READY", "color": "red" } } },
{ "type": "value", "options": { "1": { "text": "READY", "color": "green" } } }
],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "value"
}
}
],
"refresh": "",
"schemaVersion": 39,
"tags": ["po-overview", "familienarchiv"],
"templating": { "list": [] },
"time": { "from": "now-7d", "to": "now" },
"timepicker": {},
"timezone": "browser",
"title": "PO Overview",
"uid": "po-overview",
"version": 1,
"weekStart": ""
}

View File

@@ -36,3 +36,19 @@ datasources:
datasourceUid: prometheus datasourceUid: prometheus
nodeGraph: nodeGraph:
enabled: true enabled: true
# Read-only PostgreSQL datasource for the PO Overview dashboard (issue #651).
# Uses the grafana_reader role provisioned by Flyway V68. Traffic stays inside
# archiv-net, so sslmode=disable is the deliberate, accepted setting.
- name: PostgreSQL
type: postgres
uid: postgres
url: archive-db:5432
user: grafana_reader
editable: false
secureJsonData:
password: ${GRAFANA_DB_PASSWORD}
jsonData:
database: ${POSTGRES_DB}
sslmode: disable
postgresVersion: 1600

View File

@@ -16,6 +16,11 @@ GLITCHTIP_DOMAIN=https://glitchtip.archiv.raddatz.cloud
POSTGRES_USER=archiv POSTGRES_USER=archiv
# Note: GRAFANA_DB_PASSWORD is a secret and is injected by CI from
# obs-secrets.env (see .env.example for the local-dev declaration).
# It is consumed by both archive-backend (Flyway V68 placeholder) and
# obs-grafana (PostgreSQL datasource).
# PostgreSQL hostname for GlitchTip db-init and workers. # PostgreSQL hostname for GlitchTip db-init and workers.
# The actual value depends on the Compose project name — it is not a fixed string. # The actual value depends on the Compose project name — it is not a fixed string.
# CI sets POSTGRES_HOST in obs-secrets.env per environment: # CI sets POSTGRES_HOST in obs-secrets.env per environment: