Compare commits

..

6 Commits

Author SHA1 Message Date
3cd2874ed7 test(e2e): improve catalog test with mocked APIs and auth
- catalog.spec.js: added fake JWT auth setup, mocked brand/search APIs
  with Playwright route.fulfill, asserts actual rendered cards and
  search dropdown visibility
2026-04-29 07:11:40 +00:00
cf46790ed8 feat(pwa): improve service worker with background sync, push, IndexedDB
- Bumped cache version to nexus-pos-v3
- Background sync for cart (nexus-cart-sync): replays pending
  requests from IndexedDB, clears queue on success
- Push notifications: parse payload, show notification, focus/open
  /pos/sale on click
- Offline cart strategy: queue failed POST /pos/api/cart/* in
  IndexedDB, return queued JSON response
- Message handlers: SKIP_WAITING (preserved) + CLEAR_CACHES
- Periodic background sync stub commented for future cache warming
2026-04-29 07:10:47 +00:00
45b69bcae8 test(e2e): add Playwright smoke tests for catalog, inventory, checkout, auth
- catalog.spec.js: brand grid loads, search interaction
- inventory.spec.js: table loads, detail modal opens
- pos-checkout.spec.js: cart visible, catalog search from POS
- auth-guard.spec.js: unauthenticated redirect to login
2026-04-29 07:10:34 +00:00
3792e4053c feat(monitoring): add Alertmanager with alert rules
- docker-compose.monitoring.yml: added alertmanager service (port 9093)
- prometheus.yml: alerting config + rule_files entry
- alerts.yml: 5 alert rules (PostgreSQLDown, RedisDown, HighDiskUsage,
  HighMemoryUsage, NodeDown)
- alertmanager.yml: SMTP + webhook receiver, inhibit rules
2026-04-29 07:10:22 +00:00
5a913dcac1 feat(monitoring): add Grafana dashboards for PostgreSQL, Redis, System, App
- nexus-postgresql.json: connections, transactions, cache hit, WAL,
  slow queries, table bloat
- nexus-redis.json: memory, commands/sec, clients, cache hit,
  keyspace hits/misses, evicted keys
- nexus-system.json: CPU, memory, disk, network, load average
- nexus-gunicorn.json: request rate, response time, workers,
  5xx errors, memory per worker
- dashboards.yml: auto-provisioning config
2026-04-29 07:10:01 +00:00
cc9a0cf57c feat(backup): automated daily backup script + systemd timer
- scripts/backup.sh: pg_dump + project tar, S3 upload (optional),
  local retention (7 days), dry-run support
- systemd/nexus-backup.service + nexus-backup.timer: daily at 02:00 UTC
- AWS CLI v2 installed locally in tools/ for S3 uploads
2026-04-29 07:09:43 +00:00
18 changed files with 1286 additions and 6 deletions

4
.gitignore vendored
View File

@@ -87,3 +87,7 @@ package-lock.json
# Backups # Backups
backups/ backups/
# Local tools (AWS CLI)
tools/

View 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']

View File

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

View 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

View 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": []
}
}
]
}

View 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": []
}
}
]
}

View 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": []
}
}
]
}

View 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": []
}
}
]
}

View 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."

View File

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

View File

@@ -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
View 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."

View 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

View 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

View 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
View 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();
});
});

View 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 });
}
});
});

View 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);
});
});