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:
parent
c7038d9db1
commit
47844ff29b
6 changed files with 200 additions and 0 deletions
52
docker-compose.monitoring.yml
Normal file
52
docker-compose.monitoring.yml
Normal 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:
|
||||||
70
monitoring/dashboards/pedscribe-audit.json
Normal file
70
monitoring/dashboards/pedscribe-audit.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
monitoring/grafana-dashboards.yaml
Normal file
11
monitoring/grafana-dashboards.yaml
Normal 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
|
||||||
9
monitoring/grafana-datasource.yaml
Normal file
9
monitoring/grafana-datasource.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Loki
|
||||||
|
type: loki
|
||||||
|
access: proxy
|
||||||
|
url: http://loki:3100
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
38
monitoring/loki-config.yaml
Normal file
38
monitoring/loki-config.yaml
Normal 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
|
||||||
|
|
@ -7,6 +7,23 @@ if (!fs.existsSync(LOG_DIR)) {
|
||||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
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) {
|
function estimateCost(model, inputTokens, outputTokens) {
|
||||||
if (!model || !inputTokens) return 0;
|
if (!model || !inputTokens) return 0;
|
||||||
var rates = {
|
var rates = {
|
||||||
|
|
@ -39,6 +56,7 @@ var logger = {
|
||||||
e.model || null, e.tokens || null, e.duration || null, e.status || 'success'
|
e.model || null, e.tokens || null, e.duration || null, e.status || 'success'
|
||||||
]
|
]
|
||||||
).catch(function(err) { console.error('[Logger] Audit failed:', err.message); });
|
).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) {
|
apiCall: function(userId, endpoint, data) {
|
||||||
|
|
@ -53,6 +71,7 @@ var logger = {
|
||||||
d.ip || null, d.error || null
|
d.ip || null, d.error || null
|
||||||
]
|
]
|
||||||
).catch(function(err) { console.error('[Logger] API log failed:', err.message); });
|
).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) {
|
access: function(userId, action, req, success) {
|
||||||
|
|
@ -65,6 +84,7 @@ var logger = {
|
||||||
success ? true : false
|
success ? true : false
|
||||||
]
|
]
|
||||||
).catch(function(err) { console.error('[Logger] Access log failed:', err.message); });
|
).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) {
|
file: function(level, message, data) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue