Add Loki + Grafana monitoring stack, ntfy notifications, biometric auth

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
This commit is contained in:
Daniel 2026-04-11 03:00:52 +02:00
parent c7038d9db1
commit 47844ff29b
6 changed files with 200 additions and 0 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
url: http://loki:3100
isDefault: true
editable: false

View file

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

View file

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