@mxpicture/tesla-superchargers-http 0.1.17 → 0.1.19
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/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +10 -0
- package/dist/server.js.map +1 -1
- package/dist/ui.d.ts +17 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +239 -0
- package/dist/ui.js.map +1 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* POST /reset-failures body: { source, id } | { source, all: true }
|
|
17
17
|
* POST /notify-failures body: { threshold? }
|
|
18
18
|
* POST /notify-unmatched-invoices
|
|
19
|
+
* GET / (the web UI — a static page driving the above)
|
|
19
20
|
* GET /health (unauthenticated liveness probe)
|
|
20
21
|
*
|
|
21
22
|
* The database connection comes from the standard `PG*` environment variables;
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AACnE,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC;AAC1C,IAAI,CAAC,KAAK,EAAE,CAAC;IACX,OAAO,CAAC,KAAK,CACX,qEAAqE,CACtE,CAAC;IACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,IAAI,CAAC;AACnD,MAAM,MAAM,GAAG,YAAY,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,aAAa,EAAE,EAAE,CAAC,CAAC;AAElE,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,6BAA6B,IAAI,EAAE,CAAC,CAAC,CAAC;AAE5E,qEAAqE;AACrE,MAAM,QAAQ,GAAG,GAAS,EAAE;IAC1B,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,KAAK,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACnE,CAAC,CAAC;AACF,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;AAC/B,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC"}
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,MAAM,EAEZ,MAAM,WAAW,CAAC;AAGnB,OAAO,KAAK,EAAE,eAAe,EAAU,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,MAAM,EAEZ,MAAM,WAAW,CAAC;AAGnB,OAAO,KAAK,EAAE,eAAe,EAAU,MAAM,YAAY,CAAC;AAM1D,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,eAAe,CAAC;CAC3B;AAuED,mFAAmF;AACnF,eAAO,MAAM,YAAY,GAAI,MAAM,aAAa,KAAG,MAmB/C,CAAC"}
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer as createHttpServer, } from "node:http";
|
|
2
2
|
import { isAuthorized } from "./auth.js";
|
|
3
3
|
import { HttpError } from "./errors.js";
|
|
4
|
+
import { UI_HTML } from "./ui.js";
|
|
4
5
|
/** Reject bodies larger than this (bytes) before buffering them. */
|
|
5
6
|
const MAX_BODY_BYTES = 1_000_000;
|
|
6
7
|
const sendJson = (res, status, payload) => {
|
|
@@ -35,6 +36,15 @@ const handle = async (req, res, opts) => {
|
|
|
35
36
|
// unauthenticated liveness probe
|
|
36
37
|
if (req.method === "GET" && url.pathname === "/health")
|
|
37
38
|
return sendJson(res, 200, { status: "ok" });
|
|
39
|
+
// The web UI (a static, secret-free shell). Served unauthenticated: behind
|
|
40
|
+
// Caddy the SSO portal gates the browser before it reaches here, and its own
|
|
41
|
+
// command calls carry the Caddy-injected bearer token; the page holds nothing
|
|
42
|
+
// sensitive on its own.
|
|
43
|
+
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
|
|
44
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
45
|
+
res.end(UI_HTML);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
38
48
|
if (!isAuthorized(req.headers.authorization, opts.token))
|
|
39
49
|
return sendJson(res, 401, { error: "unauthorized" });
|
|
40
50
|
const command = opts.commands[url.pathname.replace(/^\/+/, "")];
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,YAAY,IAAI,gBAAgB,GAIjC,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|
package/dist/ui.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web UI, served at `GET /` by the server (see `server.ts`). It is one
|
|
3
|
+
* self-contained HTML document — inline CSS + JS, no build step, no assets — so
|
|
4
|
+
* it compiles into `dist` as a plain string and is served as-is.
|
|
5
|
+
*
|
|
6
|
+
* Each command is a small form that calls the matching endpoint on the same
|
|
7
|
+
* origin (read-only lookups as GET, side-effecting actions as POST with a
|
|
8
|
+
* confirm) and renders the JSON response. The page never sends a credential:
|
|
9
|
+
* behind Caddy the SSO portal authenticates the browser and Caddy injects the
|
|
10
|
+
* bearer token upstream, so a same-origin fetch is already authorized. Run
|
|
11
|
+
* locally without that front, the command calls answer 401 (the page says so).
|
|
12
|
+
*
|
|
13
|
+
* The inner <script> deliberately avoids template literals so it can live inside
|
|
14
|
+
* this outer template string without escaping.
|
|
15
|
+
*/
|
|
16
|
+
export declare const UI_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>Tesla Superchargers</title>\n<style>\n :root {\n --bg: #f6f7f9; --card: #ffffff; --ink: #1a1d21; --muted: #6b7280;\n --line: #e4e7eb; --accent: #2563eb; --action: #b45309; --ok: #047857;\n --err: #b91c1c; --code-bg: #0f172a; --code-ink: #e2e8f0;\n }\n @media (prefers-color-scheme: dark) {\n :root {\n --bg: #0f1216; --card: #171b21; --ink: #e6e8eb; --muted: #9aa4b2;\n --line: #262b33; --accent: #60a5fa; --action: #f59e0b; --ok: #34d399;\n --err: #f87171; --code-bg: #0b0f16; --code-ink: #dbe2ea;\n }\n }\n * { box-sizing: border-box; }\n body {\n margin: 0; background: var(--bg); color: var(--ink);\n font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;\n }\n header {\n padding: 20px 24px; border-bottom: 1px solid var(--line);\n display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap;\n }\n header h1 { margin: 0; font-size: 18px; font-weight: 650; }\n header p { margin: 0; color: var(--muted); font-size: 13px; }\n main {\n max-width: 1100px; margin: 0 auto; padding: 24px;\n display: grid; gap: 16px;\n grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));\n }\n .card {\n background: var(--card); border: 1px solid var(--line); border-radius: 10px;\n padding: 16px; display: flex; flex-direction: column; gap: 10px;\n }\n .card .title { display: flex; align-items: center; gap: 8px; }\n .card h2 { margin: 0; font-size: 15px; font-weight: 600; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }\n .badge {\n font-size: 11px; font-weight: 700; letter-spacing: .03em; padding: 2px 6px;\n border-radius: 5px; color: #fff;\n }\n .badge.get { background: var(--accent); }\n .badge.post { background: var(--action); }\n .desc { margin: 0; color: var(--muted); font-size: 13px; }\n .fields { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n .field { display: flex; flex-direction: column; gap: 3px; }\n .field.wide { grid-column: 1 / -1; }\n .field label { font-size: 12px; color: var(--muted); }\n .field input, .field select {\n padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px;\n background: var(--bg); color: var(--ink); font-size: 14px; width: 100%;\n }\n .row { display: flex; align-items: center; gap: 8px; margin-top: 2px; }\n button {\n align-self: flex-start; padding: 7px 14px; border: 0; border-radius: 7px;\n background: var(--accent); color: #fff; font-size: 14px; font-weight: 600;\n cursor: pointer;\n }\n .card.action button { background: var(--action); }\n button:disabled { opacity: .55; cursor: default; }\n pre.result {\n margin: 0; padding: 10px 12px; border-radius: 8px; background: var(--code-bg);\n color: var(--code-ink); font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;\n max-height: 340px; overflow: auto; white-space: pre-wrap; word-break: break-word;\n border-left: 3px solid var(--line);\n }\n pre.result.ok { border-left-color: var(--ok); }\n pre.result.err { border-left-color: var(--err); }\n pre.result.running { border-left-color: var(--accent); color: var(--muted); }\n</style>\n</head>\n<body>\n<header>\n <h1>Tesla Superchargers</h1>\n <p>Run a command against the price-history database. Actions are highlighted and ask to confirm.</p>\n</header>\n<main id=\"commands\"></main>\n<script>\n(function () {\n var F = {\n lat: { name: \"lat\", label: \"Latitude\", type: \"text\", placeholder: \"49.3135644\", required: true },\n lon: { name: \"lon\", label: \"Longitude\", type: \"text\", placeholder: \"12.1445587\", required: true },\n radiusKm: { name: \"radiusKm\", label: \"Radius (km)\", type: \"number\", placeholder: \"default\" },\n locale: { name: \"locale\", label: \"Locale\", type: \"text\", placeholder: \"de-DE\" },\n maxFetches: { name: \"maxFetches\", label: \"Max fetches\", type: \"number\", placeholder: \"default\" },\n at: { name: \"at\", label: \"At (local)\", type: \"datetime-local\", wide: true },\n source: { name: \"source\", label: \"Source\", type: \"select\", options: [\"all\", \"charges\", \"chargers\", \"slugs\"] },\n sourceCon: { name: \"source\", label: \"Source\", type: \"select\", options: [\"charges\", \"chargers\", \"slugs\", \"all\"] },\n threshold: { name: \"threshold\", label: \"Threshold\", type: \"number\", placeholder: \"default\" },\n id: { name: \"id\", label: \"Id\", type: \"text\", placeholder: \"reset all if empty\", wide: true }\n };\n\n var COMMANDS = [\n { name: \"find-slugs\", method: \"GET\", kind: \"read\", fields: [F.lat, F.lon, F.radiusKm],\n desc: \"Nearest location slugs (tesla_uuid) to a coordinate.\" },\n { name: \"find-chargers\", method: \"GET\", kind: \"read\", fields: [F.lat, F.lon, F.locale, F.radiusKm, F.maxFetches],\n desc: \"Nearest chargers to a coordinate. May fetch tesla.com on demand \u2014 can be slow.\" },\n { name: \"find-prices\", method: \"GET\", kind: \"read\", fields: [F.lat, F.lon, F.at, F.radiusKm],\n desc: \"Supercharger prices valid at a time (default now) for a coordinate.\" },\n { name: \"find-unpriced-charges\", method: \"GET\", kind: \"read\", fields: [],\n desc: \"TeslaMate Supercharger charges with no cost yet.\" },\n { name: \"find-failures\", method: \"GET\", kind: \"read\", fields: [F.source, F.threshold],\n desc: \"Rows with repeated failures at or above the threshold.\" },\n { name: \"find-unmatched-invoices\", method: \"GET\", kind: \"read\", fields: [],\n desc: \"Supercharger charges without a matched invoice.\" },\n { name: \"sync-invoices-dropbox\", method: \"POST\", kind: \"action\", fields: [],\n desc: \"Import Tesla charging-history CSV exports from Dropbox.\" },\n { name: \"process-unpriced-charges\", method: \"POST\", kind: \"action\", fields: [],\n desc: \"Backfill cost on unpriced charges from stored Supercharger prices.\" },\n { name: \"reset-failures\", method: \"POST\", kind: \"action\", fields: [F.sourceCon, F.id],\n desc: \"Clear failure state. Leave id empty to reset ALL rows of the source.\",\n buildBody: function (v) { return v.id ? { source: v.source, id: v.id } : { source: v.source, all: true }; } }\n ];\n\n function el(tag, attrs, text) {\n var e = document.createElement(tag);\n if (attrs) for (var k in attrs) e.setAttribute(k, attrs[k]);\n if (text != null) e.textContent = text;\n return e;\n }\n\n function fieldNode(f) {\n var wrap = el(\"div\", { class: \"field\" + (f.wide ? \" wide\" : \"\") });\n wrap.appendChild(el(\"label\", null, f.label));\n var input;\n if (f.type === \"select\") {\n input = el(\"select\", { name: f.name });\n f.options.forEach(function (o) { input.appendChild(el(\"option\", { value: o }, o)); });\n } else {\n input = el(\"input\", { name: f.name, type: f.type });\n if (f.placeholder) input.setAttribute(\"placeholder\", f.placeholder);\n }\n wrap.appendChild(input);\n return wrap;\n }\n\n function collect(cmd, form) {\n var values = {};\n cmd.fields.forEach(function (f) {\n var input = form.elements[f.name];\n var val = input ? String(input.value).trim() : \"\";\n if (f.type === \"datetime-local\" && val) val = new Date(val).toISOString();\n if (val !== \"\") values[f.name] = val;\n });\n return values;\n }\n\n function show(out, cls, text) {\n out.hidden = false;\n out.className = \"result \" + cls;\n out.textContent = text;\n }\n\n function pretty(text) {\n try { return JSON.stringify(JSON.parse(text), null, 2); } catch (e) { return text; }\n }\n\n function run(cmd, form, out, btn) {\n var values = collect(cmd, form);\n for (var i = 0; i < cmd.fields.length; i++) {\n var f = cmd.fields[i];\n if (f.required && !values[f.name]) { show(out, \"err\", \"Please fill in \" + f.label + \".\"); return; }\n }\n\n var url = \"/\" + cmd.name;\n var opts = { method: cmd.method, headers: {} };\n if (cmd.method === \"GET\") {\n var qs = new URLSearchParams(values).toString();\n if (qs) url += \"?\" + qs;\n } else {\n if (!window.confirm(\"Run \" + cmd.name + \"? This performs a real action.\")) return;\n var body = cmd.buildBody ? cmd.buildBody(values) : values;\n opts.headers[\"content-type\"] = \"application/json\";\n opts.body = JSON.stringify(body);\n }\n\n btn.disabled = true;\n show(out, \"running\", \"Running\u2026\");\n fetch(url, opts).then(function (res) {\n return res.text().then(function (text) {\n if (res.ok) show(out, \"ok\", pretty(text));\n else if (res.status === 401) show(out, \"err\", \"401 Unauthorized \u2014 sign in via the SSO portal (running locally, the command endpoints need the bearer token).\");\n else show(out, \"err\", res.status + \" \u2014 \" + pretty(text));\n });\n }).catch(function (e) {\n show(out, \"err\", \"Request failed: \" + (e && e.message ? e.message : e));\n }).then(function () { btn.disabled = false; });\n }\n\n function card(cmd) {\n var form = el(\"form\", { class: \"card \" + cmd.kind });\n var title = el(\"div\", { class: \"title\" });\n title.appendChild(el(\"span\", { class: \"badge \" + (cmd.method === \"GET\" ? \"get\" : \"post\") }, cmd.method));\n title.appendChild(el(\"h2\", null, cmd.name));\n form.appendChild(title);\n form.appendChild(el(\"p\", { class: \"desc\" }, cmd.desc));\n\n if (cmd.fields.length) {\n var fields = el(\"div\", { class: \"fields\" });\n cmd.fields.forEach(function (f) { fields.appendChild(fieldNode(f)); });\n form.appendChild(fields);\n }\n\n var btn = el(\"button\", { type: \"submit\" }, cmd.kind === \"action\" ? \"Run action\" : \"Run\");\n form.appendChild(btn);\n var out = el(\"pre\", { class: \"result\", hidden: \"hidden\" });\n form.appendChild(out);\n\n form.addEventListener(\"submit\", function (e) { e.preventDefault(); run(cmd, form, out, btn); });\n return form;\n }\n\n var root = document.getElementById(\"commands\");\n COMMANDS.forEach(function (cmd) { root.appendChild(card(cmd)); });\n})();\n</script>\n</body>\n</html>\n";
|
|
17
|
+
//# sourceMappingURL=ui.d.ts.map
|
package/dist/ui.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ui.d.ts","sourceRoot":"","sources":["../src/ui.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,OAAO,21UA8NnB,CAAC"}
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web UI, served at `GET /` by the server (see `server.ts`). It is one
|
|
3
|
+
* self-contained HTML document — inline CSS + JS, no build step, no assets — so
|
|
4
|
+
* it compiles into `dist` as a plain string and is served as-is.
|
|
5
|
+
*
|
|
6
|
+
* Each command is a small form that calls the matching endpoint on the same
|
|
7
|
+
* origin (read-only lookups as GET, side-effecting actions as POST with a
|
|
8
|
+
* confirm) and renders the JSON response. The page never sends a credential:
|
|
9
|
+
* behind Caddy the SSO portal authenticates the browser and Caddy injects the
|
|
10
|
+
* bearer token upstream, so a same-origin fetch is already authorized. Run
|
|
11
|
+
* locally without that front, the command calls answer 401 (the page says so).
|
|
12
|
+
*
|
|
13
|
+
* The inner <script> deliberately avoids template literals so it can live inside
|
|
14
|
+
* this outer template string without escaping.
|
|
15
|
+
*/
|
|
16
|
+
export const UI_HTML = `<!doctype html>
|
|
17
|
+
<html lang="en">
|
|
18
|
+
<head>
|
|
19
|
+
<meta charset="utf-8" />
|
|
20
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
21
|
+
<title>Tesla Superchargers</title>
|
|
22
|
+
<style>
|
|
23
|
+
:root {
|
|
24
|
+
--bg: #f6f7f9; --card: #ffffff; --ink: #1a1d21; --muted: #6b7280;
|
|
25
|
+
--line: #e4e7eb; --accent: #2563eb; --action: #b45309; --ok: #047857;
|
|
26
|
+
--err: #b91c1c; --code-bg: #0f172a; --code-ink: #e2e8f0;
|
|
27
|
+
}
|
|
28
|
+
@media (prefers-color-scheme: dark) {
|
|
29
|
+
:root {
|
|
30
|
+
--bg: #0f1216; --card: #171b21; --ink: #e6e8eb; --muted: #9aa4b2;
|
|
31
|
+
--line: #262b33; --accent: #60a5fa; --action: #f59e0b; --ok: #34d399;
|
|
32
|
+
--err: #f87171; --code-bg: #0b0f16; --code-ink: #dbe2ea;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
* { box-sizing: border-box; }
|
|
36
|
+
body {
|
|
37
|
+
margin: 0; background: var(--bg); color: var(--ink);
|
|
38
|
+
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
39
|
+
}
|
|
40
|
+
header {
|
|
41
|
+
padding: 20px 24px; border-bottom: 1px solid var(--line);
|
|
42
|
+
display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap;
|
|
43
|
+
}
|
|
44
|
+
header h1 { margin: 0; font-size: 18px; font-weight: 650; }
|
|
45
|
+
header p { margin: 0; color: var(--muted); font-size: 13px; }
|
|
46
|
+
main {
|
|
47
|
+
max-width: 1100px; margin: 0 auto; padding: 24px;
|
|
48
|
+
display: grid; gap: 16px;
|
|
49
|
+
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
50
|
+
}
|
|
51
|
+
.card {
|
|
52
|
+
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
|
53
|
+
padding: 16px; display: flex; flex-direction: column; gap: 10px;
|
|
54
|
+
}
|
|
55
|
+
.card .title { display: flex; align-items: center; gap: 8px; }
|
|
56
|
+
.card h2 { margin: 0; font-size: 15px; font-weight: 600; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
57
|
+
.badge {
|
|
58
|
+
font-size: 11px; font-weight: 700; letter-spacing: .03em; padding: 2px 6px;
|
|
59
|
+
border-radius: 5px; color: #fff;
|
|
60
|
+
}
|
|
61
|
+
.badge.get { background: var(--accent); }
|
|
62
|
+
.badge.post { background: var(--action); }
|
|
63
|
+
.desc { margin: 0; color: var(--muted); font-size: 13px; }
|
|
64
|
+
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
65
|
+
.field { display: flex; flex-direction: column; gap: 3px; }
|
|
66
|
+
.field.wide { grid-column: 1 / -1; }
|
|
67
|
+
.field label { font-size: 12px; color: var(--muted); }
|
|
68
|
+
.field input, .field select {
|
|
69
|
+
padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px;
|
|
70
|
+
background: var(--bg); color: var(--ink); font-size: 14px; width: 100%;
|
|
71
|
+
}
|
|
72
|
+
.row { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
|
|
73
|
+
button {
|
|
74
|
+
align-self: flex-start; padding: 7px 14px; border: 0; border-radius: 7px;
|
|
75
|
+
background: var(--accent); color: #fff; font-size: 14px; font-weight: 600;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
}
|
|
78
|
+
.card.action button { background: var(--action); }
|
|
79
|
+
button:disabled { opacity: .55; cursor: default; }
|
|
80
|
+
pre.result {
|
|
81
|
+
margin: 0; padding: 10px 12px; border-radius: 8px; background: var(--code-bg);
|
|
82
|
+
color: var(--code-ink); font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
83
|
+
max-height: 340px; overflow: auto; white-space: pre-wrap; word-break: break-word;
|
|
84
|
+
border-left: 3px solid var(--line);
|
|
85
|
+
}
|
|
86
|
+
pre.result.ok { border-left-color: var(--ok); }
|
|
87
|
+
pre.result.err { border-left-color: var(--err); }
|
|
88
|
+
pre.result.running { border-left-color: var(--accent); color: var(--muted); }
|
|
89
|
+
</style>
|
|
90
|
+
</head>
|
|
91
|
+
<body>
|
|
92
|
+
<header>
|
|
93
|
+
<h1>Tesla Superchargers</h1>
|
|
94
|
+
<p>Run a command against the price-history database. Actions are highlighted and ask to confirm.</p>
|
|
95
|
+
</header>
|
|
96
|
+
<main id="commands"></main>
|
|
97
|
+
<script>
|
|
98
|
+
(function () {
|
|
99
|
+
var F = {
|
|
100
|
+
lat: { name: "lat", label: "Latitude", type: "text", placeholder: "49.3135644", required: true },
|
|
101
|
+
lon: { name: "lon", label: "Longitude", type: "text", placeholder: "12.1445587", required: true },
|
|
102
|
+
radiusKm: { name: "radiusKm", label: "Radius (km)", type: "number", placeholder: "default" },
|
|
103
|
+
locale: { name: "locale", label: "Locale", type: "text", placeholder: "de-DE" },
|
|
104
|
+
maxFetches: { name: "maxFetches", label: "Max fetches", type: "number", placeholder: "default" },
|
|
105
|
+
at: { name: "at", label: "At (local)", type: "datetime-local", wide: true },
|
|
106
|
+
source: { name: "source", label: "Source", type: "select", options: ["all", "charges", "chargers", "slugs"] },
|
|
107
|
+
sourceCon: { name: "source", label: "Source", type: "select", options: ["charges", "chargers", "slugs", "all"] },
|
|
108
|
+
threshold: { name: "threshold", label: "Threshold", type: "number", placeholder: "default" },
|
|
109
|
+
id: { name: "id", label: "Id", type: "text", placeholder: "reset all if empty", wide: true }
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
var COMMANDS = [
|
|
113
|
+
{ name: "find-slugs", method: "GET", kind: "read", fields: [F.lat, F.lon, F.radiusKm],
|
|
114
|
+
desc: "Nearest location slugs (tesla_uuid) to a coordinate." },
|
|
115
|
+
{ name: "find-chargers", method: "GET", kind: "read", fields: [F.lat, F.lon, F.locale, F.radiusKm, F.maxFetches],
|
|
116
|
+
desc: "Nearest chargers to a coordinate. May fetch tesla.com on demand — can be slow." },
|
|
117
|
+
{ name: "find-prices", method: "GET", kind: "read", fields: [F.lat, F.lon, F.at, F.radiusKm],
|
|
118
|
+
desc: "Supercharger prices valid at a time (default now) for a coordinate." },
|
|
119
|
+
{ name: "find-unpriced-charges", method: "GET", kind: "read", fields: [],
|
|
120
|
+
desc: "TeslaMate Supercharger charges with no cost yet." },
|
|
121
|
+
{ name: "find-failures", method: "GET", kind: "read", fields: [F.source, F.threshold],
|
|
122
|
+
desc: "Rows with repeated failures at or above the threshold." },
|
|
123
|
+
{ name: "find-unmatched-invoices", method: "GET", kind: "read", fields: [],
|
|
124
|
+
desc: "Supercharger charges without a matched invoice." },
|
|
125
|
+
{ name: "sync-invoices-dropbox", method: "POST", kind: "action", fields: [],
|
|
126
|
+
desc: "Import Tesla charging-history CSV exports from Dropbox." },
|
|
127
|
+
{ name: "process-unpriced-charges", method: "POST", kind: "action", fields: [],
|
|
128
|
+
desc: "Backfill cost on unpriced charges from stored Supercharger prices." },
|
|
129
|
+
{ name: "reset-failures", method: "POST", kind: "action", fields: [F.sourceCon, F.id],
|
|
130
|
+
desc: "Clear failure state. Leave id empty to reset ALL rows of the source.",
|
|
131
|
+
buildBody: function (v) { return v.id ? { source: v.source, id: v.id } : { source: v.source, all: true }; } }
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
function el(tag, attrs, text) {
|
|
135
|
+
var e = document.createElement(tag);
|
|
136
|
+
if (attrs) for (var k in attrs) e.setAttribute(k, attrs[k]);
|
|
137
|
+
if (text != null) e.textContent = text;
|
|
138
|
+
return e;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function fieldNode(f) {
|
|
142
|
+
var wrap = el("div", { class: "field" + (f.wide ? " wide" : "") });
|
|
143
|
+
wrap.appendChild(el("label", null, f.label));
|
|
144
|
+
var input;
|
|
145
|
+
if (f.type === "select") {
|
|
146
|
+
input = el("select", { name: f.name });
|
|
147
|
+
f.options.forEach(function (o) { input.appendChild(el("option", { value: o }, o)); });
|
|
148
|
+
} else {
|
|
149
|
+
input = el("input", { name: f.name, type: f.type });
|
|
150
|
+
if (f.placeholder) input.setAttribute("placeholder", f.placeholder);
|
|
151
|
+
}
|
|
152
|
+
wrap.appendChild(input);
|
|
153
|
+
return wrap;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function collect(cmd, form) {
|
|
157
|
+
var values = {};
|
|
158
|
+
cmd.fields.forEach(function (f) {
|
|
159
|
+
var input = form.elements[f.name];
|
|
160
|
+
var val = input ? String(input.value).trim() : "";
|
|
161
|
+
if (f.type === "datetime-local" && val) val = new Date(val).toISOString();
|
|
162
|
+
if (val !== "") values[f.name] = val;
|
|
163
|
+
});
|
|
164
|
+
return values;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function show(out, cls, text) {
|
|
168
|
+
out.hidden = false;
|
|
169
|
+
out.className = "result " + cls;
|
|
170
|
+
out.textContent = text;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function pretty(text) {
|
|
174
|
+
try { return JSON.stringify(JSON.parse(text), null, 2); } catch (e) { return text; }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function run(cmd, form, out, btn) {
|
|
178
|
+
var values = collect(cmd, form);
|
|
179
|
+
for (var i = 0; i < cmd.fields.length; i++) {
|
|
180
|
+
var f = cmd.fields[i];
|
|
181
|
+
if (f.required && !values[f.name]) { show(out, "err", "Please fill in " + f.label + "."); return; }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
var url = "/" + cmd.name;
|
|
185
|
+
var opts = { method: cmd.method, headers: {} };
|
|
186
|
+
if (cmd.method === "GET") {
|
|
187
|
+
var qs = new URLSearchParams(values).toString();
|
|
188
|
+
if (qs) url += "?" + qs;
|
|
189
|
+
} else {
|
|
190
|
+
if (!window.confirm("Run " + cmd.name + "? This performs a real action.")) return;
|
|
191
|
+
var body = cmd.buildBody ? cmd.buildBody(values) : values;
|
|
192
|
+
opts.headers["content-type"] = "application/json";
|
|
193
|
+
opts.body = JSON.stringify(body);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
btn.disabled = true;
|
|
197
|
+
show(out, "running", "Running…");
|
|
198
|
+
fetch(url, opts).then(function (res) {
|
|
199
|
+
return res.text().then(function (text) {
|
|
200
|
+
if (res.ok) show(out, "ok", pretty(text));
|
|
201
|
+
else if (res.status === 401) show(out, "err", "401 Unauthorized — sign in via the SSO portal (running locally, the command endpoints need the bearer token).");
|
|
202
|
+
else show(out, "err", res.status + " — " + pretty(text));
|
|
203
|
+
});
|
|
204
|
+
}).catch(function (e) {
|
|
205
|
+
show(out, "err", "Request failed: " + (e && e.message ? e.message : e));
|
|
206
|
+
}).then(function () { btn.disabled = false; });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function card(cmd) {
|
|
210
|
+
var form = el("form", { class: "card " + cmd.kind });
|
|
211
|
+
var title = el("div", { class: "title" });
|
|
212
|
+
title.appendChild(el("span", { class: "badge " + (cmd.method === "GET" ? "get" : "post") }, cmd.method));
|
|
213
|
+
title.appendChild(el("h2", null, cmd.name));
|
|
214
|
+
form.appendChild(title);
|
|
215
|
+
form.appendChild(el("p", { class: "desc" }, cmd.desc));
|
|
216
|
+
|
|
217
|
+
if (cmd.fields.length) {
|
|
218
|
+
var fields = el("div", { class: "fields" });
|
|
219
|
+
cmd.fields.forEach(function (f) { fields.appendChild(fieldNode(f)); });
|
|
220
|
+
form.appendChild(fields);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
var btn = el("button", { type: "submit" }, cmd.kind === "action" ? "Run action" : "Run");
|
|
224
|
+
form.appendChild(btn);
|
|
225
|
+
var out = el("pre", { class: "result", hidden: "hidden" });
|
|
226
|
+
form.appendChild(out);
|
|
227
|
+
|
|
228
|
+
form.addEventListener("submit", function (e) { e.preventDefault(); run(cmd, form, out, btn); });
|
|
229
|
+
return form;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
var root = document.getElementById("commands");
|
|
233
|
+
COMMANDS.forEach(function (cmd) { root.appendChild(card(cmd)); });
|
|
234
|
+
})();
|
|
235
|
+
</script>
|
|
236
|
+
</body>
|
|
237
|
+
</html>
|
|
238
|
+
`;
|
|
239
|
+
//# sourceMappingURL=ui.js.map
|
package/dist/ui.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ui.js","sourceRoot":"","sources":["../src/ui.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8NtB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mxpicture/tesla-superchargers-http",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"description": "Http server for interacting with superchargers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "MXPicture",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@mxpicture/tesla-superchargers-api": "^0.1.
|
|
36
|
-
"@mxpicture/tesla-superchargers-mate": "^0.1.
|
|
35
|
+
"@mxpicture/tesla-superchargers-api": "^0.1.19",
|
|
36
|
+
"@mxpicture/tesla-superchargers-mate": "^0.1.19"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/node": "^25.2.3",
|