1var CONFIG_BASE_URL = "plexusBaseUrl";
2var CONFIG_ADMIN_KEY = "plexusAdminKey";
3var MAX_ROWS_PER_GROUP = 4;
4var quotaGroups = [];
5var currentPageIndex = 0;
6var providerDisplayNames = {};
7
8var ROW_KEYS = [
9 {
10 label: "ROW_0_LABEL",
11 detail: "ROW_0_DETAIL",
12 percent: "ROW_0_PERCENT",
13 status: "ROW_0_STATUS",
14 hasBar: "ROW_0_HAS_BAR"
15 },
16 {
17 label: "ROW_1_LABEL",
18 detail: "ROW_1_DETAIL",
19 percent: "ROW_1_PERCENT",
20 status: "ROW_1_STATUS",
21 hasBar: "ROW_1_HAS_BAR"
22 },
23 {
24 label: "ROW_2_LABEL",
25 detail: "ROW_2_DETAIL",
26 percent: "ROW_2_PERCENT",
27 status: "ROW_2_STATUS",
28 hasBar: "ROW_2_HAS_BAR"
29 },
30 {
31 label: "ROW_3_LABEL",
32 detail: "ROW_3_DETAIL",
33 percent: "ROW_3_PERCENT",
34 status: "ROW_3_STATUS",
35 hasBar: "ROW_3_HAS_BAR"
36 }
37];
38
39function trimSlash(value) {
40 return String(value || "").replace(/\/+$/, "");
41}
42
43function sendError(message) {
44 Pebble.sendAppMessage({ ERROR: String(message).slice(0, 38) });
45}
46
47function xhrRequest(url, adminKey, callback) {
48 var xhr = new XMLHttpRequest();
49 xhr.onload = function () {
50 if (xhr.status < 200 || xhr.status >= 300) {
51 callback(new Error("HTTP " + xhr.status), null);
52 return;
53 }
54 callback(null, xhr.responseText);
55 };
56 xhr.onerror = function () {
57 callback(new Error("Network error"), null);
58 };
59 xhr.open("GET", url);
60 xhr.setRequestHeader("x-admin-key", adminKey);
61 xhr.send();
62}
63
64function asNumber(value) {
65 if (typeof value === "number" && isFinite(value)) return value;
66 if (typeof value === "string" && value !== "" && isFinite(Number(value)))
67 return Number(value);
68 return null;
69}
70
71function statusRank(status) {
72 if (status === "critical" || status === "exhausted") return 4;
73 if (status === "warning") return 3;
74 if (status === "unknown") return 2;
75 if (status === "ok") return 1;
76 return 0;
77}
78
79function meterUrgency(meter) {
80 var rank = statusRank(meter.status);
81 var percent = asNumber(meter.utilizationPercent);
82 if (percent === null) percent = 0;
83 return rank * 1000 + percent;
84}
85
86function groupHasComparableMeter(group) {
87 for (var i = 0; i < group.meters.length; i += 1) {
88 if (group.meters[i].hasBar) return true;
89 }
90 return false;
91}
92
93function groupUrgency(group) {
94 var urgency = 0;
95 for (var i = 0; i < group.meters.length; i += 1) {
96 if (!group.meters[i].hasBar) continue;
97 urgency = Math.max(urgency, meterUrgency(group.meters[i]));
98 }
99 return urgency;
100}
101
102function clampPageIndex(pageIndex, pageCount) {
103 var index = asNumber(pageIndex);
104 if (index === null) index = currentPageIndex;
105 index = Math.round(index);
106 if (pageCount <= 0) return 0;
107 if (index < 0) return pageCount - 1;
108 if (index >= pageCount) return 0;
109 return index;
110}
111
112function formatNumber(value) {
113 var number = asNumber(value);
114 if (number === null) return "?";
115 if (Math.abs(number) >= 1000)
116 return String(Math.round(number).toLocaleString());
117 if (Math.abs(number) >= 100) return String(Math.round(number));
118 if (Math.abs(number) >= 10) return String(Math.round(number * 10) / 10);
119 return String(Math.round(number * 100) / 100);
120}
121
122function formatCompactNumber(value) {
123 var number = asNumber(value);
124 if (number === null) return "?";
125 var absolute = Math.abs(number);
126 if (absolute >= 1000000)
127 return String(Math.round(number / 100000) / 10) + "m";
128 if (absolute >= 1000) return String(Math.round(number / 100) / 10) + "k";
129 return formatNumber(number);
130}
131
132function formatValue(value, unit) {
133 var number = asNumber(value);
134 if (number === null) return "?";
135 if (unit === "usd") return "$" + number.toFixed(2);
136 if (unit === "percent" || unit === "percentage")
137 return formatNumber(number) + "%";
138 if (unit === "hypercredits") return formatCompactNumber(number) + " hc";
139 if (unit === "requests") return formatCompactNumber(number) + " req";
140 if (unit === "kwh") return formatCompactNumber(number) + " kWh";
141 if (!unit) return formatNumber(number);
142 return formatCompactNumber(number) + " " + String(unit).slice(0, 4);
143}
144
145function formatMeterDetail(meter) {
146 var percent = asNumber(meter.utilizationPercent);
147 if (meter.hasBar && percent !== null)
148 return String(Math.round(percent)) + "%";
149
150 var remaining = asNumber(meter.remaining);
151 if (remaining !== null) return formatValue(remaining, meter.unit);
152
153 var limit = asNumber(meter.limit);
154 if (limit !== null) return formatValue(limit, meter.unit);
155
156 return "—";
157}
158
159function shortResetForGroup(group) {
160 var soonest = null;
161 for (var i = 0; i < group.meters.length; i += 1) {
162 if (!group.meters[i].resetsAt) continue;
163 var reset = new Date(group.meters[i].resetsAt);
164 if (!isFinite(reset.getTime())) continue;
165 if (soonest === null || reset.getTime() < soonest.getTime())
166 soonest = reset;
167 }
168 if (soonest === null) return "No reset time";
169
170 var diffMs = soonest.getTime() - Date.now();
171 var prefix = diffMs >= 0 ? "Next reset " : "Reset ";
172 var absMinutes = Math.round(Math.abs(diffMs) / 60000);
173 if (absMinutes < 90) return prefix + absMinutes + "m";
174 var absHours = Math.round(absMinutes / 60);
175 if (absHours < 48) return prefix + absHours + "h";
176 return prefix + Math.round(absHours / 24) + "d";
177}
178
179function hasComparableLimit(meter) {
180 var percent = asNumber(meter.utilizationPercent);
181 var limit = asNumber(meter.limit);
182 if (percent === null) return false;
183 if (meter.kind === "balance") return false;
184 if (meter.unit === "percent" || meter.unit === "percentage") return true;
185 if (limit === null || limit <= 0) return false;
186 return true;
187}
188
189function normalizeMeter(quota, meter) {
190 var percent = asNumber(meter.utilizationPercent);
191 return {
192 checkerId: quota.checkerId || quota.provider || "unknown",
193 provider: quota.provider || quota.checkerId || "unknown",
194 label: meter.label || meter.key || "Quota",
195 kind: meter.kind || "unknown",
196 status: meter.status || "unknown",
197 unit: meter.unit || "",
198 limit: meter.limit,
199 remaining: meter.remaining,
200 utilizationPercent: percent,
201 resetsAt: meter.resetsAt,
202 hasBar: hasComparableLimit(meter)
203 };
204}
205
206function displayNameForQuota(quota, id, displayNames) {
207 return (
208 displayNames[id] ||
209 quota.display_name ||
210 quota.displayName ||
211 quota.name ||
212 quota.providerName ||
213 quota.provider ||
214 id
215 );
216}
217
218function compareText(left, right) {
219 var normalizedLeft = String(left || "").toLowerCase();
220 var normalizedRight = String(right || "").toLowerCase();
221 if (normalizedLeft < normalizedRight) return -1;
222 if (normalizedLeft > normalizedRight) return 1;
223 return 0;
224}
225
226function compareMeters(left, right) {
227 if (left.hasBar !== right.hasBar) return left.hasBar ? -1 : 1;
228 var urgencyDelta = meterUrgency(right) - meterUrgency(left);
229 if (urgencyDelta !== 0) return urgencyDelta;
230 return compareText(left.label, right.label);
231}
232
233function flattenGroups(response, displayNames) {
234 var quotas = JSON.parse(response);
235 var groupsById = {};
236 var groups = [];
237
238 for (var i = 0; i < quotas.length; i += 1) {
239 var quota = quotas[i];
240 if (!quota || quota.success !== true || !quota.meters) continue;
241 var id = quota.checkerId || quota.provider || "unknown";
242 if (!groupsById[id]) {
243 groupsById[id] = {
244 id: id,
245 title: displayNameForQuota(quota, id, displayNames || {}),
246 meters: []
247 };
248 groups.push(groupsById[id]);
249 }
250 for (var j = 0; j < quota.meters.length; j += 1) {
251 if (quota.meters[j])
252 groupsById[id].meters.push(normalizeMeter(quota, quota.meters[j]));
253 }
254 }
255
256 for (var groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
257 groups[groupIndex].meters.sort(compareMeters);
258 }
259 groups.sort(function (left, right) {
260 var leftHasComparableMeter = groupHasComparableMeter(left);
261 var rightHasComparableMeter = groupHasComparableMeter(right);
262 if (leftHasComparableMeter !== rightHasComparableMeter)
263 return leftHasComparableMeter ? -1 : 1;
264 var urgencyDelta = groupUrgency(right) - groupUrgency(left);
265 if (urgencyDelta !== 0) return urgencyDelta;
266 return compareText(left.id, right.id);
267 });
268
269 return groups;
270}
271
272function sendGroupPage(pageIndex) {
273 if (quotaGroups.length === 0) {
274 sendError("No quota groups found");
275 return;
276 }
277
278 currentPageIndex = clampPageIndex(pageIndex, quotaGroups.length);
279 var group = quotaGroups[currentPageIndex];
280 var dictionary = {
281 QUOTA_TITLE: String(group.title).slice(0, 38),
282 QUOTA_STATUS: "",
283 QUOTA_RESET: shortResetForGroup(group).slice(0, 38),
284 QUOTA_LABEL: "",
285 ROW_COUNT: group.meters.length,
286 PAGE_INDEX: currentPageIndex,
287 PAGE_COUNT: quotaGroups.length,
288 UPDATED_AT: currentTimeText()
289 };
290
291 var worstStatusRank = 1;
292 var pageHasBar = false;
293 var rowsToSend = Math.min(group.meters.length, MAX_ROWS_PER_GROUP);
294 for (var i = 0; i < rowsToSend; i += 1) {
295 var meter = group.meters[i];
296 var keys = ROW_KEYS[i];
297 var rank = statusRank(meter.status);
298 if (meter.hasBar) pageHasBar = true;
299 worstStatusRank = Math.max(worstStatusRank, rank);
300 dictionary[keys.label] = String(meter.label).slice(0, 22);
301 dictionary[keys.detail] = formatMeterDetail(meter).slice(0, 26);
302 dictionary[keys.percent] = Math.max(
303 0,
304 Math.min(100, Math.round(asNumber(meter.utilizationPercent) || 0))
305 );
306 dictionary[keys.status] = rank;
307 dictionary[keys.hasBar] = meter.hasBar ? 1 : 0;
308 }
309
310 if (pageHasBar) {
311 if (worstStatusRank >= 4) dictionary.QUOTA_STATUS = "CRITICAL";
312 else if (worstStatusRank >= 3) dictionary.QUOTA_STATUS = "WARNING";
313 else dictionary.QUOTA_STATUS = "OK";
314 }
315
316 Pebble.sendAppMessage(dictionary);
317}
318
319function currentTimeText() {
320 var now = new Date();
321 var minutes = now.getMinutes();
322 return (
323 "at " + now.getHours() + ":" + (minutes < 10 ? "0" + minutes : minutes)
324 );
325}
326
327function parseProviderDisplayNames(responseText) {
328 var providers = JSON.parse(responseText);
329 var displayNames = {};
330 if (!providers || typeof providers !== "object") return displayNames;
331
332 for (var id in providers) {
333 if (!Object.prototype.hasOwnProperty.call(providers, id)) continue;
334 var provider = providers[id];
335 if (!provider || typeof provider !== "object") continue;
336 var displayName =
337 provider.display_name || provider.displayName || provider.name;
338 if (displayName) displayNames[id] = String(displayName);
339 }
340
341 return displayNames;
342}
343
344function refreshQuotaWithDisplayNames(
345 baseUrl,
346 adminKey,
347 pageIndex,
348 displayNames
349) {
350 xhrRequest(
351 baseUrl + "/v0/management/quotas",
352 adminKey,
353 function (error, responseText) {
354 if (error) {
355 sendError(error.message);
356 return;
357 }
358 try {
359 quotaGroups = flattenGroups(responseText, displayNames);
360 sendGroupPage(pageIndex);
361 } catch (e) {
362 sendError("Bad quota response");
363 }
364 }
365 );
366}
367
368function refreshQuota(pageIndex) {
369 var baseUrl = trimSlash(localStorage.getItem(CONFIG_BASE_URL));
370 var adminKey = localStorage.getItem(CONFIG_ADMIN_KEY);
371 if (!baseUrl || !adminKey) {
372 sendError("Set URL and admin key");
373 return;
374 }
375
376 xhrRequest(
377 baseUrl + "/v0/management/providers",
378 adminKey,
379 function (providerError, providerResponseText) {
380 var displayNames = providerDisplayNames;
381 try {
382 if (!providerError) {
383 displayNames = parseProviderDisplayNames(providerResponseText);
384 providerDisplayNames = displayNames;
385 }
386 } catch (e) {
387 displayNames = providerDisplayNames;
388 }
389 refreshQuotaWithDisplayNames(baseUrl, adminKey, pageIndex, displayNames);
390 }
391 );
392}
393
394function htmlEscape(value) {
395 return String(value || "")
396 .replace(/&/g, "&")
397 .replace(/</g, "<")
398 .replace(/>/g, ">")
399 .replace(/"/g, """);
400}
401
402function configurationHtml() {
403 var baseUrl = htmlEscape(localStorage.getItem(CONFIG_BASE_URL));
404 var adminKey = htmlEscape(localStorage.getItem(CONFIG_ADMIN_KEY));
405 return (
406 '<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1">' +
407 "<title>Pebblexus</title><style>body{font:16px sans-serif;margin:1rem;line-height:1.4}" +
408 "label{display:block;margin:1rem 0 .25rem}input{box-sizing:border-box;width:100%;font:inherit;padding:.5rem}" +
409 "button{font:inherit;margin-top:1rem;padding:.7rem 1rem;width:100%}.hint{color:#555;font-size:.9rem}</style></head>" +
410 '<body><h1>Pebblexus</h1><p class="hint">Your admin key stays in PebbleKit JS storage on this phone and is not sent to the watch.</p>' +
411 '<form id="form"><label>Plexus base URL</label><input id="baseUrl" type="url" required value="' +
412 baseUrl +
413 '" placeholder="https://plexus.example">' +
414 '<label>Admin key</label><input id="adminKey" type="password" required value="' +
415 adminKey +
416 '">' +
417 '<button type="submit">Save</button></form><script>document.getElementById("form").addEventListener("submit",function(e){e.preventDefault();' +
418 'var data={plexusBaseUrl:document.getElementById("baseUrl").value,plexusAdminKey:document.getElementById("adminKey").value};' +
419 "var encoded=encodeURIComponent(JSON.stringify(data));var match=location.href.match(/[?&]return_to=([^&#]+)/);" +
420 'if(match){location.href=decodeURIComponent(match[1])+encoded;}else{location.href="pebblejs://close#"+encoded;}});</script></body></html>'
421 );
422}
423
424Pebble.addEventListener("ready", function () {
425 refreshQuota();
426});
427
428Pebble.addEventListener("appmessage", function (e) {
429 if (!e.payload) return;
430 if (e.payload.REQUEST_PAGE !== undefined) {
431 if (quotaGroups.length === 0) {
432 refreshQuota(e.payload.REQUEST_PAGE);
433 return;
434 }
435 sendGroupPage(e.payload.REQUEST_PAGE);
436 return;
437 }
438 if (e.payload.REQUEST_QUOTA) refreshQuota(e.payload.PAGE_INDEX);
439});
440
441Pebble.addEventListener("showConfiguration", function () {
442 Pebble.openURL("data:text/html," + encodeURIComponent(configurationHtml()));
443});
444
445Pebble.addEventListener("webviewclosed", function (e) {
446 if (!e || !e.response) return;
447 try {
448 var response = JSON.parse(decodeURIComponent(e.response));
449 localStorage.setItem(CONFIG_BASE_URL, trimSlash(response.plexusBaseUrl));
450 localStorage.setItem(CONFIG_ADMIN_KEY, response.plexusAdminKey || "");
451 refreshQuota();
452 } catch (error) {
453 sendError("Config save failed");
454 }
455});