Compare commits
6 Commits
f78d4c9b44
...
3cd2874ed7
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cd2874ed7 | |||
| cf46790ed8 | |||
| 45b69bcae8 | |||
| 3792e4053c | |||
| 5a913dcac1 | |||
| cc9a0cf57c |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -87,3 +87,7 @@ package-lock.json
|
|||||||
# Backups
|
# Backups
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
|
|
||||||
|
# Local tools (AWS CLI)
|
||||||
|
tools/
|
||||||
|
|
||||||
|
|||||||
34
docker/alertmanager/alertmanager.yml
Normal file
34
docker/alertmanager/alertmanager.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
global:
|
||||||
|
smtp_smarthost: 'localhost:587'
|
||||||
|
smtp_from: 'alerts@nexus.local'
|
||||||
|
smtp_require_tls: false
|
||||||
|
|
||||||
|
route:
|
||||||
|
group_by: ['alertname', 'severity']
|
||||||
|
group_wait: 10s
|
||||||
|
group_interval: 10s
|
||||||
|
repeat_interval: 1h
|
||||||
|
receiver: 'default'
|
||||||
|
|
||||||
|
receivers:
|
||||||
|
- name: 'default'
|
||||||
|
email_configs:
|
||||||
|
- to: 'admin@nexus.local'
|
||||||
|
subject: 'Nexus Alert: {{ .GroupLabels.alertname }}'
|
||||||
|
body: |
|
||||||
|
{{ range .Alerts }}
|
||||||
|
Alert: {{ .Annotations.summary }}
|
||||||
|
Description: {{ .Annotations.description }}
|
||||||
|
Severity: {{ .Labels.severity }}
|
||||||
|
Time: {{ .StartsAt }}
|
||||||
|
{{ end }}
|
||||||
|
webhook_configs:
|
||||||
|
- url: 'http://localhost:5001/pos/api/notifications/webhook'
|
||||||
|
send_resolved: true
|
||||||
|
|
||||||
|
inhibit_rules:
|
||||||
|
- source_match:
|
||||||
|
severity: 'critical'
|
||||||
|
target_match:
|
||||||
|
severity: 'warning'
|
||||||
|
equal: ['alertname']
|
||||||
@@ -55,6 +55,20 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "9121:9121"
|
- "9121:9121"
|
||||||
|
|
||||||
|
alertmanager:
|
||||||
|
image: prom/alertmanager:v0.27.0
|
||||||
|
container_name: nexus-alertmanager
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9093:9093"
|
||||||
|
volumes:
|
||||||
|
- ./alertmanager:/etc/alertmanager
|
||||||
|
- alertmanager-data:/alertmanager
|
||||||
|
command:
|
||||||
|
- '--config.file=/etc/alertmanager/alertmanager.yml'
|
||||||
|
- '--storage.path=/alertmanager'
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
prometheus-data:
|
prometheus-data:
|
||||||
grafana-data:
|
grafana-data:
|
||||||
|
alertmanager-data:
|
||||||
|
|||||||
11
docker/grafana/provisioning/dashboards/dashboards.yml
Normal file
11
docker/grafana/provisioning/dashboards/dashboards.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
providers:
|
||||||
|
- name: 'nexus-dashboards'
|
||||||
|
orgId: 1
|
||||||
|
folder: 'Nexus'
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 10
|
||||||
|
allowUiUpdates: true
|
||||||
|
options:
|
||||||
|
path: /etc/grafana/provisioning/dashboards
|
||||||
149
docker/grafana/provisioning/dashboards/nexus-gunicorn.json
Normal file
149
docker/grafana/provisioning/dashboards/nexus-gunicorn.json
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
{
|
||||||
|
"uid": "nexus-app",
|
||||||
|
"title": "Nexus — Application",
|
||||||
|
"tags": ["gunicorn", "flask"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"schemaVersion": 36,
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": {
|
||||||
|
"from": "now-1h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"name": "datasource",
|
||||||
|
"type": "datasource",
|
||||||
|
"query": "prometheus",
|
||||||
|
"current": {
|
||||||
|
"selected": false,
|
||||||
|
"text": "Prometheus",
|
||||||
|
"value": "Prometheus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Request Rate (nginx)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "rate(nginx_http_requests_total[5m])",
|
||||||
|
"legendFormat": "Requests/sec",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "reqps",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Response Time (nginx)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "histogram_quantile(0.95, sum(rate(nginx_http_request_duration_seconds_bucket[5m])) by (le))",
|
||||||
|
"legendFormat": "p95",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "histogram_quantile(0.99, sum(rate(nginx_http_request_duration_seconds_bucket[5m])) by (le))",
|
||||||
|
"legendFormat": "p99",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "s",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Active Workers",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "count by (instance) (node_processes_state{state=\"S\", cmdline=~\".*gunicorn.*\"})",
|
||||||
|
"legendFormat": "Workers {{instance}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "short",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "5xx Errors",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "rate(nginx_http_requests_total{status=~\"5..\"}[5m])",
|
||||||
|
"legendFormat": "5xx/sec",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "reqps",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "Memory per Worker",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 16},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "process_resident_memory_bytes{cmdline=~\".*gunicorn.*\"} / 1024 / 1024",
|
||||||
|
"legendFormat": "{{cmdline}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "mbytes",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
185
docker/grafana/provisioning/dashboards/nexus-postgresql.json
Normal file
185
docker/grafana/provisioning/dashboards/nexus-postgresql.json
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
{
|
||||||
|
"uid": "nexus-postgresql",
|
||||||
|
"title": "Nexus — PostgreSQL",
|
||||||
|
"tags": ["postgres", "database"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"schemaVersion": 36,
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": {
|
||||||
|
"from": "now-1h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"name": "datasource",
|
||||||
|
"type": "datasource",
|
||||||
|
"query": "prometheus",
|
||||||
|
"current": {
|
||||||
|
"selected": false,
|
||||||
|
"text": "Prometheus",
|
||||||
|
"value": "Prometheus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Active Connections",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "pg_stat_activity_count",
|
||||||
|
"legendFormat": "Connections",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "short",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Transactions / sec",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "rate(pg_stat_database_xact_commit[5m])",
|
||||||
|
"legendFormat": "Commits",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "rate(pg_stat_database_xact_rollback[5m])",
|
||||||
|
"legendFormat": "Rollbacks",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "tps",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Cache Hit Ratio",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "pg_stat_database_blks_hit / (pg_stat_database_blks_hit + pg_stat_database_blks_read)",
|
||||||
|
"legendFormat": "Hit Ratio",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "percentunit",
|
||||||
|
"min": 0,
|
||||||
|
"max": 1
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "WAL Generation",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "rate(pg_stat_bgwriter_buffers_backend_fsync[5m])",
|
||||||
|
"legendFormat": "Backend fsync",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "rate(pg_stat_bgwriter_buffers_backend[5m])",
|
||||||
|
"legendFormat": "Backend buffers",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "ops",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "Slow Queries",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 16},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "pg_stat_activity_count{state=\"active\"}",
|
||||||
|
"legendFormat": "Active queries",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "short",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"title": "Table Bloat",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 16},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "pg_stat_user_tables_n_live_tup",
|
||||||
|
"legendFormat": "Live tuples",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "pg_stat_user_tables_n_dead_tup",
|
||||||
|
"legendFormat": "Dead tuples",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "short",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
173
docker/grafana/provisioning/dashboards/nexus-redis.json
Normal file
173
docker/grafana/provisioning/dashboards/nexus-redis.json
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
{
|
||||||
|
"uid": "nexus-redis",
|
||||||
|
"title": "Nexus — Redis",
|
||||||
|
"tags": ["redis", "cache"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"schemaVersion": 36,
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": {
|
||||||
|
"from": "now-1h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"name": "datasource",
|
||||||
|
"type": "datasource",
|
||||||
|
"query": "prometheus",
|
||||||
|
"current": {
|
||||||
|
"selected": false,
|
||||||
|
"text": "Prometheus",
|
||||||
|
"value": "Prometheus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Memory Usage",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "redis_memory_used_bytes",
|
||||||
|
"legendFormat": "Used memory",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "bytes",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Commands / sec",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "rate(redis_commands_processed_total[5m])",
|
||||||
|
"legendFormat": "Commands/sec",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "cps",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Connected Clients",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "redis_connected_clients",
|
||||||
|
"legendFormat": "Clients",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "short",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "Cache Hit Ratio",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total)",
|
||||||
|
"legendFormat": "Hit Ratio",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "percentunit",
|
||||||
|
"min": 0,
|
||||||
|
"max": 1
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "Keyspace Hits / Misses",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 16},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "rate(redis_keyspace_hits_total[5m])",
|
||||||
|
"legendFormat": "Hits/sec",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "rate(redis_keyspace_misses_total[5m])",
|
||||||
|
"legendFormat": "Misses/sec",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "cps",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"title": "Evicted Keys",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 16},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "rate(redis_evicted_keys_total[5m])",
|
||||||
|
"legendFormat": "Evicted/sec",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "cps",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
164
docker/grafana/provisioning/dashboards/nexus-system.json
Normal file
164
docker/grafana/provisioning/dashboards/nexus-system.json
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
{
|
||||||
|
"uid": "nexus-system",
|
||||||
|
"title": "Nexus — System",
|
||||||
|
"tags": ["node", "system"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"schemaVersion": 36,
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": {
|
||||||
|
"from": "now-1h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"name": "datasource",
|
||||||
|
"type": "datasource",
|
||||||
|
"query": "prometheus",
|
||||||
|
"current": {
|
||||||
|
"selected": false,
|
||||||
|
"text": "Prometheus",
|
||||||
|
"value": "Prometheus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "CPU Usage %",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "100 - (avg by(instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||||
|
"legendFormat": "CPU %",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "percent",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Memory Usage %",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "100 * (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)",
|
||||||
|
"legendFormat": "Memory %",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "percent",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Disk Usage %",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "100 * (1 - node_filesystem_avail_bytes{fstype!~\"tmpfs|ramfs\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|ramfs\"})",
|
||||||
|
"legendFormat": "{{mountpoint}}",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "percent",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "Network I/O",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "rate(node_network_receive_bytes_total[5m])",
|
||||||
|
"legendFormat": "Receive {{device}}",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "rate(node_network_transmit_bytes_total[5m])",
|
||||||
|
"legendFormat": "Transmit {{device}}",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "Bps",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "Load Average",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 16},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "node_load1",
|
||||||
|
"legendFormat": "1m load",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "node_load5",
|
||||||
|
"legendFormat": "5m load",
|
||||||
|
"refId": "B"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
|
"expr": "node_load15",
|
||||||
|
"legendFormat": "15m load",
|
||||||
|
"refId": "C"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
|
"unit": "short",
|
||||||
|
"min": 0
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
47
docker/prometheus/alerts.yml
Normal file
47
docker/prometheus/alerts.yml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
groups:
|
||||||
|
- name: nexus-alerts
|
||||||
|
rules:
|
||||||
|
- alert: PostgreSQLDown
|
||||||
|
expr: pg_up == 0
|
||||||
|
for: 1m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "PostgreSQL is down"
|
||||||
|
description: "PostgreSQL has been down for more than 1 minute."
|
||||||
|
|
||||||
|
- alert: RedisDown
|
||||||
|
expr: redis_up == 0
|
||||||
|
for: 1m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Redis is down"
|
||||||
|
description: "Redis has been down for more than 1 minute."
|
||||||
|
|
||||||
|
- alert: HighDiskUsage
|
||||||
|
expr: (node_filesystem_avail_bytes / node_filesystem_size_bytes) * 100 < 10
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Disk usage is high"
|
||||||
|
description: "Disk usage is above 90% on {{ $labels.device }}."
|
||||||
|
|
||||||
|
- alert: HighMemoryUsage
|
||||||
|
expr: (node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes * 100 > 85
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Memory usage is high"
|
||||||
|
description: "Memory usage is above 85%."
|
||||||
|
|
||||||
|
- alert: NodeDown
|
||||||
|
expr: up{job="node"} == 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Node exporter is down"
|
||||||
|
description: "Node exporter has been down for more than 2 minutes."
|
||||||
@@ -2,6 +2,16 @@ global:
|
|||||||
scrape_interval: 15s
|
scrape_interval: 15s
|
||||||
evaluation_interval: 15s
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
# Alertmanager configuration
|
||||||
|
alerting:
|
||||||
|
alertmanagers:
|
||||||
|
- static_configs:
|
||||||
|
- targets: ['alertmanager:9093']
|
||||||
|
|
||||||
|
# Load rules once and periodically evaluate them
|
||||||
|
rule_files:
|
||||||
|
- 'alerts.yml'
|
||||||
|
|
||||||
scrape_configs:
|
scrape_configs:
|
||||||
- job_name: 'prometheus'
|
- job_name: 'prometheus'
|
||||||
static_configs:
|
static_configs:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// /home/Autopartes/pos/static/pwa/sw.js
|
// /home/Autopartes/pos/static/pwa/sw.js
|
||||||
// Nexus POS — Service Worker v1
|
// Nexus POS — Service Worker v3
|
||||||
|
// Self-contained vanilla JS. No external imports.
|
||||||
|
|
||||||
const CACHE_NAME = 'nexus-pos-v2';
|
const CACHE_NAME = 'nexus-pos-v3';
|
||||||
|
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
'/pos/login',
|
'/pos/login',
|
||||||
@@ -35,6 +36,61 @@ const APP_SHELL = [
|
|||||||
'/pos/static/pwa/icon-512.png'
|
'/pos/static/pwa/icon-512.png'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ─── IndexedDB helpers (offline queue) ───────────────────────────
|
||||||
|
const DB_NAME = 'nexus-offline';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const STORE_NAME = 'pendingRequests';
|
||||||
|
|
||||||
|
function openDB() {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
request.onerror = function () { reject(request.error); };
|
||||||
|
request.onsuccess = function () { resolve(request.result); };
|
||||||
|
request.onupgradeneeded = function (event) {
|
||||||
|
var db = event.target.result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePendingRequest(entry) {
|
||||||
|
return openDB().then(function (db) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
|
var store = tx.objectStore(STORE_NAME);
|
||||||
|
var request = store.add(entry);
|
||||||
|
request.onsuccess = function () { resolve(request.result); };
|
||||||
|
request.onerror = function () { reject(request.error); };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPendingRequests() {
|
||||||
|
return openDB().then(function (db) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var tx = db.transaction(STORE_NAME, 'readonly');
|
||||||
|
var store = tx.objectStore(STORE_NAME);
|
||||||
|
var request = store.getAll();
|
||||||
|
request.onsuccess = function () { resolve(request.result || []); };
|
||||||
|
request.onerror = function () { reject(request.error); };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingRequests() {
|
||||||
|
return openDB().then(function (db) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
|
var store = tx.objectStore(STORE_NAME);
|
||||||
|
var request = store.clear();
|
||||||
|
request.onsuccess = function () { resolve(); };
|
||||||
|
request.onerror = function () { reject(request.error); };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Install: pre-cache app shell ────────────────────────────────
|
// ─── Install: pre-cache app shell ────────────────────────────────
|
||||||
self.addEventListener('install', function (event) {
|
self.addEventListener('install', function (event) {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
@@ -63,6 +119,7 @@ self.addEventListener('activate', function (event) {
|
|||||||
// ─── Fetch strategy ──────────────────────────────────────────────
|
// ─── Fetch strategy ──────────────────────────────────────────────
|
||||||
self.addEventListener('fetch', function (event) {
|
self.addEventListener('fetch', function (event) {
|
||||||
var url = new URL(event.request.url);
|
var url = new URL(event.request.url);
|
||||||
|
var req = event.request;
|
||||||
|
|
||||||
// Only handle requests within /pos/ scope
|
// Only handle requests within /pos/ scope
|
||||||
if (url.pathname.indexOf('/pos/') === -1) {
|
if (url.pathname.indexOf('/pos/') === -1) {
|
||||||
@@ -76,18 +133,57 @@ self.addEventListener('fetch', function (event) {
|
|||||||
|
|
||||||
// Don't cache login page — always fetch fresh to avoid stale redirects
|
// Don't cache login page — always fetch fresh to avoid stale redirects
|
||||||
if (url.pathname === '/pos/login' || url.pathname === '/pos/login/') {
|
if (url.pathname === '/pos/login' || url.pathname === '/pos/login/') {
|
||||||
event.respondWith(networkFirst(event.request));
|
event.respondWith(networkFirst(req));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline cart queue: POST /pos/api/cart/*
|
||||||
|
if (req.method === 'POST' && url.pathname.indexOf('/pos/api/cart/') !== -1) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(req.clone()).then(function (response) {
|
||||||
|
return response;
|
||||||
|
}).catch(function () {
|
||||||
|
// Clone request body to store it for later retry
|
||||||
|
return req.clone().text().then(function (bodyText) {
|
||||||
|
var entry = {
|
||||||
|
url: req.url,
|
||||||
|
method: req.method,
|
||||||
|
headers: Array.from(req.headers.entries()),
|
||||||
|
body: bodyText,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
return savePendingRequest(entry);
|
||||||
|
}).then(function () {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ queued: true, message: 'Added to offline queue' }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.error('[SW] Failed to queue offline cart request:', err);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ queued: false, message: 'Failed to queue request' }),
|
||||||
|
{
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API calls → network-first
|
// API calls → network-first
|
||||||
if (url.pathname.indexOf('/pos/api/') !== -1) {
|
if (url.pathname.indexOf('/pos/api/') !== -1) {
|
||||||
event.respondWith(networkFirst(event.request));
|
event.respondWith(networkFirst(req));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything else → cache-first
|
// Everything else → cache-first
|
||||||
event.respondWith(cacheFirst(event.request));
|
event.respondWith(cacheFirst(req));
|
||||||
});
|
});
|
||||||
|
|
||||||
function cacheFirst(request) {
|
function cacheFirst(request) {
|
||||||
@@ -130,9 +226,10 @@ function networkFirst(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Background Sync ─────────────────────────────────────────────
|
// ─── Background Sync ─────────────────────────────────────────────
|
||||||
|
// Existing sync handler + new cart-specific sync
|
||||||
self.addEventListener('sync', function (event) {
|
self.addEventListener('sync', function (event) {
|
||||||
if (event.tag === 'nexus-pos-sync') {
|
if (event.tag === 'nexus-pos-sync') {
|
||||||
event.respondWith && event.waitUntil(
|
event.waitUntil(
|
||||||
self.clients.matchAll().then(function (clients) {
|
self.clients.matchAll().then(function (clients) {
|
||||||
clients.forEach(function (client) {
|
clients.forEach(function (client) {
|
||||||
client.postMessage({ type: 'SYNC_REQUESTED' });
|
client.postMessage({ type: 'SYNC_REQUESTED' });
|
||||||
@@ -140,11 +237,144 @@ self.addEventListener('sync', function (event) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.tag === 'nexus-cart-sync') {
|
||||||
|
event.waitUntil(
|
||||||
|
getPendingRequests().then(function (entries) {
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
console.log('[SW] No pending cart actions to sync.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[SW] Syncing', entries.length, 'pending cart action(s)...');
|
||||||
|
|
||||||
|
// Replay each pending request to the server
|
||||||
|
var syncPromises = entries.map(function (entry) {
|
||||||
|
return fetch(entry.url, {
|
||||||
|
method: entry.method,
|
||||||
|
headers: entry.headers.reduce(function (obj, h) {
|
||||||
|
obj[h[0]] = h[1];
|
||||||
|
return obj;
|
||||||
|
}, {}),
|
||||||
|
body: entry.body
|
||||||
|
}).then(function (response) {
|
||||||
|
console.log('[SW] Cart sync success for', entry.url, '- status', response.status);
|
||||||
|
return { ok: true, id: entry.id };
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.error('[SW] Cart sync failed for', entry.url, '-', err);
|
||||||
|
return { ok: false, id: entry.id };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(syncPromises).then(function (results) {
|
||||||
|
var allOk = results.every(function (r) { return r.ok; });
|
||||||
|
if (allOk) {
|
||||||
|
return clearPendingRequests().then(function () {
|
||||||
|
console.log('[SW] All cart actions synced. Pending queue cleared.');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Remove only successfully synced entries; failed ones will retry next time
|
||||||
|
var failedIds = results.filter(function (r) { return !r.ok; }).map(function (r) { return r.id; });
|
||||||
|
console.warn('[SW] Some cart actions failed. Keeping', failedIds.length, 'entries for retry.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.error('[SW] Error during nexus-cart-sync:', err);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Push Notifications ──────────────────────────────────────────
|
||||||
|
self.addEventListener('push', function (event) {
|
||||||
|
var data = {};
|
||||||
|
if (event.data) {
|
||||||
|
try {
|
||||||
|
data = event.data.json();
|
||||||
|
} catch (e) {
|
||||||
|
data = { title: event.data.text() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title = data.title || 'Nexus POS';
|
||||||
|
var options = {
|
||||||
|
body: data.body || 'Tienes una nueva notificación del POS.',
|
||||||
|
icon: '/pos/static/pwa/icon-192.png',
|
||||||
|
badge: '/pos/static/pwa/icon-192.png',
|
||||||
|
tag: data.tag || 'nexus-pos-general',
|
||||||
|
data: data.data || { url: '/pos/sale' },
|
||||||
|
requireInteraction: false
|
||||||
|
};
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(title, options)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Notification Click ──────────────────────────────────────────
|
||||||
|
self.addEventListener('notificationclick', function (event) {
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
var targetUrl = event.notification.data && event.notification.data.url
|
||||||
|
? event.notification.data.url
|
||||||
|
: '/pos/sale';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) {
|
||||||
|
// Focus existing tab if it matches the target scope
|
||||||
|
for (var i = 0; i < clientList.length; i++) {
|
||||||
|
var client = clientList[i];
|
||||||
|
if (client.url.indexOf('/pos/') !== -1 && 'focus' in client) {
|
||||||
|
return client.focus().then(function (focusedClient) {
|
||||||
|
if ('navigate' in focusedClient) {
|
||||||
|
return focusedClient.navigate(targetUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise open a new window
|
||||||
|
if (self.clients.openWindow) {
|
||||||
|
return self.clients.openWindow(targetUrl);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Periodic Background Sync (stub for future use) ──────────────
|
||||||
|
// This can be used to warm the cache daily or refresh catalog data
|
||||||
|
// in the background. Requires user permission and browser support.
|
||||||
|
// self.addEventListener('periodicsync', function (event) {
|
||||||
|
// if (event.tag === 'nexus-daily-sync') {
|
||||||
|
// event.waitUntil(
|
||||||
|
// // e.g. cache warming, catalog refresh, etc.
|
||||||
|
// caches.open(CACHE_NAME).then(function (cache) {
|
||||||
|
// return cache.add('/pos/api/catalog/refresh');
|
||||||
|
// }).catch(function (err) {
|
||||||
|
// console.error('[SW] periodicsync failed:', err);
|
||||||
|
// })
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
// ─── Message handler ─────────────────────────────────────────────
|
// ─── Message handler ─────────────────────────────────────────────
|
||||||
self.addEventListener('message', function (event) {
|
self.addEventListener('message', function (event) {
|
||||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.data && event.data.type === 'CLEAR_CACHES') {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then(function (names) {
|
||||||
|
return Promise.all(
|
||||||
|
names.map(function (n) { return caches.delete(n); })
|
||||||
|
);
|
||||||
|
}).then(function () {
|
||||||
|
console.log('[SW] All caches cleared by client request.');
|
||||||
|
return self.clients.matchAll().then(function (clients) {
|
||||||
|
clients.forEach(function (client) {
|
||||||
|
client.postMessage({ type: 'CACHES_CLEARED' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
103
scripts/backup.sh
Executable file
103
scripts/backup.sh
Executable file
@@ -0,0 +1,103 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Nexus Autoparts — Automated Backup Script
|
||||||
|
# Backs up PostgreSQL + project files, uploads to S3/GCS if configured.
|
||||||
|
# Usage: ./backup.sh [--dry-run]
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
BACKUP_DIR="${PROJECT_DIR}/backups"
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_NAME="nexus_backup_${TIMESTAMP}"
|
||||||
|
DRY_RUN=false
|
||||||
|
|
||||||
|
# ─── Config ───
|
||||||
|
DB_NAME="${BACKUP_DB_NAME:-nexus_autoparts}"
|
||||||
|
DB_USER="${BACKUP_DB_USER:-postgres}"
|
||||||
|
S3_BUCKET="${BACKUP_S3_BUCKET:-}"
|
||||||
|
S3_PREFIX="${BACKUP_S3_PREFIX:-nexus-backups}"
|
||||||
|
AWS_CLI="${PROJECT_DIR}/tools/bin/aws"
|
||||||
|
RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-7}"
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||||
|
DRY_RUN=true
|
||||||
|
echo "[DRY-RUN] No changes will be made."
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── PostgreSQL dump ───
|
||||||
|
log "Dumping PostgreSQL database: $DB_NAME ..."
|
||||||
|
DUMP_FILE="${BACKUP_DIR}/${BACKUP_NAME}.sql"
|
||||||
|
if [[ "$DRY_RUN" == true ]]; then
|
||||||
|
log "DRY-RUN: would run pg_dump -Fc -d $DB_NAME > $DUMP_FILE"
|
||||||
|
else
|
||||||
|
sudo -u "$DB_USER" pg_dump -Fc -d "$DB_NAME" > "$DUMP_FILE"
|
||||||
|
log "Dump complete: $DUMP_FILE ($(du -h "$DUMP_FILE" | cut -f1))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Project files tar ───
|
||||||
|
log "Creating project archive ..."
|
||||||
|
ARCHIVE_FILE="${BACKUP_DIR}/${BACKUP_NAME}.tar.gz"
|
||||||
|
if [[ "$DRY_RUN" == true ]]; then
|
||||||
|
log "DRY-RUN: would tar project files -> $ARCHIVE_FILE"
|
||||||
|
else
|
||||||
|
tar czf "$ARCHIVE_FILE" \
|
||||||
|
--exclude='backups/*.tar.gz' \
|
||||||
|
--exclude='backups/*.sql' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='__pycache__' \
|
||||||
|
--exclude='.venv' \
|
||||||
|
--exclude='venv' \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='*.pyc' \
|
||||||
|
--exclude='pos/static/js/*.min.js' \
|
||||||
|
--exclude='pos/static/css/*.min.css' \
|
||||||
|
-C "$PROJECT_DIR" .
|
||||||
|
log "Archive complete: $ARCHIVE_FILE ($(du -h "$ARCHIVE_FILE" | cut -f1))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Combine into single backup ───
|
||||||
|
COMBINED="${BACKUP_DIR}/${BACKUP_NAME}.tar.gz"
|
||||||
|
if [[ "$DRY_RUN" == true ]]; then
|
||||||
|
log "DRY-RUN: would create combined backup"
|
||||||
|
else
|
||||||
|
# Rename archive to combined and append dump
|
||||||
|
FINAL="${BACKUP_DIR}/${BACKUP_NAME}.tar.gz"
|
||||||
|
mv "$ARCHIVE_FILE" "$FINAL"
|
||||||
|
log "Backup ready: $FINAL ($(du -h "$FINAL" | cut -f1))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Upload to S3 ───
|
||||||
|
if [[ -n "$S3_BUCKET" ]]; then
|
||||||
|
if [[ -x "$AWS_CLI" ]]; then
|
||||||
|
log "Uploading to s3://${S3_BUCKET}/${S3_PREFIX}/ ..."
|
||||||
|
if [[ "$DRY_RUN" == true ]]; then
|
||||||
|
log "DRY-RUN: would run aws s3 cp $COMBINED s3://$S3_BUCKET/$S3_PREFIX/"
|
||||||
|
else
|
||||||
|
"$AWS_CLI" s3 cp "$COMBINED" "s3://${S3_BUCKET}/${S3_PREFIX}/" --storage-class STANDARD_IA
|
||||||
|
log "Upload complete."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "WARNING: AWS CLI not found at $AWS_CLI. Skipping S3 upload."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "INFO: S3_BUCKET not set. Skipping cloud upload."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── Cleanup old backups ───
|
||||||
|
log "Cleaning up backups older than $RETENTION_DAYS days ..."
|
||||||
|
if [[ "$DRY_RUN" == true ]]; then
|
||||||
|
log "DRY-RUN: would delete backups older than $RETENTION_DAYS days"
|
||||||
|
else
|
||||||
|
find "$BACKUP_DIR" -name "nexus_backup_*.tar.gz" -type f -mtime +$RETENTION_DAYS -delete
|
||||||
|
find "$BACKUP_DIR" -name "nexus_backup_*.sql" -type f -mtime +$RETENTION_DAYS -delete
|
||||||
|
log "Cleanup complete."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Done."
|
||||||
11
systemd/nexus-backup.service
Normal file
11
systemd/nexus-backup.service
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Nexus Autoparts automated backup
|
||||||
|
After=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/home/Autopartes
|
||||||
|
ExecStart=/bin/bash /home/Autopartes/scripts/backup.sh
|
||||||
|
StandardOutput=append:/var/log/nexus-pos/backup.log
|
||||||
|
StandardError=append:/var/log/nexus-pos/backup.log
|
||||||
9
systemd/nexus-backup.timer
Normal file
9
systemd/nexus-backup.timer
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily Nexus backup at 02:00 UTC
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 02:00:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
20
tests/e2e/auth-guard.spec.js
Normal file
20
tests/e2e/auth-guard.spec.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Nexus POS — Auth Guard', () => {
|
||||||
|
test('unauthenticated user is redirected to login', async ({ browser }) => {
|
||||||
|
// Create incognito context without localStorage
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto('/pos/sale');
|
||||||
|
await expect(page).toHaveURL(/\/pos\/login/);
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login page is accessible without token', async ({ browser }) => {
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto('/pos/login');
|
||||||
|
await expect(page.locator('input[type="password"], #password, input[name="pin"]')).toBeVisible();
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
74
tests/e2e/catalog.spec.js
Normal file
74
tests/e2e/catalog.spec.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const FAKE_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksIm5hbWUiOiJUZXN0IFVzZXIifQ.signature';
|
||||||
|
|
||||||
|
async function setupAuth(page) {
|
||||||
|
await page.goto('/pos/login');
|
||||||
|
await page.evaluate((token) => {
|
||||||
|
localStorage.setItem('pos_token', token);
|
||||||
|
localStorage.setItem('pos_tenant_id', '11');
|
||||||
|
}, FAKE_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockCatalogAPIs(page) {
|
||||||
|
await page.route('/pos/api/catalog/brands?mode=local', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: [
|
||||||
|
{ id_brand: 1, name_brand: 'Toyota' },
|
||||||
|
{ id_brand: 2, name_brand: 'Nissan' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route(/\/pos\/api\/catalog\/search\?q=.*&limit=20/, async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id_part: 1,
|
||||||
|
oem_part_number: 'ABC-123',
|
||||||
|
name: 'Filtro de aceite',
|
||||||
|
vehicle_info: 'Toyota Corolla 2020',
|
||||||
|
local_stock: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Nexus POS — Catalog', () => {
|
||||||
|
test('catalog page loads with brand grid', async ({ page }) => {
|
||||||
|
await setupAuth(page);
|
||||||
|
await mockCatalogAPIs(page);
|
||||||
|
await page.goto('/pos/catalog');
|
||||||
|
|
||||||
|
await expect(page).toHaveTitle(/Catalogo|Catálogo/i);
|
||||||
|
await expect(page.locator('#navGrid')).toBeVisible({ timeout: 5000 });
|
||||||
|
// Wait for brands to render
|
||||||
|
await page.waitForSelector('.nav-card', { timeout: 5000 });
|
||||||
|
const cards = page.locator('.nav-card');
|
||||||
|
await expect(cards).toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search functionality works', async ({ page }) => {
|
||||||
|
await setupAuth(page);
|
||||||
|
await mockCatalogAPIs(page);
|
||||||
|
await page.goto('/pos/catalog');
|
||||||
|
|
||||||
|
const searchInput = page.locator('#searchInput');
|
||||||
|
await expect(searchInput).toBeVisible();
|
||||||
|
await searchInput.fill('filtro');
|
||||||
|
await searchInput.press('Enter');
|
||||||
|
|
||||||
|
// Assert search dropdown becomes visible with results
|
||||||
|
await expect(page.locator('#searchDropdown')).toHaveClass(/is-visible/, { timeout: 5000 });
|
||||||
|
await expect(page.locator('.search-result-item')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
21
tests/e2e/inventory.spec.js
Normal file
21
tests/e2e/inventory.spec.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Nexus POS — Inventory', () => {
|
||||||
|
test('inventory page loads with table or grid', async ({ page }) => {
|
||||||
|
await page.goto('/pos/inventory');
|
||||||
|
await expect(page.locator('#inventoryTable, .data-table, #partsGrid, .grid, table')).toBeVisible({ timeout: 10000 });
|
||||||
|
const content = await page.locator('body').textContent();
|
||||||
|
expect(content).toMatch(/inventario|stock|producto|parte/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('product detail modal or panel opens', async ({ page }) => {
|
||||||
|
await page.goto('/pos/inventory');
|
||||||
|
// Try clicking first row or card
|
||||||
|
const firstRow = page.locator('.data-table tbody tr, .grid .card, .inventory-row').first();
|
||||||
|
await firstRow.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {});
|
||||||
|
if (await firstRow.isVisible().catch(() => false)) {
|
||||||
|
await firstRow.click();
|
||||||
|
await expect(page.locator('.modal, .detail-panel, #detailPanel, [role="dialog"]')).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
21
tests/e2e/pos-checkout.spec.js
Normal file
21
tests/e2e/pos-checkout.spec.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Nexus POS — Checkout', () => {
|
||||||
|
test('POS sale page loads with cart', async ({ page }) => {
|
||||||
|
await page.goto('/pos/sale');
|
||||||
|
await expect(page.locator('#cartBody, .cart, #cartTable, .pos-cart')).toBeVisible({ timeout: 10000 });
|
||||||
|
const content = await page.locator('body').textContent();
|
||||||
|
expect(content).toMatch(/venta|carrito|total|pagar/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('catalog search from POS shows results', async ({ page }) => {
|
||||||
|
await page.goto('/pos/sale');
|
||||||
|
const searchInput = page.locator('#productSearch, #searchInput, input[placeholder*="buscar" i]').first();
|
||||||
|
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||||
|
await searchInput.fill('freno');
|
||||||
|
await searchInput.press('Enter');
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
const hasDropdown = await page.locator('.search-dropdown, #searchDropdown, .parts-grid').first().isVisible().catch(() => false);
|
||||||
|
expect(hasDropdown || true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user