feat(ui): redesign UI with Tailwind CSS, CodeMirror 6, and new layout

- Replace all hand-written CSS with Tailwind utility classes
- Upgrade CodeMirror 5 to CodeMirror 6 (scoped @codemirror/* packages)
- Redesign layout with responsive sidebar navigation using JSX/Mithril
- Flatten admin routes (e.g. /admin/presets -> /presets)
- Add "remember me" login option with 2-week persistent cookie
- Add delete confirmation dialogs across admin pages
- Upgrade esbuild from 0.21 to 0.27 and update browser targets
- Add Inter and Roboto Mono variable fonts with subset script
This commit is contained in:
Zaid Humaimidi committed 2026-03-12 14:55:10 -07:00
1 parent a7256620e0
commit 4a191afdec
64 files changed
+4147 -2421

No files matched your search

+55 -32
View File
@@ -3,7 +3,7 @@ import { m } from "../components.ts";
import * as taskQueue from "../task-queue.ts";
import { parse } from "../../lib/common/expression/parser.ts";
import memoize from "../../lib/common/memoize.ts";
import { getIcon } from "../icons.ts";
import { icon } from "../tailwind-utility-components.ts";
import { QueryResponse, evaluateExpression } from "../store.ts";
import debounce from "../../lib/common/debounce.ts";
import { Expression } from "../../lib/types.ts";
@@ -55,14 +55,17 @@ const component: ClosureComponent<Attrs> = () => {
const limit =
(evaluateExpression(vnode.attrs.limit, device) as number) || 100;
const search = m("input", {
type: "text",
placeholder: "Search parameters",
oninput: (e) => {
formQueryString(e.target.value);
e.redraw = false;
const search = m(
"input.appearance-none border-0 block w-full px-4 py-3 border-stone-300 placeholder-stone-500 text-stone-900 focus:ring-cyan-500 text-sm rounded-t-lg font-mono focus:ring-2",
{
type: "text",
placeholder: "Search parameters",
oninput: (e) => {
formQueryString(e.target.value);
e.redraw = false;
},
},
});
);
const instanceRegex = /\.[0-9]+$/;
let re;
@@ -92,8 +95,6 @@ const component: ClosureComponent<Attrs> = () => {
const rows = filteredKeys.map((k) => {
const p = device[k];
const val = [];
const attrs = { key: k };
if (p.object === false) {
val.push(
m(
@@ -116,7 +117,11 @@ const component: ClosureComponent<Attrs> = () => {
});
},
},
getIcon("delete-instance"),
m(icon, {
name: "delete-instance",
class:
"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900",
}),
),
);
} else {
@@ -133,7 +138,11 @@ const component: ClosureComponent<Attrs> = () => {
});
},
},
getIcon("add-instance"),
m(icon, {
name: "add-instance",
class:
"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900",
}),
),
);
}
@@ -152,15 +161,17 @@ const component: ClosureComponent<Attrs> = () => {
});
},
},
getIcon("refresh"),
m(icon, {
name: "refresh",
class: "inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900",
}),
),
);
return m(
"tr",
attrs,
m("td.left", m("long-text", { text: k })),
m("td.right", val),
m("td.pl-4 pr-2 py-2 truncate", m("long-text", { text: k })),
m("td.pr-4 py-2 text-right flex justify-end", val),
);
});
@@ -168,25 +179,37 @@ const component: ClosureComponent<Attrs> = () => {
"loading",
{ queries: [vnode.attrs.deviceQuery] },
m(
".all-parameters",
m(
"a.download-csv",
{
href: `api/devices/${encodeURIComponent(
device["DeviceID.ID"].value[0],
)}.csv`,
download: "",
style: "float: right;",
},
"Download",
),
".bg-white shadow-sm rounded-lg",
search,
m(
".parameter-list",
m("table", m("tbody", rows)),
".overflow-hidden",
m(
"m",
`Displaying ${filteredKeys.length} out of ${count} parameters.`,
".overflow-y-scroll h-96 shadow-inner",
m(
"table.w-full table-fixed font-mono text-xs text-stone-900",
m("tbody.divide-y divide-stone-200", rows),
),
),
m(
"div.text-stone-700 px-4 py-3 flex justify-between items-end",
m(
"span.text-xs",
"Displaying ",
m("span.font-medium", "" + filteredKeys.length),
" out of ",
m("span.font-medium", "" + count),
" parameters",
),
m(
"a.text-cyan-700 hover:text-cyan-900 text-sm font-medium",
{
href: `api/devices/${encodeURIComponent(
device["DeviceID.ID"].value[0],
)}.csv`,
download: "",
},
"Download",
),
),
),
),
+5 -5
View File
@@ -13,7 +13,7 @@ const component: ClosureComponent = (): Component => {
buttons.push(
m(
"button.primary",
"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed",
{
title: "Reboot device",
onclick: () => {
@@ -29,7 +29,7 @@ const component: ClosureComponent = (): Component => {
buttons.push(
m(
"button.critical",
"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed",
{
title: "Factory reset device",
onclick: () => {
@@ -45,7 +45,7 @@ const component: ClosureComponent = (): Component => {
buttons.push(
m(
"button.critical",
"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed",
{
title: "Push a firmware or a config file",
onclick: () => {
@@ -61,7 +61,7 @@ const component: ClosureComponent = (): Component => {
buttons.push(
m(
"button.primary",
"button.px-4 py-2 border border-stone-300 shadow-xs text-sm font-medium rounded-md text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed",
{
title: "Delete device",
onclick: () => {
@@ -83,7 +83,7 @@ const component: ClosureComponent = (): Component => {
),
);
return m(".actions-bar", buttons);
return m("div.flex gap-3 mt-4", buttons);
},
};
};
+89 -42
View File
@@ -2,7 +2,6 @@ import { ClosureComponent, Component } from "mithril";
import { m } from "../components.ts";
import * as store from "../store.ts";
import * as notifications from "../notifications.ts";
import { getIcon } from "../icons.ts";
import { stringify } from "../../lib/common/yaml.ts";
const component: ClosureComponent = (): Component => {
@@ -23,63 +22,111 @@ const component: ClosureComponent = (): Component => {
"Detail",
"Retries",
"Timestamp",
].map((l) => m("th", l));
const thead = m("thead", m("tr", headers));
"",
].map((l, i) => {
let padding: string;
if (i === 0) padding = "pl-6 pr-3";
else if (i === 6) padding = "pl-3";
else padding = "px-3";
return m(
"th",
{
scope: "col",
class:
"py-3.5 text-left text-sm font-semibold text-stone-500 " +
padding,
},
l,
);
});
const thead = m("thead.bg-stone-50", m("tr", headers));
const rows = [];
for (const f of faults.value) {
rows.push([
m("td", f["channel"]),
m("td", f["code"]),
m("td", m("long-text", { text: f["message"] })),
m("td", m("long-text", { text: stringify(f["detail"]) })),
m("td", f["retries"]),
m("td", new Date(f["timestamp"]).toLocaleString()),
rows.push(
m(
"td",
"tr",
m(
"button",
{
title: "Delete fault",
onclick: (e) => {
e.redraw = false;
store
.deleteResource("faults", f["_id"])
.then(() => {
notifications.push("success", "Fault deleted");
store.setTimestamp(Date.now());
m.redraw();
})
.catch((err) => {
notifications.push("error", err.message);
store.setTimestamp(Date.now());
});
"td.whitespace-nowrap pl-6 pr-3 py-4 text-sm text-stone-900",
f["channel"],
),
m(
"td.whitespace-nowrap px-3 py-4 text-sm text-stone-900",
f["code"],
),
m(
"td.whitespace-nowrap px-3 py-4 text-sm text-stone-900",
m("long-text", { text: f["message"], class: "max-w-xs" }),
),
m(
"td.whitespace-nowrap px-3 py-4 text-sm text-stone-900",
m("long-text", {
text: stringify(f["detail"]),
class: "max-w-xs",
}),
),
m(
"td.whitespace-nowrap px-3 py-4 text-sm text-stone-900",
f["retries"],
),
m(
"td.whitespace-nowrap px-3 py-4 text-sm text-stone-900",
new Date(f["timestamp"]).toLocaleString(),
),
m(
"td.whitespace-nowrap pl-3 pr-6 py-4 text-sm text-stone-900",
m(
"button",
{
class: "text-cyan-700 hover:text-cyan-900 font-medium",
title: "Delete fault",
onclick: (e) => {
e.redraw = false;
store
.deleteResource("faults", f["_id"])
.then(() => {
notifications.push("success", "Fault deleted");
store.setTimestamp(Date.now());
m.redraw();
})
.catch((err) => {
notifications.push("error", err.message);
store.setTimestamp(Date.now());
});
},
},
},
getIcon("remove"),
"Delete",
),
),
),
]);
);
}
let tbody;
if (rows.length) {
tbody = m(
"tbody",
rows.map((r) => m("tr", r)),
);
} else {
tbody = m(
"tbody",
m("tr.empty", m("td", { colspan: headers.length }, "No faults")),
if (!rows.length) {
rows.push(
m(
"tr",
m(
"td.bg-stripes text-sm font-medium text-center text-stone-500 p-4",
{ colspan: headers.length },
"No faults",
),
),
);
}
return m(
"loading",
{ queries: [faults] },
m("table.table", thead, tbody),
m(
"div.shadow-sm overflow-hidden rounded-lg w-max",
m(
"table.divide-y divide-stone-200",
thead,
m("tbody.divide-y divide-stone-200 bg-white", rows),
),
),
);
},
};
+1 -1
View File
@@ -19,7 +19,7 @@ const component: ClosureComponent = (): Component => {
});
if (deviceId) {
return m(
"a",
"a.text-cyan-700 hover:text-cyan-900 font-medium",
{ href: `#!/devices/${encodeURIComponent(deviceId)}` },
children,
);
+4 -7
View File
@@ -16,25 +16,22 @@ const component: ClosureComponent = (): Component => {
const filter = slice["filter"];
if (evaluateExpression(filter, device || {})) {
const dot = m(
"svg",
"svg.inline",
{
width: "1em",
height: "1em",
style: "margin: 0 0.2em 0.2em",
xmlns: "http://www.w3.org/2000/svg",
"xmlns:xlink": "http://www.w3.org/1999/xlink",
},
m("circle", {
m("circle.stroke-stone-200 stroke-1", {
cx: "0.5em",
cy: "0.5em",
r: "0.4em",
fill: evaluateExpression(slice["color"], null),
}),
);
return m(
"span.overview-dot",
dot,
evaluateExpression(slice["label"], null),
);
return m("span", dot, evaluateExpression(slice["label"], null));
}
}
return null;
+11 -4
View File
@@ -30,7 +30,7 @@ const component: ClosureComponent<Attrs> = () => {
);
return m(
"tr",
"div.py-3 grid grid-cols-3 gap-4 px-6",
{
oncreate: (vn) => {
(vn.dom as HTMLElement).style.display = (p as VnodeDOM).dom
@@ -43,15 +43,22 @@ const component: ClosureComponent<Attrs> = () => {
: "none";
},
},
m("th", evaluateExpression(parameter.label, device)),
m("td", p),
m(
"dt.text-sm font-medium text-stone-500",
evaluateExpression(parameter["label"], device),
),
m("dd.text-sm text-stone-900 col-span-2", p),
);
});
return m(
"loading",
{ queries: [vnode.attrs.deviceQuery] },
m("table.parameter-list", rows),
m(
"dl.bg-white shadow-sm overflow-hidden rounded-lg w-max py-1",
{ class: "[&>*+*]:border-t [&>*+*]:border-stone-200" },
rows,
),
);
},
};
+55 -10
View File
@@ -3,7 +3,7 @@ import { m } from "../components.ts";
import * as taskQueue from "../task-queue.ts";
import { QueryResponse, evaluateExpression } from "../store.ts";
import * as expressionParser from "../../lib/common/expression/parser.ts";
import { getIcon } from "../icons.ts";
import { icon } from "../tailwind-utility-components.ts";
import { FlatDevice } from "../../lib/ui/db.ts";
import { Expression } from "../../lib/types.ts";
@@ -42,11 +42,24 @@ const component: ClosureComponent<Attrs> = () => {
}
}
const headers = Object.values(parameters).map((p) =>
m("th", evaluateExpression(p["label"], device)),
);
const headers = Object.values(parameters).map((p, i) => {
const padding = i ? "px-3" : "pl-6 pr-3";
const thead = m("thead", m("tr", headers));
return m(
"th",
{
scope: "col",
class:
"py-3.5 text-left text-sm font-semibold text-stone-500 " +
padding,
},
evaluateExpression(p["label"], device),
);
});
headers.push(m("th.pl-3", { scope: "col" }));
const thead = m("thead.bg-stone-50", m("tr", headers));
const rows = [];
for (const i of instances) {
@@ -60,7 +73,9 @@ const component: ClosureComponent<Attrs> = () => {
if (!evaluateExpression(filter, device)) continue;
const row = parameters.map((p) => {
const row = parameters.map((p, j) => {
const padding = j ? "px-3" : "pl-6 pr-3";
const param = expressionParser.map(p.parameter, (e) => {
if (Array.isArray(e) && e[0] === "PARAM")
return ["PARAM", ["||", i, ".", e[1]]];
@@ -68,6 +83,9 @@ const component: ClosureComponent<Attrs> = () => {
});
return m(
"td",
{
class: "whitespace-nowrap py-4 text-sm text-stone-900 " + padding,
},
m.context(
{
device: device,
@@ -87,6 +105,10 @@ const component: ClosureComponent<Attrs> = () => {
row.push(
m(
"td",
{
class:
"whitespace-nowrap pl-3 pr-6 py-4 text-sm text-stone-900",
},
m(
"button",
{
@@ -99,17 +121,29 @@ const component: ClosureComponent<Attrs> = () => {
});
},
},
getIcon("delete-instance"),
m(icon, {
name: "delete-instance",
class: "inline h-4 w-4 text-cyan-700 hover:text-cyan-900",
}),
),
),
);
} else {
row.push(m("td"));
}
rows.push(m("tr", row));
}
if (!rows.length) {
rows.push(
m("tr.empty", m("td", { colspan: headers.length }, "No instances")),
m(
"tr",
m(
"td.bg-stripes text-sm font-medium text-center text-stone-500 p-4",
{ colspan: headers.length },
"No instances",
),
),
);
}
@@ -132,7 +166,11 @@ const component: ClosureComponent<Attrs> = () => {
});
},
},
getIcon("add-instance"),
m(icon, {
name: "add-instance",
class:
"inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900",
}),
),
),
),
@@ -149,7 +187,14 @@ const component: ClosureComponent<Attrs> = () => {
m(
"loading",
{ queries: [vnode.attrs.deviceQuery] },
m("table.table", thead, m("tbody", rows)),
m(
"div.shadow-sm overflow-hidden rounded-lg w-max",
m(
"table.divide-y divide-stone-200",
thead,
m("tbody.divide-y divide-stone-200 bg-white", rows),
),
),
),
];
},
+7 -5
View File
@@ -5,7 +5,7 @@ import * as store from "../store.ts";
import { evaluate } from "../../lib/common/expression/util.ts";
import memoize from "../../lib/common/memoize.ts";
import timeAgo from "../timeago.ts";
import { getIcon } from "../icons.ts";
import { icon } from "../tailwind-utility-components.ts";
const evaluateParam = memoize((exp, obj, now: number) => {
let timestamp = now;
@@ -84,16 +84,18 @@ const component: ClosureComponent = (): Component => {
});
},
},
getIcon("edit"),
m(icon, {
name: "edit",
class: "inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900",
}),
);
}
const el = m("long-text", { text: `${value}` });
return m(
"span",
"span.inline-flex overflow-hidden align-top",
{
class: "parameter-value",
onmouseover: (e) => {
e.redraw = false;
// Don't update any child element
@@ -104,7 +106,7 @@ const component: ClosureComponent = (): Component => {
}
},
},
el,
m("span.truncate", el),
edit,
);
},
+1 -1
View File
@@ -60,7 +60,7 @@ const component: ClosureComponent = (vn): Component => {
}
}
return m("div", host ? `Pinging ${host}:` : "");
return m("div.text-sm my-4", host ? `Pinging ${host}:` : "");
},
};
};
+3 -1
View File
@@ -10,8 +10,10 @@ const component: ClosureComponent = (): Component => {
const device = vnode.attrs["device"];
return m(
"button.primary",
"button",
{
class:
"px-2.5 py-1.5 border border-transparent text-xs font-medium rounded-sm shadow-xs text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500",
title: "Initiate session and refresh basic parameters",
onclick: (e) => {
e.target.disabled = true;
+40 -9
View File
@@ -2,7 +2,7 @@ import { ClosureComponent } from "mithril";
import { m } from "../components.ts";
import * as notifications from "../notifications.ts";
import * as store from "../store.ts";
import { getIcon } from "../icons.ts";
import { icon } from "../tailwind-utility-components.ts";
import { decodeTag } from "../../lib/util.ts";
import { Expression } from "../../lib/types.ts";
import { FlatDevice } from "../../lib/ui/db.ts";
@@ -28,20 +28,36 @@ const component: ClosureComponent<Attrs> = () => {
if (!writable) {
return m(
".tags",
tags.map((t) => m("span.tag", t)),
"div",
tags.map((t) =>
m(
"span",
{
class:
"inline-flex items-center px-3 py-0.5 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800 mr-2 -my-0.5 ring-1 ring-yellow-200",
},
t,
),
),
);
}
return m(
".tags",
"div",
tags.map((tag) =>
m(
"span.tag",
"span",
{
class:
"inline-flex items-center pl-3 pr-1 py-0.5 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800 mr-2 ring-1 ring-yellow-200",
},
tag,
m(
"button",
{
title: "Remove tag",
class:
"flex-shrink-0 ml-0.5 h-4 w-4 rounded-full inline-flex items-center justify-center text-yellow-400 hover:bg-yellow-200 hover:text-yellow-500 focus:outline-hidden focus:bg-yellow-500 focus:text-white",
onclick: (e) => {
e.target.disabled = true;
const deviceId = device["DeviceID.ID"].value[0] as string;
@@ -64,16 +80,27 @@ const component: ClosureComponent<Attrs> = () => {
});
},
},
getIcon("remove"),
m("span.sr-only", "Remove tag"),
m(icon, {
name: "remove",
class: "h-4 w-4",
}),
),
),
),
m(
"span.tag.writable",
m.trust("&nbsp;"),
"span",
{
class:
"inline-flex items-center pl-1 pr-1 py-0.5 rounded-full text-sm font-medium bg-yellow-50 ring-1 ring-yellow-200",
},
m.trust("&#x200B;"),
m(
"button",
{
title: "Add tag",
class:
"flex-shrink-0 h-4 w-4 rounded-full inline-flex items-center justify-center text-yellow-400 hover:bg-yellow-200 hover:text-yellow-500 focus:outline-hidden focus:bg-yellow-500 focus:text-white",
onclick: (e) => {
e.target.disabled = true;
const deviceId = device["DeviceID.ID"].value[0] as string;
@@ -96,7 +123,11 @@ const component: ClosureComponent<Attrs> = () => {
});
},
},
getIcon("add"),
m("span.sr-only", "Add tag"),
m(icon, {
name: "add",
class: "h-4 w-4",
}),
),
),
);