index.js

  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, "&amp;")
397    .replace(/</g, "&lt;")
398    .replace(/>/g, "&gt;")
399    .replace(/"/g, "&quot;");
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});