@tomorrowos/sdk 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tomorrowos.d.ts +12 -0
- package/dist/tomorrowos.d.ts.map +1 -1
- package/dist/tomorrowos.js +48 -1
- package/package.json +1 -1
- package/templates/cms-starter/package.json +2 -2
- package/templates/cms-starter/public/index.html +8 -0
- package/templates/cms-starter/public/methods.js +150 -18
- package/templates/cms-starter/public/panel.css +36 -0
package/dist/tomorrowos.d.ts
CHANGED
|
@@ -52,6 +52,15 @@ export interface DeviceListItem {
|
|
|
52
52
|
version: number;
|
|
53
53
|
publishedAt: string;
|
|
54
54
|
}>;
|
|
55
|
+
latestErrorAt: string | null;
|
|
56
|
+
latestErrorMessage: string | null;
|
|
57
|
+
}
|
|
58
|
+
export interface DeviceLogEntry {
|
|
59
|
+
timestamp: string;
|
|
60
|
+
level: "info" | "warn" | "error";
|
|
61
|
+
message: string;
|
|
62
|
+
source?: string;
|
|
63
|
+
details?: unknown;
|
|
55
64
|
}
|
|
56
65
|
export declare class TomorrowOS extends EventEmitter {
|
|
57
66
|
readonly brand: TomorrowOSBrand;
|
|
@@ -59,6 +68,7 @@ export declare class TomorrowOS extends EventEmitter {
|
|
|
59
68
|
readonly playlists: PlaylistCatalog;
|
|
60
69
|
private readonly devices;
|
|
61
70
|
private readonly pendingDeviceMeta;
|
|
71
|
+
private readonly deviceLogs;
|
|
62
72
|
private httpServer;
|
|
63
73
|
private wss;
|
|
64
74
|
private staticRoot;
|
|
@@ -90,6 +100,8 @@ export declare class TomorrowOS extends EventEmitter {
|
|
|
90
100
|
};
|
|
91
101
|
/** List paired devices with live connection + timing fields for the CMS panel. */
|
|
92
102
|
listDevices(): Promise<DeviceListItem[]>;
|
|
103
|
+
private pushDeviceLog;
|
|
104
|
+
private getDeviceLogs;
|
|
93
105
|
private isDeviceConnected;
|
|
94
106
|
private captureHelloMeta;
|
|
95
107
|
private getOrCreatePermanentPairingCode;
|
package/dist/tomorrowos.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,IAAI,MAAM,MAAM,CAAC;AAUxB,OAAO,EAAE,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAChF,OAAO,KAAK,EAIV,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAQ1B,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,eAAe,CAAC;IACvB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,eAAe,CAAC;CACzB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAwBD,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,2EAA2E;IAC3E,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,SAAS,EAAE,OAAO,CAAC;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,kBAAkB,EAAE,OAAO,CAAC;IAC5B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,mEAAmE;IACnE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,kBAAkB,EAAE,KAAK,CAAC;QACxB,UAAU,EAAE,MAAM,CAAC;QACnB,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,IAAI,MAAM,MAAM,CAAC;AAUxB,OAAO,EAAE,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAChF,OAAO,KAAK,EAIV,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAQ1B,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,eAAe,CAAC;IACvB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,eAAe,CAAC;CACzB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAwBD,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,2EAA2E;IAC3E,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,SAAS,EAAE,OAAO,CAAC;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,kBAAkB,EAAE,OAAO,CAAC;IAC5B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,mEAAmE;IACnE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,kBAAkB,EAAE,KAAK,CAAC;QACxB,UAAU,EAAE,MAAM,CAAC;QACnB,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC,CAAC;IACH,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC;AAED,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAyID,qBAAa,UAAW,SAAQ,YAAY;IAC1C,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;IAChC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAkB;IACxC,QAAQ,CAAC,SAAS,EAAE,eAAe,CAAC;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmC;IAC3D,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAsC;IACxE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAuC;IAClE,OAAO,CAAC,UAAU,CAA4B;IAC9C,OAAO,CAAC,GAAG,CAAgC;IAC3C,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,eAAe,CAAgB;gBAE3B,OAAO,EAAE,iBAAiB;IAOtC,yFAAyF;IACnF,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;QACxD,MAAM,EAAE,OAAO,CAAC;QAChB,MAAM,CAAC,EAAE,iBAAiB,CAAC,QAAQ,CAAC,CAAC;KACtC,CAAC;YAkBY,iBAAiB;IAY/B,6EAA6E;IACvE,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAgEhE,6EAA6E;IACvE,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAA;KAAE,CAAC;IA4BvF,OAAO;uBACU,MAAM;sBA9FgC,MAAM;;2BA+FxC,MAAM;sBA9BgC,MAAM;sBAAY,OAAO;;MA+BlF;IAEF,kFAAkF;IAC5E,WAAW,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IAsD9C,OAAO,CAAC,aAAa;IASrB,OAAO,CAAC,aAAa;IAMrB,OAAO,CAAC,iBAAiB;IAKzB,OAAO,CAAC,gBAAgB;YAeV,+BAA+B;YAiC/B,iBAAiB;YASjB,iBAAiB;YA4BjB,kBAAkB;IAUhC,uFAAuF;IACvF,OAAO,CAAC,kBAAkB;YAmBZ,gBAAgB;YAMhB,uBAAuB;IAoCrC,MAAM,CAAC,QAAQ,EAAE,MAAM;oBAGD,CAAC,oBACT,MAAM,WACN,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,OAAO,CAAC;YAAE,MAAM,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;;IAU5E,OAAO,CAAC,mBAAmB;IA6D3B,MAAM,CAAC,OAAO,EAAE,aAAa,GAAG,IAAI,CAAC,MAAM;YAmD7B,iBAAiB;YAyCjB,wBAAwB;YAiBxB,cAAc;YAyCd,UAAU;YAgOV,gBAAgB;IAqG9B,OAAO,CAAC,iBAAiB;IAWzB,OAAO,CAAC,gBAAgB;CA4HzB"}
|
package/dist/tomorrowos.js
CHANGED
|
@@ -132,6 +132,7 @@ export class TomorrowOS extends EventEmitter {
|
|
|
132
132
|
playlists;
|
|
133
133
|
devices = new Map();
|
|
134
134
|
pendingDeviceMeta = new Map();
|
|
135
|
+
deviceLogs = new Map();
|
|
135
136
|
httpServer = null;
|
|
136
137
|
wss = null;
|
|
137
138
|
staticRoot = null;
|
|
@@ -267,6 +268,8 @@ export class TomorrowOS extends EventEmitter {
|
|
|
267
268
|
}
|
|
268
269
|
}
|
|
269
270
|
const screenOnlineSince = connected && record.lastBootAt ? record.lastBootAt : null;
|
|
271
|
+
const logs = this.deviceLogs.get(deviceId) ?? [];
|
|
272
|
+
const latestError = [...logs].reverse().find((entry) => entry.level === "error");
|
|
270
273
|
return {
|
|
271
274
|
deviceId,
|
|
272
275
|
pairingCode: reg?.permanentPairingCode ?? null,
|
|
@@ -288,10 +291,28 @@ export class TomorrowOS extends EventEmitter {
|
|
|
288
291
|
lastPolicyPushAt: record.lastPolicyPushAt ?? null,
|
|
289
292
|
screenOnlineActive,
|
|
290
293
|
screenOnlineLabel,
|
|
291
|
-
screenOnlineSince
|
|
294
|
+
screenOnlineSince,
|
|
295
|
+
latestErrorAt: latestError?.timestamp ?? null,
|
|
296
|
+
latestErrorMessage: latestError?.message ?? null
|
|
292
297
|
};
|
|
293
298
|
}));
|
|
294
299
|
}
|
|
300
|
+
pushDeviceLog(deviceId, entry) {
|
|
301
|
+
const key = String(deviceId || "").trim();
|
|
302
|
+
if (!key)
|
|
303
|
+
return;
|
|
304
|
+
const existing = this.deviceLogs.get(key) ?? [];
|
|
305
|
+
existing.push(entry);
|
|
306
|
+
if (existing.length > 80)
|
|
307
|
+
existing.splice(0, existing.length - 80);
|
|
308
|
+
this.deviceLogs.set(key, existing);
|
|
309
|
+
}
|
|
310
|
+
getDeviceLogs(deviceId) {
|
|
311
|
+
const key = String(deviceId || "").trim();
|
|
312
|
+
if (!key)
|
|
313
|
+
return [];
|
|
314
|
+
return [...(this.deviceLogs.get(key) ?? [])].reverse();
|
|
315
|
+
}
|
|
295
316
|
isDeviceConnected(deviceId) {
|
|
296
317
|
const ws = this.devices.get(deviceId);
|
|
297
318
|
return !!ws && ws.readyState === WebSocket.OPEN;
|
|
@@ -710,6 +731,12 @@ export class TomorrowOS extends EventEmitter {
|
|
|
710
731
|
sendJson(res, 200, { status: "success", assignments });
|
|
711
732
|
return;
|
|
712
733
|
}
|
|
734
|
+
const deviceLogsGet = /^\/device\/([^/]+)\/logs$/.exec(pathname);
|
|
735
|
+
if (req.method === "GET" && deviceLogsGet) {
|
|
736
|
+
const deviceId = decodeURIComponent(deviceLogsGet[1]);
|
|
737
|
+
sendJson(res, 200, { status: "success", logs: this.getDeviceLogs(deviceId) });
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
713
740
|
if (req.method === "DELETE" && deviceAssignmentsGet) {
|
|
714
741
|
const deviceId = decodeURIComponent(deviceAssignmentsGet[1]);
|
|
715
742
|
try {
|
|
@@ -971,6 +998,26 @@ export class TomorrowOS extends EventEmitter {
|
|
|
971
998
|
console.error("[TomorrowOS] pushLatestPolicy on resume failed:", err);
|
|
972
999
|
});
|
|
973
1000
|
})();
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
if (type === "device.log") {
|
|
1004
|
+
const deviceId = ws.deviceId;
|
|
1005
|
+
if (!deviceId)
|
|
1006
|
+
return;
|
|
1007
|
+
const rawLevel = String(msg.level ?? "info").toLowerCase();
|
|
1008
|
+
const level = rawLevel === "error" || rawLevel === "warn" ? rawLevel : "info";
|
|
1009
|
+
const message = String(msg.message ?? "").trim();
|
|
1010
|
+
if (!message)
|
|
1011
|
+
return;
|
|
1012
|
+
const source = typeof msg.source === "string" ? msg.source : undefined;
|
|
1013
|
+
const details = msg.details;
|
|
1014
|
+
const timestamp = typeof msg.timestamp === "string" && msg.timestamp.trim()
|
|
1015
|
+
? msg.timestamp
|
|
1016
|
+
: new Date().toISOString();
|
|
1017
|
+
this.pushDeviceLog(deviceId, { timestamp, level, message, source, details });
|
|
1018
|
+
if (level === "error") {
|
|
1019
|
+
console.error(`[TomorrowOS][device-log][${deviceId}] ${message}`);
|
|
1020
|
+
}
|
|
974
1021
|
}
|
|
975
1022
|
});
|
|
976
1023
|
ws.on("close", () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomorrowos/sdk",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "TomorrowOS CMS server SDK — WebSocket transport, pairing, device commands, optional static CMS UI. Includes CLI (tomorrowos init / build) and starter templates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-cms",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "CMS server on @tomorrowos/sdk. Add your UI (React, static files, etc.) alongside this server.",
|
|
5
5
|
"private": true,
|
|
6
6
|
"type": "module",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"build-player": "tomorrowos build --platform tizen"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@tomorrowos/sdk": "^0.4.
|
|
13
|
+
"@tomorrowos/sdk": "^0.4.3"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
16
|
"@types/node": "^20.0.0",
|
|
@@ -116,7 +116,15 @@
|
|
|
116
116
|
id="fileInput"
|
|
117
117
|
class="hidden"
|
|
118
118
|
accept="image/*,video/*,.wgt,.zip"
|
|
119
|
+
multiple
|
|
119
120
|
/>
|
|
121
|
+
<div id="uploadStatusShell" class="upload-status hidden" aria-live="polite">
|
|
122
|
+
<div id="uploadStatusText" class="upload-status-text">Uploading...</div>
|
|
123
|
+
<div class="upload-progress-track">
|
|
124
|
+
<div id="uploadProgressBar" class="upload-progress-bar"></div>
|
|
125
|
+
</div>
|
|
126
|
+
<div id="uploadQueueText" class="upload-queue-text"></div>
|
|
127
|
+
</div>
|
|
120
128
|
<ul id="playlistList" class="playlist-list">
|
|
121
129
|
<li class="playlist-empty" id="playlistEmpty">Select or create a playlist, then add assets.</li>
|
|
122
130
|
</ul>
|
|
@@ -19,6 +19,11 @@ let editorItems = [];
|
|
|
19
19
|
let publishModalDeviceId = null;
|
|
20
20
|
|
|
21
21
|
let devicePollTimer = null;
|
|
22
|
+
let uploadQueue = [];
|
|
23
|
+
let uploadInProgress = false;
|
|
24
|
+
|
|
25
|
+
const UPLOAD_MAX_RETRIES = 3;
|
|
26
|
+
const UPLOAD_TIMEOUT_MS = 120000;
|
|
22
27
|
|
|
23
28
|
function escapeHtml(value) {
|
|
24
29
|
return String(value ?? "")
|
|
@@ -71,6 +76,34 @@ function showResult(data) {
|
|
|
71
76
|
document.getElementById("result").textContent = JSON.stringify(data, null, 2);
|
|
72
77
|
}
|
|
73
78
|
|
|
79
|
+
function setAssetUploadBusy(isBusy) {
|
|
80
|
+
const addBtn = document.getElementById("addAssetBtn");
|
|
81
|
+
if (addBtn) {
|
|
82
|
+
addBtn.disabled = isBusy;
|
|
83
|
+
addBtn.textContent = isBusy ? "Uploading..." : "+";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function updateUploadStatusUi(status) {
|
|
88
|
+
const shell = document.getElementById("uploadStatusShell");
|
|
89
|
+
const text = document.getElementById("uploadStatusText");
|
|
90
|
+
const bar = document.getElementById("uploadProgressBar");
|
|
91
|
+
const queue = document.getElementById("uploadQueueText");
|
|
92
|
+
if (!shell || !text || !bar || !queue) return;
|
|
93
|
+
|
|
94
|
+
if (!status || status.hidden) {
|
|
95
|
+
shell.classList.add("hidden");
|
|
96
|
+
bar.style.width = "0%";
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
shell.classList.remove("hidden");
|
|
101
|
+
text.textContent = status.text || "Uploading...";
|
|
102
|
+
queue.textContent = status.queueText || "";
|
|
103
|
+
const percent = Math.max(0, Math.min(100, Number(status.percent) || 0));
|
|
104
|
+
bar.style.width = `${percent}%`;
|
|
105
|
+
}
|
|
106
|
+
|
|
74
107
|
function isLocalPanelHost(hostname) {
|
|
75
108
|
const h = String(hostname || "").toLowerCase();
|
|
76
109
|
return h === "localhost" || h === "127.0.0.1" || h === "[::1]";
|
|
@@ -657,7 +690,9 @@ function renderDeviceCards() {
|
|
|
657
690
|
["System", device.system || device.platform || "—"],
|
|
658
691
|
["Device online", formatDeviceOnlineLabel(device)],
|
|
659
692
|
["Last boot", formatDateTimeSeconds(device.lastBootAt)],
|
|
660
|
-
["Latest push", formatDateTimeSeconds(device.lastPolicyPushAt)]
|
|
693
|
+
["Latest push", formatDateTimeSeconds(device.lastPolicyPushAt)],
|
|
694
|
+
["Latest error", device.latestErrorMessage || "—"],
|
|
695
|
+
["Error at", formatDateTimeSeconds(device.latestErrorAt)]
|
|
661
696
|
];
|
|
662
697
|
for (const [label, value] of rows) {
|
|
663
698
|
const row = document.createElement("div");
|
|
@@ -702,6 +737,11 @@ function renderDeviceCards() {
|
|
|
702
737
|
clearBtn.textContent = "Clear";
|
|
703
738
|
clearBtn.addEventListener("click", () => deviceAction(device.deviceId, "content/clear"));
|
|
704
739
|
|
|
740
|
+
const logsBtn = document.createElement("button");
|
|
741
|
+
logsBtn.type = "button";
|
|
742
|
+
logsBtn.textContent = "Logs";
|
|
743
|
+
logsBtn.addEventListener("click", () => viewDeviceLogs(device.deviceId));
|
|
744
|
+
|
|
705
745
|
const unpairBtn = document.createElement("button");
|
|
706
746
|
unpairBtn.type = "button";
|
|
707
747
|
unpairBtn.className = "danger";
|
|
@@ -713,6 +753,7 @@ function renderDeviceCards() {
|
|
|
713
753
|
actions.appendChild(capBtn);
|
|
714
754
|
actions.appendChild(rebootBtn);
|
|
715
755
|
actions.appendChild(clearBtn);
|
|
756
|
+
actions.appendChild(logsBtn);
|
|
716
757
|
actions.appendChild(unpairBtn);
|
|
717
758
|
|
|
718
759
|
card.appendChild(header);
|
|
@@ -838,26 +879,68 @@ async function removePlaylistFromDevice(deviceId, playlistId) {
|
|
|
838
879
|
await fetchDevices();
|
|
839
880
|
}
|
|
840
881
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
882
|
+
function uploadFileWithProgress(file, { onProgress, attempt }) {
|
|
883
|
+
return new Promise((resolve, reject) => {
|
|
884
|
+
const xhr = new XMLHttpRequest();
|
|
885
|
+
const q = new URLSearchParams({ filename: file.name });
|
|
886
|
+
xhr.open("POST", `/media/upload?${q.toString()}`, true);
|
|
887
|
+
xhr.timeout = UPLOAD_TIMEOUT_MS;
|
|
888
|
+
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
|
889
|
+
|
|
890
|
+
xhr.upload.onprogress = (event) => {
|
|
891
|
+
if (!event.lengthComputable) return;
|
|
892
|
+
const percent = Math.round((event.loaded / event.total) * 100);
|
|
893
|
+
if (typeof onProgress === "function") onProgress(percent);
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
xhr.onerror = () => reject(new Error(`Network error on attempt ${attempt}`));
|
|
897
|
+
xhr.ontimeout = () => reject(new Error(`Upload timed out after ${UPLOAD_TIMEOUT_MS / 1000}s`));
|
|
898
|
+
|
|
899
|
+
xhr.onload = () => {
|
|
900
|
+
let payload = {};
|
|
901
|
+
try {
|
|
902
|
+
payload = xhr.responseText ? JSON.parse(xhr.responseText) : {};
|
|
903
|
+
} catch {
|
|
904
|
+
payload = {};
|
|
905
|
+
}
|
|
906
|
+
if (xhr.status >= 200 && xhr.status < 300 && payload.status !== "failed") {
|
|
907
|
+
resolve(payload);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
reject(new Error(payload.error || `Upload failed (${xhr.status})`));
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
xhr.send(file);
|
|
847
914
|
});
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function uploadFile(file, onProgress) {
|
|
918
|
+
let lastErr = null;
|
|
919
|
+
for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt += 1) {
|
|
920
|
+
try {
|
|
921
|
+
const payload = await uploadFileWithProgress(file, { onProgress, attempt });
|
|
922
|
+
return payload;
|
|
923
|
+
} catch (err) {
|
|
924
|
+
lastErr = err;
|
|
925
|
+
if (attempt >= UPLOAD_MAX_RETRIES) break;
|
|
926
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
|
|
927
|
+
}
|
|
851
928
|
}
|
|
852
|
-
|
|
929
|
+
throw lastErr || new Error("Upload failed");
|
|
853
930
|
}
|
|
854
931
|
|
|
855
|
-
async function addAssetFromFile(file) {
|
|
932
|
+
async function addAssetFromFile(file, queueIndex, queueTotal) {
|
|
856
933
|
if (!isPlaylistEditorOpen()) {
|
|
857
934
|
newPlaylistDraft();
|
|
858
935
|
}
|
|
859
936
|
const type = inferMediaType(file.name, file.type);
|
|
860
|
-
const data = await uploadFile(file)
|
|
937
|
+
const data = await uploadFile(file, (percent) => {
|
|
938
|
+
updateUploadStatusUi({
|
|
939
|
+
text: `Uploading ${file.name} (${percent}%)`,
|
|
940
|
+
percent,
|
|
941
|
+
queueText: `File ${queueIndex}/${queueTotal}`
|
|
942
|
+
});
|
|
943
|
+
});
|
|
861
944
|
const durationMs = await resolveVideoDurationMs(file, type, data);
|
|
862
945
|
editorItems.push({
|
|
863
946
|
id: crypto.randomUUID(),
|
|
@@ -870,11 +953,51 @@ async function addAssetFromFile(file) {
|
|
|
870
953
|
showResult({
|
|
871
954
|
status: "uploaded",
|
|
872
955
|
...data,
|
|
956
|
+
fileName: file.name,
|
|
873
957
|
detectedDurationMs: durationMs,
|
|
874
958
|
detectedDurationSec: Math.round(durationMs / 1000)
|
|
875
959
|
});
|
|
876
960
|
}
|
|
877
961
|
|
|
962
|
+
async function processUploadQueue() {
|
|
963
|
+
if (uploadInProgress) return;
|
|
964
|
+
if (uploadQueue.length === 0) {
|
|
965
|
+
updateUploadStatusUi({ hidden: true });
|
|
966
|
+
setAssetUploadBusy(false);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
uploadInProgress = true;
|
|
971
|
+
setAssetUploadBusy(true);
|
|
972
|
+
const total = uploadQueue.length;
|
|
973
|
+
const failures = [];
|
|
974
|
+
|
|
975
|
+
try {
|
|
976
|
+
for (let i = 0; i < total; i += 1) {
|
|
977
|
+
const file = uploadQueue[i];
|
|
978
|
+
try {
|
|
979
|
+
await addAssetFromFile(file, i + 1, total);
|
|
980
|
+
} catch (err) {
|
|
981
|
+
failures.push({ file: file.name, error: err?.message || String(err) });
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
} finally {
|
|
985
|
+
uploadQueue = [];
|
|
986
|
+
uploadInProgress = false;
|
|
987
|
+
setAssetUploadBusy(false);
|
|
988
|
+
updateUploadStatusUi({ hidden: true });
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (failures.length > 0) {
|
|
992
|
+
showResult({ status: "upload_completed_with_failures", failures });
|
|
993
|
+
alert(
|
|
994
|
+
`Uploaded with ${failures.length} failure(s). Check result panel for details.`
|
|
995
|
+
);
|
|
996
|
+
} else {
|
|
997
|
+
showResult({ status: "upload_completed", total });
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
878
1001
|
async function verify() {
|
|
879
1002
|
const code = String(document.getElementById("code").value || "")
|
|
880
1003
|
.trim()
|
|
@@ -933,6 +1056,17 @@ async function deviceAction(deviceId, action) {
|
|
|
933
1056
|
if (action === "reboot" || action === "content/clear") await fetchDevices();
|
|
934
1057
|
}
|
|
935
1058
|
|
|
1059
|
+
async function viewDeviceLogs(deviceId) {
|
|
1060
|
+
if (!deviceId) return;
|
|
1061
|
+
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/logs`);
|
|
1062
|
+
const data = await res.json();
|
|
1063
|
+
showResult({ deviceId, logs: data.logs || [] });
|
|
1064
|
+
if (!res.ok) {
|
|
1065
|
+
alert(data.error || "Failed to load logs");
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
936
1070
|
function startDevicePolling() {
|
|
937
1071
|
if (devicePollTimer) clearInterval(devicePollTimer);
|
|
938
1072
|
void fetchDevices();
|
|
@@ -963,6 +1097,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
963
1097
|
});
|
|
964
1098
|
|
|
965
1099
|
document.getElementById("addAssetBtn")?.addEventListener("click", () => {
|
|
1100
|
+
if (uploadInProgress) return;
|
|
966
1101
|
if (!isPlaylistEditorOpen()) {
|
|
967
1102
|
newPlaylistDraft();
|
|
968
1103
|
}
|
|
@@ -972,11 +1107,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
972
1107
|
document.getElementById("fileInput")?.addEventListener("change", async (ev) => {
|
|
973
1108
|
const files = ev.target.files;
|
|
974
1109
|
if (!files?.length) return;
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
} catch (err) {
|
|
978
|
-
alert(err.message);
|
|
979
|
-
}
|
|
1110
|
+
uploadQueue.push(...Array.from(files));
|
|
1111
|
+
void processUploadQueue();
|
|
980
1112
|
ev.target.value = "";
|
|
981
1113
|
});
|
|
982
1114
|
});
|
|
@@ -306,6 +306,42 @@ button.danger {
|
|
|
306
306
|
overflow-y: auto;
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
.upload-status {
|
|
310
|
+
margin: 0.25rem 0.75rem 0.5rem;
|
|
311
|
+
padding: 0.55rem 0.6rem;
|
|
312
|
+
border: 1px solid #e4e1dc;
|
|
313
|
+
border-radius: 8px;
|
|
314
|
+
background: #fafaf9;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.upload-status-text {
|
|
318
|
+
font-size: 0.78rem;
|
|
319
|
+
font-weight: 600;
|
|
320
|
+
color: #333;
|
|
321
|
+
margin-bottom: 0.35rem;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.upload-progress-track {
|
|
325
|
+
width: 100%;
|
|
326
|
+
height: 8px;
|
|
327
|
+
border-radius: 999px;
|
|
328
|
+
overflow: hidden;
|
|
329
|
+
background: #ece8e2;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.upload-progress-bar {
|
|
333
|
+
width: 0%;
|
|
334
|
+
height: 100%;
|
|
335
|
+
background: #ff8a3d;
|
|
336
|
+
transition: width 120ms linear;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.upload-queue-text {
|
|
340
|
+
margin-top: 0.35rem;
|
|
341
|
+
font-size: 0.72rem;
|
|
342
|
+
color: #666;
|
|
343
|
+
}
|
|
344
|
+
|
|
309
345
|
.playlist-empty {
|
|
310
346
|
padding: 1.5rem 1rem;
|
|
311
347
|
text-align: center;
|