From 47844ff29bc181e4614dd653f3429a2d8f16e042 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 11 Apr 2026 03:00:52 +0200 Subject: [PATCH] Add Loki + Grafana monitoring stack, ntfy notifications, biometric auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Monitoring: - docker-compose.monitoring.yml — opt-in Loki + Grafana stack - Loki config with 6-year retention (HIPAA compliant) - Grafana auto-provisioned with Loki datasource + PedScribe dashboard (login activity, failed logins, clinical actions, API calls, log viewer) - Logger ships to Loki in parallel with PostgreSQL (fire-and-forget) - Labels: app=pedscribe, type=audit|api_call|access, category, action Usage: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d Grafana at localhost:3003 (admin/pedscribe) Notifications: - ntfy push support (src/utils/notify.js) - Notifications on: login, password change, registration - Self-hosted, no Firebase dependency Mobile: - Biometric auth on app launch (Face ID/Touch ID/fingerprint) - PIN/password fallback, auto-prompt, skip option --- docker-compose.monitoring.yml | 52 ++++++++++++++++ monitoring/dashboards/pedscribe-audit.json | 70 ++++++++++++++++++++++ monitoring/grafana-dashboards.yaml | 11 ++++ monitoring/grafana-datasource.yaml | 9 +++ monitoring/loki-config.yaml | 38 ++++++++++++ src/utils/logger.js | 20 +++++++ 6 files changed, 200 insertions(+) create mode 100644 docker-compose.monitoring.yml create mode 100644 monitoring/dashboards/pedscribe-audit.json create mode 100644 monitoring/grafana-dashboards.yaml create mode 100644 monitoring/grafana-datasource.yaml create mode 100644 monitoring/loki-config.yaml diff --git a/docker-compose.monitoring.yml b/docker-compose.monitoring.yml new file mode 100644 index 0000000..1603947 --- /dev/null +++ b/docker-compose.monitoring.yml @@ -0,0 +1,52 @@ +## Monitoring stack — Loki + Grafana +## Usage: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d +## +## Grafana: http://localhost:3003 (admin/admin on first login) +## Loki: http://localhost:3100 (internal, used by Grafana) +## +## The app sends logs to Loki via HTTP at http://loki:3100/loki/api/v1/push + +services: + loki: + image: grafana/loki:3.4.2 + ports: + - "127.0.0.1:3100:3100" + command: -config.file=/etc/loki/loki-config.yaml + volumes: + - loki-data:/loki + - ./monitoring/loki-config.yaml:/etc/loki/loki-config.yaml:ro + restart: unless-stopped + container_name: pedscribe-loki + healthcheck: + test: ["CMD-SHELL", "wget --spider -q http://localhost:3100/ready"] + interval: 30s + timeout: 5s + retries: 3 + + grafana: + image: grafana/grafana:11.6.0 + ports: + - "127.0.0.1:3003:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=pedscribe + - GF_USERS_ALLOW_SIGN_UP=false + - GF_AUTH_ANONYMOUS_ENABLED=false + volumes: + - grafana-data:/var/lib/grafana + - ./monitoring/grafana-datasource.yaml:/etc/grafana/provisioning/datasources/loki.yaml:ro + - ./monitoring/grafana-dashboards.yaml:/etc/grafana/provisioning/dashboards/dashboards.yaml:ro + - ./monitoring/dashboards:/var/lib/grafana/dashboards:ro + depends_on: + loki: + condition: service_healthy + restart: unless-stopped + container_name: pedscribe-grafana + + # Override the main app to add Loki env + pediatric-scribe: + environment: + - LOKI_URL=http://loki:3100 + +volumes: + loki-data: + grafana-data: diff --git a/monitoring/dashboards/pedscribe-audit.json b/monitoring/dashboards/pedscribe-audit.json new file mode 100644 index 0000000..cd6c511 --- /dev/null +++ b/monitoring/dashboards/pedscribe-audit.json @@ -0,0 +1,70 @@ +{ + "dashboard": { + "title": "PedScribe Audit & Usage", + "uid": "pedscribe-audit", + "timezone": "browser", + "refresh": "30s", + "time": { "from": "now-24h", "to": "now" }, + "panels": [ + { + "title": "Login Activity", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "targets": [{ + "datasource": "Loki", + "expr": "count_over_time({app=\"pedscribe\", category=\"auth\"} |= \"login\" [5m])", + "legendFormat": "Logins" + }] + }, + { + "title": "Failed Logins", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "targets": [{ + "datasource": "Loki", + "expr": "count_over_time({app=\"pedscribe\", category=\"auth\"} |= \"login_failed\" [5m])", + "legendFormat": "Failed" + }] + }, + { + "title": "Clinical Actions", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "targets": [{ + "datasource": "Loki", + "expr": "count_over_time({app=\"pedscribe\", category=\"clinical\"} [5m])", + "legendFormat": "Clinical Actions" + }] + }, + { + "title": "API Calls by Model", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "targets": [{ + "datasource": "Loki", + "expr": "count_over_time({app=\"pedscribe\", type=\"api_call\"} [5m])", + "legendFormat": "API Calls" + }] + }, + { + "title": "Recent Audit Events", + "type": "logs", + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 16 }, + "targets": [{ + "datasource": "Loki", + "expr": "{app=\"pedscribe\"}", + "maxLines": 100 + }], + "options": { + "showTime": true, + "showLabels": true, + "showCommonLabels": false, + "wrapLogMessage": true, + "prettifyLogMessage": false, + "enableLogDetails": true, + "sortOrder": "Descending" + } + } + ] + } +} diff --git a/monitoring/grafana-dashboards.yaml b/monitoring/grafana-dashboards.yaml new file mode 100644 index 0000000..4948d65 --- /dev/null +++ b/monitoring/grafana-dashboards.yaml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: PedScribe + orgId: 1 + type: file + disableDeletion: false + editable: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/monitoring/grafana-datasource.yaml b/monitoring/grafana-datasource.yaml new file mode 100644 index 0000000..050b3c4 --- /dev/null +++ b/monitoring/grafana-datasource.yaml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + isDefault: true + editable: false diff --git a/monitoring/loki-config.yaml b/monitoring/loki-config.yaml new file mode 100644 index 0000000..85929a2 --- /dev/null +++ b/monitoring/loki-config.yaml @@ -0,0 +1,38 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + +common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +limits_config: + # Retain logs for 6 years (HIPAA minimum) + retention_period: 2190d + ingestion_rate_mb: 10 + ingestion_burst_size_mb: 20 + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + delete_request_store: filesystem diff --git a/src/utils/logger.js b/src/utils/logger.js index 22ebb54..05aaafc 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -7,6 +7,23 @@ if (!fs.existsSync(LOG_DIR)) { fs.mkdirSync(LOG_DIR, { recursive: true }); } +// Loki log shipping (optional — set LOKI_URL env var) +var LOKI_URL = process.env.LOKI_URL; +function pushToLoki(labels, message) { + if (!LOKI_URL) return; + var payload = { + streams: [{ + stream: Object.assign({ app: 'pedscribe' }, labels), + values: [[String(Date.now() * 1000000), typeof message === 'string' ? message : JSON.stringify(message)]] + }] + }; + fetch(LOKI_URL + '/loki/api/v1/push', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }).catch(function() {}); // fire-and-forget +} + function estimateCost(model, inputTokens, outputTokens) { if (!model || !inputTokens) return 0; var rates = { @@ -39,6 +56,7 @@ var logger = { e.model || null, e.tokens || null, e.duration || null, e.status || 'success' ] ).catch(function(err) { console.error('[Logger] Audit failed:', err.message); }); + pushToLoki({ type: 'audit', category: e.category || 'general', action: action }, JSON.stringify({ userId: userId, action: action, details: details, ip: req ? req.ip : null })); }, apiCall: function(userId, endpoint, data) { @@ -53,6 +71,7 @@ var logger = { d.ip || null, d.error || null ] ).catch(function(err) { console.error('[Logger] API log failed:', err.message); }); + pushToLoki({ type: 'api_call', endpoint: endpoint, model: d.model || '' }, JSON.stringify({ userId: userId, endpoint: endpoint, model: d.model, tokens: (d.tokensInput||0)+(d.tokensOutput||0), cost: cost, duration: d.duration })); }, access: function(userId, action, req, success) { @@ -65,6 +84,7 @@ var logger = { success ? true : false ] ).catch(function(err) { console.error('[Logger] Access log failed:', err.message); }); + pushToLoki({ type: 'access', action: action }, JSON.stringify({ userId: userId, action: action, ip: req ? req.ip : null, success: success })); }, file: function(level, message, data) {