Complete QF100 ops commands and detail UI

This commit is contained in:
Wira Basalamah
2026-06-07 02:55:57 +07:00
parent 1550484d1d
commit e3d7e60ff3
8 changed files with 608 additions and 63 deletions

View File

@ -223,6 +223,45 @@ async function triggerDynamicMqttQr({ bundle, ts }) {
return response; return response;
} }
async function triggerDynamicQrDisplay({ bundle }) {
const result = await reqAdmin(`/admin/devices/${bundle.device.id}/commands`, {
method: "POST",
body: {
command: "dynamic_qr.display",
payload: {
"qr-url": "https://pay.example/qr/qf100-smoke",
amount: 25000,
"expire-seconds": 60
}
},
_label: "POST /admin/devices/:id/commands dynamic QR display"
});
assert(result.status === "delivered", "dynamic QR display command must be delivered");
assert(result.result_payload?.topic === `soundbox/${bundle.device.serial_number}/down`, "dynamic QR display topic must use serial number");
assert(result.result_payload?.payload?.header?.category === 4, "dynamic QR display category must be 4");
return result;
}
async function triggerRebootCommand({ bundle }) {
const result = await reqAdmin(`/admin/devices/${bundle.device.id}/commands`, {
method: "POST",
body: {
command: "device.reboot",
payload: {
requested_from: "qf100_smoke"
}
},
_label: "POST /admin/devices/:id/commands reboot"
});
assert(result.status === "delivered", "reboot command must be delivered");
assert(result.result_payload?.topic === `soundbox/${bundle.device.serial_number}/down`, "reboot topic must use serial number");
assert(result.result_payload?.payload?.header?.category === 5, "reboot category must be 5");
assert(result.result_payload?.payload?.data?.command === "reboot", "reboot command payload must be reboot");
return result;
}
async function main() { async function main() {
await req("/health", { _label: "GET /health" }); await req("/health", { _label: "GET /health" });
const ts = Date.now(); const ts = Date.now();
@ -253,6 +292,8 @@ async function main() {
const staticPaymentMessage = await triggerStaticPayment({ bundle: staticBundle, ts }); const staticPaymentMessage = await triggerStaticPayment({ bundle: staticBundle, ts });
const dynamicQr = await triggerDynamicMqttQr({ bundle: dynamicBundle, ts }); const dynamicQr = await triggerDynamicMqttQr({ bundle: dynamicBundle, ts });
const dynamicQrDisplay = await triggerDynamicQrDisplay({ bundle: dynamicBundle });
const rebootCommand = await triggerRebootCommand({ bundle: staticBundle });
console.log("\nQF100 adapter smoke passed"); console.log("\nQF100 adapter smoke passed");
console.log(`static_sn=${STATIC_SN}`); console.log(`static_sn=${STATIC_SN}`);
@ -261,6 +302,8 @@ async function main() {
console.log(`dynamic_sn=${DYNAMIC_SN}`); console.log(`dynamic_sn=${DYNAMIC_SN}`);
console.log(`dynamic_device_id=${dynamicBundle.device.id}`); console.log(`dynamic_device_id=${dynamicBundle.device.id}`);
console.log(`dynamic_transaction_id=${dynamicQr.transaction_id}`); console.log(`dynamic_transaction_id=${dynamicQr.transaction_id}`);
console.log(`dynamic_qr_display_command=${dynamicQrDisplay.id || dynamicQrDisplay.command_id}`);
console.log(`reboot_command=${rebootCommand.id || rebootCommand.command_id}`);
} }
main().catch((error) => { main().catch((error) => {

View File

@ -215,7 +215,70 @@ Behavior:
- If `fw-version` is equal but `fw-build` is greater, the device starts OTA. - If `fw-version` is equal but `fw-build` is greater, the device starts OTA.
- Otherwise, the device logs that no update is needed. - Otherwise, the device logs that no update is needed.
## 5. MQTT Device Heartbeat ## 5. MQTT Dynamic QR Display Payload
The backend can ask the device to display a dynamic QR code by publishing to `mqtt.subscribe-topic`:
```json
{
"header": {
"category": 4
},
"data": {
"qr-url": "https://pay.example/qr/abc123",
"amount": 25000,
"expire-seconds": 60
}
}
```
### Required Fields
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `header.category` | number | yes | Use `4` for dynamic QR display. |
| `data.qr-url` | string | yes | Content encoded into the QR code. This can be a URL or any QR payload string. |
| `data.amount` | number | yes | Amount shown on the QR page. Must be greater than `0`. |
| `data.expire-seconds` | number | no | QR validity duration in seconds. Defaults to `60` if missing or invalid. |
Behavior:
- The device displays the QR code immediately after receiving a valid category `4` payload.
- The QR page shows `data.qr-url` as QR content and displays `data.amount`.
- When `expire-seconds` elapses, the device returns to the default `Bizone System` status screen.
- If a category `1` payment notification is received while the QR page is active, the device immediately hides the QR page and returns to the default status screen before playing the payment notification.
- This command only controls the display. It does not start the internal POS QR transaction flow.
## 6. MQTT Reboot Command Payload
The backend can ask the device to reboot by publishing to `mqtt.subscribe-topic`:
```json
{
"header": {
"category": 5
},
"data": {
"command": "reboot"
}
}
```
### Required Fields
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `header.category` | number | yes | Use `5` for device reboot command. |
| `data.command` | string | yes | Must be exactly `reboot`. |
Behavior:
- The device validates `data.command` before rebooting.
- If `data.command` is missing or not exactly `reboot`, the payload is ignored.
- When valid, the device plays the reboot audio, shows `Rebooting...`, waits about 2 seconds, then restarts.
- This command is backend-to-device only.
## 7. MQTT Device Heartbeat
The firmware publishes an application-level heartbeat over MQTT after it has connected and subscribed successfully. The firmware publishes an application-level heartbeat over MQTT after it has connected and subscribed successfully.
@ -264,6 +327,7 @@ Timing:
- The device publishes one heartbeat immediately after MQTT subscribe succeeds. - The device publishes one heartbeat immediately after MQTT subscribe succeeds.
- The device then publishes periodically using `mqtt.keep-alive` seconds from the config response. - The device then publishes periodically using `mqtt.keep-alive` seconds from the config response.
- If `mqtt.keep-alive` is `0`, firmware falls back to `60` seconds. - If `mqtt.keep-alive` is `0`, firmware falls back to `60` seconds.
- Current firmware synchronizes device time with NTP timezone `UTC+7`, so `data.time` represents WIB local time.
Backend handling: Backend handling:
@ -280,11 +344,11 @@ Backend handling:
| `data.client-id` | string | yes | MQTT client ID from config response. | | `data.client-id` | string | yes | MQTT client ID from config response. |
| `data.fw-version` | string | yes | Firmware version. | | `data.fw-version` | string | yes | Firmware version. |
| `data.fw-build` | number | yes | Firmware build number. | | `data.fw-build` | number | yes | Firmware build number. |
| `data.time` | string | yes | Device local time in `YYYYMMDDHHMMSS` format. | | `data.time` | string | yes | Device local time in `YYYYMMDDHHMMSS` format, currently WIB/UTC+7. |
| `data.battery-level` | number | yes | Battery percentage calculated by firmware, `0` to `100`. | | `data.battery-level` | number | yes | Battery percentage calculated by firmware, `0` to `100`. |
| `data.wifi-ap` | object | optional | Present only when WiFi data is available. | | `data.wifi-ap` | object | optional | Present only when WiFi data is available. |
| `data.wifi-ap.ssid` | string | optional | Current configured WiFi SSID. | | `data.wifi-ap.ssid` | string | optional | Current configured WiFi SSID. |
| `data.wifi-ap.mac` | string | optional | AP MAC/BSSID, included only when the firmware can match it from WiFi scan result. | | `data.wifi-ap.mac` | string | optional | Reserved for AP MAC/BSSID. Current stability-focused firmware does not scan AP list during heartbeat, so this is usually absent. |
| `data.wifi-ap.rssi` | number | optional | Connected WiFi RSSI from SDK. | | `data.wifi-ap.rssi` | number | optional | Connected WiFi RSSI from SDK. |
| `data.main-cell-info` | object | optional | Present only when GPRS/cellular data is available. | | `data.main-cell-info` | object | optional | Present only when GPRS/cellular data is available. |
| `data.main-cell-info.mcc` | number | optional | Parsed from IMSI when available. | | `data.main-cell-info.mcc` | number | optional | Parsed from IMSI when available. |
@ -294,12 +358,12 @@ Backend handling:
Important: Important:
- `wifi-ap` and `main-cell-info` are optional. Backend should not reject heartbeat if either object is absent. - `wifi-ap` and `main-cell-info` are optional. Backend should not reject heartbeat if either object is absent.
- `wifi-ap.mac` is optional because the current SDK exposes AP MAC through scan results, not a direct "current BSSID" API. - `wifi-ap.mac` is optional because the current SDK exposes AP MAC through scan results, not a direct "current BSSID" API. The firmware avoids WiFi scanning inside routine heartbeat to keep heartbeat stable.
- `main-cell-info.lac` and `main-cell-info.cell-id` are not sent by this firmware build because no SDK API for those values is available in this repo. - `main-cell-info.lac` and `main-cell-info.cell-id` are not sent by this firmware build because no SDK API for those values is available in this repo.
## 6. Unsupported Categories ## 8. Unsupported Categories
For any category other than `1` or `2`, the firmware still requires: For unsupported categories, the firmware still requires:
```json ```json
{ {
@ -310,9 +374,9 @@ For any category other than `1` or `2`, the firmware still requires:
} }
``` ```
The current firmware does not perform any action for unsupported categories. The current firmware handles categories `1`, `2`, `4`, and `5` from backend-to-device messages. Category `3` is used by device-to-backend heartbeat. Other categories do not perform any action.
## 7. OTA Check API Response ## 9. OTA Check API Response
After an OTA trigger, the device calls the update check API configured by `UPDATE_ADDR`. After an OTA trigger, the device calls the update check API configured by `UPDATE_ADDR`.
@ -345,7 +409,7 @@ Special error codes:
The firmware treats both as no-update conditions. The firmware treats both as no-update conditions.
## 8. OTA Result Upload ## 10. OTA Result Upload
The device uploads OTA result to `RESULT_ADDR`. The device uploads OTA result to `RESULT_ADDR`.
@ -360,7 +424,7 @@ Request body:
The current firmware sends the request but does not parse the response body. The current firmware sends the request but does not parse the response body.
## 9. Recommended Topic Design ## 11. Recommended Topic Design
Use one downlink topic per device: Use one downlink topic per device:
@ -397,7 +461,7 @@ Backend can listen for heartbeat on:
soundbox/{dev-sn}/down/heartbeat soundbox/{dev-sn}/down/heartbeat
``` ```
## 10. End-to-End Example ## 12. End-to-End Example
### Config Response ### Config Response
@ -459,6 +523,50 @@ Payload:
} }
``` ```
### Dynamic QR Publish
Publish to:
```text
soundbox/QF100123456/down
```
Payload:
```json
{
"header": {
"category": 4
},
"data": {
"qr-url": "https://pay.example/qr/abc123",
"amount": 25000,
"expire-seconds": 60
}
}
```
### Reboot Command Publish
Publish to:
```text
soundbox/QF100123456/down
```
Payload:
```json
{
"header": {
"category": 5
},
"data": {
"command": "reboot"
}
}
```
### Heartbeat From Device ### Heartbeat From Device
Subscribe to: Subscribe to:
@ -485,11 +593,12 @@ Payload received:
} }
``` ```
## 11. Notes For Backend Implementation ## 13. Notes For Backend Implementation
- Always publish valid JSON. - Always publish valid JSON.
- Always use JSON numbers for numeric fields. - Always use JSON numbers for numeric fields.
- Do not use strings for `category`, `pay-amount`, `fw-build`, `broker-port`, or `keep-alive`. - Do not use strings for `category`, `pay-amount`, `amount`, `expire-seconds`, `fw-build`, `broker-port`, or `keep-alive`.
- For reboot, send `data.command` exactly as the string `reboot`.
- Keep `client-id` unique per device. - Keep `client-id` unique per device.
- Use the device serial number `dev-sn` as the main device identifier. - Use the device serial number `dev-sn` as the main device identifier.
- The firmware logs MQTT payloads through CATStudio/DIAG, useful for debugging invalid payloads. - The firmware logs MQTT payloads through CATStudio/DIAG, useful for debugging invalid payloads.

View File

@ -39,6 +39,7 @@ import {
createDeviceHeartbeat createDeviceHeartbeat
} from "../shared/store/heartbeatStore"; } from "../shared/store/heartbeatStore";
import { import {
acknowledgeDeviceCommand,
createDeviceCommand, createDeviceCommand,
getDeviceCommandById, getDeviceCommandById,
listDeviceCommands, listDeviceCommands,
@ -98,7 +99,7 @@ import {
upsertDeviceConfig upsertDeviceConfig
} 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 { getMqttPublisherStatus, publishConfigPush } from "../shared/services/mqttPublisher"; import { getMqttPublisherStatus, publishConfigPush, publishQf100DynamicQrDisplay, publishQf100RebootCommand } from "../shared/services/mqttPublisher";
import { getMqttSubscriberStatus } from "../shared/services/mqttSubscriber"; import { getMqttSubscriberStatus } from "../shared/services/mqttSubscriber";
import { getDatabaseHealth, getServiceHealth } from "../shared/services/health"; import { getDatabaseHealth, getServiceHealth } from "../shared/services/health";
import { logger } from "../shared/services/logger"; import { logger } from "../shared/services/logger";
@ -1819,12 +1820,118 @@ router.post(
return next(new ApiError("BAD_REQUEST", "command is required", 400)); return next(new ApiError("BAD_REQUEST", "command is required", 400));
} }
const commandName = payload.command.trim();
const command = await createDeviceCommand({ const command = await createDeviceCommand({
device_id: device.id, device_id: device.id,
command: payload.command.trim(), command: commandName,
payload: payload.payload || {} payload: payload.payload || {}
}); });
if (commandName === "dynamic_qr.test" || commandName === "dynamic_qr.display") {
if (!device.serial_number) {
const failed = await acknowledgeDeviceCommand({
device_id: device.id,
command_id: command.id,
status: "failed",
reason: "DEVICE_SERIAL_NUMBER_REQUIRED",
result_payload: { command: commandName }
});
return res.status(201).json(successResponse(req, toDeviceCommandPayload(failed || command)));
}
const commandPayload = payload.payload || {};
const commandData = commandPayload.data && typeof commandPayload.data === "object"
? (commandPayload.data as Record<string, unknown>)
: commandPayload;
const amount = Number(commandData.amount || 1000);
const expireSeconds = Number(commandData["expire-seconds"] || commandData.expire_seconds || commandData.expires_in_seconds || 60);
const qrUrl = String(
commandData["qr-url"] ||
commandData.qr_url ||
commandData.qr_payload ||
commandData.qrPayload ||
`https://sms.bizone.id/ui/device-ui-qr-payment-display?device_id=${encodeURIComponent(device.id)}&amount=${encodeURIComponent(String(amount))}`
);
if (!qrUrl || !Number.isFinite(amount) || amount <= 0) {
const failed = await acknowledgeDeviceCommand({
device_id: device.id,
command_id: command.id,
status: "failed",
reason: "DYNAMIC_QR_PAYLOAD_INVALID",
result_payload: { command: commandName, qr_url: qrUrl, amount }
});
return res.status(201).json(successResponse(req, toDeviceCommandPayload(failed || command)));
}
const publishResult = await publishQf100DynamicQrDisplay(device.id, device.serial_number, {
qrUrl,
amount,
expireSeconds
});
await createMqttMessage({
direction: "downlink",
device_id: device.id,
topic: publishResult.topic,
message_type: "dynamic_qr_display",
correlation_id: command.id,
payload_json: publishResult.payload,
publish_status: publishResult.ok ? "sent" : "failed",
reason: publishResult.reason
});
const acknowledged = await acknowledgeDeviceCommand({
device_id: device.id,
command_id: command.id,
status: publishResult.ok ? "delivered" : "failed",
reason: publishResult.reason,
result_payload: {
topic: publishResult.topic,
payload: publishResult.payload,
published_at: publishResult.publishedAt
}
});
return res.status(201).json(successResponse(req, toDeviceCommandPayload(acknowledged || command)));
}
if (commandName === "device.reboot") {
if (!device.serial_number) {
const failed = await acknowledgeDeviceCommand({
device_id: device.id,
command_id: command.id,
status: "failed",
reason: "DEVICE_SERIAL_NUMBER_REQUIRED",
result_payload: { command: commandName }
});
return res.status(201).json(successResponse(req, toDeviceCommandPayload(failed || command)));
}
const publishResult = await publishQf100RebootCommand(device.id, device.serial_number);
await createMqttMessage({
direction: "downlink",
device_id: device.id,
topic: publishResult.topic,
message_type: "reboot_command",
correlation_id: command.id,
payload_json: publishResult.payload,
publish_status: publishResult.ok ? "sent" : "failed",
reason: publishResult.reason
});
const acknowledged = await acknowledgeDeviceCommand({
device_id: device.id,
command_id: command.id,
status: publishResult.ok ? "delivered" : "failed",
reason: publishResult.reason,
result_payload: {
topic: publishResult.topic,
payload: publishResult.payload,
published_at: publishResult.publishedAt
}
});
return res.status(201).json(successResponse(req, toDeviceCommandPayload(acknowledged || command)));
}
res.status(201).json(successResponse(req, toDeviceCommandPayload(command))); res.status(201).json(successResponse(req, toDeviceCommandPayload(command)));
} }
); );

View File

@ -5,9 +5,10 @@ import {
toDeviceConfigAckPayload, toDeviceConfigAckPayload,
toDeviceConfigPayload toDeviceConfigPayload
} from "../store/deviceConfigStore"; } from "../store/deviceConfigStore";
import { listHeartbeats } from "../store/heartbeatStore";
import { listMqttMessages, toMqttMessagePayload } from "../store/mqttMessageStore"; import { listMqttMessages, toMqttMessagePayload } from "../store/mqttMessageStore";
type ConfigDriftStatus = "applied" | "pending_ack" | "failed_ack" | "stale_ack" | "never_pushed"; type ConfigDriftStatus = "applied" | "pending_ack" | "failed_ack" | "stale_ack" | "pulled_not_pushed" | "never_pushed";
function deriveConfigDriftStatus(config: DeviceConfigEntity, latestAck: DeviceConfigAckEntity | null): ConfigDriftStatus { function deriveConfigDriftStatus(config: DeviceConfigEntity, latestAck: DeviceConfigAckEntity | null): ConfigDriftStatus {
if (!latestAck) { if (!latestAck) {
@ -40,8 +41,15 @@ export async function buildDeviceConfigStatus(config: DeviceConfigEntity) {
limit: 1 limit: 1
}) })
)[0]; )[0];
const latestConfigPull = (
await listHeartbeats({
device_id: config.device_id,
state: "config_pull",
limit: 1
})
)[0];
const driftStatus = latestPush ? deriveConfigDriftStatus(config, latestAck) : "never_pushed"; const driftStatus = latestPush ? deriveConfigDriftStatus(config, latestAck) : latestConfigPull ? "pulled_not_pushed" : "never_pushed";
return { return {
device_id: config.device_id, device_id: config.device_id,
@ -50,6 +58,7 @@ export async function buildDeviceConfigStatus(config: DeviceConfigEntity) {
desired_config_version: config.config_version, desired_config_version: config.config_version,
latest_ack: latestAck ? toDeviceConfigAckPayload(latestAck) : null, latest_ack: latestAck ? toDeviceConfigAckPayload(latestAck) : null,
latest_push: latestPush ? toMqttMessagePayload(latestPush) : null, latest_push: latestPush ? toMqttMessagePayload(latestPush) : null,
retry_recommended: driftStatus !== "applied" latest_config_pull: latestConfigPull || null,
retry_recommended: !["applied", "pulled_not_pushed"].includes(driftStatus)
}; };
} }

View File

@ -27,6 +27,26 @@ type Qf100PaymentSuccessPayload = {
}; };
}; };
type Qf100DynamicQrDisplayPayload = {
header: {
category: 4;
};
data: {
"qr-url": string;
amount: number;
"expire-seconds": number;
};
};
type Qf100RebootCommandPayload = {
header: {
category: 5;
};
data: {
command: "reboot";
};
};
type DynamicQrResponsePayload = { type DynamicQrResponsePayload = {
message_type: "dynamic_qr_response"; message_type: "dynamic_qr_response";
correlation_id: string; correlation_id: string;
@ -287,6 +307,45 @@ export async function publishDynamicQrResponse(deviceId: string, payload: Dynami
return publishMqttPayload(deviceId, makeDynamicQrResponseTopic(deviceId), payload); return publishMqttPayload(deviceId, makeDynamicQrResponseTopic(deviceId), payload);
} }
export async function publishQf100DynamicQrDisplay(
deviceId: string,
serialNumber: string,
payload: {
qrUrl: string;
amount: number;
expireSeconds?: number;
}
): Promise<MqttPublishResult<Qf100DynamicQrDisplayPayload>> {
const qrPayload: Qf100DynamicQrDisplayPayload = {
header: {
category: 4
},
data: {
"qr-url": payload.qrUrl,
amount: payload.amount,
"expire-seconds": payload.expireSeconds && payload.expireSeconds > 0 ? payload.expireSeconds : 60
}
};
return publishMqttPayload(deviceId, makeQf100DownlinkTopic(serialNumber), qrPayload);
}
export async function publishQf100RebootCommand(
deviceId: string,
serialNumber: string
): Promise<MqttPublishResult<Qf100RebootCommandPayload>> {
const rebootPayload: Qf100RebootCommandPayload = {
header: {
category: 5
},
data: {
command: "reboot"
}
};
return publishMqttPayload(deviceId, makeQf100DownlinkTopic(serialNumber), rebootPayload);
}
export async function publishConfigPush(deviceId: string, payload: ConfigPushPayload) { export async function publishConfigPush(deviceId: string, payload: ConfigPushPayload) {
return publishMqttPayload(deviceId, makeConfigPushTopic(deviceId), payload); return publishMqttPayload(deviceId, makeConfigPushTopic(deviceId), payload);
} }

View File

@ -455,13 +455,14 @@ Rows
<img alt="Soundbox V2 Product" class="w-20 h-20 rounded-xl bg-slate-100" data-alt="A clean professional studio product shot of a minimalist electronic soundbox speaker device with a small LCD screen and premium matte plastic finish. The lighting is soft and corporate with subtle blue reflections on the surface consistent with a high-end fintech hardware brand. The background is a clean neutral white studio setting." src="https://lh3.googleusercontent.com/aida-public/AB6AXuC-CSPTCnxQuDTN1XM0atRPM9hIcVzf3zpbuxEUGTIlC-c1BivDqPa9osmBscvoiUcJeMBwUaXbZ6Ut5FuG2a91sVtZjzWRTgLck34kJJJy3N2E9O3uVtZw6InOpX9Gkph2OJxu_Z-PkR_t3F56EVZY3u8o2iZO3iH8hj9_ajrku7g1r_l54uobcRoN3dRH3k_at6GTuGbMtSSD4ew24sX8nePUsVvILKJauQLcMKD14J6mtAGm0x5PfViQQKdJzf_pYMqKswr3Yz4"/> <img alt="Soundbox V2 Product" class="w-20 h-20 rounded-xl bg-slate-100" data-alt="A clean professional studio product shot of a minimalist electronic soundbox speaker device with a small LCD screen and premium matte plastic finish. The lighting is soft and corporate with subtle blue reflections on the surface consistent with a high-end fintech hardware brand. The background is a clean neutral white studio setting." src="https://lh3.googleusercontent.com/aida-public/AB6AXuC-CSPTCnxQuDTN1XM0atRPM9hIcVzf3zpbuxEUGTIlC-c1BivDqPa9osmBscvoiUcJeMBwUaXbZ6Ut5FuG2a91sVtZjzWRTgLck34kJJJy3N2E9O3uVtZw6InOpX9Gkph2OJxu_Z-PkR_t3F56EVZY3u8o2iZO3iH8hj9_ajrku7g1r_l54uobcRoN3dRH3k_at6GTuGbMtSSD4ew24sX8nePUsVvILKJauQLcMKD14J6mtAGm0x5PfViQQKdJzf_pYMqKswr3Yz4"/>
<div> <div>
<h4 class="font-bold text-headline-md mb-1" id="device-detail-title">-</h4> <h4 class="font-bold text-headline-md mb-1" id="device-detail-title">-</h4>
<p class="font-mono text-body-md text-slate-500" id="device-detail-serial">SN: -</p>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold bg-slate-100 text-slate-600" id="device-detail-model">Device</span> <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold bg-slate-100 text-slate-600" id="device-detail-model">Device</span>
</div> </div>
</div> </div>
<div class="space-y-6" id="device-detail-content"></div> <div class="space-y-6" id="device-detail-content"></div>
</div> </div>
<div class="p-6 border-t border-slate-200 grid grid-cols-2 gap-4"> <div class="p-6 border-t border-slate-200 grid grid-cols-2 gap-4">
<button class="w-full py-2.5 border border-slate-200 rounded-xl font-bold hover:bg-slate-50 transition-colors">Reboot Device</button> <button id="drawer-reboot-device" class="w-full py-2.5 border border-slate-200 rounded-xl font-bold hover:bg-slate-50 transition-colors">Reboot Device</button>
<button class="w-full py-2.5 bg-danger/10 text-danger rounded-xl font-bold hover:bg-danger/20 transition-colors">Unbind Merchant</button> <button class="w-full py-2.5 bg-danger/10 text-danger rounded-xl font-bold hover:bg-danger/20 transition-colors">Unbind Merchant</button>
</div> </div>
</div> </div>
@ -495,8 +496,10 @@ Rows
const detailDrawer = document.getElementById("device-detail-drawer"); const detailDrawer = document.getElementById("device-detail-drawer");
const detailCloseButton = document.getElementById("device-detail-close"); const detailCloseButton = document.getElementById("device-detail-close");
const detailTitle = document.getElementById("device-detail-title"); const detailTitle = document.getElementById("device-detail-title");
const detailSerial = document.getElementById("device-detail-serial");
const detailModel = document.getElementById("device-detail-model"); const detailModel = document.getElementById("device-detail-model");
const detailContent = document.getElementById("device-detail-content"); const detailContent = document.getElementById("device-detail-content");
const drawerRebootButton = document.getElementById("drawer-reboot-device");
const registerModal = document.getElementById("device-register-modal"); const registerModal = document.getElementById("device-register-modal");
const registerForm = document.getElementById("device-register-form"); const registerForm = document.getElementById("device-register-form");
const topbarRegisterOpenButton = document.getElementById("topbar-register-device-open"); const topbarRegisterOpenButton = document.getElementById("topbar-register-device-open");
@ -612,6 +615,9 @@ Rows
if (value === "applied") { if (value === "applied") {
return { label: "Applied", className: "bg-success/10 text-success border-success/20", icon: "check_circle" }; return { label: "Applied", className: "bg-success/10 text-success border-success/20", icon: "check_circle" };
} }
if (value === "pulled_not_pushed") {
return { label: "Pulled by Device", className: "bg-success/10 text-success border-success/20", icon: "check_circle" };
}
if (value === "pending_ack") { if (value === "pending_ack") {
return { label: "Pending ACK", className: "bg-warning/10 text-warning border-warning/20", icon: "pending" }; return { label: "Pending ACK", className: "bg-warning/10 text-warning border-warning/20", icon: "pending" };
} }
@ -834,6 +840,7 @@ Rows
let currentPage = 1; let currentPage = 1;
let pageSize = Number(pageSizeSelect?.value || 10); let pageSize = Number(pageSizeSelect?.value || 10);
let currentSearchQuery = ""; let currentSearchQuery = "";
let activeDrawerDevice = null;
let merchants = []; let merchants = [];
let outlets = []; let outlets = [];
let terminals = []; let terminals = [];
@ -855,6 +862,7 @@ Rows
tableBody.innerHTML = items tableBody.innerHTML = items
.map((device) => { .map((device) => {
const id = device.device_code || device.id || ""; const id = device.device_code || device.id || "";
const serialNumber = escapeHtml(device.serial_number || "-");
const model = device.model || "Unknown"; const model = device.model || "Unknown";
const binding = device.binding_summary || {}; const binding = device.binding_summary || {};
const merchantName = merchantMap.get(binding.merchant_id) || "Unassigned"; const merchantName = merchantMap.get(binding.merchant_id) || "Unassigned";
@ -866,7 +874,10 @@ Rows
return ` return `
<tr class="hover:bg-slate-50 transition-colors group"> <tr class="hover:bg-slate-50 transition-colors group">
<td class="px-6 py-row-height"> <td class="px-6 py-row-height">
<span class="font-mono text-primary font-bold">${id || "-"}</span> <div class="space-y-1">
<span class="block font-mono text-primary font-bold">${id || "-"}</span>
<span class="block font-mono text-[12px] text-slate-500">SN: ${serialNumber}</span>
</div>
</td> </td>
<td class="px-6 py-row-height">${model}</td> <td class="px-6 py-row-height">${model}</td>
<td class="px-6 py-row-height"> <td class="px-6 py-row-height">
@ -1140,6 +1151,7 @@ Rows
if (!detailDrawer || !detailOverlay || !detailTitle || !detailModel || !detailContent) { if (!detailDrawer || !detailOverlay || !detailTitle || !detailModel || !detailContent) {
return; return;
} }
activeDrawerDevice = device;
const binding = device.binding_summary || {}; const binding = device.binding_summary || {};
const connection = connectionMeta(device.communication_mode); const connection = connectionMeta(device.communication_mode);
@ -1152,7 +1164,11 @@ Rows
: "No active warning"; : "No active warning";
const id = device.device_code || device.id || "-"; const id = device.device_code || device.id || "-";
const serialNumber = escapeHtml(device.serial_number || "-");
detailTitle.textContent = id; detailTitle.textContent = id;
if (detailSerial) {
detailSerial.textContent = `SN: ${serialNumber}`;
}
detailModel.textContent = device.model || "Unknown"; detailModel.textContent = device.model || "Unknown";
detailModel.className = `inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold ${status.className}`; detailModel.className = `inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold ${status.className}`;
@ -1160,6 +1176,10 @@ Rows
<section> <section>
<h5 class="text-label-md font-label-md text-slate-500 uppercase mb-3">Device Detail</h5> <h5 class="text-label-md font-label-md text-slate-500 uppercase mb-3">Device Detail</h5>
<div class="bg-slate-50 p-4 rounded-xl border border-slate-100"> <div class="bg-slate-50 p-4 rounded-xl border border-slate-100">
<div class="flex justify-between gap-4 mb-2">
<span class="text-on-surface-variant">Serial Number</span>
<span class="font-mono font-bold text-right">${serialNumber}</span>
</div>
<div class="flex justify-between mb-2"> <div class="flex justify-between mb-2">
<span class="text-on-surface-variant">Model</span> <span class="text-on-surface-variant">Model</span>
<span class="font-bold">${device.model || "Unknown"}</span> <span class="font-bold">${device.model || "Unknown"}</span>
@ -1233,6 +1253,33 @@ Rows
loadDrawerConfig(device.id); loadDrawerConfig(device.id);
}; };
const sendDrawerRebootCommand = async () => {
if (!activeDrawerDevice || !drawerRebootButton) {
return;
}
const originalText = drawerRebootButton.textContent || "Reboot Device";
drawerRebootButton.disabled = true;
drawerRebootButton.textContent = "Sending...";
try {
const result = await api.createDeviceCommand(activeDrawerDevice.id, {
command: "device.reboot",
payload: { requested_from: "device_registry_drawer" }
});
drawerRebootButton.textContent = result.status === "delivered" ? "Reboot Sent" : "Reboot Queued";
window.setTimeout(() => {
drawerRebootButton.textContent = originalText;
drawerRebootButton.disabled = false;
}, 1800);
} catch (error) {
drawerRebootButton.textContent = "Reboot Failed";
window.setTimeout(() => {
drawerRebootButton.textContent = originalText;
drawerRebootButton.disabled = false;
}, 2200);
}
};
const loadDrawerConfig = async (deviceId) => { const loadDrawerConfig = async (deviceId) => {
const box = document.getElementById("device-config-status-box"); const box = document.getElementById("device-config-status-box");
if (!box || !deviceId) { if (!box || !deviceId) {
@ -1244,6 +1291,7 @@ Rows
const meta = configStatusMeta(configStatus.drift_status); const meta = configStatusMeta(configStatus.drift_status);
const latestPush = configStatus.latest_push; const latestPush = configStatus.latest_push;
const latestAck = configStatus.latest_ack; const latestAck = configStatus.latest_ack;
const latestPull = configStatus.latest_config_pull;
const canRetry = configStatus.retry_recommended; const canRetry = configStatus.retry_recommended;
box.innerHTML = ` box.innerHTML = `
<div class="flex items-center justify-between gap-3 mb-3"> <div class="flex items-center justify-between gap-3 mb-3">
@ -1253,7 +1301,11 @@ Rows
</span> </span>
<span class="font-mono text-slate-500">v${configStatus.desired_config_version || "-"}</span> <span class="font-mono text-slate-500">v${configStatus.desired_config_version || "-"}</span>
</div> </div>
<div class="grid grid-cols-2 gap-3 text-sm mb-4"> <div class="grid grid-cols-3 gap-3 text-sm mb-4">
<div>
<p class="text-slate-500">Latest Pull</p>
<p class="font-bold">${latestPull ? formatLastSeen(latestPull.received_at || latestPull.timestamp) : "-"}</p>
</div>
<div> <div>
<p class="text-slate-500">Latest Push</p> <p class="text-slate-500">Latest Push</p>
<p class="font-bold">${latestPush ? formatLastSeen(latestPush.created_at) : "-"}</p> <p class="font-bold">${latestPush ? formatLastSeen(latestPush.created_at) : "-"}</p>
@ -1472,6 +1524,7 @@ Rows
if (!detailDrawer || !detailOverlay) { if (!detailDrawer || !detailOverlay) {
return; return;
} }
activeDrawerDevice = null;
detailDrawer.classList.add("translate-x-full"); detailDrawer.classList.add("translate-x-full");
detailOverlay.classList.remove("opacity-100"); detailOverlay.classList.remove("opacity-100");
detailOverlay.classList.add("opacity-0", "pointer-events-none"); detailOverlay.classList.add("opacity-0", "pointer-events-none");
@ -1578,6 +1631,7 @@ Rows
}); });
detailOverlay?.addEventListener("click", closeDrawer); detailOverlay?.addEventListener("click", closeDrawer);
detailCloseButton?.addEventListener("click", closeDrawer); detailCloseButton?.addEventListener("click", closeDrawer);
drawerRebootButton?.addEventListener("click", sendDrawerRebootCommand);
topbarRegisterOpenButton?.addEventListener("click", openRegisterModal); topbarRegisterOpenButton?.addEventListener("click", openRegisterModal);
refreshButton?.addEventListener("click", refresh); refreshButton?.addEventListener("click", refresh);
registerCloseButton?.addEventListener("click", closeRegisterModal); registerCloseButton?.addEventListener("click", closeRegisterModal);

View File

@ -211,6 +211,10 @@
<span id="device-model">Soundbox V2 Pro</span> <span id="device-model">Soundbox V2 Pro</span>
</p> </p>
<p class="text-body-md text-on-surface-variant flex items-center gap-1.5"> <p class="text-body-md text-on-surface-variant flex items-center gap-1.5">
<span class="material-symbols-outlined text-sm">tag</span>
<span id="device-serial-number">SN: -</span>
</p>
<p class="text-body-md text-on-surface-variant flex items-center gap-1.5">
<span class="material-symbols-outlined text-sm">schedule</span> <span class="material-symbols-outlined text-sm">schedule</span>
<span id="device-last-seen">Last seen 2 mins ago</span> <span id="device-last-seen">Last seen 2 mins ago</span>
</p> </p>
@ -235,7 +239,7 @@
<!-- Tab Navigation --> <!-- Tab Navigation -->
<div class="border-b border-slate-200 mb-8 flex gap-8"> <div class="border-b border-slate-200 mb-8 flex gap-8">
<button class="pb-4 text-body-md font-bold text-primary border-b-2 border-primary" data-scroll-target="overview-section">Overview</button> <button class="pb-4 text-body-md font-bold text-primary border-b-2 border-primary" data-scroll-target="overview-section">Overview</button>
<button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="heartbeat-section">Heartbeat</button> <button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="payload-stream">Heartbeat</button>
<button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="configuration-section">Configuration</button> <button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="configuration-section">Configuration</button>
<button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="binding-section">Binding History</button> <button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="binding-section">Binding History</button>
<button id="dynamic-qr-tab" class="hidden pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="dynamic-qr-panel">Dynamic QR</button> <button id="dynamic-qr-tab" class="hidden pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors" data-scroll-target="dynamic-qr-panel">Dynamic QR</button>
@ -376,7 +380,7 @@ Loading
</button> </button>
</div> </div>
</div> </div>
<div id="heartbeat-section" class="p-4 flex-1 overflow-y-auto code-font text-[13px] text-green-400 space-y-1 custom-scroll"> <div id="payload-stream" data-section="heartbeat-section" class="p-4 flex-1 overflow-y-auto code-font text-[13px] text-green-400 space-y-1 custom-scroll">
<p class="text-slate-500">[14:02:11] INITIALIZING WEBSOCKET CONNECTION...</p> <p class="text-slate-500">[14:02:11] INITIALIZING WEBSOCKET CONNECTION...</p>
<p class="text-slate-500">[14:02:12] CONNECTED TO SND-10293_GATEWAY_V4</p> <p class="text-slate-500">[14:02:12] CONNECTED TO SND-10293_GATEWAY_V4</p>
<p class="text-success">[14:02:15] RECV: {"event": "heartbeat", "status": "online", "v_batt": 4.12, "rssi": -78, "ts": 1715421255}</p> <p class="text-success">[14:02:15] RECV: {"event": "heartbeat", "status": "online", "v_batt": 4.12, "rssi": -78, "ts": 1715421255}</p>
@ -492,7 +496,7 @@ Rotate Credential
<div class="rounded-[28px] border-[10px] border-slate-900 bg-slate-950 p-4 shadow-xl"> <div class="rounded-[28px] border-[10px] border-slate-900 bg-slate-950 p-4 shadow-xl">
<div class="rounded-2xl bg-white p-4 text-center"> <div class="rounded-2xl bg-white p-4 text-center">
<p class="text-[11px] font-bold uppercase tracking-wider text-slate-500">QRIS Payment</p> <p class="text-[11px] font-bold uppercase tracking-wider text-slate-500">QRIS Payment</p>
<div id="qr-preview-grid" class="mx-auto my-4 grid h-40 w-40 grid-cols-9 grid-rows-9 gap-1 rounded-lg bg-white p-2"></div> <div id="qr-preview-grid" class="mx-auto my-4 flex h-40 w-40 items-center justify-center rounded-lg bg-white p-2"></div>
<p id="qr-preview-amount" class="text-xl font-extrabold text-slate-950">Rp 1.000</p> <p id="qr-preview-amount" class="text-xl font-extrabold text-slate-950">Rp 1.000</p>
<p id="qr-preview-device" class="mt-1 font-mono text-[11px] text-slate-500">-</p> <p id="qr-preview-device" class="mt-1 font-mono text-[11px] text-slate-500">-</p>
</div> </div>
@ -582,7 +586,7 @@ Copy Command
const qs = new URLSearchParams(window.location.search); const qs = new URLSearchParams(window.location.search);
const deviceId = qs.get("device_id") || qs.get("deviceId") || qs.get("id") || ""; const deviceId = qs.get("device_id") || qs.get("deviceId") || qs.get("id") || "";
let activeDeviceId = deviceId; let activeDeviceId = deviceId;
const stream = document.getElementById("payload-stream"); const stream = document.getElementById("payload-stream") || document.getElementById("heartbeat-section");
const clearBtn = document.getElementById("clearConsole"); const clearBtn = document.getElementById("clearConsole");
const exportBtn = document.getElementById("export-device-logs"); const exportBtn = document.getElementById("export-device-logs");
const refreshBtn = document.getElementById("refresh-device-state"); const refreshBtn = document.getElementById("refresh-device-state");
@ -628,6 +632,7 @@ Copy Command
let showingAllEvents = false; let showingAllEvents = false;
let confirmResolver = null; let confirmResolver = null;
let latestCredentialCommand = ""; let latestCredentialCommand = "";
let liveRefreshTimer = null;
const els = { const els = {
breadcrumbCode: document.getElementById("device-breadcrumb-code"), breadcrumbCode: document.getElementById("device-breadcrumb-code"),
@ -635,6 +640,7 @@ Copy Command
statusBadge: document.getElementById("device-status-badge"), statusBadge: document.getElementById("device-status-badge"),
statusDot: document.getElementById("device-status-dot"), statusDot: document.getElementById("device-status-dot"),
model: document.getElementById("device-model"), model: document.getElementById("device-model"),
serialNumber: document.getElementById("device-serial-number"),
lastSeen: document.getElementById("device-last-seen"), lastSeen: document.getElementById("device-last-seen"),
location: document.getElementById("device-location"), location: document.getElementById("device-location"),
signalStrength: document.getElementById("device-signal-strength"), signalStrength: document.getElementById("device-signal-strength"),
@ -739,6 +745,46 @@ Copy Command
return new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeStyle: "short" }).format(ms); return new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeStyle: "short" }).format(ms);
}; };
const formatClock = (value) => {
const ms = normalizeTimestamp(value) || Date.now();
return new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
}).format(ms);
};
const escapeHtml = (value) =>
String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const formatEventTitle = (item) => {
const state = String(item?.state || item?.event || item?.type || item?.status || "heartbeat")
.replace(/_/g, " ")
.trim();
return state ? state.replace(/\b\w/g, (char) => char.toUpperCase()) : "Heartbeat";
};
const compactPayload = (item) => {
const payload = item?.payload && typeof item.payload === "object" ? item.payload : item;
const picked = {
id: payload?.id,
serial: payload?.["dev-sn"] || payload?.serial_number || currentDevice?.serial_number,
state: payload?.state || item?.state,
firmware: payload?.["fw-version"] || item?.firmware_version,
signal: item?.network_strength ?? payload?.network_strength ?? payload?.rssi ?? payload?.["wifi-ap"]?.rssi,
battery: item?.battery_level ?? payload?.battery_level ?? payload?.["battery-level"],
received_at: item?.received_at,
timestamp: item?.timestamp || payload?.time
};
return Object.fromEntries(Object.entries(picked).filter(([, value]) => value !== undefined && value !== null && value !== ""));
};
const extractHeartbeatMetrics = (heartbeat) => { const extractHeartbeatMetrics = (heartbeat) => {
if (!heartbeat || typeof heartbeat !== "object") { if (!heartbeat || typeof heartbeat !== "object") {
return {}; return {};
@ -812,17 +858,21 @@ Copy Command
item.timestamp || item.ts || item.created_at || item.updated_at, item.timestamp || item.ts || item.created_at || item.updated_at,
"unknown" "unknown"
); );
const title = item.event || item.type || item.status || "Heartbeat"; const title = formatEventTitle(item);
const details = item.message || item.description || JSON.stringify(item.payload || item); const summary = compactPayload(item);
const marker = iconClass(item.status || item.event); const marker = iconClass(item.status || item.event || item.state);
return `<div class="relative pl-8"> return `<div class="relative pl-8 min-w-0">
<div class="absolute left-0 top-1 w-6 h-6 rounded-full ${marker.color} border-4 border-white shadow-sm flex items-center justify-center"> <div class="absolute left-0 top-1 w-6 h-6 rounded-full ${marker.color} border-4 border-white shadow-sm flex items-center justify-center">
<span class="material-symbols-outlined text-[12px] text-white" style="font-variation-settings: 'FILL' 1;">${marker.icon}</span> <span class="material-symbols-outlined text-[12px] text-white" style="font-variation-settings: 'FILL' 1;">${marker.icon}</span>
</div> </div>
<p class="text-label-md font-bold">${title}</p> <div class="min-w-0 rounded-lg border border-slate-100 bg-slate-50/60 p-3">
<p class="text-[12px] text-on-surface-variant">Signal: ${metric.signal ?? "N/A"}, Battery: ${metric.battery ?? "N/A"}</p> <div class="flex items-start justify-between gap-3">
<p class="text-[12px] text-on-surface-variant">${details}</p> <p class="text-label-md font-bold text-slate-900">${escapeHtml(title)}</p>
<p class="text-[10px] text-slate-400 mt-1">${when}</p> <p class="shrink-0 text-[10px] text-slate-400">${escapeHtml(when)}</p>
</div>
<p class="mt-1 text-[12px] text-on-surface-variant">Signal: ${escapeHtml(metric.signal ?? "N/A")}, Battery: ${escapeHtml(metric.battery ?? "N/A")}</p>
<pre class="mt-2 max-h-24 overflow-auto whitespace-pre-wrap break-words rounded-md bg-white px-2 py-1.5 text-[11px] leading-5 text-slate-600">${escapeHtml(JSON.stringify(summary, null, 2))}</pre>
</div>
</div>`; </div>`;
}) })
.join(""); .join("");
@ -840,11 +890,11 @@ Copy Command
} }
rows.slice(0, 20).forEach((item) => { rows.slice(0, 20).forEach((item) => {
const when = formatDateTime(item.timestamp || item.ts || item.created_at || item.updated_at, "now"); const when = formatClock(item.timestamp || item.ts || item.created_at || item.updated_at || item.received_at);
const p = document.createElement("p"); const line = document.createElement("pre");
p.className = "text-green-400"; line.className = "whitespace-pre-wrap break-words text-green-400 leading-5";
p.textContent = `[${when}] RECV: ${JSON.stringify(item)}`; line.textContent = `[${when}] RECV heartbeat\n${JSON.stringify(compactPayload(item), null, 2)}`;
stream.appendChild(p); stream.appendChild(line);
}); });
stream.scrollTop = stream.scrollHeight; stream.scrollTop = stream.scrollHeight;
}; };
@ -889,7 +939,7 @@ Copy Command
const statusPillClass = (status) => { const statusPillClass = (status) => {
const normalized = String(status || "").toLowerCase(); const normalized = String(status || "").toLowerCase();
if (normalized === "online" || normalized === "applied") { if (normalized === "online" || normalized === "applied" || normalized === "pulled_not_pushed") {
return "bg-success/10 text-success border-success/20"; return "bg-success/10 text-success border-success/20";
} }
if (normalized === "degraded" || normalized === "stale" || normalized === "pending_ack" || normalized === "stale_ack") { if (normalized === "degraded" || normalized === "stale" || normalized === "pending_ack" || normalized === "stale_ack") {
@ -932,18 +982,21 @@ Copy Command
const version = status.desired_config_version || status.config?.config_version || "-"; const version = status.desired_config_version || status.config?.config_version || "-";
setText(els.configVersion, `Config v${version}`); setText(els.configVersion, `Config v${version}`);
if (els.configStatus) { if (els.configStatus) {
const label = String(drift).replace("_", " ").toUpperCase(); const label = drift === "pulled_not_pushed" ? "PULLED BY DEVICE" : String(drift).replace(/_/g, " ").toUpperCase();
const icon = drift === "applied" ? "check_circle" : drift === "failed_ack" ? "error" : "pending"; const icon = drift === "applied" || drift === "pulled_not_pushed" ? "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.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}`; 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 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"; const push = status.latest_push ? formatDateTime(status.latest_push.created_at, "-") : "No push";
setText(els.configDetail, `Push: ${push} · ACK: ${ack}`); const pull = status.latest_config_pull ? formatDateTime(status.latest_config_pull.received_at || status.latest_config_pull.timestamp, "-") : "No pull";
setText(els.configDetail, `Pull: ${pull} · Push: ${push} · ACK: ${ack}`);
if (els.configRetry) { if (els.configRetry) {
els.configRetry.disabled = status.retry_recommended === false; els.configRetry.disabled = status.retry_recommended === false;
els.configRetry.textContent = status.retry_recommended === false ? "Applied" : "Retry Push"; els.configRetry.textContent = status.retry_recommended === false
? (drift === "pulled_not_pushed" ? "Pulled" : "Applied")
: "Retry Push";
} }
}; };
@ -1049,9 +1102,10 @@ Copy Command
if (!stream) { if (!stream) {
return; return;
} }
const p = document.createElement("p"); const p = document.createElement("pre");
p.className = className; p.className = className;
p.textContent = `[${formatDateTime(Date.now())}] ${message}`; p.classList.add("whitespace-pre-wrap", "break-words", "leading-5");
p.textContent = `[${formatClock(Date.now())}] ${message}`;
stream.appendChild(p); stream.appendChild(p);
stream.scrollTop = stream.scrollHeight; stream.scrollTop = stream.scrollHeight;
}; };
@ -1094,21 +1148,23 @@ Copy Command
}; };
const buildQrPreviewPayload = () => ({ const buildQrPreviewPayload = () => ({
device_id: activeDeviceId || "-", header: {
device_code: currentDevice?.device_code || currentDevice?.id || "-", category: 4
amount: 1000, },
currency: "IDR", data: {
qr_mode: els.dynamicQrMode?.textContent || "dynamic", "qr-url": `https://sms.bizone.id/pay/test/${encodeURIComponent(currentDevice?.serial_number || activeDeviceId || "soundbox")}`,
expires_in_seconds: 60, amount: 1000,
preview: true "expire-seconds": 60
}
}); });
const renderQrPreviewGrid = () => { const renderFallbackQrPreviewGrid = (qrUrl) => {
if (!qrPreviewGrid) { if (!qrPreviewGrid) {
return; return;
} }
const seed = String(activeDeviceId || currentDevice?.device_code || "soundbox"); const seed = String(qrUrl || activeDeviceId || currentDevice?.device_code || "soundbox");
qrPreviewGrid.innerHTML = ""; qrPreviewGrid.innerHTML = "";
qrPreviewGrid.className = "mx-auto my-4 grid h-40 w-40 grid-cols-9 grid-rows-9 gap-1 rounded-lg bg-white p-2";
for (let index = 0; index < 81; index += 1) { for (let index = 0; index < 81; index += 1) {
const char = seed.charCodeAt(index % seed.length) || 37; const char = seed.charCodeAt(index % seed.length) || 37;
const dark = index < 9 || index % 9 === 0 || ((char + index * 7) % 5 < 2); const dark = index < 9 || index % 9 === 0 || ((char + index * 7) % 5 < 2);
@ -1118,17 +1174,31 @@ Copy Command
} }
}; };
const renderQrPreviewCode = (qrUrl) => {
if (!qrPreviewGrid) {
return;
}
qrPreviewGrid.innerHTML = "";
qrPreviewGrid.className = "mx-auto my-4 flex h-40 w-40 items-center justify-center rounded-lg bg-white p-2";
const image = document.createElement("img");
image.alt = "QR code preview";
image.className = "h-full w-full object-contain";
image.src = `https://api.qrserver.com/v1/create-qr-code/?size=192x192&margin=8&data=${encodeURIComponent(qrUrl)}`;
image.addEventListener("error", () => renderFallbackQrPreviewGrid(qrUrl), { once: true });
qrPreviewGrid.appendChild(image);
};
const openQrPreview = () => { const openQrPreview = () => {
const payload = buildQrPreviewPayload(); const payload = buildQrPreviewPayload();
renderQrPreviewGrid(); renderQrPreviewCode(payload.data["qr-url"]);
setText(qrPreviewAmount, new Intl.NumberFormat("id-ID", { setText(qrPreviewAmount, new Intl.NumberFormat("id-ID", {
style: "currency", style: "currency",
currency: "IDR", currency: "IDR",
maximumFractionDigits: 0 maximumFractionDigits: 0
}).format(payload.amount)); }).format(payload.data.amount));
setText(qrPreviewDevice, payload.device_code); setText(qrPreviewDevice, currentDevice?.device_code || currentDevice?.serial_number || activeDeviceId || "-");
setText(qrPreviewCommandPath, els.dynamicQrCommandPath?.textContent || "MQTT"); setText(qrPreviewCommandPath, els.dynamicQrCommandPath?.textContent || "MQTT");
setText(qrPreviewMode, payload.qr_mode); setText(qrPreviewMode, els.dynamicQrMode?.textContent || "dynamic");
setText(qrPreviewPayload, JSON.stringify(payload, null, 2)); setText(qrPreviewPayload, JSON.stringify(payload, null, 2));
qrPreviewModal?.classList.remove("hidden"); qrPreviewModal?.classList.remove("hidden");
qrPreviewModal?.classList.add("flex"); qrPreviewModal?.classList.add("flex");
@ -1219,7 +1289,7 @@ Copy Command
setText(els.bindingSince, formatDateTime(bindingDate, "-")); setText(els.bindingSince, formatDateTime(bindingDate, "-"));
}; };
const loadDevice = async () => { const loadDevice = async ({ preserveEventView = false } = {}) => {
try { try {
api.requireToken(); api.requireToken();
let selectedDeviceId = deviceId; let selectedDeviceId = deviceId;
@ -1253,12 +1323,15 @@ Copy Command
: []; : [];
currentDevice = device; currentDevice = device;
currentHeartbeats = heartbeats; currentHeartbeats = heartbeats;
showingAllEvents = false; if (!preserveEventView) {
showingAllEvents = false;
}
const modelCode = device.device_code || device.code || device.serial_number || device.id || "Unknown Device"; const modelCode = device.device_code || device.code || device.serial_number || device.id || "Unknown Device";
setText(els.breadcrumbCode, modelCode); setText(els.breadcrumbCode, modelCode);
setText(els.title, modelCode); setText(els.title, modelCode);
setText(els.model, device.model || device.device_model || "Unknown model"); setText(els.model, device.model || device.device_model || "Unknown model");
setText(els.serialNumber, `SN: ${device.serial_number || "-"}`);
setText(els.location, device.location || device.last_known_city || "Unknown"); setText(els.location, device.location || device.last_known_city || "Unknown");
const latest = Array.isArray(heartbeats) && heartbeats.length ? heartbeats[0] : null; const latest = Array.isArray(heartbeats) && heartbeats.length ? heartbeats[0] : null;
@ -1294,7 +1367,18 @@ Copy Command
} }
}; };
refreshBtn?.addEventListener("click", loadDevice); const startLiveRefresh = () => {
if (liveRefreshTimer) {
window.clearInterval(liveRefreshTimer);
}
liveRefreshTimer = window.setInterval(() => {
if (document.visibilityState === "visible") {
loadDevice({ preserveEventView: true });
}
}, 10000);
};
refreshBtn?.addEventListener("click", () => loadDevice());
rotateCredentialButtons.forEach((button) => button.addEventListener("click", rotateCredential)); rotateCredentialButtons.forEach((button) => button.addEventListener("click", rotateCredential));
credentialModalClose?.addEventListener("click", closeCredentialModal); credentialModalClose?.addEventListener("click", closeCredentialModal);
credentialModalDone?.addEventListener("click", closeCredentialModal); credentialModalDone?.addEventListener("click", closeCredentialModal);
@ -1325,7 +1409,7 @@ Copy Command
} }
}); });
els.dynamicQrSendTest?.addEventListener("click", () => { els.dynamicQrSendTest?.addEventListener("click", () => {
sendDeviceCommand("dynamic_qr.test", { sendDeviceCommand("dynamic_qr.display", {
...buildQrPreviewPayload(), ...buildQrPreviewPayload(),
source: "device_detail" source: "device_detail"
}, els.dynamicQrSendTest); }, els.dynamicQrSendTest);
@ -1409,7 +1493,7 @@ Copy Command
} }
}); });
qrPreviewSendTest?.addEventListener("click", () => { qrPreviewSendTest?.addEventListener("click", () => {
sendDeviceCommand("dynamic_qr.test", { sendDeviceCommand("dynamic_qr.display", {
...buildQrPreviewPayload(), ...buildQrPreviewPayload(),
source: "qr_preview_modal" source: "qr_preview_modal"
}, qrPreviewSendTest); }, qrPreviewSendTest);
@ -1480,6 +1564,7 @@ Copy Command
} }
loadDevice(); loadDevice();
startLiveRefresh();
})(); })();
</script> </script>
</body></html> </body></html>

View File

@ -181,6 +181,26 @@
<span class="text-sm font-semibold text-slate-600">Export worker</span> <span class="text-sm font-semibold text-slate-600">Export worker</span>
<span id="export-worker" class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-600">-</span> <span id="export-worker" class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-bold text-slate-600">-</span>
</div> </div>
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<p class="text-sm font-extrabold text-slate-950">Remote Actions</p>
<p class="mt-0.5 text-xs text-slate-500">Send operational command to a selected soundbox</p>
</div>
<span class="material-symbols-outlined text-blue-700">settings_remote</span>
</div>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Device</span>
<select id="command-device-select" class="w-full rounded-lg border-slate-200 bg-white py-2 text-sm focus:border-blue-600 focus:ring-blue-600">
<option value="">Select device</option>
</select>
</label>
<button id="send-reboot-command" class="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-extrabold text-slate-800 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50" disabled>
<span class="material-symbols-outlined text-[20px]">restart_alt</span>
Reboot Device
</button>
<p id="command-status" class="mt-2 min-h-5 text-xs font-semibold text-slate-500"></p>
</div>
</div> </div>
</section> </section>
@ -346,6 +366,27 @@
$("export-worker").className = `rounded-full px-2.5 py-1 text-xs font-bold ${worker?.enabled === false ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700"}`; $("export-worker").className = `rounded-full px-2.5 py-1 text-xs font-bold ${worker?.enabled === false ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700"}`;
} }
function renderCommandDevices() {
const select = $("command-device-select");
const rebootButton = $("send-reboot-command");
if (!select || !rebootButton) {
return;
}
const current = select.value;
select.innerHTML = '<option value="">Select device</option>';
state.devices.forEach((device) => {
const option = document.createElement("option");
option.value = device.id;
option.textContent = `${device.device_code || device.serial_number || device.id} · ${device.serial_number || device.model || "soundbox"}`;
select.appendChild(option);
});
if (current && state.devices.some((device) => device.id === current)) {
select.value = current;
}
rebootButton.disabled = !select.value;
}
function renderMqtt() { function renderMqtt() {
const list = $("mqtt-list"); const list = $("mqtt-list");
const messages = Array.isArray(state.mqtt?.last_messages) ? state.mqtt.last_messages : []; const messages = Array.isArray(state.mqtt?.last_messages) ? state.mqtt.last_messages : [];
@ -373,6 +414,7 @@
renderKpis(); renderKpis();
renderTable(); renderTable();
renderOps(); renderOps();
renderCommandDevices();
renderMqtt(); renderMqtt();
} }
@ -517,9 +559,46 @@
} }
} }
async function sendRebootCommand() {
const select = $("command-device-select");
const button = $("send-reboot-command");
const status = $("command-status");
const deviceId = select?.value || "";
const device = state.devices.find((item) => item.id === deviceId);
if (!deviceId || !button || !status) {
return;
}
button.disabled = true;
button.classList.add("opacity-60");
status.textContent = `Sending reboot to ${device?.device_code || device?.serial_number || deviceId}...`;
status.className = "mt-2 min-h-5 text-xs font-semibold text-slate-500";
try {
const result = await api.createDeviceCommand(deviceId, {
command: "device.reboot",
payload: { requested_from: "soundbox_ops" }
});
const topic = result?.result_payload?.topic || "-";
status.textContent = `Reboot command ${result.status || "queued"} · ${topic}`;
status.className = "mt-2 min-h-5 text-xs font-semibold text-emerald-700";
await refresh();
} catch (error) {
status.textContent = error?.message || "Unable to send reboot command.";
status.className = "mt-2 min-h-5 text-xs font-semibold text-red-700";
} finally {
button.classList.remove("opacity-60");
button.disabled = !select.value;
}
}
$("refresh-button").addEventListener("click", refresh); $("refresh-button").addEventListener("click", refresh);
$("search-input").addEventListener("input", renderTable); $("search-input").addEventListener("input", renderTable);
$("status-filter").addEventListener("change", renderTable); $("status-filter").addEventListener("change", renderTable);
$("command-device-select")?.addEventListener("change", () => {
$("send-reboot-command").disabled = !$("command-device-select").value;
$("command-status").textContent = "";
});
$("send-reboot-command")?.addEventListener("click", sendRebootCommand);
$("logout-button").addEventListener("click", () => { $("logout-button").addEventListener("click", () => {
api.clearToken(); api.clearToken();
window.location.href = "/ui/admin-login"; window.location.href = "/ui/admin-login";