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 });
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue