Continue phase 2 device ops and dynamic QR lifecycle
This commit is contained in:
@ -9,7 +9,11 @@
|
|||||||
- endpoint admin `GET /admin/audit-logs` dan `GET /admin/ledger-entries`
|
- endpoint admin `GET /admin/audit-logs` dan `GET /admin/ledger-entries`
|
||||||
- awal Fase 2: capability resolver + `POST /device/transactions/dynamic-qr` API-direct
|
- awal Fase 2: capability resolver + `POST /device/transactions/dynamic-qr` API-direct
|
||||||
- lanjutan Fase 2: MQTT dynamic QR simulator/outbox + device config push/ack
|
- lanjutan Fase 2: MQTT dynamic QR simulator/outbox + device config push/ack
|
||||||
- smoke e2e mencakup duplicate callback, invalid signature, ledger, audit, terminal tanpa binding, dynamic QR API-direct, dynamic QR MQTT, dan device config
|
- lanjutan Fase 2 berikutnya: config drift status + retry push config + MQTT trace untuk config ACK
|
||||||
|
- health summary device Fase 2 untuk admin list/detail: status, score, age_seconds, reasons
|
||||||
|
- UI ops Fase 2 di device registry/detail: health score/reasons, config drift, retry config push
|
||||||
|
- dynamic QR expiry sweep via `POST /admin/transactions/expire-due`
|
||||||
|
- smoke e2e mencakup duplicate callback, invalid signature, ledger, audit, terminal tanpa binding, dynamic QR API-direct, dynamic QR expiry sweep, dynamic QR MQTT, dan device config push/status/retry/ack
|
||||||
- fix UI lokal:
|
- fix UI lokal:
|
||||||
- CSP Helmet dilonggarkan untuk Tailwind CDN, Google Fonts/Material Symbols, dan image Googleusercontent agar desain render normal
|
- CSP Helmet dilonggarkan untuk Tailwind CDN, Google Fonts/Material Symbols, dan image Googleusercontent agar desain render normal
|
||||||
- panel kanan login admin dibuat dark glass supaya teks putih terbaca
|
- panel kanan login admin dibuat dark glass supaya teks putih terbaca
|
||||||
@ -42,10 +46,18 @@
|
|||||||
- Resolver capability untuk flow dynamic QR API/MQTT.
|
- Resolver capability untuk flow dynamic QR API/MQTT.
|
||||||
- [Backend: dynamicQrOrchestrator](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/dynamicQrOrchestrator.ts)
|
- [Backend: dynamicQrOrchestrator](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/dynamicQrOrchestrator.ts)
|
||||||
- Membuat transaksi dynamic `awaiting_payment` dan mock QR payload.
|
- Membuat transaksi dynamic `awaiting_payment` dan mock QR payload.
|
||||||
|
- [Backend: dynamicQrExpiry](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/dynamicQrExpiry.ts)
|
||||||
|
- Sweep transaksi dynamic QR `awaiting_payment` yang sudah melewati `expired_at`.
|
||||||
- [Backend: mqttMessageStore](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/mqttMessageStore.ts)
|
- [Backend: mqttMessageStore](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/mqttMessageStore.ts)
|
||||||
- Outbox/trace MQTT uplink dan downlink.
|
- Outbox/trace MQTT uplink dan downlink.
|
||||||
- [Backend: deviceConfigStore](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/deviceConfigStore.ts)
|
- [Backend: deviceConfigStore](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/deviceConfigStore.ts)
|
||||||
- Config versioned dan ACK device.
|
- Config versioned dan ACK device.
|
||||||
|
- [Backend: deviceConfigStatus](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/deviceConfigStatus.ts)
|
||||||
|
- Derivasi status drift config: `applied`, `pending_ack`, `failed_ack`, `stale_ack`, `never_pushed`.
|
||||||
|
- [UI: device-registry-monitoring](/home/wira/work/codex/qris-soundbox-platform/ui/device-registry-monitoring/index.html)
|
||||||
|
- Drawer ops menampilkan health score/reasons, config drift, latest push/ACK, dan retry config push.
|
||||||
|
- [UI: device-technical-detail](/home/wira/work/codex/qris-soundbox-platform/ui/device-technical-detail/index.html)
|
||||||
|
- Detail device menampilkan health summary dan config delivery panel dengan retry push.
|
||||||
- [App CSP](/home/wira/work/codex/qris-soundbox-platform/src/app.ts)
|
- [App CSP](/home/wira/work/codex/qris-soundbox-platform/src/app.ts)
|
||||||
- Helmet CSP disesuaikan agar asset desain eksternal dapat dimuat di lokal.
|
- Helmet CSP disesuaikan agar asset desain eksternal dapat dimuat di lokal.
|
||||||
- [UI: admin-login](/home/wira/work/codex/qris-soundbox-platform/ui/admin-login/index.html)
|
- [UI: admin-login](/home/wira/work/codex/qris-soundbox-platform/ui/admin-login/index.html)
|
||||||
@ -62,7 +74,9 @@
|
|||||||
6. Ledger Fase 1 masih placeholder `gross_income`; jangan perluas fee/payable sebelum Fase 3 kecuali diminta eksplisit.
|
6. Ledger Fase 1 masih placeholder `gross_income`; jangan perluas fee/payable sebelum Fase 3 kecuali diminta eksplisit.
|
||||||
7. Dynamic QR Fase 2 saat ini memakai mock QRIS payload lokal; integrasi partner sungguhan belum dipasang.
|
7. Dynamic QR Fase 2 saat ini memakai mock QRIS payload lokal; integrasi partner sungguhan belum dipasang.
|
||||||
8. MQTT Fase 2 saat ini memakai simulator HTTP + `mqtt_messages` outbox; broker sungguhan belum dipasang.
|
8. MQTT Fase 2 saat ini memakai simulator HTTP + `mqtt_messages` outbox; broker sungguhan belum dipasang.
|
||||||
9. Untuk cek UI lokal, gunakan `http://127.0.0.1:3100/ui/admin-login`; credential dev adalah username `admin`, password `admin`.
|
9. Config retry Fase 2 mengirim ulang config version yang sama; jangan naikkan versi kecuali settings berubah.
|
||||||
|
10. Dynamic QR expiry sweep saat ini endpoint admin/manual; bisa dinaikkan menjadi scheduler/background worker.
|
||||||
|
11. Untuk cek UI lokal, gunakan `http://127.0.0.1:3100/ui/admin-login`; credential dev adalah username `admin`, password `admin`.
|
||||||
|
|
||||||
## Urutan kerja selanjutnya (disarankan)
|
## Urutan kerja selanjutnya (disarankan)
|
||||||
1. UI/manual sanity lanjut dari titik terakhir:
|
1. UI/manual sanity lanjut dari titik terakhir:
|
||||||
@ -74,9 +88,10 @@
|
|||||||
2. Jalankan lagi `npm run smoke:e2e` sebelum lanjut Fase 2 atau sebelum commit besar.
|
2. Jalankan lagi `npm run smoke:e2e` sebelum lanjut Fase 2 atau sebelum commit besar.
|
||||||
3. Jika ada regresi, cek log server di `/tmp/qris-smoke-e2e-server.log`.
|
3. Jika ada regresi, cek log server di `/tmp/qris-smoke-e2e-server.log`.
|
||||||
4. Lanjut Fase 2 berikutnya:
|
4. Lanjut Fase 2 berikutnya:
|
||||||
- health score/filter heartbeat yang lebih akurat
|
|
||||||
- adapter broker MQTT sungguhan dari `mqtt_messages` outbox
|
- adapter broker MQTT sungguhan dari `mqtt_messages` outbox
|
||||||
- config drift/retry policy untuk device yang belum ACK
|
- scheduler otomatis untuk dynamic QR expiry sweep
|
||||||
|
- filter/sorting UI berbasis `health_summary.score` dan `health_summary.reasons`
|
||||||
|
- manual visual QA device registry/detail untuk layout mobile dan drawer
|
||||||
5. Sebelum wiring UI baru, pastikan halaman tetap mengikuti desain `design/*/code.html` dan cek kontras teks pada panel transparan/overlay.
|
5. Sebelum wiring UI baru, pastikan halaman tetap mengikuti desain `design/*/code.html` dan cek kontras teks pada panel transparan/overlay.
|
||||||
|
|
||||||
## Note kalau meneruskan sesi berikutnya
|
## Note kalau meneruskan sesi berikutnya
|
||||||
|
|||||||
@ -233,3 +233,58 @@ Log keputusan arsitektur dan implementasi yang harus dijadikan acuan eksekusi.
|
|||||||
- Dynamic MQTT request memakai `request_id` sebagai `correlation_id` dan idempotency key.
|
- Dynamic MQTT request memakai `request_id` sebagai `correlation_id` dan idempotency key.
|
||||||
- Device config selalu versioned; ACK dicatat terpisah di `device_config_acks`.
|
- Device config selalu versioned; ACK dicatat terpisah di `device_config_acks`.
|
||||||
- Status: Active
|
- Status: Active
|
||||||
|
|
||||||
|
## D-022 — Config Drift dan Retry Push Fase 2
|
||||||
|
- Tanggal: 2026-05-26
|
||||||
|
- Keputusan:
|
||||||
|
- Fase 2 menambahkan status drift config device melalui `GET /admin/devices/{deviceId}/config/status`.
|
||||||
|
- Retry config dilakukan via `POST /admin/devices/{deviceId}/config/retry-push` tanpa menaikkan `config_version`.
|
||||||
|
- ACK config dari device juga dicatat sebagai uplink trace di `mqtt_messages` dengan `message_type=config_ack`.
|
||||||
|
- Alasan:
|
||||||
|
- Operasi perlu membedakan config `applied`, `pending_ack`, `failed_ack`, `stale_ack`, dan `never_pushed` sebelum broker MQTT sungguhan dipasang.
|
||||||
|
- Retry harus mengirim ulang desired config yang sama agar idempotent dan tidak membuat drift versi buatan.
|
||||||
|
- Dampak / implikasi:
|
||||||
|
- Config yang sudah `applied` tidak boleh di-retry kecuali admin mengirim `force=true`.
|
||||||
|
- `mqtt_messages` menjadi timeline awal untuk config push dan ACK device.
|
||||||
|
- Saat broker MQTT asli masuk, endpoint retry tetap menjadi trigger adapter/outbox, bukan tempat menyimpan logic konfigurasi baru.
|
||||||
|
- Status: Active
|
||||||
|
|
||||||
|
## D-023 — Health Summary Device Fase 2
|
||||||
|
- Tanggal: 2026-05-26
|
||||||
|
- Keputusan:
|
||||||
|
- Endpoint admin device menambahkan `health_summary` berisi `status`, `score`, `age_seconds`, dan `reasons`.
|
||||||
|
- `derived_status` tetap dipertahankan untuk kompatibilitas UI, namun nilainya berasal dari rule health summary yang sama.
|
||||||
|
- Alasan:
|
||||||
|
- Operasi membutuhkan konteks kenapa device online/degraded/stale/offline, bukan hanya label status.
|
||||||
|
- Skor 0-100 memudahkan sorting/filter dashboard tanpa mengubah threshold status Fase 1.
|
||||||
|
- Dampak / implikasi:
|
||||||
|
- Status masih mengikuti threshold Fase 1: online <90 detik, stale >90 detik, offline >15 menit, degraded untuk sinyal/baterai buruk.
|
||||||
|
- UI dapat memakai `health_summary.reasons` untuk badge/tooltip ops.
|
||||||
|
- Status: Active
|
||||||
|
|
||||||
|
## D-024 — UI Ops Device Fase 2
|
||||||
|
- Tanggal: 2026-05-26
|
||||||
|
- Keputusan:
|
||||||
|
- UI device registry dan device technical detail menampilkan `health_summary` dan config delivery status.
|
||||||
|
- Retry config push tersedia dari drawer device registry dan halaman detail device.
|
||||||
|
- Alasan:
|
||||||
|
- Operator perlu melihat status Fase 2 tanpa membuka raw payload/API response.
|
||||||
|
- Retry config adalah tindakan operasional langsung, sehingga harus dekat dengan status drift config.
|
||||||
|
- Dampak / implikasi:
|
||||||
|
- UI memakai endpoint `GET /admin/devices/{deviceId}/config/status` dan `POST /admin/devices/{deviceId}/config/retry-push`.
|
||||||
|
- `derived_status` tetap dipakai sebagai fallback/compatibility, sementara health score/reasons menjadi konteks tambahan.
|
||||||
|
- Status: Active
|
||||||
|
|
||||||
|
## D-025 — Dynamic QR Expiry Sweep Fase 2
|
||||||
|
- Tanggal: 2026-05-26
|
||||||
|
- Keputusan:
|
||||||
|
- Dynamic QR `awaiting_payment` yang melewati `expired_at` dapat ditutup oleh sweep internal `POST /admin/transactions/expire-due`.
|
||||||
|
- Sweep hanya berlaku untuk transaksi `qr_mode=dynamic` dan status `awaiting_payment`.
|
||||||
|
- Alasan:
|
||||||
|
- Fase 2 tidak boleh bergantung penuh pada callback partner untuk menutup QR yang kadaluarsa.
|
||||||
|
- Expiry internal menjaga daftar transaksi pending tetap akurat untuk ops dan device flow.
|
||||||
|
- Dampak / implikasi:
|
||||||
|
- Sweep menulis `STATE_CHANGED` event dengan reason `dynamic_qr_expired`.
|
||||||
|
- Callback paid yang datang setelah transaksi sudah `expired` tetap ditolak oleh state transition guard.
|
||||||
|
- Endpoint admin ini bisa menjadi dasar scheduler/background worker saat fase operasional berikutnya.
|
||||||
|
- Status: Active
|
||||||
|
|||||||
@ -59,12 +59,15 @@ Dokumen ini dibuat supaya tim bisa langsung mulai:
|
|||||||
- `GET /admin/devices/{id}/notifications`
|
- `GET /admin/devices/{id}/notifications`
|
||||||
- `GET /admin/devices/{id}/config`
|
- `GET /admin/devices/{id}/config`
|
||||||
- `PATCH /admin/devices/{id}/config`
|
- `PATCH /admin/devices/{id}/config`
|
||||||
|
- `GET /admin/devices/{id}/config/status`
|
||||||
|
- `POST /admin/devices/{id}/config/retry-push`
|
||||||
- `GET /admin/devices/{id}/mqtt-messages`
|
- `GET /admin/devices/{id}/mqtt-messages`
|
||||||
- `GET /admin/audit-logs`
|
- `GET /admin/audit-logs`
|
||||||
- `GET /admin/ledger-entries`
|
- `GET /admin/ledger-entries`
|
||||||
- `GET /admin/transactions`
|
- `GET /admin/transactions`
|
||||||
- `GET /admin/transactions/{transactionId}`
|
- `GET /admin/transactions/{transactionId}`
|
||||||
- `POST /admin/transactions`
|
- `POST /admin/transactions`
|
||||||
|
- `POST /admin/transactions/expire-due`
|
||||||
- `GET /admin/transactions/{transactionId}/events`
|
- `GET /admin/transactions/{transactionId}/events`
|
||||||
- `POST /admin/transactions/{transactionId}/retry-notification`
|
- `POST /admin/transactions/{transactionId}/retry-notification`
|
||||||
- `POST /admin/seed`
|
- `POST /admin/seed`
|
||||||
@ -94,7 +97,7 @@ Cleanup hanya menarget entitas smoke (`Smoke Merchant`, `PR-`, `DEV-`) agar data
|
|||||||
PORT=3100 ADMIN_TOKEN=admin-dev-token DEVICE_TOKEN=device-dev-token INTEGRATION_WEBHOOK_SECRET=dev-callback-secret PGHOST=127.0.0.1 PGPORT=5432 PGUSER=postgres PGPASSWORD=postgres PGDATABASE=qris_soundbox_platform npm run smoke:flow
|
PORT=3100 ADMIN_TOKEN=admin-dev-token DEVICE_TOKEN=device-dev-token INTEGRATION_WEBHOOK_SECRET=dev-callback-secret PGHOST=127.0.0.1 PGPORT=5432 PGUSER=postgres PGPASSWORD=postgres PGDATABASE=qris_soundbox_platform npm run smoke:flow
|
||||||
```
|
```
|
||||||
|
|
||||||
Smoke flow akan melakukan create merchant/device/transaction + heartbeat + callback paid + verifikasi event/heartbeat/notification, duplicate callback, invalid signature, audit log, ledger placeholder, skenario terminal tanpa binding, dynamic QR API-direct, dynamic QR MQTT, dan device config push/ack.
|
Smoke flow akan melakukan create merchant/device/transaction + heartbeat + callback paid + verifikasi event/heartbeat/notification, duplicate callback, invalid signature, audit log, ledger placeholder, skenario terminal tanpa binding, dynamic QR API-direct, expiry sweep dynamic QR, dynamic QR MQTT, device config push/retry/status/ack, dan trace MQTT config ack.
|
||||||
|
|
||||||
### Smoke test end-to-end (bootstrap + flow + cleanup)
|
### Smoke test end-to-end (bootstrap + flow + cleanup)
|
||||||
|
|
||||||
@ -118,4 +121,6 @@ Perintah ini menjalankan:
|
|||||||
- `GET /ui` => katalog halaman UI dari seluruh `design/*`.
|
- `GET /ui` => katalog halaman UI dari seluruh `design/*`.
|
||||||
- `GET /ui/:page` => buka halaman berdasarkan slug (contoh: `/ui/admin-login`, `/ui/admin-dashboard-overview`, `/ui/merchant-login`).
|
- `GET /ui/:page` => buka halaman berdasarkan slug (contoh: `/ui/admin-login`, `/ui/admin-dashboard-overview`, `/ui/merchant-login`).
|
||||||
|
|
||||||
Status lanjutan: Fase 1 core flow sudah tercakup smoke e2e. Fase 2 sudah aktif untuk capability resolver, dynamic QR API-direct, dynamic QR MQTT via outbox, dan device config push/ack.
|
Status lanjutan: Fase 1 core flow sudah tercakup smoke e2e. Fase 2 sudah aktif untuk capability resolver, dynamic QR API-direct, dynamic QR MQTT via outbox, dan device config push/status/retry/ack.
|
||||||
|
|
||||||
|
Catatan Fase 2 ops: endpoint daftar/detail device admin juga mengirim `health_summary` (`status`, `score`, `age_seconds`, `reasons`) untuk membantu triage device. UI device registry dan device technical detail sudah menampilkan health summary, config drift, dan retry config push.
|
||||||
|
|||||||
158
dist/routes/admin.js
vendored
158
dist/routes/admin.js
vendored
@ -9,7 +9,7 @@ import { createMerchant, getMerchantById, listMerchants, patchMerchant, toMercha
|
|||||||
import { createOutlet, createTerminal, getOutletById, getTerminalById, listOutlets, listTerminals, patchOutlet, patchTerminal, toOutletPayload, toTerminalPayload } from "../shared/store/locationStore";
|
import { createOutlet, createTerminal, getOutletById, getTerminalById, listOutlets, listTerminals, patchOutlet, patchTerminal, toOutletPayload, toTerminalPayload } from "../shared/store/locationStore";
|
||||||
import { bindDevice, getActiveBindingByDevice, getActiveBindingByTerminal, toBindingPayload, unbindDevice } from "../shared/store/bindingStore";
|
import { bindDevice, getActiveBindingByDevice, getActiveBindingByTerminal, toBindingPayload, unbindDevice } from "../shared/store/bindingStore";
|
||||||
import { createDevice, getDeviceById, listDevices, patchDevice, toDevicePayload } from "../shared/store/deviceStore";
|
import { createDevice, getDeviceById, listDevices, patchDevice, toDevicePayload } from "../shared/store/deviceStore";
|
||||||
import { deriveDeviceStatus, getHeartbeatCountForDeviceLastHours, getLatestHeartbeatByDeviceId, listHeartbeats, createDeviceHeartbeat } from "../shared/store/heartbeatStore";
|
import { deriveDeviceHealthSummary, getHeartbeatCountForDeviceLastHours, getLatestHeartbeatByDeviceId, listHeartbeats, createDeviceHeartbeat } from "../shared/store/heartbeatStore";
|
||||||
import { createDeviceCommand, getDeviceCommandById, listDeviceCommands, toDeviceCommandPayload, toDeviceCommandPayloadBrief } from "../shared/store/deviceCommandStore";
|
import { createDeviceCommand, getDeviceCommandById, listDeviceCommands, toDeviceCommandPayload, toDeviceCommandPayloadBrief } from "../shared/store/deviceCommandStore";
|
||||||
import { createTransaction, getTransactionById, listTransactions, toTransactionEventPayload, toTransactionPayload, getTransactionEvents } from "../shared/store/transactionStore";
|
import { createTransaction, getTransactionById, listTransactions, toTransactionEventPayload, toTransactionPayload, getTransactionEvents } from "../shared/store/transactionStore";
|
||||||
import { getNotificationByTransactionId, listNotifications, listNotificationsByDevice, toNotificationPayload } from "../shared/store/notificationStore";
|
import { getNotificationByTransactionId, listNotifications, listNotificationsByDevice, toNotificationPayload } from "../shared/store/notificationStore";
|
||||||
@ -20,6 +20,8 @@ import { resolveDeviceCapabilitySummary } from "../shared/services/deviceCapabil
|
|||||||
import { getOrCreateDeviceConfig, listDeviceConfigAcks, toDeviceConfigAckPayload, toDeviceConfigPayload, upsertDeviceConfig } from "../shared/store/deviceConfigStore";
|
import { getOrCreateDeviceConfig, listDeviceConfigAcks, toDeviceConfigAckPayload, toDeviceConfigPayload, upsertDeviceConfig } from "../shared/store/deviceConfigStore";
|
||||||
import { listMqttMessages, toMqttMessagePayload, createMqttMessage } from "../shared/store/mqttMessageStore";
|
import { listMqttMessages, toMqttMessagePayload, createMqttMessage } from "../shared/store/mqttMessageStore";
|
||||||
import { publishConfigPush } from "../shared/services/mqttPublisher";
|
import { publishConfigPush } from "../shared/services/mqttPublisher";
|
||||||
|
import { buildDeviceConfigStatus } from "../shared/services/deviceConfigStatus";
|
||||||
|
import { expireDueDynamicQrTransactions } from "../shared/services/dynamicQrExpiry";
|
||||||
const router = Router();
|
const router = Router();
|
||||||
function parseIdempotentReplay(req) {
|
function parseIdempotentReplay(req) {
|
||||||
return req.body.__idempotentReplay;
|
return req.body.__idempotentReplay;
|
||||||
@ -124,14 +126,16 @@ function buildBindingSummary(binding) {
|
|||||||
}
|
}
|
||||||
async function buildDeviceAdminPayload(device) {
|
async function buildDeviceAdminPayload(device) {
|
||||||
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
||||||
|
const healthSummary = deriveDeviceHealthSummary({
|
||||||
|
last_seen_at: device.last_seen_at,
|
||||||
|
network_strength: latestHeartbeat?.network_strength ?? null,
|
||||||
|
battery_level: latestHeartbeat?.battery_level ?? null
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...toDevicePayload(device),
|
...toDevicePayload(device),
|
||||||
capability_summary: resolveDeviceCapabilitySummary(device),
|
capability_summary: resolveDeviceCapabilitySummary(device),
|
||||||
derived_status: deriveDeviceStatus({
|
derived_status: healthSummary.status,
|
||||||
last_seen_at: device.last_seen_at,
|
health_summary: healthSummary,
|
||||||
network_strength: latestHeartbeat?.network_strength ?? null,
|
|
||||||
battery_level: latestHeartbeat?.battery_level ?? null
|
|
||||||
}),
|
|
||||||
heartbeat_count_24h: await getHeartbeatCountForDeviceLastHours(device.id),
|
heartbeat_count_24h: await getHeartbeatCountForDeviceLastHours(device.id),
|
||||||
binding_summary: buildBindingSummary(await getActiveBindingByDevice(device.id)),
|
binding_summary: buildBindingSummary(await getActiveBindingByDevice(device.id)),
|
||||||
latest_heartbeat: latestHeartbeat
|
latest_heartbeat: latestHeartbeat
|
||||||
@ -151,13 +155,15 @@ async function deriveDeviceStatusesForDashboard() {
|
|||||||
const devices = await listDevices();
|
const devices = await listDevices();
|
||||||
return Promise.all(devices.map(async (device) => {
|
return Promise.all(devices.map(async (device) => {
|
||||||
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
||||||
|
const healthSummary = deriveDeviceHealthSummary({
|
||||||
|
last_seen_at: device.last_seen_at,
|
||||||
|
network_strength: latestHeartbeat?.network_strength ?? null,
|
||||||
|
battery_level: latestHeartbeat?.battery_level ?? null
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
device,
|
device,
|
||||||
status: deriveDeviceStatus({
|
status: healthSummary.status,
|
||||||
last_seen_at: device.last_seen_at,
|
healthSummary
|
||||||
network_strength: latestHeartbeat?.network_strength ?? null,
|
|
||||||
battery_level: latestHeartbeat?.battery_level ?? null
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@ -197,6 +203,24 @@ async function auditAdminAction(req, payload) {
|
|||||||
trace_id: req.traceId
|
trace_id: req.traceId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async function publishDeviceConfigPush(deviceId, config) {
|
||||||
|
const mqttPayload = {
|
||||||
|
message_type: "config_push",
|
||||||
|
config_version: config.config_version,
|
||||||
|
settings: config.settings_json
|
||||||
|
};
|
||||||
|
const publishResult = await publishConfigPush(deviceId, mqttPayload);
|
||||||
|
return createMqttMessage({
|
||||||
|
direction: "downlink",
|
||||||
|
device_id: deviceId,
|
||||||
|
topic: publishResult.topic,
|
||||||
|
message_type: "config_push",
|
||||||
|
correlation_id: `config:${config.config_version}`,
|
||||||
|
payload_json: mqttPayload,
|
||||||
|
publish_status: publishResult.ok ? "sent" : "failed",
|
||||||
|
reason: publishResult.reason
|
||||||
|
});
|
||||||
|
}
|
||||||
function validatePayoutConfig(payload) {
|
function validatePayoutConfig(payload) {
|
||||||
const mode = normalizeMerchantMode(payload.payout_mode);
|
const mode = normalizeMerchantMode(payload.payout_mode);
|
||||||
if (mode === "merchant_direct") {
|
if (mode === "merchant_direct") {
|
||||||
@ -739,15 +763,16 @@ router.get("/devices", requireAdminToken, async (req, res) => {
|
|||||||
const evaluated = await Promise.all(rawDevices.map(async (device) => {
|
const evaluated = await Promise.all(rawDevices.map(async (device) => {
|
||||||
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
||||||
const binding = merchantId ? await getActiveBindingByDevice(device.id) : null;
|
const binding = merchantId ? await getActiveBindingByDevice(device.id) : null;
|
||||||
|
const healthSummary = deriveDeviceHealthSummary({
|
||||||
|
last_seen_at: device.last_seen_at,
|
||||||
|
network_strength: latestHeartbeat?.network_strength ?? null,
|
||||||
|
battery_level: latestHeartbeat?.battery_level ?? null
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
device,
|
device,
|
||||||
latestHeartbeat,
|
latestHeartbeat,
|
||||||
binding,
|
binding,
|
||||||
derivedStatus: deriveDeviceStatus({
|
derivedStatus: healthSummary.status
|
||||||
last_seen_at: device.last_seen_at,
|
|
||||||
network_strength: latestHeartbeat?.network_strength ?? null,
|
|
||||||
battery_level: latestHeartbeat?.battery_level ?? null
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
const data = evaluated
|
const data = evaluated
|
||||||
@ -789,6 +814,11 @@ router.get("/devices/:deviceId", requireAdminToken, async (req, res, next) => {
|
|||||||
const activeBinding = await getActiveBindingByDevice(device.id);
|
const activeBinding = await getActiveBindingByDevice(device.id);
|
||||||
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
||||||
const heartbeatCount24h = await getHeartbeatCountForDeviceLastHours(device.id);
|
const heartbeatCount24h = await getHeartbeatCountForDeviceLastHours(device.id);
|
||||||
|
const healthSummary = deriveDeviceHealthSummary({
|
||||||
|
last_seen_at: device.last_seen_at,
|
||||||
|
network_strength: latestHeartbeat?.network_strength ?? null,
|
||||||
|
battery_level: latestHeartbeat?.battery_level ?? null
|
||||||
|
});
|
||||||
const notifications = (await listNotificationsByDevice(device.id))
|
const notifications = (await listNotificationsByDevice(device.id))
|
||||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
@ -796,11 +826,8 @@ router.get("/devices/:deviceId", requireAdminToken, async (req, res, next) => {
|
|||||||
res.json(successResponse(req, {
|
res.json(successResponse(req, {
|
||||||
...toDevicePayload(device),
|
...toDevicePayload(device),
|
||||||
capability_summary: resolveDeviceCapabilitySummary(device),
|
capability_summary: resolveDeviceCapabilitySummary(device),
|
||||||
derived_status: deriveDeviceStatus({
|
derived_status: healthSummary.status,
|
||||||
last_seen_at: device.last_seen_at,
|
health_summary: healthSummary,
|
||||||
network_strength: latestHeartbeat?.network_strength ?? null,
|
|
||||||
battery_level: latestHeartbeat?.battery_level ?? null
|
|
||||||
}),
|
|
||||||
active_binding: activeBinding ? toBindingPayload(activeBinding) : null,
|
active_binding: activeBinding ? toBindingPayload(activeBinding) : null,
|
||||||
latest_heartbeat: latestHeartbeat
|
latest_heartbeat: latestHeartbeat
|
||||||
? {
|
? {
|
||||||
@ -994,7 +1021,16 @@ router.get("/devices/:deviceId/config", requireAdminToken, async (req, res, next
|
|||||||
}
|
}
|
||||||
const config = await getOrCreateDeviceConfig(device.id);
|
const config = await getOrCreateDeviceConfig(device.id);
|
||||||
const acks = (await listDeviceConfigAcks(device.id, 10)).map(toDeviceConfigAckPayload);
|
const acks = (await listDeviceConfigAcks(device.id, 10)).map(toDeviceConfigAckPayload);
|
||||||
res.json(successResponse(req, { ...toDeviceConfigPayload(config), latest_acks: acks }));
|
const status = await buildDeviceConfigStatus(config);
|
||||||
|
res.json(successResponse(req, { ...toDeviceConfigPayload(config), latest_acks: acks, config_status: status }));
|
||||||
|
});
|
||||||
|
router.get("/devices/:deviceId/config/status", requireAdminToken, async (req, res, next) => {
|
||||||
|
const device = await getDeviceById(req.params.deviceId);
|
||||||
|
if (!device) {
|
||||||
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
||||||
|
}
|
||||||
|
const config = await getOrCreateDeviceConfig(device.id);
|
||||||
|
res.json(successResponse(req, await buildDeviceConfigStatus(config)));
|
||||||
});
|
});
|
||||||
router.patch("/devices/:deviceId/config", requireAdminToken, async (req, res, next) => {
|
router.patch("/devices/:deviceId/config", requireAdminToken, async (req, res, next) => {
|
||||||
const device = await getDeviceById(req.params.deviceId);
|
const device = await getDeviceById(req.params.deviceId);
|
||||||
@ -1010,22 +1046,7 @@ router.patch("/devices/:deviceId/config", requireAdminToken, async (req, res, ne
|
|||||||
settings_json: payload.settings,
|
settings_json: payload.settings,
|
||||||
config_version: payload.config_version
|
config_version: payload.config_version
|
||||||
});
|
});
|
||||||
const mqttPayload = {
|
const outbox = await publishDeviceConfigPush(device.id, config);
|
||||||
message_type: "config_push",
|
|
||||||
config_version: config.config_version,
|
|
||||||
settings: config.settings_json
|
|
||||||
};
|
|
||||||
const publishResult = await publishConfigPush(device.id, mqttPayload);
|
|
||||||
const outbox = await createMqttMessage({
|
|
||||||
direction: "downlink",
|
|
||||||
device_id: device.id,
|
|
||||||
topic: publishResult.topic,
|
|
||||||
message_type: "config_push",
|
|
||||||
correlation_id: `config:${config.config_version}`,
|
|
||||||
payload_json: mqttPayload,
|
|
||||||
publish_status: publishResult.ok ? "sent" : "failed",
|
|
||||||
reason: publishResult.reason
|
|
||||||
});
|
|
||||||
await auditAdminAction(req, {
|
await auditAdminAction(req, {
|
||||||
action: "device.config_push",
|
action: "device.config_push",
|
||||||
entity_type: "device",
|
entity_type: "device",
|
||||||
@ -1040,6 +1061,36 @@ router.patch("/devices/:deviceId/config", requireAdminToken, async (req, res, ne
|
|||||||
downlink_message: toMqttMessagePayload(outbox)
|
downlink_message: toMqttMessagePayload(outbox)
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
router.post("/devices/:deviceId/config/retry-push", requireAdminToken, async (req, res, next) => {
|
||||||
|
const device = await getDeviceById(req.params.deviceId);
|
||||||
|
if (!device) {
|
||||||
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
||||||
|
}
|
||||||
|
const payload = (req.body || {});
|
||||||
|
const config = await getOrCreateDeviceConfig(device.id);
|
||||||
|
const beforeStatus = await buildDeviceConfigStatus(config);
|
||||||
|
if (beforeStatus.drift_status === "applied" && !payload.force) {
|
||||||
|
return next(new ApiError("CONFIG_ALREADY_APPLIED", "config already applied; set force=true to push again", 409));
|
||||||
|
}
|
||||||
|
const outbox = await publishDeviceConfigPush(device.id, config);
|
||||||
|
const afterStatus = await buildDeviceConfigStatus(config);
|
||||||
|
await auditAdminAction(req, {
|
||||||
|
action: "device.config_retry_push",
|
||||||
|
entity_type: "device",
|
||||||
|
entity_id: device.id,
|
||||||
|
before_json: beforeStatus,
|
||||||
|
after_json: {
|
||||||
|
...afterStatus,
|
||||||
|
downlink_message_id: outbox.id,
|
||||||
|
force: payload.force === true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json(successResponse(req, {
|
||||||
|
config: toDeviceConfigPayload(config),
|
||||||
|
config_status: afterStatus,
|
||||||
|
downlink_message: toMqttMessagePayload(outbox)
|
||||||
|
}));
|
||||||
|
});
|
||||||
router.get("/devices/:deviceId/mqtt-messages", requireAdminToken, async (req, res, next) => {
|
router.get("/devices/:deviceId/mqtt-messages", requireAdminToken, async (req, res, next) => {
|
||||||
const device = await getDeviceById(req.params.deviceId);
|
const device = await getDeviceById(req.params.deviceId);
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -1106,6 +1157,9 @@ router.post("/transactions", requireAdminToken, idempotency({ scope: "transactio
|
|||||||
if (payload.status && !parseTransactionStatusFilter(payload.status)) {
|
if (payload.status && !parseTransactionStatusFilter(payload.status)) {
|
||||||
return next(new ApiError("BAD_REQUEST", "invalid status", 400));
|
return next(new ApiError("BAD_REQUEST", "invalid status", 400));
|
||||||
}
|
}
|
||||||
|
if (payload.expired_at && Number.isNaN(Date.parse(payload.expired_at))) {
|
||||||
|
return next(new ApiError("BAD_REQUEST", "expired_at must be valid ISO datetime", 400));
|
||||||
|
}
|
||||||
const created = await createTransaction({
|
const created = await createTransaction({
|
||||||
merchant_id: merchant.id,
|
merchant_id: merchant.id,
|
||||||
outlet_id: outlet.id,
|
outlet_id: outlet.id,
|
||||||
@ -1116,7 +1170,8 @@ router.post("/transactions", requireAdminToken, idempotency({ scope: "transactio
|
|||||||
currency: payload.currency,
|
currency: payload.currency,
|
||||||
qr_mode: payload.qr_mode || "static",
|
qr_mode: payload.qr_mode || "static",
|
||||||
initiation_mode: payload.initiation_mode || "static",
|
initiation_mode: payload.initiation_mode || "static",
|
||||||
status: payload.status || "initiated"
|
status: payload.status || "initiated",
|
||||||
|
expired_at: payload.expired_at
|
||||||
});
|
});
|
||||||
await auditAdminAction(req, {
|
await auditAdminAction(req, {
|
||||||
action: "transaction.create",
|
action: "transaction.create",
|
||||||
@ -1161,6 +1216,31 @@ router.get("/transactions", requireAdminToken, async (req, res, next) => {
|
|||||||
})
|
})
|
||||||
.map(toTransactionPayload)));
|
.map(toTransactionPayload)));
|
||||||
});
|
});
|
||||||
|
router.post("/transactions/expire-due", requireAdminToken, async (req, res, next) => {
|
||||||
|
const limitRaw = req.body?.limit ?? req.query.limit;
|
||||||
|
const limit = limitRaw === undefined || limitRaw === "" ? 100 : Number(limitRaw);
|
||||||
|
if (!Number.isFinite(limit) || limit <= 0) {
|
||||||
|
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
|
||||||
|
}
|
||||||
|
const result = await expireDueDynamicQrTransactions({
|
||||||
|
limit,
|
||||||
|
source: "admin",
|
||||||
|
request_id: req.requestId
|
||||||
|
});
|
||||||
|
await auditAdminAction(req, {
|
||||||
|
action: "transaction.expire_due_dynamic_qr",
|
||||||
|
entity_type: "transaction_batch",
|
||||||
|
entity_id: `dynamic_qr_expiry_${result.swept_at}`,
|
||||||
|
after_json: {
|
||||||
|
scanned: result.scanned,
|
||||||
|
expired_count: result.expired_count,
|
||||||
|
skipped_count: result.skipped_count,
|
||||||
|
expired_ids: result.expired.map((tx) => tx.id),
|
||||||
|
skipped: result.skipped
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json(successResponse(req, result));
|
||||||
|
});
|
||||||
router.get("/transactions/:transactionId", requireAdminToken, async (req, res, next) => {
|
router.get("/transactions/:transactionId", requireAdminToken, async (req, res, next) => {
|
||||||
const tx = await getTransactionById(req.params.transactionId);
|
const tx = await getTransactionById(req.params.transactionId);
|
||||||
if (!tx) {
|
if (!tx) {
|
||||||
|
|||||||
15
dist/routes/device.js
vendored
15
dist/routes/device.js
vendored
@ -340,6 +340,21 @@ router.post("/config/ack", requireDeviceToken, async (req, res, next) => {
|
|||||||
reason: payload.reason,
|
reason: payload.reason,
|
||||||
payload_json: payload.result_payload || {}
|
payload_json: payload.result_payload || {}
|
||||||
});
|
});
|
||||||
|
await createMqttMessage({
|
||||||
|
direction: "uplink",
|
||||||
|
device_id: device.id,
|
||||||
|
topic: `devices/${device.id}/uplink/config/ack`,
|
||||||
|
message_type: "config_ack",
|
||||||
|
correlation_id: `config:${configVersion}`,
|
||||||
|
payload_json: {
|
||||||
|
message_type: "config_ack",
|
||||||
|
device_id: device.id,
|
||||||
|
config_version: configVersion,
|
||||||
|
status: payload.status,
|
||||||
|
reason: payload.reason,
|
||||||
|
result_payload: payload.result_payload || {}
|
||||||
|
}
|
||||||
|
});
|
||||||
res.json(successResponse(req, toDeviceConfigAckPayload(ack)));
|
res.json(successResponse(req, toDeviceConfigAckPayload(ack)));
|
||||||
});
|
});
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
37
dist/shared/services/deviceConfigStatus.js
vendored
Normal file
37
dist/shared/services/deviceConfigStatus.js
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { getLatestDeviceConfigAck, toDeviceConfigAckPayload, toDeviceConfigPayload } from "../store/deviceConfigStore";
|
||||||
|
import { listMqttMessages, toMqttMessagePayload } from "../store/mqttMessageStore";
|
||||||
|
function deriveConfigDriftStatus(config, latestAck) {
|
||||||
|
if (!latestAck) {
|
||||||
|
return "pending_ack";
|
||||||
|
}
|
||||||
|
if (latestAck.config_version < config.config_version) {
|
||||||
|
return "stale_ack";
|
||||||
|
}
|
||||||
|
if (latestAck.config_version > config.config_version) {
|
||||||
|
return "pending_ack";
|
||||||
|
}
|
||||||
|
if (latestAck.status === "failed") {
|
||||||
|
return "failed_ack";
|
||||||
|
}
|
||||||
|
return "applied";
|
||||||
|
}
|
||||||
|
export async function buildDeviceConfigStatus(config) {
|
||||||
|
const latestAck = await getLatestDeviceConfigAck(config.device_id);
|
||||||
|
const latestPush = (await listMqttMessages({
|
||||||
|
device_id: config.device_id,
|
||||||
|
direction: "downlink",
|
||||||
|
message_type: "config_push",
|
||||||
|
correlation_id: `config:${config.config_version}`,
|
||||||
|
limit: 1
|
||||||
|
}))[0];
|
||||||
|
const driftStatus = latestPush ? deriveConfigDriftStatus(config, latestAck) : "never_pushed";
|
||||||
|
return {
|
||||||
|
device_id: config.device_id,
|
||||||
|
config: toDeviceConfigPayload(config),
|
||||||
|
drift_status: driftStatus,
|
||||||
|
desired_config_version: config.config_version,
|
||||||
|
latest_ack: latestAck ? toDeviceConfigAckPayload(latestAck) : null,
|
||||||
|
latest_push: latestPush ? toMqttMessagePayload(latestPush) : null,
|
||||||
|
retry_recommended: driftStatus !== "applied"
|
||||||
|
};
|
||||||
|
}
|
||||||
37
dist/shared/services/dynamicQrExpiry.js
vendored
Normal file
37
dist/shared/services/dynamicQrExpiry.js
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { listDueDynamicQrTransactions, toTransactionPayload, updateTransactionStatus } from "../store/transactionStore";
|
||||||
|
export async function expireDueDynamicQrTransactions(input) {
|
||||||
|
const due = await listDueDynamicQrTransactions(input?.limit || 100);
|
||||||
|
const expired = [];
|
||||||
|
const skipped = [];
|
||||||
|
const sweptAt = new Date().toISOString();
|
||||||
|
for (const tx of due) {
|
||||||
|
try {
|
||||||
|
const updated = await updateTransactionStatus(tx.id, "expired", {
|
||||||
|
source: input?.source || "system",
|
||||||
|
expired_at: tx.expired_at || sweptAt,
|
||||||
|
eventContext: {
|
||||||
|
reason: "dynamic_qr_expired",
|
||||||
|
expired_at: tx.expired_at,
|
||||||
|
swept_at: sweptAt,
|
||||||
|
request_id: input?.request_id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expired.push(toTransactionPayload(updated));
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
skipped.push({
|
||||||
|
transaction_id: tx.id,
|
||||||
|
partner_reference: tx.partner_reference,
|
||||||
|
reason: error instanceof Error ? error.message : "UNKNOWN_ERROR"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
scanned: due.length,
|
||||||
|
expired_count: expired.length,
|
||||||
|
skipped_count: skipped.length,
|
||||||
|
swept_at: sweptAt,
|
||||||
|
expired,
|
||||||
|
skipped
|
||||||
|
};
|
||||||
|
}
|
||||||
7
dist/shared/store/deviceConfigStore.js
vendored
7
dist/shared/store/deviceConfigStore.js
vendored
@ -81,6 +81,13 @@ export async function listDeviceConfigAcks(deviceId, limit = 50) {
|
|||||||
LIMIT $2`, [deviceId, Math.min(Math.max(limit, 1), 200)]);
|
LIMIT $2`, [deviceId, Math.min(Math.max(limit, 1), 200)]);
|
||||||
return rows.map(mapAck);
|
return rows.map(mapAck);
|
||||||
}
|
}
|
||||||
|
export async function getLatestDeviceConfigAck(deviceId) {
|
||||||
|
const { rows } = await getPool().query(`SELECT * FROM device_config_acks
|
||||||
|
WHERE device_id = $1
|
||||||
|
ORDER BY acked_at DESC
|
||||||
|
LIMIT 1`, [deviceId]);
|
||||||
|
return rows[0] ? mapAck(rows[0]) : null;
|
||||||
|
}
|
||||||
export function toDeviceConfigPayload(config) {
|
export function toDeviceConfigPayload(config) {
|
||||||
return { ...config };
|
return { ...config };
|
||||||
}
|
}
|
||||||
|
|||||||
60
dist/shared/store/heartbeatStore.js
vendored
60
dist/shared/store/heartbeatStore.js
vendored
@ -100,3 +100,63 @@ export function deriveDeviceStatus(input) {
|
|||||||
}
|
}
|
||||||
return "online";
|
return "online";
|
||||||
}
|
}
|
||||||
|
export function deriveDeviceHealthSummary(input) {
|
||||||
|
const now = Date.now();
|
||||||
|
const lastSeen = Date.parse(input?.last_seen_at || "");
|
||||||
|
const reasons = [];
|
||||||
|
if (!Number.isFinite(lastSeen)) {
|
||||||
|
return {
|
||||||
|
status: "offline",
|
||||||
|
score: 0,
|
||||||
|
age_seconds: null,
|
||||||
|
reasons: ["no_heartbeat"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const ageSeconds = Math.max(0, Math.floor((now - lastSeen) / 1000));
|
||||||
|
const networkStrength = typeof input?.network_strength === "number" ? input.network_strength : null;
|
||||||
|
const batteryLevel = typeof input?.battery_level === "number" ? input.battery_level : null;
|
||||||
|
if (ageSeconds > 900) {
|
||||||
|
reasons.push("offline_threshold_exceeded");
|
||||||
|
}
|
||||||
|
else if (ageSeconds > 90) {
|
||||||
|
reasons.push("stale_threshold_exceeded");
|
||||||
|
}
|
||||||
|
if (typeof networkStrength === "number" && networkStrength < 40) {
|
||||||
|
reasons.push("low_signal");
|
||||||
|
}
|
||||||
|
if (typeof batteryLevel === "number" && batteryLevel < 20) {
|
||||||
|
reasons.push("low_battery");
|
||||||
|
}
|
||||||
|
let status = "online";
|
||||||
|
if (reasons.includes("offline_threshold_exceeded")) {
|
||||||
|
status = "offline";
|
||||||
|
}
|
||||||
|
else if (reasons.includes("stale_threshold_exceeded")) {
|
||||||
|
status = "stale";
|
||||||
|
}
|
||||||
|
else if (reasons.includes("low_signal") || reasons.includes("low_battery")) {
|
||||||
|
status = "degraded";
|
||||||
|
}
|
||||||
|
let score = 100;
|
||||||
|
if (ageSeconds > 900) {
|
||||||
|
score -= 80;
|
||||||
|
}
|
||||||
|
else if (ageSeconds > 90) {
|
||||||
|
score -= 35;
|
||||||
|
}
|
||||||
|
else if (ageSeconds > 60) {
|
||||||
|
score -= 10;
|
||||||
|
}
|
||||||
|
if (typeof networkStrength === "number") {
|
||||||
|
score -= Math.max(0, 40 - networkStrength);
|
||||||
|
}
|
||||||
|
if (typeof batteryLevel === "number") {
|
||||||
|
score -= Math.max(0, 20 - batteryLevel);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
score: Math.max(0, Math.min(100, Math.round(score))),
|
||||||
|
age_seconds: ageSeconds,
|
||||||
|
reasons
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
11
dist/shared/store/transactionStore.js
vendored
11
dist/shared/store/transactionStore.js
vendored
@ -192,6 +192,17 @@ export async function listTransactions(filter) {
|
|||||||
const { rows } = await getPool().query("SELECT * FROM transactions ORDER BY created_at DESC");
|
const { rows } = await getPool().query("SELECT * FROM transactions ORDER BY created_at DESC");
|
||||||
return rows.map(mapTransaction);
|
return rows.map(mapTransaction);
|
||||||
}
|
}
|
||||||
|
export async function listDueDynamicQrTransactions(limit = 100) {
|
||||||
|
const safeLimit = Math.min(Math.max(limit, 1), 500);
|
||||||
|
const { rows } = await getPool().query(`SELECT * FROM transactions
|
||||||
|
WHERE qr_mode = 'dynamic'
|
||||||
|
AND status = 'awaiting_payment'
|
||||||
|
AND expired_at IS NOT NULL
|
||||||
|
AND expired_at <= NOW()
|
||||||
|
ORDER BY expired_at ASC
|
||||||
|
LIMIT ${safeLimit}`);
|
||||||
|
return rows.map(mapTransaction);
|
||||||
|
}
|
||||||
export async function getTransactionEvents(transactionId) {
|
export async function getTransactionEvents(transactionId) {
|
||||||
const { rows } = await getPool().query("SELECT * FROM transaction_events WHERE transaction_id = $1 ORDER BY created_at ASC", [transactionId]);
|
const { rows } = await getPool().query("SELECT * FROM transaction_events WHERE transaction_id = $1 ORDER BY created_at ASC", [transactionId]);
|
||||||
return rows.map(mapEvent);
|
return rows.map(mapEvent);
|
||||||
|
|||||||
@ -197,6 +197,7 @@ async function reqDevice(path, opts = {}) {
|
|||||||
await reqAdmin(`/admin/audit-logs?entity_id=${txId}`, { _label: 'GET /admin/audit-logs' });
|
await reqAdmin(`/admin/audit-logs?entity_id=${txId}`, { _label: 'GET /admin/audit-logs' });
|
||||||
await reqAdmin(`/admin/transactions/${txId}/heartbeats`, { _label: 'GET /admin/transactions/:id/heartbeats' });
|
await reqAdmin(`/admin/transactions/${txId}/heartbeats`, { _label: 'GET /admin/transactions/:id/heartbeats' });
|
||||||
await reqAdmin(`/admin/devices/${deviceId}/heartbeats`, { _label: 'GET /admin/devices/:id/heartbeats' });
|
await reqAdmin(`/admin/devices/${deviceId}/heartbeats`, { _label: 'GET /admin/devices/:id/heartbeats' });
|
||||||
|
await reqAdmin(`/admin/devices/${deviceId}`, { _label: 'GET /admin/devices/:id health summary' });
|
||||||
await reqAdmin('/admin/notifications/failed', { _label: 'GET /admin/notifications/failed' });
|
await reqAdmin('/admin/notifications/failed', { _label: 'GET /admin/notifications/failed' });
|
||||||
await reqAdmin(`/admin/transactions/${txId}/retry-notification`, {
|
await reqAdmin(`/admin/transactions/${txId}/retry-notification`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -349,6 +350,31 @@ async function reqDevice(path, opts = {}) {
|
|||||||
await reqAdmin(`/admin/transactions/${dynamicQr?.data?.transaction_id}`, {
|
await reqAdmin(`/admin/transactions/${dynamicQr?.data?.transaction_id}`, {
|
||||||
_label: 'GET /admin/transactions/:id dynamic-api'
|
_label: 'GET /admin/transactions/:id dynamic-api'
|
||||||
});
|
});
|
||||||
|
const dueDynamicTx = await reqAdmin('/admin/transactions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
partner_reference: `DUE-DYN-${ts}`,
|
||||||
|
merchant_id: merchantId,
|
||||||
|
outlet_id: dynamicOutletId,
|
||||||
|
terminal_id: dynamicTerminalId,
|
||||||
|
device_id: dynamicDeviceId,
|
||||||
|
amount: 12000,
|
||||||
|
currency: 'IDR',
|
||||||
|
qr_mode: 'dynamic',
|
||||||
|
initiation_mode: 'dynamic_api',
|
||||||
|
status: 'awaiting_payment',
|
||||||
|
expired_at: new Date(Date.now() - 60_000).toISOString()
|
||||||
|
},
|
||||||
|
_label: 'POST /admin/transactions due dynamic'
|
||||||
|
});
|
||||||
|
await reqAdmin('/admin/transactions/expire-due', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { limit: 10 },
|
||||||
|
_label: 'POST /admin/transactions/expire-due'
|
||||||
|
});
|
||||||
|
await reqAdmin(`/admin/transactions/${dueDynamicTx?.data?.id}`, {
|
||||||
|
_label: 'GET /admin/transactions/:id expired dynamic'
|
||||||
|
});
|
||||||
|
|
||||||
const mqttOutlet = await reqAdmin(`/admin/merchants/${merchantId}/outlets`, {
|
const mqttOutlet = await reqAdmin(`/admin/merchants/${merchantId}/outlets`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -452,6 +478,14 @@ async function reqDevice(path, opts = {}) {
|
|||||||
_label: 'PATCH /admin/devices/:id/config'
|
_label: 'PATCH /admin/devices/:id/config'
|
||||||
});
|
});
|
||||||
const configVersion = pushedConfig?.data?.config?.config_version;
|
const configVersion = pushedConfig?.data?.config?.config_version;
|
||||||
|
await reqAdmin(`/admin/devices/${dynamicDeviceId}/config/status`, {
|
||||||
|
_label: 'GET /admin/devices/:id/config/status pending'
|
||||||
|
});
|
||||||
|
await reqAdmin(`/admin/devices/${dynamicDeviceId}/config/retry-push`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {},
|
||||||
|
_label: 'POST /admin/devices/:id/config/retry-push'
|
||||||
|
});
|
||||||
await reqDevice(`/device/config?device_id=${dynamicDeviceId}`, {
|
await reqDevice(`/device/config?device_id=${dynamicDeviceId}`, {
|
||||||
_label: 'GET /device/config'
|
_label: 'GET /device/config'
|
||||||
});
|
});
|
||||||
@ -468,9 +502,21 @@ async function reqDevice(path, opts = {}) {
|
|||||||
await reqAdmin(`/admin/devices/${dynamicDeviceId}/config`, {
|
await reqAdmin(`/admin/devices/${dynamicDeviceId}/config`, {
|
||||||
_label: 'GET /admin/devices/:id/config'
|
_label: 'GET /admin/devices/:id/config'
|
||||||
});
|
});
|
||||||
|
await reqAdmin(`/admin/devices/${dynamicDeviceId}/config/status`, {
|
||||||
|
_label: 'GET /admin/devices/:id/config/status applied'
|
||||||
|
});
|
||||||
|
await reqExpect(`/admin/devices/${dynamicDeviceId}/config/retry-push`, 409, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
|
||||||
|
body: {},
|
||||||
|
_label: 'POST /admin/devices/:id/config/retry-push already applied'
|
||||||
|
});
|
||||||
await reqAdmin(`/admin/devices/${dynamicDeviceId}/mqtt-messages?message_type=config_push`, {
|
await reqAdmin(`/admin/devices/${dynamicDeviceId}/mqtt-messages?message_type=config_push`, {
|
||||||
_label: 'GET /admin/devices/:id/mqtt-messages config'
|
_label: 'GET /admin/devices/:id/mqtt-messages config'
|
||||||
});
|
});
|
||||||
|
await reqAdmin(`/admin/devices/${dynamicDeviceId}/mqtt-messages?message_type=config_ack`, {
|
||||||
|
_label: 'GET /admin/devices/:id/mqtt-messages config ack'
|
||||||
|
});
|
||||||
|
|
||||||
console.log(`Smoke point 4 flow done. tx=${txId} device=${deviceId}`);
|
console.log(`Smoke point 4 flow done. tx=${txId} device=${deviceId}`);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -28,7 +28,7 @@ import {
|
|||||||
toDevicePayload
|
toDevicePayload
|
||||||
} from "../shared/store/deviceStore";
|
} from "../shared/store/deviceStore";
|
||||||
import {
|
import {
|
||||||
deriveDeviceStatus,
|
deriveDeviceHealthSummary,
|
||||||
getHeartbeatCountForDeviceLastHours,
|
getHeartbeatCountForDeviceLastHours,
|
||||||
getLatestHeartbeatByDeviceId,
|
getLatestHeartbeatByDeviceId,
|
||||||
listHeartbeats,
|
listHeartbeats,
|
||||||
@ -68,6 +68,8 @@ import {
|
|||||||
} from "../shared/store/deviceConfigStore";
|
} from "../shared/store/deviceConfigStore";
|
||||||
import { listMqttMessages, toMqttMessagePayload, createMqttMessage } from "../shared/store/mqttMessageStore";
|
import { listMqttMessages, toMqttMessagePayload, createMqttMessage } from "../shared/store/mqttMessageStore";
|
||||||
import { publishConfigPush } from "../shared/services/mqttPublisher";
|
import { publishConfigPush } from "../shared/services/mqttPublisher";
|
||||||
|
import { buildDeviceConfigStatus } from "../shared/services/deviceConfigStatus";
|
||||||
|
import { expireDueDynamicQrTransactions } from "../shared/services/dynamicQrExpiry";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -121,6 +123,10 @@ type DeviceConfigInput = {
|
|||||||
config_version?: number;
|
config_version?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DeviceConfigRetryInput = {
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type BindingInput = {
|
type BindingInput = {
|
||||||
merchant_id?: string;
|
merchant_id?: string;
|
||||||
outlet_id?: string;
|
outlet_id?: string;
|
||||||
@ -147,6 +153,7 @@ type TransactionCreateInput = {
|
|||||||
qr_mode?: "static" | "dynamic";
|
qr_mode?: "static" | "dynamic";
|
||||||
initiation_mode?: "manual" | "dynamic_api" | "dynamic_mqtt" | "static";
|
initiation_mode?: "manual" | "dynamic_api" | "dynamic_mqtt" | "static";
|
||||||
status?: "initiated" | "awaiting_payment";
|
status?: "initiated" | "awaiting_payment";
|
||||||
|
expired_at?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseIdempotentReplay(req: Request) {
|
function parseIdempotentReplay(req: Request) {
|
||||||
@ -277,14 +284,16 @@ function buildBindingSummary(
|
|||||||
|
|
||||||
async function buildDeviceAdminPayload(device: DeviceEntity) {
|
async function buildDeviceAdminPayload(device: DeviceEntity) {
|
||||||
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
||||||
|
const healthSummary = deriveDeviceHealthSummary({
|
||||||
|
last_seen_at: device.last_seen_at,
|
||||||
|
network_strength: latestHeartbeat?.network_strength ?? null,
|
||||||
|
battery_level: latestHeartbeat?.battery_level ?? null
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...toDevicePayload(device),
|
...toDevicePayload(device),
|
||||||
capability_summary: resolveDeviceCapabilitySummary(device),
|
capability_summary: resolveDeviceCapabilitySummary(device),
|
||||||
derived_status: deriveDeviceStatus({
|
derived_status: healthSummary.status,
|
||||||
last_seen_at: device.last_seen_at,
|
health_summary: healthSummary,
|
||||||
network_strength: latestHeartbeat?.network_strength ?? null,
|
|
||||||
battery_level: latestHeartbeat?.battery_level ?? null
|
|
||||||
}),
|
|
||||||
heartbeat_count_24h: await getHeartbeatCountForDeviceLastHours(device.id),
|
heartbeat_count_24h: await getHeartbeatCountForDeviceLastHours(device.id),
|
||||||
binding_summary: buildBindingSummary(await getActiveBindingByDevice(device.id)),
|
binding_summary: buildBindingSummary(await getActiveBindingByDevice(device.id)),
|
||||||
latest_heartbeat: latestHeartbeat
|
latest_heartbeat: latestHeartbeat
|
||||||
@ -307,13 +316,15 @@ async function deriveDeviceStatusesForDashboard() {
|
|||||||
return Promise.all(
|
return Promise.all(
|
||||||
devices.map(async (device) => {
|
devices.map(async (device) => {
|
||||||
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
||||||
|
const healthSummary = deriveDeviceHealthSummary({
|
||||||
|
last_seen_at: device.last_seen_at,
|
||||||
|
network_strength: latestHeartbeat?.network_strength ?? null,
|
||||||
|
battery_level: latestHeartbeat?.battery_level ?? null
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
device,
|
device,
|
||||||
status: deriveDeviceStatus({
|
status: healthSummary.status,
|
||||||
last_seen_at: device.last_seen_at,
|
healthSummary
|
||||||
network_strength: latestHeartbeat?.network_strength ?? null,
|
|
||||||
battery_level: latestHeartbeat?.battery_level ?? null
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -369,6 +380,25 @@ async function auditAdminAction(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function publishDeviceConfigPush(deviceId: string, config: Awaited<ReturnType<typeof getOrCreateDeviceConfig>>) {
|
||||||
|
const mqttPayload = {
|
||||||
|
message_type: "config_push" as const,
|
||||||
|
config_version: config.config_version,
|
||||||
|
settings: config.settings_json
|
||||||
|
};
|
||||||
|
const publishResult = await publishConfigPush(deviceId, mqttPayload);
|
||||||
|
return createMqttMessage({
|
||||||
|
direction: "downlink",
|
||||||
|
device_id: deviceId,
|
||||||
|
topic: publishResult.topic,
|
||||||
|
message_type: "config_push",
|
||||||
|
correlation_id: `config:${config.config_version}`,
|
||||||
|
payload_json: mqttPayload,
|
||||||
|
publish_status: publishResult.ok ? "sent" : "failed",
|
||||||
|
reason: publishResult.reason
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function validatePayoutConfig(payload: MerchantCreateInput) {
|
function validatePayoutConfig(payload: MerchantCreateInput) {
|
||||||
const mode = normalizeMerchantMode(payload.payout_mode);
|
const mode = normalizeMerchantMode(payload.payout_mode);
|
||||||
if (mode === "merchant_direct") {
|
if (mode === "merchant_direct") {
|
||||||
@ -1038,15 +1068,16 @@ router.get("/devices", requireAdminToken, async (req: Request, res: Response) =>
|
|||||||
rawDevices.map(async (device) => {
|
rawDevices.map(async (device) => {
|
||||||
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
||||||
const binding = merchantId ? await getActiveBindingByDevice(device.id) : null;
|
const binding = merchantId ? await getActiveBindingByDevice(device.id) : null;
|
||||||
|
const healthSummary = deriveDeviceHealthSummary({
|
||||||
|
last_seen_at: device.last_seen_at,
|
||||||
|
network_strength: latestHeartbeat?.network_strength ?? null,
|
||||||
|
battery_level: latestHeartbeat?.battery_level ?? null
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
device,
|
device,
|
||||||
latestHeartbeat,
|
latestHeartbeat,
|
||||||
binding,
|
binding,
|
||||||
derivedStatus: deriveDeviceStatus({
|
derivedStatus: healthSummary.status
|
||||||
last_seen_at: device.last_seen_at,
|
|
||||||
network_strength: latestHeartbeat?.network_strength ?? null,
|
|
||||||
battery_level: latestHeartbeat?.battery_level ?? null
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -1098,9 +1129,14 @@ router.get("/devices/:deviceId", requireAdminToken, async (req: Request, res: Re
|
|||||||
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeBinding = await getActiveBindingByDevice(device.id);
|
const activeBinding = await getActiveBindingByDevice(device.id);
|
||||||
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id);
|
||||||
const heartbeatCount24h = await getHeartbeatCountForDeviceLastHours(device.id);
|
const heartbeatCount24h = await getHeartbeatCountForDeviceLastHours(device.id);
|
||||||
|
const healthSummary = deriveDeviceHealthSummary({
|
||||||
|
last_seen_at: device.last_seen_at,
|
||||||
|
network_strength: latestHeartbeat?.network_strength ?? null,
|
||||||
|
battery_level: latestHeartbeat?.battery_level ?? null
|
||||||
|
});
|
||||||
const notifications = (await listNotificationsByDevice(device.id))
|
const notifications = (await listNotificationsByDevice(device.id))
|
||||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
@ -1110,11 +1146,8 @@ router.get("/devices/:deviceId", requireAdminToken, async (req: Request, res: Re
|
|||||||
successResponse(req, {
|
successResponse(req, {
|
||||||
...toDevicePayload(device),
|
...toDevicePayload(device),
|
||||||
capability_summary: resolveDeviceCapabilitySummary(device),
|
capability_summary: resolveDeviceCapabilitySummary(device),
|
||||||
derived_status: deriveDeviceStatus({
|
derived_status: healthSummary.status,
|
||||||
last_seen_at: device.last_seen_at,
|
health_summary: healthSummary,
|
||||||
network_strength: latestHeartbeat?.network_strength ?? null,
|
|
||||||
battery_level: latestHeartbeat?.battery_level ?? null
|
|
||||||
}),
|
|
||||||
active_binding: activeBinding ? toBindingPayload(activeBinding) : null,
|
active_binding: activeBinding ? toBindingPayload(activeBinding) : null,
|
||||||
latest_heartbeat: latestHeartbeat
|
latest_heartbeat: latestHeartbeat
|
||||||
? {
|
? {
|
||||||
@ -1360,7 +1393,18 @@ router.get("/devices/:deviceId/config", requireAdminToken, async (req: Request,
|
|||||||
|
|
||||||
const config = await getOrCreateDeviceConfig(device.id);
|
const config = await getOrCreateDeviceConfig(device.id);
|
||||||
const acks = (await listDeviceConfigAcks(device.id, 10)).map(toDeviceConfigAckPayload);
|
const acks = (await listDeviceConfigAcks(device.id, 10)).map(toDeviceConfigAckPayload);
|
||||||
res.json(successResponse(req, { ...toDeviceConfigPayload(config), latest_acks: acks }));
|
const status = await buildDeviceConfigStatus(config);
|
||||||
|
res.json(successResponse(req, { ...toDeviceConfigPayload(config), latest_acks: acks, config_status: status }));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/devices/:deviceId/config/status", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const device = await getDeviceById(req.params.deviceId);
|
||||||
|
if (!device) {
|
||||||
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getOrCreateDeviceConfig(device.id);
|
||||||
|
res.json(successResponse(req, await buildDeviceConfigStatus(config)));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.patch("/devices/:deviceId/config", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
router.patch("/devices/:deviceId/config", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@ -1379,22 +1423,7 @@ router.patch("/devices/:deviceId/config", requireAdminToken, async (req: Request
|
|||||||
settings_json: payload.settings,
|
settings_json: payload.settings,
|
||||||
config_version: payload.config_version
|
config_version: payload.config_version
|
||||||
});
|
});
|
||||||
const mqttPayload = {
|
const outbox = await publishDeviceConfigPush(device.id, config);
|
||||||
message_type: "config_push" as const,
|
|
||||||
config_version: config.config_version,
|
|
||||||
settings: config.settings_json
|
|
||||||
};
|
|
||||||
const publishResult = await publishConfigPush(device.id, mqttPayload);
|
|
||||||
const outbox = await createMqttMessage({
|
|
||||||
direction: "downlink",
|
|
||||||
device_id: device.id,
|
|
||||||
topic: publishResult.topic,
|
|
||||||
message_type: "config_push",
|
|
||||||
correlation_id: `config:${config.config_version}`,
|
|
||||||
payload_json: mqttPayload,
|
|
||||||
publish_status: publishResult.ok ? "sent" : "failed",
|
|
||||||
reason: publishResult.reason
|
|
||||||
});
|
|
||||||
|
|
||||||
await auditAdminAction(req, {
|
await auditAdminAction(req, {
|
||||||
action: "device.config_push",
|
action: "device.config_push",
|
||||||
@ -1414,6 +1443,43 @@ router.patch("/devices/:deviceId/config", requireAdminToken, async (req: Request
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("/devices/:deviceId/config/retry-push", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const device = await getDeviceById(req.params.deviceId);
|
||||||
|
if (!device) {
|
||||||
|
return next(new ApiError("NOT_FOUND", "device not found", 404));
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (req.body || {}) as DeviceConfigRetryInput;
|
||||||
|
const config = await getOrCreateDeviceConfig(device.id);
|
||||||
|
const beforeStatus = await buildDeviceConfigStatus(config);
|
||||||
|
if (beforeStatus.drift_status === "applied" && !payload.force) {
|
||||||
|
return next(new ApiError("CONFIG_ALREADY_APPLIED", "config already applied; set force=true to push again", 409));
|
||||||
|
}
|
||||||
|
|
||||||
|
const outbox = await publishDeviceConfigPush(device.id, config);
|
||||||
|
const afterStatus = await buildDeviceConfigStatus(config);
|
||||||
|
|
||||||
|
await auditAdminAction(req, {
|
||||||
|
action: "device.config_retry_push",
|
||||||
|
entity_type: "device",
|
||||||
|
entity_id: device.id,
|
||||||
|
before_json: beforeStatus,
|
||||||
|
after_json: {
|
||||||
|
...afterStatus,
|
||||||
|
downlink_message_id: outbox.id,
|
||||||
|
force: payload.force === true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
successResponse(req, {
|
||||||
|
config: toDeviceConfigPayload(config),
|
||||||
|
config_status: afterStatus,
|
||||||
|
downlink_message: toMqttMessagePayload(outbox)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/devices/:deviceId/mqtt-messages", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
router.get("/devices/:deviceId/mqtt-messages", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const device = await getDeviceById(req.params.deviceId);
|
const device = await getDeviceById(req.params.deviceId);
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -1501,6 +1567,10 @@ router.post(
|
|||||||
return next(new ApiError("BAD_REQUEST", "invalid status", 400));
|
return next(new ApiError("BAD_REQUEST", "invalid status", 400));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.expired_at && Number.isNaN(Date.parse(payload.expired_at))) {
|
||||||
|
return next(new ApiError("BAD_REQUEST", "expired_at must be valid ISO datetime", 400));
|
||||||
|
}
|
||||||
|
|
||||||
const created = await createTransaction({
|
const created = await createTransaction({
|
||||||
merchant_id: merchant.id,
|
merchant_id: merchant.id,
|
||||||
outlet_id: outlet.id,
|
outlet_id: outlet.id,
|
||||||
@ -1511,7 +1581,8 @@ router.post(
|
|||||||
currency: payload.currency,
|
currency: payload.currency,
|
||||||
qr_mode: payload.qr_mode || "static",
|
qr_mode: payload.qr_mode || "static",
|
||||||
initiation_mode: payload.initiation_mode || "static",
|
initiation_mode: payload.initiation_mode || "static",
|
||||||
status: payload.status || "initiated"
|
status: payload.status || "initiated",
|
||||||
|
expired_at: payload.expired_at
|
||||||
});
|
});
|
||||||
|
|
||||||
await auditAdminAction(req, {
|
await auditAdminAction(req, {
|
||||||
@ -1574,6 +1645,35 @@ router.get("/transactions", requireAdminToken, async (req: Request, res: Respons
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("/transactions/expire-due", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const limitRaw = (req.body as { limit?: unknown } | undefined)?.limit ?? req.query.limit;
|
||||||
|
const limit = limitRaw === undefined || limitRaw === "" ? 100 : Number(limitRaw);
|
||||||
|
if (!Number.isFinite(limit) || limit <= 0) {
|
||||||
|
return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await expireDueDynamicQrTransactions({
|
||||||
|
limit,
|
||||||
|
source: "admin",
|
||||||
|
request_id: req.requestId
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditAdminAction(req, {
|
||||||
|
action: "transaction.expire_due_dynamic_qr",
|
||||||
|
entity_type: "transaction_batch",
|
||||||
|
entity_id: `dynamic_qr_expiry_${result.swept_at}`,
|
||||||
|
after_json: {
|
||||||
|
scanned: result.scanned,
|
||||||
|
expired_count: result.expired_count,
|
||||||
|
skipped_count: result.skipped_count,
|
||||||
|
expired_ids: result.expired.map((tx) => tx.id),
|
||||||
|
skipped: result.skipped
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(successResponse(req, result));
|
||||||
|
});
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/transactions/:transactionId",
|
"/transactions/:transactionId",
|
||||||
requireAdminToken,
|
requireAdminToken,
|
||||||
|
|||||||
@ -455,6 +455,21 @@ router.post("/config/ack", requireDeviceToken, async (req: Request, res: Respons
|
|||||||
reason: payload.reason,
|
reason: payload.reason,
|
||||||
payload_json: payload.result_payload || {}
|
payload_json: payload.result_payload || {}
|
||||||
});
|
});
|
||||||
|
await createMqttMessage({
|
||||||
|
direction: "uplink",
|
||||||
|
device_id: device.id,
|
||||||
|
topic: `devices/${device.id}/uplink/config/ack`,
|
||||||
|
message_type: "config_ack",
|
||||||
|
correlation_id: `config:${configVersion}`,
|
||||||
|
payload_json: {
|
||||||
|
message_type: "config_ack",
|
||||||
|
device_id: device.id,
|
||||||
|
config_version: configVersion,
|
||||||
|
status: payload.status,
|
||||||
|
reason: payload.reason,
|
||||||
|
result_payload: payload.result_payload || {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
res.json(successResponse(req, toDeviceConfigAckPayload(ack)));
|
res.json(successResponse(req, toDeviceConfigAckPayload(ack)));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -20,7 +20,8 @@ export type ErrorCode =
|
|||||||
| "NOTIFICATION_RETRY_EXHAUSTED"
|
| "NOTIFICATION_RETRY_EXHAUSTED"
|
||||||
| "INVALID_AMOUNT"
|
| "INVALID_AMOUNT"
|
||||||
| "DEVICE_NOT_BOUND"
|
| "DEVICE_NOT_BOUND"
|
||||||
| "DEVICE_CAPABILITY_NOT_SUPPORTED";
|
| "DEVICE_CAPABILITY_NOT_SUPPORTED"
|
||||||
|
| "CONFIG_ALREADY_APPLIED";
|
||||||
|
|
||||||
export interface ApiErrorShape {
|
export interface ApiErrorShape {
|
||||||
code: ErrorCode;
|
code: ErrorCode;
|
||||||
|
|||||||
55
src/shared/services/deviceConfigStatus.ts
Normal file
55
src/shared/services/deviceConfigStatus.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
DeviceConfigAckEntity,
|
||||||
|
DeviceConfigEntity,
|
||||||
|
getLatestDeviceConfigAck,
|
||||||
|
toDeviceConfigAckPayload,
|
||||||
|
toDeviceConfigPayload
|
||||||
|
} from "../store/deviceConfigStore";
|
||||||
|
import { listMqttMessages, toMqttMessagePayload } from "../store/mqttMessageStore";
|
||||||
|
|
||||||
|
type ConfigDriftStatus = "applied" | "pending_ack" | "failed_ack" | "stale_ack" | "never_pushed";
|
||||||
|
|
||||||
|
function deriveConfigDriftStatus(config: DeviceConfigEntity, latestAck: DeviceConfigAckEntity | null): ConfigDriftStatus {
|
||||||
|
if (!latestAck) {
|
||||||
|
return "pending_ack";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestAck.config_version < config.config_version) {
|
||||||
|
return "stale_ack";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestAck.config_version > config.config_version) {
|
||||||
|
return "pending_ack";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestAck.status === "failed") {
|
||||||
|
return "failed_ack";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "applied";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildDeviceConfigStatus(config: DeviceConfigEntity) {
|
||||||
|
const latestAck = await getLatestDeviceConfigAck(config.device_id);
|
||||||
|
const latestPush = (
|
||||||
|
await listMqttMessages({
|
||||||
|
device_id: config.device_id,
|
||||||
|
direction: "downlink",
|
||||||
|
message_type: "config_push",
|
||||||
|
correlation_id: `config:${config.config_version}`,
|
||||||
|
limit: 1
|
||||||
|
})
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
const driftStatus = latestPush ? deriveConfigDriftStatus(config, latestAck) : "never_pushed";
|
||||||
|
|
||||||
|
return {
|
||||||
|
device_id: config.device_id,
|
||||||
|
config: toDeviceConfigPayload(config),
|
||||||
|
drift_status: driftStatus,
|
||||||
|
desired_config_version: config.config_version,
|
||||||
|
latest_ack: latestAck ? toDeviceConfigAckPayload(latestAck) : null,
|
||||||
|
latest_push: latestPush ? toMqttMessagePayload(latestPush) : null,
|
||||||
|
retry_recommended: driftStatus !== "applied"
|
||||||
|
};
|
||||||
|
}
|
||||||
47
src/shared/services/dynamicQrExpiry.ts
Normal file
47
src/shared/services/dynamicQrExpiry.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
listDueDynamicQrTransactions,
|
||||||
|
toTransactionPayload,
|
||||||
|
updateTransactionStatus
|
||||||
|
} from "../store/transactionStore";
|
||||||
|
|
||||||
|
export async function expireDueDynamicQrTransactions(input?: {
|
||||||
|
limit?: number;
|
||||||
|
source?: "system" | "admin";
|
||||||
|
request_id?: string;
|
||||||
|
}) {
|
||||||
|
const due = await listDueDynamicQrTransactions(input?.limit || 100);
|
||||||
|
const expired = [];
|
||||||
|
const skipped = [];
|
||||||
|
const sweptAt = new Date().toISOString();
|
||||||
|
|
||||||
|
for (const tx of due) {
|
||||||
|
try {
|
||||||
|
const updated = await updateTransactionStatus(tx.id, "expired", {
|
||||||
|
source: input?.source || "system",
|
||||||
|
expired_at: tx.expired_at || sweptAt,
|
||||||
|
eventContext: {
|
||||||
|
reason: "dynamic_qr_expired",
|
||||||
|
expired_at: tx.expired_at,
|
||||||
|
swept_at: sweptAt,
|
||||||
|
request_id: input?.request_id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expired.push(toTransactionPayload(updated));
|
||||||
|
} catch (error) {
|
||||||
|
skipped.push({
|
||||||
|
transaction_id: tx.id,
|
||||||
|
partner_reference: tx.partner_reference,
|
||||||
|
reason: error instanceof Error ? error.message : "UNKNOWN_ERROR"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scanned: due.length,
|
||||||
|
expired_count: expired.length,
|
||||||
|
skipped_count: skipped.length,
|
||||||
|
swept_at: sweptAt,
|
||||||
|
expired,
|
||||||
|
skipped
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -131,6 +131,18 @@ export async function listDeviceConfigAcks(deviceId: string, limit = 50): Promis
|
|||||||
return rows.map(mapAck);
|
return rows.map(mapAck);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getLatestDeviceConfigAck(deviceId: string): Promise<DeviceConfigAckEntity | null> {
|
||||||
|
const { rows } = await getPool().query(
|
||||||
|
`SELECT * FROM device_config_acks
|
||||||
|
WHERE device_id = $1
|
||||||
|
ORDER BY acked_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[deviceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows[0] ? mapAck(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function toDeviceConfigPayload(config: DeviceConfigEntity) {
|
export function toDeviceConfigPayload(config: DeviceConfigEntity) {
|
||||||
return { ...config };
|
return { ...config };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
export type DeviceHealthStatus = "online" | "offline" | "degraded" | "stale";
|
export type DeviceHealthStatus = "online" | "offline" | "degraded" | "stale";
|
||||||
|
|
||||||
|
export type DeviceHealthReason =
|
||||||
|
| "no_heartbeat"
|
||||||
|
| "offline_threshold_exceeded"
|
||||||
|
| "stale_threshold_exceeded"
|
||||||
|
| "low_signal"
|
||||||
|
| "low_battery";
|
||||||
|
|
||||||
export interface DeviceHeartbeatEntity {
|
export interface DeviceHeartbeatEntity {
|
||||||
id: string;
|
id: string;
|
||||||
device_id: string;
|
device_id: string;
|
||||||
@ -171,3 +178,75 @@ export function deriveDeviceStatus(
|
|||||||
|
|
||||||
return "online";
|
return "online";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deriveDeviceHealthSummary(
|
||||||
|
input?: {
|
||||||
|
last_seen_at?: string | null;
|
||||||
|
network_strength?: number | null;
|
||||||
|
battery_level?: number | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const now = Date.now();
|
||||||
|
const lastSeen = Date.parse(input?.last_seen_at || "");
|
||||||
|
const reasons: DeviceHealthReason[] = [];
|
||||||
|
|
||||||
|
if (!Number.isFinite(lastSeen)) {
|
||||||
|
return {
|
||||||
|
status: "offline" as DeviceHealthStatus,
|
||||||
|
score: 0,
|
||||||
|
age_seconds: null,
|
||||||
|
reasons: ["no_heartbeat"] as DeviceHealthReason[]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageSeconds = Math.max(0, Math.floor((now - lastSeen) / 1000));
|
||||||
|
const networkStrength = typeof input?.network_strength === "number" ? input.network_strength : null;
|
||||||
|
const batteryLevel = typeof input?.battery_level === "number" ? input.battery_level : null;
|
||||||
|
|
||||||
|
if (ageSeconds > 900) {
|
||||||
|
reasons.push("offline_threshold_exceeded");
|
||||||
|
} else if (ageSeconds > 90) {
|
||||||
|
reasons.push("stale_threshold_exceeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof networkStrength === "number" && networkStrength < 40) {
|
||||||
|
reasons.push("low_signal");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof batteryLevel === "number" && batteryLevel < 20) {
|
||||||
|
reasons.push("low_battery");
|
||||||
|
}
|
||||||
|
|
||||||
|
let status: DeviceHealthStatus = "online";
|
||||||
|
if (reasons.includes("offline_threshold_exceeded")) {
|
||||||
|
status = "offline";
|
||||||
|
} else if (reasons.includes("stale_threshold_exceeded")) {
|
||||||
|
status = "stale";
|
||||||
|
} else if (reasons.includes("low_signal") || reasons.includes("low_battery")) {
|
||||||
|
status = "degraded";
|
||||||
|
}
|
||||||
|
|
||||||
|
let score = 100;
|
||||||
|
if (ageSeconds > 900) {
|
||||||
|
score -= 80;
|
||||||
|
} else if (ageSeconds > 90) {
|
||||||
|
score -= 35;
|
||||||
|
} else if (ageSeconds > 60) {
|
||||||
|
score -= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof networkStrength === "number") {
|
||||||
|
score -= Math.max(0, 40 - networkStrength);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof batteryLevel === "number") {
|
||||||
|
score -= Math.max(0, 20 - batteryLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
score: Math.max(0, Math.min(100, Math.round(score))),
|
||||||
|
age_seconds: ageSeconds,
|
||||||
|
reasons
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -320,6 +320,21 @@ export async function listTransactions(filter?: {
|
|||||||
return rows.map(mapTransaction);
|
return rows.map(mapTransaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listDueDynamicQrTransactions(limit = 100): Promise<TransactionEntity[]> {
|
||||||
|
const safeLimit = Math.min(Math.max(limit, 1), 500);
|
||||||
|
const { rows } = await getPool().query(
|
||||||
|
`SELECT * FROM transactions
|
||||||
|
WHERE qr_mode = 'dynamic'
|
||||||
|
AND status = 'awaiting_payment'
|
||||||
|
AND expired_at IS NOT NULL
|
||||||
|
AND expired_at <= NOW()
|
||||||
|
ORDER BY expired_at ASC
|
||||||
|
LIMIT ${safeLimit}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map(mapTransaction);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTransactionEvents(transactionId: string): Promise<TransactionEventEntity[]> {
|
export async function getTransactionEvents(transactionId: string): Promise<TransactionEventEntity[]> {
|
||||||
const { rows } = await getPool().query(
|
const { rows } = await getPool().query(
|
||||||
"SELECT * FROM transaction_events WHERE transaction_id = $1 ORDER BY created_at ASC",
|
"SELECT * FROM transaction_events WHERE transaction_id = $1 ORDER BY created_at ASC",
|
||||||
|
|||||||
@ -422,7 +422,19 @@
|
|||||||
return `${days} day${days === 1 ? "" : "s"} ago`;
|
return `${days} day${days === 1 ? "" : "s"} ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const healthMeta = (value) => {
|
const healthMeta = (value, summary) => {
|
||||||
|
if (summary && typeof summary.score === "number") {
|
||||||
|
if (summary.score >= 90) {
|
||||||
|
return { value: `${summary.score}%`, className: "text-success", icon: "favorite" };
|
||||||
|
}
|
||||||
|
if (summary.score >= 65) {
|
||||||
|
return { value: `${summary.score}%`, className: "text-success", icon: "favorite" };
|
||||||
|
}
|
||||||
|
if (summary.score >= 35) {
|
||||||
|
return { value: `${summary.score}%`, className: "text-warning", icon: "heart_minus" };
|
||||||
|
}
|
||||||
|
return { value: `${summary.score}%`, className: "text-danger", icon: "heart_broken" };
|
||||||
|
}
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return { value: "N/A", className: "text-slate-500", icon: "heart_broken" };
|
return { value: "N/A", className: "text-slate-500", icon: "heart_broken" };
|
||||||
}
|
}
|
||||||
@ -443,6 +455,31 @@
|
|||||||
return { value: "Poor", className: "text-danger", icon: "heart_broken" };
|
return { value: "Poor", className: "text-danger", icon: "heart_broken" };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reasonLabels = {
|
||||||
|
no_heartbeat: "No heartbeat",
|
||||||
|
offline_threshold_exceeded: "Offline threshold",
|
||||||
|
stale_threshold_exceeded: "Stale heartbeat",
|
||||||
|
low_signal: "Low signal",
|
||||||
|
low_battery: "Low battery"
|
||||||
|
};
|
||||||
|
|
||||||
|
const configStatusMeta = (status) => {
|
||||||
|
const value = normalizeText(status);
|
||||||
|
if (value === "applied") {
|
||||||
|
return { label: "Applied", className: "bg-success/10 text-success border-success/20", icon: "check_circle" };
|
||||||
|
}
|
||||||
|
if (value === "pending_ack") {
|
||||||
|
return { label: "Pending ACK", className: "bg-warning/10 text-warning border-warning/20", icon: "pending" };
|
||||||
|
}
|
||||||
|
if (value === "failed_ack") {
|
||||||
|
return { label: "Failed ACK", className: "bg-danger/10 text-danger border-danger/20", icon: "error" };
|
||||||
|
}
|
||||||
|
if (value === "stale_ack") {
|
||||||
|
return { label: "Stale ACK", className: "bg-warning/10 text-warning border-warning/20", icon: "sync_problem" };
|
||||||
|
}
|
||||||
|
return { label: "Never pushed", className: "bg-slate-100 text-slate-600 border-slate-200", icon: "cloud_off" };
|
||||||
|
};
|
||||||
|
|
||||||
const statusMeta = (status) => {
|
const statusMeta = (status) => {
|
||||||
const value = normalizeText(status);
|
const value = normalizeText(status);
|
||||||
if (value === "online") {
|
if (value === "online") {
|
||||||
@ -459,6 +496,13 @@
|
|||||||
dot: "bg-warning"
|
dot: "bg-warning"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (value === "stale") {
|
||||||
|
return {
|
||||||
|
label: "Stale",
|
||||||
|
className: "bg-warning/10 text-warning border border-warning/20",
|
||||||
|
dot: "bg-warning"
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
label: "Offline",
|
label: "Offline",
|
||||||
className: "bg-slate-100 text-slate-500 border border-slate-200",
|
className: "bg-slate-100 text-slate-500 border border-slate-200",
|
||||||
@ -501,7 +545,7 @@
|
|||||||
const merchantName = merchantMap.get(binding.merchant_id) || "Unassigned";
|
const merchantName = merchantMap.get(binding.merchant_id) || "Unassigned";
|
||||||
const status = statusMeta(device.derived_status);
|
const status = statusMeta(device.derived_status);
|
||||||
const connection = connectionMeta(device.communication_mode);
|
const connection = connectionMeta(device.communication_mode);
|
||||||
const health = healthMeta(device.latest_heartbeat);
|
const health = healthMeta(device.latest_heartbeat, device.health_summary);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="hover:bg-slate-50 transition-colors group">
|
<tr class="hover:bg-slate-50 transition-colors group">
|
||||||
@ -649,7 +693,11 @@
|
|||||||
const binding = device.binding_summary || {};
|
const binding = device.binding_summary || {};
|
||||||
const connection = connectionMeta(device.communication_mode);
|
const connection = connectionMeta(device.communication_mode);
|
||||||
const status = statusMeta(device.derived_status);
|
const status = statusMeta(device.derived_status);
|
||||||
const health = healthMeta(device.latest_heartbeat);
|
const health = healthMeta(device.latest_heartbeat, device.health_summary);
|
||||||
|
const summary = device.health_summary || {};
|
||||||
|
const reasons = Array.isArray(summary.reasons) && summary.reasons.length
|
||||||
|
? summary.reasons.map((item) => reasonLabels[item] || item).join(", ")
|
||||||
|
: "No active warning";
|
||||||
|
|
||||||
const id = device.device_code || device.id || "-";
|
const id = device.device_code || device.id || "-";
|
||||||
detailTitle.textContent = id;
|
detailTitle.textContent = id;
|
||||||
@ -683,6 +731,22 @@
|
|||||||
<span class="text-on-surface-variant">Health</span>
|
<span class="text-on-surface-variant">Health</span>
|
||||||
<span class="${health.className}">${health.value}</span>
|
<span class="${health.className}">${health.value}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-3 pt-3 border-t border-slate-200">
|
||||||
|
<div class="flex justify-between mb-2">
|
||||||
|
<span class="text-on-surface-variant">Heartbeat Age</span>
|
||||||
|
<span class="font-bold">${typeof summary.age_seconds === "number" ? `${summary.age_seconds}s` : "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<span class="text-on-surface-variant">Reason</span>
|
||||||
|
<span class="text-right font-bold">${reasons}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="device-config-section">
|
||||||
|
<h5 class="text-label-md font-label-md text-slate-500 uppercase mb-3">Config Delivery</h5>
|
||||||
|
<div class="bg-slate-50 p-4 rounded-xl border border-slate-100" id="device-config-status-box">
|
||||||
|
<p class="text-slate-500">Loading config status...</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
@ -713,6 +777,59 @@
|
|||||||
detailOverlay.classList.remove("pointer-events-none", "opacity-0");
|
detailOverlay.classList.remove("pointer-events-none", "opacity-0");
|
||||||
detailOverlay.classList.add("opacity-100");
|
detailOverlay.classList.add("opacity-100");
|
||||||
detailDrawer.classList.remove("translate-x-full");
|
detailDrawer.classList.remove("translate-x-full");
|
||||||
|
|
||||||
|
loadDrawerConfig(device.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDrawerConfig = async (deviceId) => {
|
||||||
|
const box = document.getElementById("device-config-status-box");
|
||||||
|
if (!box || !deviceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configStatus = await api.getDeviceConfigStatus(deviceId);
|
||||||
|
const meta = configStatusMeta(configStatus.drift_status);
|
||||||
|
const latestPush = configStatus.latest_push;
|
||||||
|
const latestAck = configStatus.latest_ack;
|
||||||
|
const canRetry = configStatus.retry_recommended;
|
||||||
|
box.innerHTML = `
|
||||||
|
<div class="flex items-center justify-between gap-3 mb-3">
|
||||||
|
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${meta.className}">
|
||||||
|
<span class="material-symbols-outlined text-[16px]">${meta.icon}</span>
|
||||||
|
${meta.label}
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-slate-500">v${configStatus.desired_config_version || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3 text-sm mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-slate-500">Latest Push</p>
|
||||||
|
<p class="font-bold">${latestPush ? formatLastSeen(latestPush.created_at) : "-"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-slate-500">Latest ACK</p>
|
||||||
|
<p class="font-bold">${latestAck ? latestAck.status : "-"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="device-config-retry" class="w-full px-3 py-2 rounded-lg text-sm font-bold border ${canRetry ? "bg-primary text-white border-primary" : "bg-white text-slate-500 border-slate-200"}">
|
||||||
|
${canRetry ? "Retry Config Push" : "Config Applied"}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
const retryButton = document.getElementById("device-config-retry");
|
||||||
|
retryButton?.addEventListener("click", async () => {
|
||||||
|
retryButton.disabled = true;
|
||||||
|
retryButton.textContent = "Retrying...";
|
||||||
|
try {
|
||||||
|
await api.retryDeviceConfigPush(deviceId, canRetry ? {} : { force: true });
|
||||||
|
await loadDrawerConfig(deviceId);
|
||||||
|
} catch (error) {
|
||||||
|
retryButton.disabled = false;
|
||||||
|
retryButton.textContent = "Retry Failed";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
box.innerHTML = '<p class="text-danger">Unable to load config status.</p>';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDrawer = () => {
|
const closeDrawer = () => {
|
||||||
|
|||||||
@ -287,6 +287,38 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Fase 2 Ops Summary -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-gutter">
|
||||||
|
<div class="bg-surface-container-lowest border border-slate-200 p-card-padding rounded-xl shadow-sm">
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-label-md text-on-surface-variant mb-1">Health Summary</p>
|
||||||
|
<h3 class="font-headline-lg text-headline-lg text-on-surface" id="device-health-score">-</h3>
|
||||||
|
</div>
|
||||||
|
<span id="device-health-status" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border bg-slate-100 text-slate-600 border-slate-200">
|
||||||
|
<span class="material-symbols-outlined text-[16px]">monitor_heart</span>
|
||||||
|
Unknown
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-body-md text-on-surface-variant" id="device-health-reasons">No health summary yet.</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface-container-lowest border border-slate-200 p-card-padding rounded-xl shadow-sm">
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-label-md text-on-surface-variant mb-1">Config Delivery</p>
|
||||||
|
<h3 class="font-headline-md text-headline-md text-on-surface" id="device-config-version">Config -</h3>
|
||||||
|
</div>
|
||||||
|
<span id="device-config-status" class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border bg-slate-100 text-slate-600 border-slate-200">
|
||||||
|
<span class="material-symbols-outlined text-[16px]">pending</span>
|
||||||
|
Loading
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="text-body-md text-on-surface-variant" id="device-config-detail">Waiting for config status.</p>
|
||||||
|
<button id="device-config-retry" class="px-3 py-2 bg-primary text-white rounded-lg text-label-md font-bold hover:opacity-90 disabled:opacity-50">Retry Push</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Merchant Binding Info -->
|
<!-- Merchant Binding Info -->
|
||||||
<div class="bg-surface-container-lowest border border-slate-200 rounded-xl overflow-hidden">
|
<div class="bg-surface-container-lowest border border-slate-200 rounded-xl overflow-hidden">
|
||||||
<div class="px-card-padding py-4 border-b border-slate-100 flex justify-between items-center bg-surface-container-low">
|
<div class="px-card-padding py-4 border-b border-slate-100 flex justify-between items-center bg-surface-container-low">
|
||||||
@ -421,7 +453,14 @@
|
|||||||
firmwareStatus: document.getElementById("device-firmware-status"),
|
firmwareStatus: document.getElementById("device-firmware-status"),
|
||||||
bindingMerchant: document.getElementById("device-binding-merchant"),
|
bindingMerchant: document.getElementById("device-binding-merchant"),
|
||||||
bindingMerchantId: document.getElementById("device-binding-merchant-id"),
|
bindingMerchantId: document.getElementById("device-binding-merchant-id"),
|
||||||
bindingSince: document.getElementById("device-binding-since")
|
bindingSince: document.getElementById("device-binding-since"),
|
||||||
|
healthScore: document.getElementById("device-health-score"),
|
||||||
|
healthStatus: document.getElementById("device-health-status"),
|
||||||
|
healthReasons: document.getElementById("device-health-reasons"),
|
||||||
|
configVersion: document.getElementById("device-config-version"),
|
||||||
|
configStatus: document.getElementById("device-config-status"),
|
||||||
|
configDetail: document.getElementById("device-config-detail"),
|
||||||
|
configRetry: document.getElementById("device-config-retry")
|
||||||
};
|
};
|
||||||
|
|
||||||
const setText = (el, value, fallback = "-") => {
|
const setText = (el, value, fallback = "-") => {
|
||||||
@ -500,8 +539,8 @@
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
signal: heartbeat.rssi ?? heartbeat.rssi_dbm ?? heartbeat.signal ?? heartbeat.signal_strength ?? heartbeat.rsrp,
|
signal: heartbeat.network_strength ?? heartbeat.rssi ?? heartbeat.rssi_dbm ?? heartbeat.signal ?? heartbeat.signal_strength ?? heartbeat.rsrp,
|
||||||
battery: heartbeat.battery_voltage ?? heartbeat.v_batt ?? heartbeat.voltage ?? heartbeat.battery ?? heartbeat.batt ?? null,
|
battery: heartbeat.battery_level ?? heartbeat.battery_voltage ?? heartbeat.v_batt ?? heartbeat.voltage ?? heartbeat.battery ?? heartbeat.batt ?? null,
|
||||||
ts: heartbeat.ts ?? heartbeat.timestamp ?? heartbeat.created_at ?? heartbeat.updated_at
|
ts: heartbeat.ts ?? heartbeat.timestamp ?? heartbeat.created_at ?? heartbeat.updated_at
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -633,6 +672,89 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const healthReasonLabels = {
|
||||||
|
no_heartbeat: "No heartbeat",
|
||||||
|
offline_threshold_exceeded: "Offline threshold exceeded",
|
||||||
|
stale_threshold_exceeded: "Heartbeat is stale",
|
||||||
|
low_signal: "Low signal",
|
||||||
|
low_battery: "Low battery"
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusPillClass = (status) => {
|
||||||
|
const normalized = String(status || "").toLowerCase();
|
||||||
|
if (normalized === "online" || normalized === "applied") {
|
||||||
|
return "bg-success/10 text-success border-success/20";
|
||||||
|
}
|
||||||
|
if (normalized === "degraded" || normalized === "stale" || normalized === "pending_ack" || normalized === "stale_ack") {
|
||||||
|
return "bg-warning/10 text-warning border-warning/20";
|
||||||
|
}
|
||||||
|
if (normalized === "offline" || normalized === "failed_ack") {
|
||||||
|
return "bg-danger/10 text-danger border-danger/20";
|
||||||
|
}
|
||||||
|
return "bg-slate-100 text-slate-600 border-slate-200";
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderHealthSummary = (summary) => {
|
||||||
|
if (!summary) {
|
||||||
|
setText(els.healthScore, "-");
|
||||||
|
setText(els.healthReasons, "No health summary yet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setText(els.healthScore, typeof summary.score === "number" ? `${summary.score}%` : "-");
|
||||||
|
const status = summary.status || "unknown";
|
||||||
|
if (els.healthStatus) {
|
||||||
|
els.healthStatus.className = `inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${statusPillClass(status)}`;
|
||||||
|
els.healthStatus.innerHTML = `<span class="material-symbols-outlined text-[16px]">monitor_heart</span>${String(status).replace("_", " ").toUpperCase()}`;
|
||||||
|
}
|
||||||
|
const reasons = Array.isArray(summary.reasons) && summary.reasons.length
|
||||||
|
? summary.reasons.map((item) => healthReasonLabels[item] || item).join(", ")
|
||||||
|
: "No active warning";
|
||||||
|
setText(
|
||||||
|
els.healthReasons,
|
||||||
|
`${reasons}${typeof summary.age_seconds === "number" ? ` · ${summary.age_seconds}s since heartbeat` : ""}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderConfigStatus = (status) => {
|
||||||
|
if (!status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drift = status.drift_status || "unknown";
|
||||||
|
const version = status.desired_config_version || status.config?.config_version || "-";
|
||||||
|
setText(els.configVersion, `Config v${version}`);
|
||||||
|
if (els.configStatus) {
|
||||||
|
const label = String(drift).replace("_", " ").toUpperCase();
|
||||||
|
const icon = drift === "applied" ? "check_circle" : drift === "failed_ack" ? "error" : "pending";
|
||||||
|
els.configStatus.className = `inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${statusPillClass(drift)}`;
|
||||||
|
els.configStatus.innerHTML = `<span class="material-symbols-outlined text-[16px]">${icon}</span>${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ack = status.latest_ack ? `${status.latest_ack.status} at ${formatDateTime(status.latest_ack.acked_at, "-")}` : "No ACK";
|
||||||
|
const push = status.latest_push ? formatDateTime(status.latest_push.created_at, "-") : "No push";
|
||||||
|
setText(els.configDetail, `Push: ${push} · ACK: ${ack}`);
|
||||||
|
if (els.configRetry) {
|
||||||
|
els.configRetry.disabled = status.retry_recommended === false;
|
||||||
|
els.configRetry.textContent = status.retry_recommended === false ? "Applied" : "Retry Push";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadConfigStatus = async () => {
|
||||||
|
if (!deviceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const status = await api.getDeviceConfigStatus(deviceId);
|
||||||
|
renderConfigStatus(status);
|
||||||
|
} catch (error) {
|
||||||
|
setText(els.configDetail, "Unable to load config status.");
|
||||||
|
if (els.configRetry) {
|
||||||
|
els.configRetry.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadBindingDetails = async (device) => {
|
const loadBindingDetails = async (device) => {
|
||||||
const binding = device?.binding_summary || {};
|
const binding = device?.binding_summary || {};
|
||||||
let merchantName = binding.merchant_id || "Unbound";
|
let merchantName = binding.merchant_id || "Unbound";
|
||||||
@ -701,6 +823,7 @@
|
|||||||
|
|
||||||
const latestMetric = extractHeartbeatMetrics(latest);
|
const latestMetric = extractHeartbeatMetrics(latest);
|
||||||
setDerivedStatus(device, heartbeats);
|
setDerivedStatus(device, heartbeats);
|
||||||
|
renderHealthSummary(device.health_summary);
|
||||||
setText(
|
setText(
|
||||||
els.firmwareVersion,
|
els.firmwareVersion,
|
||||||
device.firmware_version || device.fw_version || device.firmware || "-"
|
device.firmware_version || device.fw_version || device.firmware || "-"
|
||||||
@ -710,6 +833,7 @@
|
|||||||
renderStream(heartbeats);
|
renderStream(heartbeats);
|
||||||
renderEvents(heartbeats);
|
renderEvents(heartbeats);
|
||||||
await loadBindingDetails(device);
|
await loadBindingDetails(device);
|
||||||
|
await loadConfigStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[device detail] failed loading", error);
|
console.error("[device detail] failed loading", error);
|
||||||
setText(els.title, "Unable to load device");
|
setText(els.title, "Unable to load device");
|
||||||
@ -717,6 +841,24 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
refreshBtn?.addEventListener("click", loadDevice);
|
refreshBtn?.addEventListener("click", loadDevice);
|
||||||
|
els.configRetry?.addEventListener("click", async () => {
|
||||||
|
if (!deviceId || !els.configRetry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
els.configRetry.disabled = true;
|
||||||
|
els.configRetry.textContent = "Retrying...";
|
||||||
|
try {
|
||||||
|
await api.retryDeviceConfigPush(deviceId, {});
|
||||||
|
await loadConfigStatus();
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await api.retryDeviceConfigPush(deviceId, { force: true });
|
||||||
|
await loadConfigStatus();
|
||||||
|
} catch (retryError) {
|
||||||
|
els.configRetry.textContent = "Retry Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
clearBtn?.addEventListener("click", () => {
|
clearBtn?.addEventListener("click", () => {
|
||||||
if (stream) {
|
if (stream) {
|
||||||
stream.innerHTML = '<p class="text-slate-500">--- CONSOLE CLEARED ---</p>';
|
stream.innerHTML = '<p class="text-slate-500">--- CONSOLE CLEARED ---</p>';
|
||||||
|
|||||||
@ -135,6 +135,13 @@ window.AdminUIAPI = {
|
|||||||
getDevice: (id) => adminFetch(`/admin/devices/${id}`),
|
getDevice: (id) => adminFetch(`/admin/devices/${id}`),
|
||||||
getDeviceHeartbeats: (id, query) =>
|
getDeviceHeartbeats: (id, query) =>
|
||||||
adminFetch(`/admin/devices/${id}/heartbeats`, { query }),
|
adminFetch(`/admin/devices/${id}/heartbeats`, { query }),
|
||||||
|
getDeviceConfig: (id) => adminFetch(`/admin/devices/${id}/config`),
|
||||||
|
getDeviceConfigStatus: (id) => adminFetch(`/admin/devices/${id}/config/status`),
|
||||||
|
retryDeviceConfigPush: (id, payload) =>
|
||||||
|
adminFetch(`/admin/devices/${id}/config/retry-push`, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload || {}
|
||||||
|
}),
|
||||||
listTransactions: (query) => adminFetch("/admin/transactions", { query }),
|
listTransactions: (query) => adminFetch("/admin/transactions", { query }),
|
||||||
getDashboardSummary: () => adminFetch("/admin/dashboard/summary"),
|
getDashboardSummary: () => adminFetch("/admin/dashboard/summary"),
|
||||||
formatMoney,
|
formatMoney,
|
||||||
|
|||||||
Reference in New Issue
Block a user