Prepare Soundbox Ops deployment

This commit is contained in:
Wira Basalamah
2026-06-06 20:58:04 +07:00
parent 60b1537c4c
commit 00580a98fc
16 changed files with 3238 additions and 270 deletions

View File

@ -1,12 +1,70 @@
# Codex Handoff - QRIS Soundbox Platform
Tanggal update: 2026-06-03, Asia/Jakarta.
Tanggal update: 2026-06-06, Asia/Jakarta.
Dokumen ini adalah snapshot kerja terakhir untuk melanjutkan project tanpa perlu membaca ulang seluruh chat.
## Update Terbaru - 2026-06-06
- Fokus produk sekarang: `sms.bizone.id` menjadi portal utama Soundbox Ops / Monitoring, bukan katalog UI atau dashboard admin campuran.
- Default route sudah disesuaikan:
- `GET /` redirect ke `/ui/soundbox-ops`;
- `GET /ui` redirect ke `/ui/soundbox-ops`;
- halaman Soundbox Ops tetap berada di `/ui/soundbox-ops`.
- Server target deploy diganti menjadi Debian 13, memakai user khusus `qrisapp`.
- Dokumen deploy:
- `DEBIAN12_APP_SERVER_SETUP.md` diganti menjadi `DEBIAN13_APP_SERVER_SETUP.md`;
- README sudah menunjuk ke dokumen Debian 13;
- install dari server kosong mencakup package dasar, Node.js 22, PostgreSQL, Nginx, Certbot, UFW, env production, migration, systemd, TLS, dan verification;
- semua proses app/repo/build/migration/service dijalankan sebagai `qrisapp`;
- root/sudo hanya untuk install package, direktori, env, Nginx, systemd.
- Env deploy sekarang mencantumkan `DATABASE_URL` selain `PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE`, supaya `npm run deploy:check-env` tidak gagal dengan error `DATABASE_URL is required`.
- Endpoint config device sudah men-support:
- `GET /speaker/dev-config`;
- `POST /speaker/dev-config`;
- lookup device berdasarkan `dev-sn` ke `devices.serial_number`;
- device tidak dikunci model tertentu, sehingga tipe device asli bisa diinput lewat master data/model dan matching tetap berdasarkan SN.
- Response config device tetap vendor-compatible:
- `error-code`;
- `mqtt.broker-ip`;
- `mqtt.broker-port`;
- `mqtt.client-id`;
- `mqtt.user-name`;
- `mqtt.password`;
- `mqtt.subscribe-topic`;
- `mqtt.keep-alive`.
- Production target:
- HTTP config URL device: `https://sms.bizone.id/speaker/dev-config`;
- MQTT broker: `broker.bizone.id`;
- MQTT TLS default: `mqtts://broker.bizone.id:8883`;
- jika firmware belum support TLS, siapkan listener non-TLS terbatas atau patch firmware.
- Dashboard Soundbox Ops sudah dirapikan untuk fokus operasional:
- sidebar hanya menu ops: Monitoring, Registry, MQTT Trace, Config & Commands, Catalog;
- menu Register Device dihapus dari sidebar, action register ada di Device Registry;
- Technical Detail dihapus dari sidebar, akses detail lewat row action di Device Registry;
- footer/dev nav UI katalog dihapus dari halaman ops yang relevan.
- Master data `Catalog` sudah ditambahkan:
- vendor create/edit/delete;
- model create/edit/delete;
- model code generated backend;
- model thumbnail upload;
- country dropdown searchable dengan Palestine/Palestina masuk list;
- structured vendor contact: PIC name, phone/WhatsApp, email, notes.
- Device Registry sudah dibuat live dari API, bukan dummy:
- KPI dari data device;
- search berfungsi;
- pagination nyata;
- filter reset page;
- row action 3-dot untuk View Device Detail / Quick Inspect.
- Device Detail sudah dibuat lebih operasional:
- tombol refresh, retry push, test QR, copy payload, clear console, reboot, firmware update, unbind, rotate MQTT credential, decommission punya action;
- Dynamic QR preview dibuka sebagai modal inline, bukan pindah ke dashboard lain.
- Verifikasi terakhir lokal:
- `npm run typecheck`: pass.
## Status Terakhir
- Fokus hari ini bergeser dari production readiness umum ke integrasi device soundbox QF100.
- Fokus sebelumnya bergeser dari production readiness umum ke integrasi device soundbox QF100.
- Folder SDK lokal `QF100-60s-l511-SecondApp-260107/` ditemukan dan dianalisis. Folder ini sengaja tidak dimasukkan git.
- Backend sekarang sudah punya adapter awal untuk firmware QF100 sample:
- config server vendor-compatible;

View File

@ -1,6 +1,6 @@
# Debian 12 App Server Setup
# Debian 13 App Server Setup
Panduan ini untuk menyiapkan server kosong Debian 12 sebagai app server QRIS Soundbox Platform.
Panduan ini untuk menyiapkan server kosong Debian 13 sebagai app server QRIS Soundbox Platform.
- App domain: `sms.bizone.id`
- App user: `qrisapp`
@ -32,6 +32,7 @@ sudo apt install -y \
curl \
gnupg \
git \
openssl \
build-essential \
nginx \
certbot \
@ -65,7 +66,7 @@ Jangan buka port `3000` ke internet. Traffic publik masuk lewat Nginx.
## 5. App User
Buat user khusus untuk menjalankan service.
Buat user khusus untuk menjalankan service. Semua proses aplikasi, install dependency, build, migration, dan systemd runtime dijalankan sebagai `qrisapp`; user `root`/sudo hanya dipakai untuk install paket, membuat direktori, menulis env, Nginx, dan systemd.
```bash
sudo adduser --system --group --home /opt/qris-soundbox qrisapp
@ -74,23 +75,28 @@ sudo install -d -o root -g qrisapp -m 750 /etc/qris-soundbox
sudo install -d -o qrisapp -g qrisapp -m 750 /var/lib/qris-soundbox/exports
```
Untuk deploy via git/rsync, operator boleh masuk dengan user sudo biasa, lalu copy release ke `/opt/qris-soundbox` dan set ownership ke `qrisapp:qrisapp`.
Untuk deploy via git/rsync, operator boleh login dengan user sudo biasa, tetapi command yang menyentuh repo/app dijalankan via `sudo -u qrisapp ...`.
## 6. PostgreSQL
Buat database dan user production.
```bash
sudo -u postgres psql
```
DB_PASSWORD="$(openssl rand -hex 32)"
echo "Simpan DB password ini untuk env: ${DB_PASSWORD}"
Di prompt `psql`:
```sql
CREATE USER qris_app WITH PASSWORD '5174e2c2fb3f8424806d1e5a4ca873a3dd33aace06a7b99c49f1bed9d1f42c4a';
sudo -u postgres psql -v app_password="$DB_PASSWORD" <<'SQL'
CREATE USER qris_app WITH PASSWORD :'app_password';
CREATE DATABASE qris_soundbox_platform OWNER qris_app;
GRANT ALL PRIVILEGES ON DATABASE qris_soundbox_platform TO qris_app;
\q
SQL
```
Kalau user/database sudah pernah dibuat, gunakan reset password:
```sql
ALTER USER qris_app WITH PASSWORD '<password-baru>';
```
## 7. Deploy Code
@ -193,6 +199,13 @@ PGPORT=5432
PGUSER=qris_app
PGPASSWORD=CHANGE_ME_STRONG_DB_PASSWORD
PGDATABASE=qris_soundbox_platform
DATABASE_URL=postgresql://qris_app:CHANGE_ME_STRONG_DB_PASSWORD@127.0.0.1:5432/qris_soundbox_platform
```
Ganti semua `CHANGE_ME_*` dengan secret production. Untuk generate secret cepat:
```bash
openssl rand -hex 32
```
Lock permission:
@ -317,7 +330,52 @@ sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; s
```bash
curl -fsS https://sms.bizone.id/health
curl -fsS https://sms.bizone.id/health/deep
curl -I https://sms.bizone.id/
curl -I https://sms.bizone.id/ui
curl -I https://sms.bizone.id/ui/soundbox-ops
```
Expected:
- `https://sms.bizone.id/` redirects to `/ui/soundbox-ops`.
- `https://sms.bizone.id/ui` redirects to `/ui/soundbox-ops`.
- `https://sms.bizone.id/ui/soundbox-ops` returns the Soundbox Ops portal HTML.
Test config endpoint shape. Replace `9011001900006` with a serial number already registered and active in Device Registry.
```bash
curl -fsS https://sms.bizone.id/speaker/dev-config \
-H 'Content-Type: application/json' \
-d '{
"dev-model": "QF100",
"item-number": "00",
"dev-sn": "9011001900006",
"hardware-config": "0x0F",
"fw-version": "1.1.2",
"fw-build": 13,
"app-config-version": 21,
"imei": "12345678123456789",
"imsi": "310150123456789",
"iccid": "898600MFSSYYGXXXXXXP"
}'
```
Expected successful device config response:
```json
{
"error-code": 0,
"mqtt": {
"broker-ip": "broker.bizone.id",
"broker-port": 8883,
"client-id": "<device-id>",
"user-name": "qris-backend",
"password": "<mqtt-password>",
"subscribe-topic": "devices/<device-id>/downlink/qf100",
"publish-topic": "devices/<device-id>/uplink/qf100",
"keep-alive": 60
}
}
```
Run production preflight from server:

View File

@ -14,7 +14,7 @@ Paket ini berisi blueprint final v1 untuk platform merchant aggregator QRIS + so
- 09-screen-inventory.md
- 10-design-blueprint.md
- 11-low-fi-wireframes.md
- DEBIAN12_APP_SERVER_SETUP.md
- DEBIAN13_APP_SERVER_SETUP.md
- 18-mqtt-broker-mosquitto-debian13.md
## Tujuan

View File

@ -0,0 +1,66 @@
BEGIN;
CREATE TABLE IF NOT EXISTS soundbox_vendors (
id TEXT PRIMARY KEY,
vendor_code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
country TEXT,
support_contact TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS soundbox_models (
id TEXT PRIMARY KEY,
vendor_id TEXT NOT NULL REFERENCES soundbox_vendors (id) ON DELETE CASCADE,
model_code TEXT NOT NULL,
name TEXT NOT NULL,
communication_mode TEXT NOT NULL DEFAULT 'mqtt' CHECK (communication_mode IN ('static', 'mqtt', 'api')),
screen_flag BOOLEAN NOT NULL DEFAULT false,
qr_mode TEXT NOT NULL DEFAULT 'static' CHECK (qr_mode IN ('static', 'dynamic_mqtt', 'dynamic_api')),
mqtt_payload_profile TEXT,
capability_template_json JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE (vendor_id, model_code)
);
CREATE INDEX IF NOT EXISTS idx_soundbox_models_vendor ON soundbox_models (vendor_id, status);
INSERT INTO soundbox_vendors (id, vendor_code, name, country, support_contact, status, created_at, updated_at)
VALUES ('vendor_qf', 'QF', 'QF', NULL, NULL, 'active', NOW(), NOW())
ON CONFLICT (vendor_code) DO NOTHING;
INSERT INTO soundbox_models (
id,
vendor_id,
model_code,
name,
communication_mode,
screen_flag,
qr_mode,
mqtt_payload_profile,
capability_template_json,
status,
created_at,
updated_at
)
VALUES (
'model_qf100',
'vendor_qf',
'QF100',
'QF100',
'mqtt',
false,
'static',
'qf100',
'{"device_type":"static_soundbox","screen":false,"qr_mode":"static","mqtt_payload_profile":"qf100","flows":["static_payment_notification"],"features":{"payment_sound":true,"dynamic_qr":false}}'::jsonb,
'active',
NOW(),
NOW()
)
ON CONFLICT (vendor_id, model_code) DO NOTHING;
COMMIT;

View File

@ -0,0 +1,8 @@
BEGIN;
ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_pic_name TEXT;
ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_email TEXT;
ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_phone TEXT;
ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_notes TEXT;
COMMIT;

View File

@ -0,0 +1,5 @@
BEGIN;
ALTER TABLE soundbox_models ADD COLUMN IF NOT EXISTS thumbnail_url TEXT;
COMMIT;

View File

@ -23,6 +23,26 @@ const app = express();
if (env.TRUST_PROXY === "true") {
app.set("trust proxy", 1);
}
const allowedPreviewOrigins = new Set([
"http://127.0.0.1:4173",
"http://localhost:4173"
]);
app.use((req, res, next) => {
const origin = req.header("origin");
if (origin && allowedPreviewOrigins.has(origin)) {
res.header("Access-Control-Allow-Origin", origin);
res.header("Vary", "Origin");
res.header("Access-Control-Allow-Headers", "authorization, content-type, x-request-id, idempotency-key");
res.header("Access-Control-Allow-Methods", "GET,POST,PATCH,PUT,DELETE,OPTIONS");
}
if (req.method === "OPTIONS") {
return res.sendStatus(204);
}
return next();
});
startNotificationOrchestrator();
startDynamicQrExpiryScheduler();
startExportJobWorker();
@ -105,8 +125,7 @@ function resolveUiPageFile(slug: string) {
}
app.get("/ui", (_req, res) => {
const filePath = path.resolve(process.cwd(), "ui/index.html");
res.sendFile(filePath);
res.redirect(302, "/ui/soundbox-ops");
});
app.get("/ui/hub", (_req, res) => {

View File

@ -112,6 +112,20 @@ import { buildDeviceConfigStatus } from "../shared/services/deviceConfigStatus";
import { expireDueDynamicQrTransactions } from "../shared/services/dynamicQrExpiry";
import { getDynamicQrExpirySchedulerStatus } from "../shared/services/dynamicQrExpiryScheduler";
import { createSettlementAdjustmentExportJob, getExportJobWorkerStatus } from "../shared/services/exportJobWorker";
import {
createSoundboxModel,
createSoundboxVendor,
deleteSoundboxModel,
deleteSoundboxVendor,
getSoundboxModelById,
getSoundboxVendorById,
listSoundboxModels,
listSoundboxVendors,
patchSoundboxModel,
patchSoundboxVendor,
toSoundboxModelPayload,
toSoundboxVendorPayload
} from "../shared/store/soundboxCatalogStore";
const router = Router();
@ -142,6 +156,31 @@ type TerminalCreateInput = {
status?: "active" | "inactive";
};
type SoundboxVendorInput = {
vendor_code?: string;
name?: string;
country?: string;
support_contact?: string;
support_pic_name?: string;
support_email?: string;
support_phone?: string;
support_notes?: string;
status?: "active" | "inactive";
};
type SoundboxModelInput = {
vendor_id?: string;
model_code?: string;
name?: string;
communication_mode?: "static" | "mqtt" | "api";
screen_flag?: boolean;
qr_mode?: "static" | "dynamic_mqtt" | "dynamic_api";
mqtt_payload_profile?: string;
thumbnail_url?: string;
capability_template_json?: Record<string, unknown>;
status?: "active" | "inactive";
};
type DeviceCreateInput = {
device_code?: string;
serial_number?: string;
@ -1231,6 +1270,240 @@ router.patch("/terminals/:terminalId", requireAdminToken, async (req: Request, r
}
});
router.get("/soundbox-vendors", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
const statusRaw = (req.query.status as string | undefined)?.trim();
const status = parseDeviceStatusValue(statusRaw);
if (statusRaw && !status) {
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
res.json(successResponse(req, (await listSoundboxVendors({ status })).map(toSoundboxVendorPayload)));
});
router.post("/soundbox-vendors", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
const payload = req.body as SoundboxVendorInput;
const vendorCode = payload?.vendor_code?.trim();
const name = payload?.name?.trim();
if (!vendorCode || !name) {
return next(new ApiError("BAD_REQUEST", "vendor_code and name are required", 400));
}
if (payload.status && !parseDeviceStatusValue(payload.status)) {
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
try {
const created = await createSoundboxVendor({
vendor_code: vendorCode,
name,
country: payload.country?.trim() || undefined,
support_contact: payload.support_contact?.trim() || undefined,
support_pic_name: payload.support_pic_name?.trim() || undefined,
support_email: payload.support_email?.trim() || undefined,
support_phone: payload.support_phone?.trim() || undefined,
support_notes: payload.support_notes?.trim() || undefined,
status: payload.status
});
await auditAdminAction(req, {
action: "soundbox_vendor.create",
entity_type: "soundbox_vendor",
entity_id: created.id,
after_json: toSoundboxVendorPayload(created)
});
res.status(201).json(successResponse(req, toSoundboxVendorPayload(created)));
} catch (err) {
return next(err as Error);
}
});
router.patch("/soundbox-vendors/:vendorId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
const payload = req.body as SoundboxVendorInput;
if (!payload || Object.keys(payload).length === 0) {
return next(new ApiError("BAD_REQUEST", "patch payload required", 400));
}
if (payload.status && !parseDeviceStatusValue(payload.status)) {
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
try {
const existing = await getSoundboxVendorById(req.params.vendorId);
const updated = await patchSoundboxVendor(req.params.vendorId, {
...(payload.vendor_code !== undefined ? { vendor_code: payload.vendor_code.trim() } : {}),
...(payload.name !== undefined ? { name: payload.name.trim() } : {}),
...(payload.country !== undefined ? { country: payload.country.trim() } : {}),
...(payload.support_contact !== undefined ? { support_contact: payload.support_contact.trim() } : {}),
...(payload.support_pic_name !== undefined ? { support_pic_name: payload.support_pic_name.trim() } : {}),
...(payload.support_email !== undefined ? { support_email: payload.support_email.trim() } : {}),
...(payload.support_phone !== undefined ? { support_phone: payload.support_phone.trim() } : {}),
...(payload.support_notes !== undefined ? { support_notes: payload.support_notes.trim() } : {}),
...(payload.status !== undefined ? { status: payload.status } : {})
});
await auditAdminAction(req, {
action: "soundbox_vendor.update",
entity_type: "soundbox_vendor",
entity_id: updated.id,
before_json: existing ? toSoundboxVendorPayload(existing) : null,
after_json: toSoundboxVendorPayload(updated)
});
res.json(successResponse(req, toSoundboxVendorPayload(updated)));
} catch (err) {
if (err instanceof Error && err.message === "SOUNDBOX_VENDOR_NOT_FOUND") {
return next(new ApiError("NOT_FOUND", "soundbox vendor not found", 404));
}
return next(err as Error);
}
});
router.delete("/soundbox-vendors/:vendorId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
try {
const deleted = await deleteSoundboxVendor(req.params.vendorId);
await auditAdminAction(req, {
action: "soundbox_vendor.delete",
entity_type: "soundbox_vendor",
entity_id: deleted.id,
before_json: toSoundboxVendorPayload(deleted)
});
res.json(successResponse(req, toSoundboxVendorPayload(deleted)));
} catch (err) {
if (err instanceof Error && err.message === "SOUNDBOX_VENDOR_NOT_FOUND") {
return next(new ApiError("NOT_FOUND", "soundbox vendor not found", 404));
}
if (err instanceof Error && err.message === "SOUNDBOX_VENDOR_HAS_MODELS") {
return next(new ApiError("CONFLICT", "delete models for this vendor first", 409));
}
return next(err as Error);
}
});
router.get("/soundbox-models", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
const statusRaw = (req.query.status as string | undefined)?.trim();
const status = parseDeviceStatusValue(statusRaw);
if (statusRaw && !status) {
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
const vendorId = (req.query.vendor_id as string | undefined)?.trim();
const vendorCode = (req.query.vendor_code as string | undefined)?.trim();
res.json(
successResponse(
req,
(await listSoundboxModels({ vendor_id: vendorId, vendor_code: vendorCode, status })).map(toSoundboxModelPayload)
)
);
});
router.post("/soundbox-models", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
const payload = req.body as SoundboxModelInput;
const vendorId = payload?.vendor_id?.trim();
const name = payload?.name?.trim();
if (!vendorId || !name) {
return next(new ApiError("BAD_REQUEST", "vendor_id and name are required", 400));
}
if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) {
return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400));
}
if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) {
return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400));
}
if (payload.status && !parseDeviceStatusValue(payload.status)) {
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
const vendor = await getSoundboxVendorById(vendorId);
if (!vendor) {
return next(new ApiError("BAD_REQUEST", "vendor_id is invalid", 400));
}
try {
const created = await createSoundboxModel({
vendor_id: vendorId,
model_code: payload.model_code?.trim() || undefined,
name,
communication_mode: payload.communication_mode,
screen_flag: Boolean(payload.screen_flag),
qr_mode: payload.qr_mode,
mqtt_payload_profile: payload.mqtt_payload_profile?.trim() || undefined,
thumbnail_url: payload.thumbnail_url?.trim() || undefined,
capability_template_json: payload.capability_template_json,
status: payload.status
});
await auditAdminAction(req, {
action: "soundbox_model.create",
entity_type: "soundbox_model",
entity_id: created.id,
after_json: toSoundboxModelPayload(created)
});
res.status(201).json(successResponse(req, toSoundboxModelPayload(created)));
} catch (err) {
return next(err as Error);
}
});
router.patch("/soundbox-models/:modelId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
const payload = req.body as SoundboxModelInput;
if (!payload || Object.keys(payload).length === 0) {
return next(new ApiError("BAD_REQUEST", "patch payload required", 400));
}
if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) {
return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400));
}
if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) {
return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400));
}
if (payload.status && !parseDeviceStatusValue(payload.status)) {
return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400));
}
if (payload.vendor_id) {
const vendor = await getSoundboxVendorById(payload.vendor_id);
if (!vendor) {
return next(new ApiError("BAD_REQUEST", "vendor_id is invalid", 400));
}
}
try {
const existing = await getSoundboxModelById(req.params.modelId);
const updated = await patchSoundboxModel(req.params.modelId, {
...(payload.vendor_id !== undefined ? { vendor_id: payload.vendor_id.trim() } : {}),
...(payload.model_code !== undefined ? { model_code: payload.model_code.trim() } : {}),
...(payload.name !== undefined ? { name: payload.name.trim() } : {}),
...(payload.communication_mode !== undefined ? { communication_mode: payload.communication_mode } : {}),
...(payload.screen_flag !== undefined ? { screen_flag: Boolean(payload.screen_flag) } : {}),
...(payload.qr_mode !== undefined ? { qr_mode: payload.qr_mode } : {}),
...(payload.mqtt_payload_profile !== undefined ? { mqtt_payload_profile: payload.mqtt_payload_profile.trim() } : {}),
...(payload.thumbnail_url !== undefined ? { thumbnail_url: payload.thumbnail_url.trim() } : {}),
...(payload.capability_template_json !== undefined ? { capability_template_json: payload.capability_template_json } : {}),
...(payload.status !== undefined ? { status: payload.status } : {})
});
await auditAdminAction(req, {
action: "soundbox_model.update",
entity_type: "soundbox_model",
entity_id: updated.id,
before_json: existing ? toSoundboxModelPayload(existing) : null,
after_json: toSoundboxModelPayload(updated)
});
res.json(successResponse(req, toSoundboxModelPayload(updated)));
} catch (err) {
if (err instanceof Error && err.message === "SOUNDBOX_MODEL_NOT_FOUND") {
return next(new ApiError("NOT_FOUND", "soundbox model not found", 404));
}
return next(err as Error);
}
});
router.delete("/soundbox-models/:modelId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => {
try {
const deleted = await deleteSoundboxModel(req.params.modelId);
await auditAdminAction(req, {
action: "soundbox_model.delete",
entity_type: "soundbox_model",
entity_id: deleted.id,
before_json: toSoundboxModelPayload(deleted)
});
res.json(successResponse(req, toSoundboxModelPayload(deleted)));
} catch (err) {
if (err instanceof Error && err.message === "SOUNDBOX_MODEL_NOT_FOUND") {
return next(new ApiError("NOT_FOUND", "soundbox model not found", 404));
}
return next(err as Error);
}
});
router.post("/devices", requireAdminToken, idempotency({ scope: "device.create", required: false }), async (req: Request, res: Response, next: NextFunction) => {
if (parseIdempotentReplay(req)) {
return res.status(201).json(getReplayResponse(req));

View File

@ -9,6 +9,7 @@ type Qf100ConfigRequest = {
"dev-model"?: string;
"item-number"?: string;
"dev-sn"?: string;
"hardware-config"?: string;
"fw-version"?: string;
"fw-build"?: number;
"app-config-version"?: number;
@ -47,7 +48,7 @@ function vendorError(res: Response, code: number, message: string) {
});
}
router.get("/dev-config", async (req: Request, res: Response, next: NextFunction) => {
async function handleDevConfig(req: Request, res: Response, next: NextFunction) {
try {
const payload = getRequestPayload(req);
const serialNumber = String(payload["dev-sn"] || "").trim();
@ -81,6 +82,7 @@ router.get("/dev-config", async (req: Request, res: Response, next: NextFunction
dev_model: payload["dev-model"],
item_number: payload["item-number"],
dev_sn: serialNumber,
hardware_config: payload["hardware-config"],
fw_build: payload["fw-build"],
app_config_version: payload["app-config-version"],
imei: payload.imei,
@ -120,6 +122,9 @@ router.get("/dev-config", async (req: Request, res: Response, next: NextFunction
} catch (error) {
next(error);
}
});
}
router.get("/dev-config", handleDevConfig);
router.post("/dev-config", handleDevConfig);
export default router;

View File

@ -137,6 +137,45 @@ CREATE TABLE IF NOT EXISTS terminals (
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS soundbox_vendors (
id TEXT PRIMARY KEY,
vendor_code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
country TEXT,
support_contact TEXT,
support_pic_name TEXT,
support_email TEXT,
support_phone TEXT,
support_notes TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_pic_name TEXT;
ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_email TEXT;
ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_phone TEXT;
ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_notes TEXT;
CREATE TABLE IF NOT EXISTS soundbox_models (
id TEXT PRIMARY KEY,
vendor_id TEXT NOT NULL REFERENCES soundbox_vendors (id) ON DELETE CASCADE,
model_code TEXT NOT NULL,
name TEXT NOT NULL,
communication_mode TEXT NOT NULL DEFAULT 'mqtt' CHECK (communication_mode IN ('static', 'mqtt', 'api')),
screen_flag BOOLEAN NOT NULL DEFAULT false,
qr_mode TEXT NOT NULL DEFAULT 'static' CHECK (qr_mode IN ('static', 'dynamic_mqtt', 'dynamic_api')),
mqtt_payload_profile TEXT,
thumbnail_url TEXT,
capability_template_json JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE (vendor_id, model_code)
);
CREATE INDEX IF NOT EXISTS idx_soundbox_models_vendor ON soundbox_models (vendor_id, status);
ALTER TABLE soundbox_models ADD COLUMN IF NOT EXISTS thumbnail_url TEXT;
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
device_code TEXT NOT NULL UNIQUE,
@ -168,6 +207,42 @@ ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_rotated_at TIMESTAMPTZ;
ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_revoked_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_devices_credential_status ON devices (credential_status);
INSERT INTO soundbox_vendors (id, vendor_code, name, country, support_contact, status, created_at, updated_at)
VALUES ('vendor_qf', 'QF', 'QF', NULL, NULL, 'active', NOW(), NOW())
ON CONFLICT (vendor_code) DO NOTHING;
INSERT INTO soundbox_models (
id,
vendor_id,
model_code,
name,
communication_mode,
screen_flag,
qr_mode,
mqtt_payload_profile,
thumbnail_url,
capability_template_json,
status,
created_at,
updated_at
)
VALUES (
'model_qf100',
'vendor_qf',
'QF100',
'QF100',
'mqtt',
false,
'static',
'qf100',
NULL,
'{"device_type":"static_soundbox","screen":false,"qr_mode":"static","mqtt_payload_profile":"qf100","flows":["static_payment_notification"],"features":{"payment_sound":true,"dynamic_qr":false}}'::jsonb,
'active',
NOW(),
NOW()
)
ON CONFLICT (vendor_id, model_code) DO NOTHING;
CREATE TABLE IF NOT EXISTS device_bindings (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE,

View File

@ -0,0 +1,376 @@
import { randomUUID } from "node:crypto";
import { getPool } from "../db/pool";
export type CatalogStatus = "active" | "inactive";
export type CatalogCommunicationMode = "static" | "mqtt" | "api";
export type CatalogQrMode = "static" | "dynamic_mqtt" | "dynamic_api";
export interface SoundboxVendorEntity {
id: string;
vendor_code: string;
name: string;
country?: string;
support_contact?: string;
support_pic_name?: string;
support_email?: string;
support_phone?: string;
support_notes?: string;
status: CatalogStatus;
created_at: string;
updated_at: string;
}
export interface SoundboxModelEntity {
id: string;
vendor_id: string;
vendor_code?: string;
vendor_name?: string;
model_code: string;
name: string;
communication_mode: CatalogCommunicationMode;
screen_flag: boolean;
qr_mode: CatalogQrMode;
mqtt_payload_profile?: string;
thumbnail_url?: string;
capability_template_json: Record<string, unknown>;
status: CatalogStatus;
created_at: string;
updated_at: string;
}
function nowIso() {
return new Date().toISOString();
}
function slugCode(value: string) {
const slug = value
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
return slug || "MODEL";
}
async function generateModelCode(vendorId: string, modelName: string) {
const vendor = await getSoundboxVendorById(vendorId);
const vendorCode = slugCode(vendor?.vendor_code || "VENDOR");
const baseCode = `${vendorCode}_${slugCode(modelName)}`.slice(0, 80);
let candidate = baseCode;
let suffix = 2;
while (true) {
const { rows } = await getPool().query(
"SELECT 1 FROM soundbox_models WHERE vendor_id = $1 AND model_code = $2 LIMIT 1",
[vendorId, candidate]
);
if (!rows.length) {
return candidate;
}
candidate = `${baseCode}_${suffix++}`.slice(0, 96);
}
}
function mapVendor(row: any): SoundboxVendorEntity {
return {
id: row.id,
vendor_code: row.vendor_code,
name: row.name,
country: row.country || undefined,
support_contact: row.support_contact || undefined,
support_pic_name: row.support_pic_name || undefined,
support_email: row.support_email || undefined,
support_phone: row.support_phone || undefined,
support_notes: row.support_notes || undefined,
status: row.status,
created_at: row.created_at,
updated_at: row.updated_at
};
}
function mapModel(row: any): SoundboxModelEntity {
return {
id: row.id,
vendor_id: row.vendor_id,
vendor_code: row.vendor_code || undefined,
vendor_name: row.vendor_name || undefined,
model_code: row.model_code,
name: row.name,
communication_mode: row.communication_mode,
screen_flag: Boolean(row.screen_flag),
qr_mode: row.qr_mode,
mqtt_payload_profile: row.mqtt_payload_profile || undefined,
thumbnail_url: row.thumbnail_url || undefined,
capability_template_json: row.capability_template_json || {},
status: row.status,
created_at: row.created_at,
updated_at: row.updated_at
};
}
export async function listSoundboxVendors(filter?: { status?: CatalogStatus }): Promise<SoundboxVendorEntity[]> {
const clauses: string[] = [];
const params: unknown[] = [];
if (filter?.status) {
clauses.push("status = $1");
params.push(filter.status);
}
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
const { rows } = await getPool().query(`SELECT * FROM soundbox_vendors ${where} ORDER BY name ASC`, params);
return rows.map(mapVendor);
}
export async function getSoundboxVendorById(id: string): Promise<SoundboxVendorEntity | null> {
const { rows } = await getPool().query("SELECT * FROM soundbox_vendors WHERE id = $1", [id]);
return rows.length ? mapVendor(rows[0]) : null;
}
export async function getSoundboxVendorByCode(vendorCode: string): Promise<SoundboxVendorEntity | null> {
const { rows } = await getPool().query("SELECT * FROM soundbox_vendors WHERE vendor_code = $1", [vendorCode]);
return rows.length ? mapVendor(rows[0]) : null;
}
export async function createSoundboxVendor(payload: {
vendor_code: string;
name: string;
country?: string;
support_contact?: string;
support_pic_name?: string;
support_email?: string;
support_phone?: string;
support_notes?: string;
status?: CatalogStatus;
}): Promise<SoundboxVendorEntity> {
const id = randomUUID();
const now = nowIso();
const { rows } = await getPool().query(
`INSERT INTO soundbox_vendors (
id,
vendor_code,
name,
country,
support_contact,
support_pic_name,
support_email,
support_phone,
support_notes,
status,
created_at,
updated_at
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING *`,
[
id,
payload.vendor_code,
payload.name,
payload.country || null,
payload.support_contact || null,
payload.support_pic_name || null,
payload.support_email || null,
payload.support_phone || null,
payload.support_notes || null,
payload.status || "active",
now,
now
]
);
return mapVendor(rows[0]);
}
export async function patchSoundboxVendor(
id: string,
patch: Partial<Omit<SoundboxVendorEntity, "id" | "created_at" | "updated_at">>
): Promise<SoundboxVendorEntity> {
const existing = await getSoundboxVendorById(id);
if (!existing) {
throw new Error("SOUNDBOX_VENDOR_NOT_FOUND");
}
const merged = { ...existing, ...patch, updated_at: nowIso() };
const { rows } = await getPool().query(
`UPDATE soundbox_vendors
SET vendor_code = $2,
name = $3,
country = $4,
support_contact = $5,
support_pic_name = $6,
support_email = $7,
support_phone = $8,
support_notes = $9,
status = $10,
updated_at = $11
WHERE id = $1
RETURNING *`,
[
id,
merged.vendor_code,
merged.name,
merged.country || null,
merged.support_contact || null,
merged.support_pic_name || null,
merged.support_email || null,
merged.support_phone || null,
merged.support_notes || null,
merged.status,
merged.updated_at
]
);
return mapVendor(rows[0]);
}
export async function deleteSoundboxVendor(id: string): Promise<SoundboxVendorEntity> {
const existing = await getSoundboxVendorById(id);
if (!existing) {
throw new Error("SOUNDBOX_VENDOR_NOT_FOUND");
}
const { rows: modelRows } = await getPool().query("SELECT COUNT(*)::int AS count FROM soundbox_models WHERE vendor_id = $1", [id]);
if ((modelRows[0]?.count || 0) > 0) {
throw new Error("SOUNDBOX_VENDOR_HAS_MODELS");
}
await getPool().query("DELETE FROM soundbox_vendors WHERE id = $1", [id]);
return existing;
}
export async function listSoundboxModels(filter?: {
vendor_id?: string;
vendor_code?: string;
status?: CatalogStatus;
}): Promise<SoundboxModelEntity[]> {
const clauses: string[] = [];
const params: unknown[] = [];
let i = 1;
if (filter?.vendor_id) {
clauses.push(`m.vendor_id = $${i++}`);
params.push(filter.vendor_id);
}
if (filter?.vendor_code) {
clauses.push(`v.vendor_code = $${i++}`);
params.push(filter.vendor_code);
}
if (filter?.status) {
clauses.push(`m.status = $${i++}`);
params.push(filter.status);
}
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
const { rows } = await getPool().query(
`SELECT m.*, v.vendor_code, v.name AS vendor_name
FROM soundbox_models m
JOIN soundbox_vendors v ON v.id = m.vendor_id
${where}
ORDER BY v.name ASC, m.model_code ASC`,
params
);
return rows.map(mapModel);
}
export async function getSoundboxModelById(id: string): Promise<SoundboxModelEntity | null> {
const { rows } = await getPool().query(
`SELECT m.*, v.vendor_code, v.name AS vendor_name
FROM soundbox_models m
JOIN soundbox_vendors v ON v.id = m.vendor_id
WHERE m.id = $1`,
[id]
);
return rows.length ? mapModel(rows[0]) : null;
}
export async function createSoundboxModel(payload: {
vendor_id: string;
model_code?: string;
name: string;
communication_mode?: CatalogCommunicationMode;
screen_flag?: boolean;
qr_mode?: CatalogQrMode;
mqtt_payload_profile?: string;
thumbnail_url?: string;
capability_template_json?: Record<string, unknown>;
status?: CatalogStatus;
}): Promise<SoundboxModelEntity> {
const id = randomUUID();
const now = nowIso();
const modelCode = payload.model_code?.trim() || await generateModelCode(payload.vendor_id, payload.name);
const { rows } = await getPool().query(
`INSERT INTO soundbox_models (
id, vendor_id, model_code, name, communication_mode, screen_flag, qr_mode,
mqtt_payload_profile, thumbnail_url, capability_template_json, status, created_at, updated_at
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING *`,
[
id,
payload.vendor_id,
modelCode,
payload.name,
payload.communication_mode || "mqtt",
payload.screen_flag ?? false,
payload.qr_mode || "static",
payload.mqtt_payload_profile || null,
payload.thumbnail_url || null,
payload.capability_template_json || {},
payload.status || "active",
now,
now
]
);
const created = await getSoundboxModelById(rows[0].id);
return created || mapModel(rows[0]);
}
export async function patchSoundboxModel(
id: string,
patch: Partial<Omit<SoundboxModelEntity, "id" | "created_at" | "updated_at" | "vendor_code" | "vendor_name">>
): Promise<SoundboxModelEntity> {
const existing = await getSoundboxModelById(id);
if (!existing) {
throw new Error("SOUNDBOX_MODEL_NOT_FOUND");
}
const merged = { ...existing, ...patch, updated_at: nowIso() };
const { rows } = await getPool().query(
`UPDATE soundbox_models
SET vendor_id = $2,
model_code = $3,
name = $4,
communication_mode = $5,
screen_flag = $6,
qr_mode = $7,
mqtt_payload_profile = $8,
thumbnail_url = $9,
capability_template_json = $10,
status = $11,
updated_at = $12
WHERE id = $1
RETURNING *`,
[
id,
merged.vendor_id,
merged.model_code,
merged.name,
merged.communication_mode,
merged.screen_flag,
merged.qr_mode,
merged.mqtt_payload_profile || null,
merged.thumbnail_url || null,
merged.capability_template_json || {},
merged.status,
merged.updated_at
]
);
const updated = await getSoundboxModelById(rows[0].id);
return updated || mapModel(rows[0]);
}
export async function deleteSoundboxModel(id: string): Promise<SoundboxModelEntity> {
const existing = await getSoundboxModelById(id);
if (!existing) {
throw new Error("SOUNDBOX_MODEL_NOT_FOUND");
}
await getPool().query("DELETE FROM soundbox_models WHERE id = $1", [id]);
return existing;
}
export function toSoundboxVendorPayload(vendor: SoundboxVendorEntity) {
return { ...vendor };
}
export function toSoundboxModelPayload(model: SoundboxModelEntity) {
return { ...model };
}

File diff suppressed because it is too large Load Diff

View File

@ -120,45 +120,37 @@
</head>
<body class="bg-background text-on-surface font-body-md antialiased">
<!-- Side Navigation Shell -->
<aside class="w-64 h-full fixed left-0 top-0 bg-surface-container-lowest border-r border-slate-200 flex flex-col py-6 px-4 gap-2 z-50">
<div class="mb-8 px-2">
<h1 class="font-headline-md text-headline-md font-bold text-primary">Soundbox Ops</h1>
<p class="text-label-md text-on-surface-variant">Admin Console</p>
<aside class="fixed inset-y-0 left-0 z-50 hidden w-64 border-r border-slate-200 bg-white px-4 py-6 lg:flex lg:flex-col">
<div class="px-2">
<h1 class="text-[22px] font-extrabold leading-tight text-blue-700">Soundbox Ops</h1>
<p class="mt-1 text-[12px] font-bold uppercase leading-none text-slate-500">Monitoring Console</p>
</div>
<nav class="flex-1 space-y-1">
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/admin-dashboard-overview">
<span class="material-symbols-outlined text-[20px]">dashboard</span>
<span class="font-body-md">Overview</span>
<nav class="mt-8 flex flex-1 flex-col gap-1">
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none transition-colors text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-ops">
<span class="material-symbols-outlined text-[22px] shrink-0">monitor_heart</span>
<span class="truncate">Monitoring</span>
</a>
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/merchant-detail-view">
<span class="material-symbols-outlined text-[20px]">storefront</span>
<span class="font-body-md">Merchant Management</span>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none transition-colors bg-blue-50 text-blue-700" href="/ui/device-registry-monitoring">
<span class="material-symbols-outlined text-[22px] shrink-0">speaker_group</span>
<span class="truncate">Registry</span>
</a>
<a class="flex items-center gap-3 px-3 py-2 bg-secondary-container text-on-secondary-container font-bold rounded-lg group" href="/ui/device-technical-detail">
<span class="material-symbols-outlined text-[20px]">speaker_group</span>
<span class="font-body-md">Device Registry</span>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none transition-colors text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-ops#mqtt-trace">
<span class="material-symbols-outlined text-[22px] shrink-0">lan</span>
<span class="truncate">MQTT Trace</span>
</a>
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/transaction-history-monitoring">
<span class="material-symbols-outlined text-[20px]">receipt_long</span>
<span class="font-body-md">Transactions</span>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none transition-colors text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-ops#config-commands">
<span class="material-symbols-outlined text-[22px] shrink-0">settings_remote</span>
<span class="truncate">Config & Commands</span>
</a>
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/settlement-batch-management">
<span class="material-symbols-outlined text-[20px]">account_balance</span>
<span class="font-body-md">Ledger &amp; Settlement</span>
</a>
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/admin-reconciliation-management">
<span class="material-symbols-outlined text-[20px]">history_edu</span>
<span class="font-body-md">Audit Control</span>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none transition-colors text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-catalog">
<span class="material-symbols-outlined text-[22px] shrink-0">category</span>
<span class="truncate">Catalog</span>
</a>
</nav>
<div class="mt-auto border-t border-slate-100 pt-4 space-y-1">
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/hub">
<span class="material-symbols-outlined text-[20px]">settings</span>
<span class="font-body-md">Settings</span>
</a>
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/hub">
<span class="material-symbols-outlined text-[20px]">help</span>
<span class="font-body-md">Support</span>
<div class="border-t border-slate-200 pt-4">
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none transition-colors text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/admin-login">
<span class="material-symbols-outlined text-[22px] shrink-0">logout</span>
<span class="truncate">Logout</span>
</a>
</div>
</aside>
@ -166,14 +158,14 @@
<header class="fixed top-0 right-0 h-[72px] flex justify-between items-center w-[calc(100%-256px)] ml-64 px-page-padding bg-surface-container-lowest border-b border-slate-200 z-40">
<div class="flex items-center gap-4 bg-surface-container-low px-3 py-1.5 rounded-full w-96 border border-slate-200">
<span class="material-symbols-outlined text-slate-500">search</span>
<input class="bg-transparent border-none focus:ring-0 text-body-md w-full" placeholder="Search devices, merchants, or serials..." type="text"/>
<input id="detail-device-search" class="bg-transparent border-none focus:ring-0 text-body-md w-full" placeholder="Search devices, merchants, or serials..." type="text"/>
</div>
<div class="flex items-center gap-6">
<div class="flex items-center gap-4 text-on-surface-variant">
<button class="hover:text-primary transition-colors flex items-center gap-1">
<button id="detail-notification-button" class="hover:text-primary transition-colors flex items-center gap-1">
<span class="material-symbols-outlined">notifications</span>
</button>
<button class="hover:text-primary transition-colors flex items-center gap-1">
<button id="detail-calendar-button" class="hover:text-primary transition-colors flex items-center gap-1">
<span class="material-symbols-outlined">calendar_today</span>
</button>
</div>
@ -242,18 +234,19 @@
</div>
<!-- Tab Navigation -->
<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">Overview</button>
<button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors">Heartbeat</button>
<button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors">Configuration</button>
<button class="pb-4 text-body-md font-medium text-on-surface-variant hover:text-primary transition-colors">Binding History</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="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 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>
</div>
<!-- Grid Layout -->
<div class="grid grid-cols-12 gap-gutter">
<!-- Left Column: Primary Content -->
<div class="col-span-12 lg:col-span-8 space-y-gutter">
<!-- KPI Metrics Bento Grid -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-gutter">
<div class="bg-surface-container-lowest border border-slate-200 p-card-padding rounded-xl shadow-sm">
<div id="overview-section" class="grid grid-cols-1 md:grid-cols-3 gap-gutter">
<div id="configuration-section" class="bg-surface-container-lowest border border-slate-200 p-card-padding rounded-xl shadow-sm">
<p class="text-label-md text-on-surface-variant mb-2">Signal Strength (4G)</p>
<div class="flex items-end justify-between">
<h3 class="font-metric-lg text-metric-lg text-on-surface" id="device-signal-strength">-78 dBm</h3>
@ -319,11 +312,39 @@ Loading
</div>
</div>
</div>
<!-- Dynamic QR Operations: shown only for dynamic-capable devices -->
<div id="dynamic-qr-panel" class="hidden bg-surface-container-lowest border border-slate-200 rounded-xl p-card-padding 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">Dynamic QR Operations</p>
<h3 class="font-headline-md text-headline-md text-on-surface" id="dynamic-qr-title">Screen QR enabled</h3>
</div>
<span class="material-symbols-outlined text-primary">qr_code_2</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="bg-slate-50 border border-slate-100 rounded-lg p-3">
<p class="text-[11px] uppercase text-slate-500 font-bold">QR Mode</p>
<p id="dynamic-qr-mode" class="text-body-md font-bold text-on-surface">dynamic</p>
</div>
<div class="bg-slate-50 border border-slate-100 rounded-lg p-3">
<p class="text-[11px] uppercase text-slate-500 font-bold">Display</p>
<p id="dynamic-qr-display" class="text-body-md font-bold text-on-surface">Screen required</p>
</div>
<div class="bg-slate-50 border border-slate-100 rounded-lg p-3">
<p class="text-[11px] uppercase text-slate-500 font-bold">Command Path</p>
<p id="dynamic-qr-command-path" class="text-body-md font-bold text-on-surface">MQTT</p>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-3">
<button id="dynamic-qr-open-preview" class="px-3 py-2 bg-primary text-white rounded-lg text-label-md font-bold hover:opacity-90">Open QR Display Preview</button>
<button class="px-3 py-2 border border-slate-200 rounded-lg text-label-md font-bold hover:bg-slate-50" id="dynamic-qr-send-test">Send Test QR</button>
</div>
</div>
<!-- Merchant Binding Info -->
<div class="bg-surface-container-lowest border border-slate-200 rounded-xl overflow-hidden">
<div id="binding-section" 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">
<h4 class="font-headline-md text-headline-md">Current Merchant Binding</h4>
<button class="text-primary text-label-md font-bold hover:underline">Change Merchant</button>
<button id="change-merchant-binding" class="text-primary text-label-md font-bold hover:underline">Change Merchant</button>
</div>
<div class="p-card-padding flex items-center gap-6">
<div class="w-14 h-14 rounded-full bg-slate-100 border border-slate-200 flex items-center justify-center overflow-hidden">
@ -347,7 +368,7 @@ Loading
<span class="text-label-md text-slate-100 font-bold uppercase tracking-widest">Live Payload Stream</span>
</div>
<div class="flex gap-3">
<button class="text-slate-400 hover:text-white transition-colors">
<button id="copy-payload-stream" class="text-slate-400 hover:text-white transition-colors">
<span class="material-symbols-outlined text-[18px]">content_copy</span>
</button>
<button class="text-slate-400 hover:text-white transition-colors" id="clearConsole">
@ -355,7 +376,7 @@ Loading
</button>
</div>
</div>
<div class="p-4 flex-1 overflow-y-auto code-font text-[13px] text-green-400 space-y-1 custom-scroll" id="payload-stream">
<div id="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: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>
@ -373,21 +394,21 @@ Loading
<h4 class="font-headline-md text-headline-md">Remote Actions</h4>
</div>
<div class="p-card-padding space-y-3">
<button class="w-full flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50 transition-all group">
<button id="reboot-device" class="w-full flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50 transition-all group">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-on-surface-variant group-hover:text-primary">restart_alt</span>
<span class="font-body-md font-bold">Reboot Device</span>
</div>
<span class="material-symbols-outlined text-slate-300">chevron_right</span>
</button>
<button class="w-full flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50 transition-all group">
<button id="update-device-firmware" class="w-full flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50 transition-all group">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-on-surface-variant group-hover:text-primary">system_update</span>
<span class="font-body-md font-bold">Update Firmware</span>
</div>
<span class="bg-primary/10 text-primary text-[10px] font-bold px-2 py-0.5 rounded-full">OTA Ready</span>
</button>
<button class="w-full flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50 transition-all group">
<button id="unbind-device" class="w-full flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50 transition-all group">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined text-on-surface-variant group-hover:text-primary">lock_open</span>
<span class="font-body-md font-bold">Unbind Merchant</span>
@ -402,7 +423,7 @@ Loading
<span class="material-symbols-outlined text-slate-300">chevron_right</span>
</button>
<div class="pt-2">
<button class="w-full py-2.5 bg-danger/10 text-danger border border-danger/20 font-bold text-body-md rounded-lg hover:bg-danger/20 transition-all flex items-center justify-center gap-2">
<button id="decommission-device" class="w-full py-2.5 bg-danger/10 text-danger border border-danger/20 font-bold text-body-md rounded-lg hover:bg-danger/20 transition-all flex items-center justify-center gap-2">
<span class="material-symbols-outlined text-[20px]">delete_forever</span>
Decommission Device
</button>
@ -456,6 +477,56 @@ Rotate Credential
<span class="material-symbols-outlined">add</span>
</button>
</div>
<div id="qr-preview-modal" class="fixed inset-0 z-[105] hidden items-center justify-center bg-slate-900/60 px-4">
<div class="w-full max-w-2xl overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl">
<div class="flex items-start justify-between gap-4 border-b border-slate-100 p-card-padding">
<div>
<h3 class="font-headline-md text-headline-md text-on-surface">QR Display Preview</h3>
<p class="mt-1 text-body-md text-on-surface-variant">Simulasi tampilan layar dynamic soundbox untuk device ini.</p>
</div>
<button id="qr-preview-close" class="flex h-10 w-10 items-center justify-center rounded-lg text-on-surface-variant hover:bg-slate-100">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="grid gap-5 p-card-padding md:grid-cols-[260px_minmax(0,1fr)]">
<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">
<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>
<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>
</div>
</div>
<div class="space-y-3">
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<p class="text-[11px] font-bold uppercase text-slate-500">Command Path</p>
<p id="qr-preview-command-path" class="mt-1 font-bold text-on-surface">MQTT</p>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<p class="text-[11px] font-bold uppercase text-slate-500">QR Mode</p>
<p id="qr-preview-mode" class="mt-1 font-bold text-on-surface">dynamic</p>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<p class="text-[11px] font-bold uppercase text-slate-500">Payload</p>
<pre id="qr-preview-payload" class="mt-2 max-h-44 overflow-auto whitespace-pre-wrap font-mono text-[12px] text-slate-700"></pre>
</div>
<button id="qr-preview-send-test" class="w-full rounded-lg bg-primary px-4 py-2.5 font-bold text-white hover:opacity-90">Send Test QR to Device</button>
</div>
</div>
</div>
</div>
<div id="detail-confirm-modal" class="fixed inset-0 z-[110] hidden items-center justify-center bg-slate-900/60 px-4">
<div class="w-full max-w-md overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl">
<div class="border-b border-slate-100 p-card-padding">
<h3 id="detail-confirm-title" class="font-headline-md text-headline-md text-on-surface">Confirm action</h3>
<p id="detail-confirm-message" class="mt-2 text-body-md text-on-surface-variant"></p>
</div>
<div class="flex justify-end gap-2 p-card-padding">
<button id="detail-confirm-cancel" class="rounded-lg border border-slate-200 px-4 py-2 font-bold text-body-md hover:bg-slate-50">Cancel</button>
<button id="detail-confirm-submit" class="rounded-lg bg-danger px-4 py-2 font-bold text-body-md text-white hover:opacity-90">Continue</button>
</div>
</div>
</div>
<div id="credential-modal" class="fixed inset-0 z-[100] hidden items-center justify-center bg-slate-900/60 px-4">
<div class="bg-surface-container-lowest rounded-xl border border-slate-200 shadow-2xl w-full max-w-xl overflow-hidden">
<div class="p-card-padding border-b border-slate-100 flex items-start justify-between gap-4">
@ -516,6 +587,30 @@ Copy Command
const exportBtn = document.getElementById("export-device-logs");
const refreshBtn = document.getElementById("refresh-device-state");
const viewAllEventsBtn = document.getElementById("view-all-events");
const deviceSearch = document.getElementById("detail-device-search");
const notificationButton = document.getElementById("detail-notification-button");
const calendarButton = document.getElementById("detail-calendar-button");
const copyPayloadButton = document.getElementById("copy-payload-stream");
const rebootButton = document.getElementById("reboot-device");
const updateFirmwareButton = document.getElementById("update-device-firmware");
const unbindButton = document.getElementById("unbind-device");
const decommissionButton = document.getElementById("decommission-device");
const changeMerchantButton = document.getElementById("change-merchant-binding");
const qrPreviewOpen = document.getElementById("dynamic-qr-open-preview");
const qrPreviewModal = document.getElementById("qr-preview-modal");
const qrPreviewClose = document.getElementById("qr-preview-close");
const qrPreviewGrid = document.getElementById("qr-preview-grid");
const qrPreviewAmount = document.getElementById("qr-preview-amount");
const qrPreviewDevice = document.getElementById("qr-preview-device");
const qrPreviewCommandPath = document.getElementById("qr-preview-command-path");
const qrPreviewMode = document.getElementById("qr-preview-mode");
const qrPreviewPayload = document.getElementById("qr-preview-payload");
const qrPreviewSendTest = document.getElementById("qr-preview-send-test");
const confirmModal = document.getElementById("detail-confirm-modal");
const confirmTitle = document.getElementById("detail-confirm-title");
const confirmMessage = document.getElementById("detail-confirm-message");
const confirmCancel = document.getElementById("detail-confirm-cancel");
const confirmSubmit = document.getElementById("detail-confirm-submit");
const rotateCredentialButtons = [
document.getElementById("rotate-device-credential"),
document.getElementById("rotate-device-credential-secondary")
@ -528,6 +623,10 @@ Copy Command
const credentialCopyCommand = document.getElementById("credential-copy-command");
const backLink = document.querySelector("a[href='#']");
const eventHost = document.getElementById("device-events");
let currentDevice = null;
let currentHeartbeats = [];
let showingAllEvents = false;
let confirmResolver = null;
let latestCredentialCommand = "";
const els = {
@ -554,6 +653,12 @@ Copy Command
configStatus: document.getElementById("device-config-status"),
configDetail: document.getElementById("device-config-detail"),
configRetry: document.getElementById("device-config-retry"),
dynamicQrTab: document.getElementById("dynamic-qr-tab"),
dynamicQrPanel: document.getElementById("dynamic-qr-panel"),
dynamicQrMode: document.getElementById("dynamic-qr-mode"),
dynamicQrDisplay: document.getElementById("dynamic-qr-display"),
dynamicQrCommandPath: document.getElementById("dynamic-qr-command-path"),
dynamicQrSendTest: document.getElementById("dynamic-qr-send-test"),
credentialStatus: document.getElementById("device-credential-status"),
mqttUsername: document.getElementById("device-mqtt-username"),
credentialIssued: document.getElementById("device-credential-issued"),
@ -675,12 +780,14 @@ Copy Command
}
};
const renderEvents = (heartbeats) => {
const renderEvents = (heartbeats, showAll = false) => {
if (!eventHost) {
return;
}
const rows = Array.isArray(heartbeats) ? heartbeats.slice(0, 6) : [];
const rows = Array.isArray(heartbeats)
? (showAll ? heartbeats : heartbeats.slice(0, 6))
: [];
if (!rows.length) {
eventHost.innerHTML =
'<p class="text-label-md text-slate-400">No recent events yet.</p>';
@ -840,6 +947,54 @@ Copy Command
}
};
const parseJsonMaybe = (value) => {
if (!value) {
return {};
}
if (typeof value === "object") {
return value;
}
try {
return JSON.parse(value);
} catch (_error) {
return {};
}
};
const isDynamicDevice = (device) => {
const capability = parseJsonMaybe(device.capability_profile_json || device.capability_summary);
const text = [
device.qr_mode,
device.terminal_qr_mode,
device.device_type,
device.model,
device.communication_mode,
capability?.qr_mode,
capability?.terminal_qr_mode,
capability?.features?.dynamic_qr ? "dynamic_qr" : "",
Array.isArray(capability?.flows) ? capability.flows.join(" ") : ""
]
.map((item) => String(item || "").toLowerCase())
.join(" ");
return text.includes("dynamic") || text.includes("screen") || text.includes("dynamic_qr");
};
const renderDynamicQrPanel = (device) => {
const dynamic = isDynamicDevice(device);
els.dynamicQrTab?.classList.toggle("hidden", !dynamic);
els.dynamicQrPanel?.classList.toggle("hidden", !dynamic);
if (!dynamic) {
return;
}
const capability = parseJsonMaybe(device.capability_profile_json || device.capability_summary);
const commandPath = String(device.communication_mode || "").toLowerCase() === "api" ? "API" : "MQTT";
setText(els.dynamicQrMode, device.qr_mode || device.terminal_qr_mode || "dynamic");
setText(els.dynamicQrDisplay, capability?.features?.dynamic_qr?.display || "Screen required");
setText(els.dynamicQrCommandPath, commandPath);
};
const shellQuote = (value) => `'${String(value || "").replace(/'/g, "'\\''")}'`;
const renderCredentialSummary = (device) => {
@ -890,6 +1045,117 @@ Copy Command
await navigator.clipboard.writeText(value);
};
const appendStreamLine = (message, className = "text-blue-400") => {
if (!stream) {
return;
}
const p = document.createElement("p");
p.className = className;
p.textContent = `[${formatDateTime(Date.now())}] ${message}`;
stream.appendChild(p);
stream.scrollTop = stream.scrollHeight;
};
const setButtonLoading = (button, loading, label) => {
if (!button) {
return;
}
if (!button.dataset.originalHtml) {
button.dataset.originalHtml = button.innerHTML;
}
button.disabled = loading;
button.classList.toggle("opacity-60", loading);
if (loading && label) {
button.innerHTML = `<span class="material-symbols-outlined text-[18px]">sync</span>${label}`;
} else if (!loading) {
button.innerHTML = button.dataset.originalHtml;
}
};
const closeConfirm = (value) => {
confirmModal?.classList.add("hidden");
confirmModal?.classList.remove("flex");
if (confirmResolver) {
confirmResolver(Boolean(value));
confirmResolver = null;
}
};
const confirmAction = ({ title, message, buttonLabel = "Continue" }) => {
setText(confirmTitle, title);
setText(confirmMessage, message);
setText(confirmSubmit, buttonLabel);
confirmModal?.classList.remove("hidden");
confirmModal?.classList.add("flex");
confirmCancel?.focus();
return new Promise((resolve) => {
confirmResolver = resolve;
});
};
const buildQrPreviewPayload = () => ({
device_id: activeDeviceId || "-",
device_code: currentDevice?.device_code || currentDevice?.id || "-",
amount: 1000,
currency: "IDR",
qr_mode: els.dynamicQrMode?.textContent || "dynamic",
expires_in_seconds: 60,
preview: true
});
const renderQrPreviewGrid = () => {
if (!qrPreviewGrid) {
return;
}
const seed = String(activeDeviceId || currentDevice?.device_code || "soundbox");
qrPreviewGrid.innerHTML = "";
for (let index = 0; index < 81; index += 1) {
const char = seed.charCodeAt(index % seed.length) || 37;
const dark = index < 9 || index % 9 === 0 || ((char + index * 7) % 5 < 2);
const cell = document.createElement("span");
cell.className = `${dark ? "bg-slate-950" : "bg-white"} rounded-[2px]`;
qrPreviewGrid.appendChild(cell);
}
};
const openQrPreview = () => {
const payload = buildQrPreviewPayload();
renderQrPreviewGrid();
setText(qrPreviewAmount, new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
maximumFractionDigits: 0
}).format(payload.amount));
setText(qrPreviewDevice, payload.device_code);
setText(qrPreviewCommandPath, els.dynamicQrCommandPath?.textContent || "MQTT");
setText(qrPreviewMode, payload.qr_mode);
setText(qrPreviewPayload, JSON.stringify(payload, null, 2));
qrPreviewModal?.classList.remove("hidden");
qrPreviewModal?.classList.add("flex");
};
const closeQrPreview = () => {
qrPreviewModal?.classList.add("hidden");
qrPreviewModal?.classList.remove("flex");
};
const sendDeviceCommand = async (command, payload = {}, button = null) => {
if (!activeDeviceId) {
return null;
}
setButtonLoading(button, true, "Sending...");
try {
const result = await api.createDeviceCommand(activeDeviceId, { command, payload });
appendStreamLine(`SEND: ${JSON.stringify({ command, payload, command_id: result.id })}`);
return result;
} catch (error) {
appendStreamLine(`ERROR: ${error?.message || `Unable to send ${command}`}`, "text-danger");
throw error;
} finally {
setButtonLoading(button, false);
}
};
const rotateCredential = async () => {
if (!activeDeviceId) {
return;
@ -985,6 +1251,9 @@ Copy Command
: Array.isArray(heartbeatResponse?.heartbeats)
? heartbeatResponse.heartbeats
: [];
currentDevice = device;
currentHeartbeats = heartbeats;
showingAllEvents = false;
const modelCode = device.device_code || device.code || device.serial_number || device.id || "Unknown Device";
setText(els.breadcrumbCode, modelCode);
@ -1003,6 +1272,7 @@ Copy Command
const latestMetric = extractHeartbeatMetrics(latest);
setDerivedStatus(device, heartbeats);
renderHealthSummary(device.health_summary);
renderDynamicQrPanel(device);
renderCredentialSummary(device);
setText(
els.firmwareVersion,
@ -1011,7 +1281,11 @@ Copy Command
setText(els.firmwareStatus, device.communication_mode || "Operational");
renderSignalStatus(latestMetric.signal, latestMetric.battery, latestMetric.status);
renderStream(heartbeats);
renderEvents(heartbeats);
renderEvents(heartbeats, showingAllEvents);
if (viewAllEventsBtn) {
viewAllEventsBtn.textContent = heartbeats.length > 6 ? "View All Events" : "All Events Shown";
viewAllEventsBtn.disabled = heartbeats.length <= 6;
}
await loadBindingDetails(device);
await loadConfigStatus();
} catch (error) {
@ -1050,24 +1324,155 @@ Copy Command
}
}
});
els.dynamicQrSendTest?.addEventListener("click", () => {
sendDeviceCommand("dynamic_qr.test", {
...buildQrPreviewPayload(),
source: "device_detail"
}, els.dynamicQrSendTest);
});
clearBtn?.addEventListener("click", () => {
if (stream) {
stream.innerHTML = '<p class="text-slate-500">--- CONSOLE CLEARED ---</p>';
}
});
exportBtn?.addEventListener("click", async () => {
const copyStream = async () => {
if (!stream || !navigator.clipboard) {
return;
}
try {
await navigator.clipboard.writeText(stream.textContent || "");
appendStreamLine("INFO: payload stream copied to clipboard", "text-slate-400");
} catch (error) {
console.warn("[device detail] copy failed", error);
}
};
copyPayloadButton?.addEventListener("click", copyStream);
exportBtn?.addEventListener("click", async () => {
await copyStream();
});
viewAllEventsBtn?.addEventListener("click", () => {
if (eventHost) {
eventHost.classList.toggle("max-h-96");
showingAllEvents = !showingAllEvents;
renderEvents(currentHeartbeats, showingAllEvents);
viewAllEventsBtn.textContent = showingAllEvents ? "Show Recent Events" : "View All Events";
});
rebootButton?.addEventListener("click", () => sendDeviceCommand("device.reboot", { requested_from: "device_detail" }, rebootButton));
updateFirmwareButton?.addEventListener("click", () => sendDeviceCommand("firmware.update", {
requested_from: "device_detail",
target_version: currentDevice?.firmware_version || "latest"
}, updateFirmwareButton));
unbindButton?.addEventListener("click", async () => {
const confirmed = await confirmAction({
title: "Unbind merchant",
message: "This will remove the active merchant/outlet/terminal binding from this device.",
buttonLabel: "Unbind"
});
if (!confirmed || !activeDeviceId) {
return;
}
setButtonLoading(unbindButton, true, "Unbinding...");
try {
await api.unbindDevice(activeDeviceId);
appendStreamLine("INFO: device merchant binding removed", "text-slate-400");
await loadDevice();
} catch (error) {
appendStreamLine(`ERROR: ${error?.message || "Unable to unbind device"}`, "text-danger");
} finally {
setButtonLoading(unbindButton, false);
}
});
decommissionButton?.addEventListener("click", async () => {
const confirmed = await confirmAction({
title: "Decommission device",
message: "This will mark the device inactive. It will remain in records but should no longer be treated as an active unit.",
buttonLabel: "Decommission"
});
if (!confirmed || !activeDeviceId) {
return;
}
setButtonLoading(decommissionButton, true, "Decommissioning...");
try {
await api.patchDevice(activeDeviceId, { status: "inactive" });
await sendDeviceCommand("device.decommission", { requested_from: "device_detail" });
appendStreamLine("INFO: device marked inactive", "text-slate-400");
await loadDevice();
} catch (error) {
appendStreamLine(`ERROR: ${error?.message || "Unable to decommission device"}`, "text-danger");
} finally {
setButtonLoading(decommissionButton, false);
}
});
qrPreviewOpen?.addEventListener("click", openQrPreview);
qrPreviewClose?.addEventListener("click", closeQrPreview);
qrPreviewModal?.addEventListener("click", (event) => {
if (event.target === qrPreviewModal) {
closeQrPreview();
}
});
qrPreviewSendTest?.addEventListener("click", () => {
sendDeviceCommand("dynamic_qr.test", {
...buildQrPreviewPayload(),
source: "qr_preview_modal"
}, qrPreviewSendTest);
});
changeMerchantButton?.addEventListener("click", () => {
window.location.href = `/ui/device-registry-monitoring?focus=${encodeURIComponent(activeDeviceId || "")}`;
});
notificationButton?.addEventListener("click", () => {
window.location.href = activeDeviceId
? `/ui/soundbox-ops#mqtt-trace`
: "/ui/soundbox-ops";
});
calendarButton?.addEventListener("click", () => {
document.getElementById("device-events")?.scrollIntoView({ behavior: "smooth", block: "start" });
});
document.querySelectorAll("[data-scroll-target]").forEach((button) => {
button.addEventListener("click", () => {
const target = document.getElementById(button.getAttribute("data-scroll-target"));
target?.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
deviceSearch?.addEventListener("keydown", async (event) => {
if (event.key !== "Enter") {
return;
}
event.preventDefault();
const q = deviceSearch.value.trim().toLowerCase();
if (!q) {
return;
}
try {
const devices = await api.listDevices();
const match = (Array.isArray(devices) ? devices : []).find((device) =>
[
device.id,
device.device_code,
device.serial_number,
device.vendor,
device.model,
device.mqtt_username
].filter(Boolean).join(" ").toLowerCase().includes(q)
);
if (match) {
window.location.href = `/ui/device-technical-detail?device_id=${encodeURIComponent(match.id)}`;
} else {
appendStreamLine(`INFO: no device matched search "${q}"`, "text-slate-400");
}
} catch (error) {
appendStreamLine(`ERROR: ${error?.message || "Search failed"}`, "text-danger");
}
});
confirmCancel?.addEventListener("click", () => closeConfirm(false));
confirmSubmit?.addEventListener("click", () => closeConfirm(true));
confirmModal?.addEventListener("click", (event) => {
if (event.target === confirmModal) {
closeConfirm(false);
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeQrPreview();
closeConfirm(false);
closeCredentialModal();
}
});
if (backLink) {
@ -1077,13 +1482,4 @@ Copy Command
loadDevice();
})();
</script>
<!-- ui-nav -->
<div id="__sb_nav" style="position:fixed;left:16px;bottom:16px;z-index:9999;background:#fff;border:1px solid #e2e8f0;padding:8px 10px;border-radius:8px;box-shadow:0 6px 24px rgba(15,23,42,0.12);font-family:Inter,Arial,sans-serif;font-size:12px;line-height:1.4">
<a href="/ui" style="margin-right:8px;color:#2563eb;text-decoration:none;font-weight:600">UI Catalog</a>
<a href="/ui/hub" style="margin-right:8px;color:#2563eb;text-decoration:none;font-weight:600">Hub</a>
<a href="/ui/admin-login" style="margin-right:8px;color:#2563eb;text-decoration:none;font-weight:600">Admin Login</a>
<a href="/ui/merchant-login" style="margin-right:8px;color:#2563eb;text-decoration:none;font-weight:600">Merchant Login</a>
<a href="/ui/admin-dashboard-overview" style="margin-right:0;color:#2563eb;text-decoration:none;font-weight:600">Dashboard</a>
</div>
'
</body></html>

View File

@ -1,6 +1,21 @@
const ADMIN_TOKEN_KEY = "admin_token";
const ADMIN_PROFILE_KEY = "admin_profile";
function resolveAdminApiBase() {
const host = window.location.hostname;
const port = window.location.port;
const configured = window.SOUNDBOX_ADMIN_API_BASE || "";
if (configured) {
return configured.replace(/\/$/, "");
}
if ((host === "127.0.0.1" || host === "localhost") && port === "4173") {
return "http://127.0.0.1:3100";
}
return "";
}
const ADMIN_API_BASE = resolveAdminApiBase();
function formatMoney(value) {
const number = Number(value || 0);
if (!Number.isFinite(number)) {
@ -50,7 +65,8 @@ async function adminFetch(path, options = {}) {
} = options;
const suffix = buildQuery(query || {});
const url = suffix ? `${path}?${suffix}` : path;
const relativeUrl = suffix ? `${path}?${suffix}` : path;
const url = `${ADMIN_API_BASE}${relativeUrl}`;
const headers = {
...extraHeaders
};
@ -203,8 +219,65 @@ window.AdminUIAPI = {
}),
listTerminals: (query) => adminFetch("/admin/terminals", { query }),
getTerminal: (id) => adminFetch(`/admin/terminals/${id}`),
listSoundboxVendors: (query) => adminFetch("/admin/soundbox-vendors", { query }),
createSoundboxVendor: (payload) =>
adminFetch("/admin/soundbox-vendors", {
method: "POST",
body: payload || {}
}),
patchSoundboxVendor: (id, payload) =>
adminFetch(`/admin/soundbox-vendors/${id}`, {
method: "PATCH",
body: payload || {}
}),
deleteSoundboxVendor: (id) =>
adminFetch(`/admin/soundbox-vendors/${id}`, {
method: "DELETE"
}),
listSoundboxModels: (query) => adminFetch("/admin/soundbox-models", { query }),
createSoundboxModel: (payload) =>
adminFetch("/admin/soundbox-models", {
method: "POST",
body: payload || {}
}),
patchSoundboxModel: (id, payload) =>
adminFetch(`/admin/soundbox-models/${id}`, {
method: "PATCH",
body: payload || {}
}),
deleteSoundboxModel: (id) =>
adminFetch(`/admin/soundbox-models/${id}`, {
method: "DELETE"
}),
listDevices: (query) => adminFetch("/admin/devices", { query }),
createDevice: (payload) =>
adminFetch("/admin/devices", {
method: "POST",
body: payload || {}
}),
getDevice: (id) => adminFetch(`/admin/devices/${id}`),
patchDevice: (id, payload) =>
adminFetch(`/admin/devices/${id}`, {
method: "PATCH",
body: payload || {}
}),
bindDevice: (id, payload) =>
adminFetch(`/admin/devices/${id}/bind`, {
method: "POST",
body: payload || {}
}),
unbindDevice: (id) =>
adminFetch(`/admin/devices/${id}/unbind`, {
method: "POST",
body: {}
}),
createDeviceCommand: (id, payload) =>
adminFetch(`/admin/devices/${id}/commands`, {
method: "POST",
body: payload || {}
}),
listDeviceCommands: (id, query) =>
adminFetch(`/admin/devices/${id}/commands`, { query }),
rotateDeviceCredential: (id) =>
adminFetch(`/admin/devices/${id}/credentials/rotate`, {
method: "POST",

View File

@ -0,0 +1,723 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Soundbox Catalog | Soundbox Ops</title>
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
<style>
body { font-family: Inter, Arial, sans-serif; }
.mono { font-family: "JetBrains Mono", monospace; }
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 24; vertical-align: middle; }
</style>
</head>
<body class="min-h-screen bg-slate-50 text-slate-950">
<aside class="fixed inset-y-0 left-0 z-50 hidden w-64 border-r border-slate-200 bg-white px-4 py-6 lg:flex lg:flex-col">
<div class="px-2">
<h1 class="text-[22px] font-extrabold leading-tight text-blue-700">Soundbox Ops</h1>
<p class="mt-1 text-[12px] font-bold uppercase leading-none text-slate-500">Monitoring Console</p>
</div>
<nav class="mt-8 flex flex-1 flex-col gap-1">
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-ops">
<span class="material-symbols-outlined shrink-0 text-[22px]">monitor_heart</span>
<span class="truncate">Monitoring</span>
</a>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/device-registry-monitoring">
<span class="material-symbols-outlined shrink-0 text-[22px]">speaker_group</span>
<span class="truncate">Registry</span>
</a>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-ops#mqtt-trace">
<span class="material-symbols-outlined shrink-0 text-[22px]">lan</span>
<span class="truncate">MQTT Trace</span>
</a>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-ops#config-commands">
<span class="material-symbols-outlined shrink-0 text-[22px]">settings_remote</span>
<span class="truncate">Config & Commands</span>
</a>
<a class="flex h-11 items-center gap-3 rounded-lg bg-blue-50 px-3 text-[15px] font-semibold leading-none text-blue-700" href="/ui/soundbox-catalog">
<span class="material-symbols-outlined shrink-0 text-[22px]">category</span>
<span class="truncate">Catalog</span>
</a>
</nav>
<div class="border-t border-slate-200 pt-4">
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 hover:bg-slate-100 hover:text-blue-700" href="/ui/admin-login">
<span class="material-symbols-outlined shrink-0 text-[22px]">logout</span>
<span class="truncate">Logout</span>
</a>
</div>
</aside>
<header class="sticky top-0 z-30 border-b border-slate-200 bg-white/95 px-4 py-4 backdrop-blur lg:ml-64 lg:px-8">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<div class="flex items-center gap-2 text-sm font-semibold text-slate-500">
<span class="material-symbols-outlined text-[18px]">category</span>
Device master data
</div>
<h2 class="mt-1 text-2xl font-extrabold tracking-normal">Soundbox Catalog</h2>
</div>
<button id="refresh-button" class="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-bold text-slate-700 hover:bg-slate-50">
<span class="material-symbols-outlined text-[20px]">sync</span>
Refresh
</button>
</div>
</header>
<main class="lg:ml-64">
<section class="px-4 py-6 lg:px-8">
<div id="alert" class="mb-4 hidden rounded-lg border px-4 py-3 text-sm font-semibold"></div>
<div class="grid gap-6 xl:grid-cols-[420px_minmax(0,1fr)]">
<section class="space-y-6">
<form id="vendor-form" class="rounded-lg border border-slate-200 bg-white">
<div class="border-b border-slate-200 px-5 py-4">
<h3 id="vendor-form-title" class="text-lg font-extrabold">Vendor</h3>
</div>
<div class="space-y-4 p-5">
<input id="vendor-id" type="hidden" />
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Vendor Code</span>
<input id="vendor-code" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="QF" required />
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Vendor Name</span>
<input id="vendor-name" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="QF" required />
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Country</span>
<input id="vendor-country" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" list="country-options" placeholder="Search country" autocomplete="off" />
<datalist id="country-options"></datalist>
</label>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">PIC Name</span>
<input id="vendor-support-pic" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="Contact person" />
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Phone / WhatsApp</span>
<input id="vendor-support-phone" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="+62..." />
</label>
</div>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Email</span>
<input id="vendor-support-email" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="support@example.com" type="email" />
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Notes</span>
<textarea id="vendor-support-notes" class="min-h-20 w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="Escalation hours, Telegram group, sparepart notes, etc."></textarea>
</label>
<div class="grid grid-cols-1 gap-3 md:grid-cols-[1fr_auto]">
<button id="vendor-submit" class="rounded-lg bg-blue-700 px-4 py-2.5 text-sm font-bold text-white hover:bg-blue-800" type="submit">Create Vendor</button>
<button id="vendor-cancel-edit" class="hidden rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-bold text-slate-700 hover:bg-slate-50" type="button">Cancel</button>
</div>
</div>
</form>
<form id="model-form" class="rounded-lg border border-slate-200 bg-white">
<div class="border-b border-slate-200 px-5 py-4">
<h3 id="model-form-title" class="text-lg font-extrabold">Model</h3>
</div>
<div class="space-y-4 p-5">
<input id="model-id" type="hidden" />
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Vendor</span>
<select id="model-vendor" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" required></select>
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Model Code</span>
<input id="model-code" class="w-full rounded-lg border-slate-200 bg-slate-50 text-sm text-slate-500" disabled value="Generated after create" />
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Name</span>
<input id="model-name" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="QF100" required />
</label>
<div class="grid grid-cols-2 gap-3">
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Communication</span>
<select id="model-communication" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700">
<option value="mqtt">MQTT</option>
<option value="static">Static</option>
<option value="api">API</option>
</select>
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">QR Mode</span>
<select id="model-qr-mode" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700">
<option value="static">Static</option>
<option value="dynamic_mqtt">Dynamic MQTT</option>
<option value="dynamic_api">Dynamic API</option>
</select>
</label>
</div>
<label class="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm font-semibold">
<input id="model-screen" class="rounded border-slate-300 text-blue-700 focus:ring-blue-700" type="checkbox" />
Has screen display
</label>
<label class="block">
<span class="mb-1 block text-xs font-bold uppercase text-slate-500">Payload Profile</span>
<input id="model-payload-profile" class="w-full rounded-lg border-slate-200 text-sm focus:border-blue-700 focus:ring-blue-700" placeholder="Optional" />
</label>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<span class="mb-2 block text-xs font-bold uppercase text-slate-500">Thumbnail</span>
<div class="flex items-center gap-3">
<div id="model-thumbnail-preview" class="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-slate-200 bg-white text-slate-400">
<span class="material-symbols-outlined text-[28px]">image</span>
</div>
<div class="min-w-0 flex-1">
<input id="model-thumbnail-file" class="block w-full text-xs font-semibold text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-blue-700 file:px-3 file:py-2 file:text-xs file:font-bold file:text-white hover:file:bg-blue-800" type="file" accept="image/*" />
<p class="mt-1 text-xs text-slate-500">One image only, max 512 KB.</p>
</div>
</div>
<button id="model-thumbnail-clear" class="mt-3 hidden rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-bold text-slate-700 hover:bg-slate-100" type="button">Remove Thumbnail</button>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-[1fr_auto]">
<button id="model-submit" class="rounded-lg bg-blue-700 px-4 py-2.5 text-sm font-bold text-white hover:bg-blue-800" type="submit">Create Model</button>
<button id="model-cancel-edit" class="hidden rounded-lg border border-slate-200 px-4 py-2.5 text-sm font-bold text-slate-700 hover:bg-slate-50" type="button">Cancel</button>
</div>
</div>
</form>
</section>
<section class="space-y-6">
<div class="grid gap-4 md:grid-cols-3">
<article class="rounded-lg border border-slate-200 bg-white p-4">
<p class="text-xs font-bold uppercase text-slate-500">Active Vendors</p>
<p id="kpi-vendors" class="mt-2 text-3xl font-extrabold">0</p>
</article>
<article class="rounded-lg border border-slate-200 bg-white p-4">
<p class="text-xs font-bold uppercase text-slate-500">Active Models</p>
<p id="kpi-models" class="mt-2 text-3xl font-extrabold">0</p>
</article>
<article class="rounded-lg border border-slate-200 bg-white p-4">
<p class="text-xs font-bold uppercase text-slate-500">Dynamic Models</p>
<p id="kpi-dynamic" class="mt-2 text-3xl font-extrabold">0</p>
</article>
</div>
<section class="rounded-lg border border-slate-200 bg-white">
<div class="flex items-center justify-between border-b border-slate-200 px-5 py-4">
<h3 class="text-lg font-extrabold">Vendors</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="border-b border-slate-200 bg-slate-50 text-xs font-bold uppercase text-slate-500">
<tr>
<th class="px-5 py-3">Code</th>
<th class="px-5 py-3">Name</th>
<th class="px-5 py-3">Contact</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3 text-right">Action</th>
</tr>
</thead>
<tbody id="vendor-table" class="divide-y divide-slate-100">
<tr><td class="px-5 py-6 text-center text-slate-500" colspan="5">Loading vendors...</td></tr>
</tbody>
</table>
</div>
</section>
<section class="rounded-lg border border-slate-200 bg-white">
<div class="flex items-center justify-between border-b border-slate-200 px-5 py-4">
<h3 class="text-lg font-extrabold">Models</h3>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="border-b border-slate-200 bg-slate-50 text-xs font-bold uppercase text-slate-500">
<tr>
<th class="px-5 py-3">Vendor</th>
<th class="px-5 py-3">Model</th>
<th class="px-5 py-3">Mode</th>
<th class="px-5 py-3">Capability</th>
<th class="px-5 py-3">Status</th>
<th class="px-5 py-3 text-right">Action</th>
</tr>
</thead>
<tbody id="model-table" class="divide-y divide-slate-100">
<tr><td class="px-5 py-6 text-center text-slate-500" colspan="6">Loading models...</td></tr>
</tbody>
</table>
</div>
</section>
</section>
</div>
</section>
</main>
<div id="delete-confirm-modal" class="fixed inset-0 z-[80] hidden items-center justify-center bg-slate-950/50 px-4 py-6">
<div class="w-full max-w-md rounded-lg border border-slate-200 bg-white shadow-xl">
<div class="border-b border-slate-200 px-5 py-4">
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-red-50 text-red-700">
<span class="material-symbols-outlined text-[24px]">delete</span>
</div>
<div class="min-w-0">
<h3 id="delete-confirm-title" class="text-lg font-extrabold">Delete item</h3>
<p id="delete-confirm-message" class="mt-1 text-sm font-medium leading-5 text-slate-600"></p>
</div>
</div>
</div>
<div class="px-5 py-4">
<div class="rounded-lg border border-red-100 bg-red-50 px-3 py-2 text-sm font-semibold text-red-800">
This action cannot be undone.
</div>
</div>
<div class="flex justify-end gap-3 border-t border-slate-200 px-5 py-4">
<button id="delete-confirm-cancel" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-bold text-slate-700 hover:bg-slate-50" type="button">Cancel</button>
<button id="delete-confirm-submit" class="rounded-lg bg-red-700 px-4 py-2 text-sm font-bold text-white hover:bg-red-800" type="button">Delete</button>
</div>
</div>
</div>
<script src="/ui/shared/admin-api.js"></script>
<script>
(function () {
const api = window.AdminUIAPI;
const alertBox = document.getElementById("alert");
const deleteConfirmModal = document.getElementById("delete-confirm-modal");
const deleteConfirmTitle = document.getElementById("delete-confirm-title");
const deleteConfirmMessage = document.getElementById("delete-confirm-message");
const deleteConfirmCancel = document.getElementById("delete-confirm-cancel");
const deleteConfirmSubmit = document.getElementById("delete-confirm-submit");
const vendorForm = document.getElementById("vendor-form");
const vendorFormTitle = document.getElementById("vendor-form-title");
const vendorSubmit = document.getElementById("vendor-submit");
const vendorCancelEdit = document.getElementById("vendor-cancel-edit");
const modelForm = document.getElementById("model-form");
const modelFormTitle = document.getElementById("model-form-title");
const modelSubmit = document.getElementById("model-submit");
const modelCancelEdit = document.getElementById("model-cancel-edit");
const modelThumbnailFile = document.getElementById("model-thumbnail-file");
const modelThumbnailPreview = document.getElementById("model-thumbnail-preview");
const modelThumbnailClear = document.getElementById("model-thumbnail-clear");
const vendorTable = document.getElementById("vendor-table");
const modelTable = document.getElementById("model-table");
const modelVendor = document.getElementById("model-vendor");
const countryOptions = document.getElementById("country-options");
let vendors = [];
let models = [];
let modelThumbnailDataUrl = "";
let deleteConfirmResolver = null;
const countryCodes = [
"AF","AX","AL","DZ","AS","AD","AO","AI","AQ","AG","AR","AM","AW","AU","AT","AZ",
"BS","BH","BD","BB","BY","BE","BZ","BJ","BM","BT","BO","BQ","BA","BW","BV","BR",
"IO","BN","BG","BF","BI","CV","KH","CM","CA","KY","CF","TD","CL","CN","CX","CC",
"CO","KM","CG","CD","CK","CR","CI","HR","CU","CW","CY","CZ","DK","DJ","DM","DO",
"EC","EG","SV","GQ","ER","EE","SZ","ET","FK","FO","FJ","FI","FR","GF","PF","TF",
"GA","GM","GE","DE","GH","GI","GR","GL","GD","GP","GU","GT","GG","GN","GW","GY",
"HT","HM","VA","HN","HK","HU","IS","IN","ID","IR","IQ","IE","IM","IL","IT","JM",
"JP","JE","JO","KZ","KE","KI","KP","KR","KW","KG","LA","LV","LB","LS","LR","LY",
"LI","LT","LU","MO","MG","MW","MY","MV","ML","MT","MH","MQ","MR","MU","YT","MX",
"FM","MD","MC","MN","ME","MS","MA","MZ","MM","NA","NR","NP","NL","NC","NZ","NI",
"NE","NG","NU","NF","MK","MP","NO","OM","PK","PW","PS","PA","PG","PY","PE","PH",
"PN","PL","PT","PR","QA","RE","RO","RU","RW","BL","SH","KN","LC","MF","PM","VC",
"WS","SM","ST","SA","SN","RS","SC","SL","SG","SX","SK","SI","SB","SO","ZA","GS",
"SS","ES","LK","SD","SR","SJ","SE","CH","SY","TW","TJ","TZ","TH","TL","TG","TK",
"TO","TT","TN","TR","TM","TC","TV","UG","UA","AE","GB","US","UM","UY","UZ","VU",
"VE","VN","VG","VI","WF","EH","YE","ZM","ZW"
];
const hydrateCountryOptions = () => {
if (!countryOptions) {
return;
}
const displayNames = typeof Intl !== "undefined" && Intl.DisplayNames
? new Intl.DisplayNames(["en"], { type: "region" })
: null;
const countries = countryCodes
.map((code) => displayNames ? displayNames.of(code) : code)
.filter(Boolean)
.sort((a, b) => a.localeCompare(b));
const withAliases = Array.from(new Set([...countries, "Palestine", "Palestina"]));
countryOptions.innerHTML = withAliases
.sort((a, b) => a.localeCompare(b))
.map((country) => `<option value="${country}"></option>`)
.join("");
};
const showAlert = (message, type) => {
alertBox.textContent = message;
alertBox.className = `mb-4 rounded-lg border px-4 py-3 text-sm font-semibold ${type === "error" ? "border-red-200 bg-red-50 text-red-700" : "border-emerald-200 bg-emerald-50 text-emerald-700"}`;
alertBox.classList.remove("hidden");
};
const closeDeleteConfirm = (confirmed) => {
deleteConfirmModal.classList.add("hidden");
deleteConfirmModal.classList.remove("flex");
if (deleteConfirmResolver) {
deleteConfirmResolver(Boolean(confirmed));
deleteConfirmResolver = null;
}
};
const confirmDelete = ({ title, message, buttonLabel }) => {
deleteConfirmTitle.textContent = title;
deleteConfirmMessage.textContent = message;
deleteConfirmSubmit.textContent = buttonLabel || "Delete";
deleteConfirmModal.classList.remove("hidden");
deleteConfirmModal.classList.add("flex");
deleteConfirmCancel.focus();
return new Promise((resolve) => {
deleteConfirmResolver = resolve;
});
};
const statusPill = (status) => {
const active = status === "active";
return `<span class="inline-flex rounded-full border px-2 py-0.5 text-xs font-bold ${active ? "border-emerald-200 bg-emerald-50 text-emerald-700" : "border-slate-200 bg-slate-100 text-slate-500"}">${active ? "Active" : "Inactive"}</span>`;
};
const resetVendorForm = () => {
vendorForm.reset();
document.getElementById("vendor-id").value = "";
vendorFormTitle.textContent = "Vendor";
vendorSubmit.textContent = "Create Vendor";
vendorCancelEdit.classList.add("hidden");
document.getElementById("vendor-code").disabled = false;
};
const renderModelThumbnailPreview = (thumbnailUrl) => {
if (thumbnailUrl) {
modelThumbnailPreview.innerHTML = `<img class="h-full w-full object-cover" alt="Model thumbnail" src="${thumbnailUrl}" />`;
modelThumbnailClear.classList.remove("hidden");
return;
}
modelThumbnailPreview.innerHTML = '<span class="material-symbols-outlined text-[28px]">image</span>';
modelThumbnailClear.classList.add("hidden");
};
const resetModelForm = () => {
modelForm.reset();
document.getElementById("model-id").value = "";
document.getElementById("model-code").value = "Generated after create";
modelFormTitle.textContent = "Model";
modelSubmit.textContent = "Create Model";
modelCancelEdit.classList.add("hidden");
modelThumbnailDataUrl = "";
modelThumbnailFile.value = "";
renderModelThumbnailPreview("");
};
const fillModelForm = (model) => {
document.getElementById("model-id").value = model.id || "";
modelVendor.value = model.vendor_id || "";
document.getElementById("model-code").value = model.model_code || "Generated after create";
document.getElementById("model-name").value = model.name || "";
document.getElementById("model-communication").value = model.communication_mode || "mqtt";
document.getElementById("model-qr-mode").value = model.qr_mode || "static";
document.getElementById("model-screen").checked = Boolean(model.screen_flag);
document.getElementById("model-payload-profile").value = model.mqtt_payload_profile || "";
modelThumbnailDataUrl = model.thumbnail_url || "";
modelThumbnailFile.value = "";
renderModelThumbnailPreview(modelThumbnailDataUrl);
modelFormTitle.textContent = `Edit Model - ${model.model_code}`;
modelSubmit.textContent = "Update Model";
modelCancelEdit.classList.remove("hidden");
modelForm.scrollIntoView({ behavior: "smooth", block: "start" });
};
const fillVendorForm = (vendor) => {
document.getElementById("vendor-id").value = vendor.id || "";
document.getElementById("vendor-code").value = vendor.vendor_code || "";
document.getElementById("vendor-name").value = vendor.name || "";
document.getElementById("vendor-country").value = vendor.country || "";
document.getElementById("vendor-support-pic").value = vendor.support_pic_name || "";
document.getElementById("vendor-support-phone").value = vendor.support_phone || "";
document.getElementById("vendor-support-email").value = vendor.support_email || "";
document.getElementById("vendor-support-notes").value = vendor.support_notes || vendor.support_contact || "";
vendorFormTitle.textContent = `Edit Vendor - ${vendor.vendor_code}`;
vendorSubmit.textContent = "Update Vendor";
vendorCancelEdit.classList.remove("hidden");
document.getElementById("vendor-code").disabled = true;
vendorForm.scrollIntoView({ behavior: "smooth", block: "start" });
};
const renderVendorOptions = () => {
modelVendor.innerHTML = "";
vendors.forEach((vendor) => {
const option = document.createElement("option");
option.value = vendor.id;
option.textContent = `${vendor.vendor_code} - ${vendor.name}`;
modelVendor.appendChild(option);
});
};
const renderVendors = () => {
document.getElementById("kpi-vendors").textContent = String(vendors.filter((item) => item.status === "active").length);
if (!vendors.length) {
vendorTable.innerHTML = '<tr><td class="px-5 py-6 text-center text-slate-500" colspan="5">No vendors yet.</td></tr>';
return;
}
vendorTable.innerHTML = vendors.map((vendor) => `
<tr>
<td class="px-5 py-4 mono font-bold text-blue-700">${vendor.vendor_code}</td>
<td class="px-5 py-4 font-semibold">${vendor.name}</td>
<td class="px-5 py-4 text-slate-600">
<div class="font-semibold text-slate-800">${vendor.support_pic_name || vendor.support_contact || "-"}</div>
<div class="text-xs">${vendor.support_phone || "-"}</div>
<div class="text-xs">${vendor.support_email || vendor.country || "-"}</div>
</td>
<td class="px-5 py-4">${statusPill(vendor.status)}</td>
<td class="px-5 py-4 text-right">
<div class="flex justify-end gap-2">
<button class="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-bold text-blue-700 hover:bg-blue-50" data-action="edit-vendor" data-id="${vendor.id}">Edit</button>
<button class="rounded-lg border border-red-200 px-3 py-1.5 text-xs font-bold text-red-700 hover:bg-red-50" data-action="delete-vendor" data-id="${vendor.id}">Delete</button>
</div>
</td>
</tr>
`).join("");
vendorTable.querySelectorAll("[data-action='edit-vendor']").forEach((button) => {
button.addEventListener("click", (event) => {
const vendor = vendors.find((item) => item.id === event.currentTarget.getAttribute("data-id"));
if (vendor) {
fillVendorForm(vendor);
}
});
});
vendorTable.querySelectorAll("[data-action='delete-vendor']").forEach((button) => {
button.addEventListener("click", async (event) => {
const vendor = vendors.find((item) => item.id === event.currentTarget.getAttribute("data-id"));
if (!vendor) {
return;
}
const confirmed = await confirmDelete({
title: "Delete vendor",
message: `Delete ${vendor.vendor_code} - ${vendor.name}? Vendor can only be deleted when it has no models.`,
buttonLabel: "Delete Vendor"
});
if (!confirmed) {
return;
}
try {
await api.deleteSoundboxVendor(vendor.id);
if (document.getElementById("vendor-id").value === vendor.id) {
resetVendorForm();
}
showAlert("Vendor deleted.", "success");
await refresh();
} catch (error) {
showAlert(error.message || "Unable to delete vendor.", "error");
}
});
});
};
const renderModels = () => {
document.getElementById("kpi-models").textContent = String(models.filter((item) => item.status === "active").length);
document.getElementById("kpi-dynamic").textContent = String(models.filter((item) => item.qr_mode !== "static").length);
if (!models.length) {
modelTable.innerHTML = '<tr><td class="px-5 py-6 text-center text-slate-500" colspan="6">No models yet.</td></tr>';
return;
}
modelTable.innerHTML = models.map((model) => `
<tr>
<td class="px-5 py-4 mono text-slate-600">${model.vendor_code || "-"}</td>
<td class="px-5 py-4">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-slate-200 bg-slate-50 text-slate-400">
${model.thumbnail_url ? `<img class="h-full w-full object-cover" alt="${model.name}" src="${model.thumbnail_url}" />` : '<span class="material-symbols-outlined text-[22px]">image</span>'}
</div>
<div>
<div class="font-bold">${model.model_code}</div>
<div class="text-xs text-slate-500">${model.name}</div>
</div>
</div>
</td>
<td class="px-5 py-4">
<div class="font-semibold uppercase">${model.communication_mode}</div>
<div class="mono text-xs text-slate-500">${model.mqtt_payload_profile || "-"}</div>
</td>
<td class="px-5 py-4">
<span class="inline-flex rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-xs font-bold text-slate-700">${model.screen_flag ? "Screen" : "Sound only"}</span>
<span class="ml-1 inline-flex rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-xs font-bold text-slate-700">${model.qr_mode}</span>
</td>
<td class="px-5 py-4">${statusPill(model.status)}</td>
<td class="px-5 py-4 text-right">
<div class="flex justify-end gap-2">
<button class="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-bold text-blue-700 hover:bg-blue-50" data-action="edit-model" data-id="${model.id}">Edit</button>
<button class="rounded-lg border border-red-200 px-3 py-1.5 text-xs font-bold text-red-700 hover:bg-red-50" data-action="delete-model" data-id="${model.id}">Delete</button>
</div>
</td>
</tr>
`).join("");
modelTable.querySelectorAll("[data-action='edit-model']").forEach((button) => {
button.addEventListener("click", (event) => {
const model = models.find((item) => item.id === event.currentTarget.getAttribute("data-id"));
if (model) {
fillModelForm(model);
}
});
});
modelTable.querySelectorAll("[data-action='delete-model']").forEach((button) => {
button.addEventListener("click", async (event) => {
const model = models.find((item) => item.id === event.currentTarget.getAttribute("data-id"));
if (!model) {
return;
}
const confirmed = await confirmDelete({
title: "Delete model",
message: `Delete ${model.model_code} - ${model.name}?`,
buttonLabel: "Delete Model"
});
if (!confirmed) {
return;
}
try {
await api.deleteSoundboxModel(model.id);
if (document.getElementById("model-id").value === model.id) {
resetModelForm();
}
showAlert("Model deleted.", "success");
await refresh();
} catch (error) {
showAlert(error.message || "Unable to delete model.", "error");
}
});
});
};
const refresh = async () => {
try {
api.requireToken();
const [vendorRows, modelRows] = await Promise.all([
api.listSoundboxVendors(),
api.listSoundboxModels()
]);
vendors = Array.isArray(vendorRows) ? vendorRows : [];
models = Array.isArray(modelRows) ? modelRows : [];
renderVendorOptions();
renderVendors();
renderModels();
} catch (error) {
showAlert(error.message || "Unable to load catalog.", "error");
}
};
vendorForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
const vendorId = document.getElementById("vendor-id").value;
const payload = {
name: document.getElementById("vendor-name").value.trim(),
country: document.getElementById("vendor-country").value.trim() || undefined,
support_pic_name: document.getElementById("vendor-support-pic").value.trim() || undefined,
support_email: document.getElementById("vendor-support-email").value.trim() || undefined,
support_phone: document.getElementById("vendor-support-phone").value.trim() || undefined,
support_notes: document.getElementById("vendor-support-notes").value.trim() || undefined,
status: "active"
};
if (vendorId) {
await api.patchSoundboxVendor(vendorId, payload);
showAlert("Vendor updated.", "success");
} else {
await api.createSoundboxVendor({
...payload,
vendor_code: document.getElementById("vendor-code").value.trim()
});
showAlert("Vendor created.", "success");
}
resetVendorForm();
await refresh();
} catch (error) {
showAlert(error.message || "Unable to save vendor.", "error");
}
});
vendorCancelEdit.addEventListener("click", resetVendorForm);
modelThumbnailFile.addEventListener("change", () => {
const file = modelThumbnailFile.files && modelThumbnailFile.files[0];
if (!file) {
return;
}
if (!file.type.startsWith("image/")) {
showAlert("Thumbnail must be an image file.", "error");
modelThumbnailFile.value = "";
return;
}
if (file.size > 512 * 1024) {
showAlert("Thumbnail maximum size is 512 KB.", "error");
modelThumbnailFile.value = "";
return;
}
const reader = new FileReader();
reader.onload = () => {
modelThumbnailDataUrl = String(reader.result || "");
renderModelThumbnailPreview(modelThumbnailDataUrl);
};
reader.onerror = () => showAlert("Unable to read thumbnail file.", "error");
reader.readAsDataURL(file);
});
modelThumbnailClear.addEventListener("click", () => {
modelThumbnailDataUrl = "";
modelThumbnailFile.value = "";
renderModelThumbnailPreview("");
});
modelForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
const modelId = document.getElementById("model-id").value;
const screen = document.getElementById("model-screen").checked;
const qrMode = document.getElementById("model-qr-mode").value;
const payloadProfile = document.getElementById("model-payload-profile").value.trim();
const payload = {
vendor_id: modelVendor.value,
name: document.getElementById("model-name").value.trim(),
communication_mode: document.getElementById("model-communication").value,
screen_flag: screen,
qr_mode: qrMode,
mqtt_payload_profile: payloadProfile || undefined,
thumbnail_url: modelThumbnailDataUrl || undefined,
capability_template_json: {
device_type: screen ? "dynamic_screen_soundbox" : "static_soundbox",
screen,
qr_mode: qrMode,
...(payloadProfile ? { mqtt_payload_profile: payloadProfile } : {}),
flows: screen ? ["static_payment_notification", "dynamic_qr:mqtt"] : ["static_payment_notification"],
features: {
payment_sound: true,
dynamic_qr: screen ? { mqtt: qrMode === "dynamic_mqtt", display: "screen" } : false
}
},
status: "active"
};
if (modelId) {
await api.patchSoundboxModel(modelId, payload);
showAlert("Model updated.", "success");
} else {
await api.createSoundboxModel(payload);
showAlert("Model created.", "success");
}
resetModelForm();
await refresh();
} catch (error) {
showAlert(error.message || "Unable to save model.", "error");
}
});
modelCancelEdit.addEventListener("click", resetModelForm);
deleteConfirmCancel.addEventListener("click", () => closeDeleteConfirm(false));
deleteConfirmSubmit.addEventListener("click", () => closeDeleteConfirm(true));
deleteConfirmModal.addEventListener("click", (event) => {
if (event.target === deleteConfirmModal) {
closeDeleteConfirm(false);
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !deleteConfirmModal.classList.contains("hidden")) {
closeDeleteConfirm(false);
}
});
document.getElementById("refresh-button").addEventListener("click", refresh);
hydrateCountryOptions();
refresh();
})();
</script>
</body>
</html>

View File

@ -15,34 +15,38 @@
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 999px; }
</style>
</head>
<body class="min-h-screen bg-slate-50 text-slate-950">
<aside class="fixed inset-y-0 left-0 z-40 hidden w-64 border-r border-slate-200 bg-white px-4 py-6 lg:flex lg:flex-col">
<body id="top" class="min-h-screen bg-slate-50 text-slate-950">
<aside class="fixed inset-y-0 left-0 z-50 hidden w-64 border-r border-slate-200 bg-white px-4 py-6 lg:flex lg:flex-col">
<div class="px-2">
<h1 class="text-xl font-extrabold text-blue-700">Soundbox Ops</h1>
<p class="mt-1 text-xs font-semibold uppercase text-slate-500">Monitoring Console</p>
<h1 class="text-[22px] font-extrabold leading-tight text-blue-700">Soundbox Ops</h1>
<p class="mt-1 text-[12px] font-bold uppercase leading-none text-slate-500">Monitoring Console</p>
</div>
<nav class="mt-8 flex flex-1 flex-col gap-1">
<a class="flex items-center gap-3 rounded-lg bg-blue-50 px-3 py-2 font-bold text-blue-700" href="/ui/soundbox-ops">
<span class="material-symbols-outlined">monitor_heart</span>
Soundbox Monitoring
<a class="flex h-11 items-center gap-3 rounded-lg bg-blue-50 px-3 text-[15px] font-semibold leading-none text-blue-700 transition-colors" href="/ui/soundbox-ops">
<span class="material-symbols-outlined shrink-0 text-[22px]">monitor_heart</span>
<span class="truncate">Monitoring</span>
</a>
<a class="flex items-center gap-3 rounded-lg px-3 py-2 text-slate-600 hover:bg-slate-100" href="/ui/device-registry-monitoring">
<span class="material-symbols-outlined">speaker_group</span>
Device Registry
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 transition-colors hover:bg-slate-100 hover:text-blue-700" href="/ui/device-registry-monitoring">
<span class="material-symbols-outlined shrink-0 text-[22px]">speaker_group</span>
<span class="truncate">Registry</span>
</a>
<a class="flex items-center gap-3 rounded-lg px-3 py-2 text-slate-600 hover:bg-slate-100" href="/ui/transaction-history-monitoring">
<span class="material-symbols-outlined">receipt_long</span>
Transactions
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 transition-colors hover:bg-slate-100 hover:text-blue-700" href="#mqtt-trace">
<span class="material-symbols-outlined shrink-0 text-[22px]">lan</span>
<span class="truncate">MQTT Trace</span>
</a>
<a class="flex items-center gap-3 rounded-lg px-3 py-2 text-slate-600 hover:bg-slate-100" href="/ui/admin-dashboard-overview">
<span class="material-symbols-outlined">dashboard</span>
Admin Overview
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 transition-colors hover:bg-slate-100 hover:text-blue-700" href="#config-commands">
<span class="material-symbols-outlined shrink-0 text-[22px]">settings_remote</span>
<span class="truncate">Config & Commands</span>
</a>
<a class="flex h-11 items-center gap-3 rounded-lg px-3 text-[15px] font-semibold leading-none text-slate-600 transition-colors hover:bg-slate-100 hover:text-blue-700" href="/ui/soundbox-catalog">
<span class="material-symbols-outlined shrink-0 text-[22px]">category</span>
<span class="truncate">Catalog</span>
</a>
</nav>
<div class="border-t border-slate-200 pt-4">
<button id="logout-button" class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-slate-600 hover:bg-slate-100">
<span class="material-symbols-outlined">logout</span>
Logout
<button id="logout-button" class="flex h-11 w-full items-center gap-3 rounded-lg px-3 text-left text-[15px] font-semibold leading-none text-slate-600 transition-colors hover:bg-slate-100 hover:text-blue-700">
<span class="material-symbols-outlined shrink-0 text-[22px]">logout</span>
<span class="truncate">Logout</span>
</button>
</div>
</aside>
@ -124,7 +128,7 @@
</div>
<div class="mt-6 grid gap-6 xl:grid-cols-[minmax(0,1.5fr)_minmax(360px,0.8fr)]">
<section class="rounded-lg border border-slate-200 bg-white">
<section id="fleet-status" class="rounded-lg border border-slate-200 bg-white">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 px-5 py-4">
<div>
<h3 class="text-lg font-extrabold">Fleet Status</h3>
@ -155,10 +159,10 @@
</section>
<aside class="space-y-6">
<section class="rounded-lg border border-slate-200 bg-white">
<section id="config-commands" class="rounded-lg border border-slate-200 bg-white transition-shadow">
<div class="border-b border-slate-200 px-5 py-4">
<h3 class="text-lg font-extrabold">Operations Health</h3>
<p id="ops-generated" class="mt-1 text-sm text-slate-500">Waiting for summary</p>
<h3 class="text-lg font-extrabold">Config & Commands</h3>
<p id="ops-generated" class="mt-1 text-sm text-slate-500">Broker, config worker, and notification state</p>
</div>
<div class="space-y-3 p-5">
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-4 py-3">
@ -180,7 +184,7 @@
</div>
</section>
<section class="rounded-lg border border-slate-200 bg-white">
<section id="mqtt-trace" class="rounded-lg border border-slate-200 bg-white transition-shadow">
<div class="border-b border-slate-200 px-5 py-4">
<h3 class="text-lg font-extrabold">Recent MQTT Trace</h3>
<p class="mt-1 text-sm text-slate-500">latest uplink and downlink records</p>
@ -199,6 +203,9 @@
(function () {
const api = window.AdminUIAPI;
const state = { devices: [], merchants: new Map(), mqtt: null, observability: null };
const isPreviewMode =
new URLSearchParams(window.location.search).get("preview") === "1" ||
((window.location.hostname === "127.0.0.1" || window.location.hostname === "localhost") && window.location.port === "4173");
const $ = (id) => document.getElementById(id);
const normalize = (value) => String(value || "").toLowerCase().trim();
@ -369,9 +376,124 @@
renderMqtt();
}
function focusSection(id) {
const target = document.getElementById(id);
if (!target) {
return;
}
target.scrollIntoView({ behavior: "smooth", block: "start" });
target.classList.add("ring-2", "ring-blue-500", "ring-offset-2");
window.setTimeout(() => {
target.classList.remove("ring-2", "ring-blue-500", "ring-offset-2");
}, 1400);
}
function loadPreviewData() {
const now = Date.now();
state.merchants = new Map([
["merchant_mbiz", "MBiz Jakarta"],
["merchant_demo", "Demo Mart"],
["merchant_qf100", "QF100 Pilot Store"]
]);
state.devices = [
{
id: "dev_qf100_static_01",
device_code: "QF100-STATIC-01",
serial_number: "SN-QF100-0001",
vendor: "QF100",
model: "QF100 Static",
communication_mode: "mqtt",
derived_status: "online",
latest_heartbeat: new Date(now - 90 * 1000).toISOString(),
health_summary: { score: 98 },
binding_summary: { merchant_id: "merchant_qf100" }
},
{
id: "dev_qf100_dynamic_01",
device_code: "QF100-DYN-01",
serial_number: "SN-QF100-0002",
vendor: "QF100",
model: "QF100 Dynamic",
communication_mode: "mqtt",
derived_status: "stale",
latest_heartbeat: new Date(now - 42 * 60 * 1000).toISOString(),
health_summary: { score: 64 },
binding_summary: { merchant_id: "merchant_mbiz" }
},
{
id: "dev_counter_03",
device_code: "SND-COUNTER-03",
serial_number: "SN-DEMO-0003",
vendor: "Generic",
model: "Soundbox V2",
communication_mode: "api",
derived_status: "degraded",
latest_heartbeat: new Date(now - 12 * 60 * 1000).toISOString(),
health_summary: { score: 72 },
binding_summary: { merchant_id: "merchant_demo" }
},
{
id: "dev_stock_04",
device_code: "SND-STOCK-04",
serial_number: "SN-DEMO-0004",
vendor: "Generic",
model: "Unassigned Stock",
communication_mode: "static",
derived_status: "offline",
latest_heartbeat: null,
health_summary: { score: 0 },
binding_summary: null
}
];
state.mqtt = {
publisher: {
mode: "broker",
connected: true,
broker_url: "mqtts://broker.bizone.id:8883"
},
subscriber: {
connected: true
},
last_messages: [
{
direction: "downlink",
topic: "devices/dev_qf100_static_01/downlink/qf100",
message_type: "payment_success",
publish_status: "sent",
created_at: new Date(now - 75 * 1000).toISOString()
},
{
direction: "uplink",
topic: "devices/dev_qf100_dynamic_01/uplink/dynamic-qr/request",
message_type: "dynamic_qr_request",
publish_status: "recorded",
created_at: new Date(now - 7 * 60 * 1000).toISOString()
},
{
direction: "downlink",
topic: "devices/dev_counter_03/downlink/config/push",
message_type: "config_push",
publish_status: "sent",
created_at: new Date(now - 18 * 60 * 1000).toISOString()
}
]
};
state.observability = {
generated_at: new Date().toISOString(),
database: { status: "ok" },
notifications: { pending_count: 1, failed_count: 0 },
export_jobs: { worker: { enabled: true } }
};
renderAll();
}
async function refresh() {
const error = $("error-banner");
error.classList.add("hidden");
if (isPreviewMode && !api.getToken()) {
loadPreviewData();
return;
}
try {
api.requireToken();
const [devices, merchants, mqtt, observability] = await Promise.all([
@ -402,8 +524,22 @@
api.clearToken();
window.location.href = "/ui/admin-login";
});
document.querySelectorAll("a[href^='#']").forEach((link) => {
link.addEventListener("click", (event) => {
const id = link.getAttribute("href").slice(1);
if (!id) {
return;
}
event.preventDefault();
history.replaceState(null, "", `#${id}`);
focusSection(id);
});
});
refresh();
if (window.location.hash) {
window.setTimeout(() => focusSection(window.location.hash.slice(1)), 250);
}
window.setInterval(refresh, 30000);
})();
</script>