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

+34 -4
View File
@@ -110,12 +110,28 @@ const inlineDepsPlugin = {
const deps = [ const deps = [
"parsimmon", "parsimmon",
"espresso-iisojs", "espresso-iisojs",
"codemirror", "@codemirror",
"mithril", "mithril",
"yaml", "yaml",
]; ];
const depFiles = new Set();
build.onResolve({ filter: /^[^.]/ }, async (args) => { build.onResolve({ filter: /^[^.]/ }, async (args) => {
if (deps.some((d) => args.path.startsWith(d))) return undefined; if (args.pluginData === "inlineDeps") return undefined;
if (
depFiles.has(args.importer) ||
deps.some((d) => args.path.startsWith(d))
) {
const res = await build.resolve(args.path, {
importer: args.importer,
namespace: args.namespace,
resolveDir: args.resolveDir,
kind: args.kind,
with: args.with,
pluginData: "inlineDeps",
});
depFiles.add(res.path);
return res;
}
return { sideEffects: false, external: true }; return { sideEffects: false, external: true };
}); });
}, },
@@ -226,6 +242,16 @@ async function copyStatic(): Promise<void> {
} }
async function generateCss(): Promise<void> { async function generateCss(): Promise<void> {
const tailwindPlugin = {
name: "tailwind",
setup(build) {
build.onLoad({ filter: /\/ui\/css\/app.css$/ }, async (args) => {
const res = await execAsync(`npx @tailwindcss/cli -i ${args.path}`);
return { loader: "css", contents: res.stdout };
});
},
} as esbuild.Plugin;
const res = await esbuild.build({ const res = await esbuild.build({
bundle: true, bundle: true,
absWorkingDir: INPUT_DIR, absWorkingDir: INPUT_DIR,
@@ -235,7 +261,11 @@ async function generateCss(): Promise<void> {
entryPoints: ["ui/css/app.css"], entryPoints: ["ui/css/app.css"],
entryNames: "[dir]/[name]-[hash]", entryNames: "[dir]/[name]-[hash]",
outfile: path.join(OUTPUT_DIR, "public/app.css"), outfile: path.join(OUTPUT_DIR, "public/app.css"),
target: ["chrome109", "safari15.6", "firefox115", "opera102", "edge118"], plugins: [tailwindPlugin],
loader: {
".woff2": "dataurl",
},
target: ["chrome111", "safari16.4", "firefox128"],
metafile: true, metafile: true,
}); });
@@ -293,7 +323,7 @@ async function generateFrontendJs(): Promise<void> {
sourcesContent: false, sourcesContent: false,
platform: "browser", platform: "browser",
format: "esm", format: "esm",
target: ["chrome109", "safari15.6", "firefox115", "opera102", "edge118"], target: ["chrome111", "safari16.4", "firefox128"],
entryPoints: ["ui/app.ts"], entryPoints: ["ui/app.ts"],
entryNames: "[dir]/[name]-[hash]", entryNames: "[dir]/[name]-[hash]",
outdir: path.join(OUTPUT_DIR, "public"), outdir: path.join(OUTPUT_DIR, "public"),
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
cd "$(dirname "$0")"
declare -A FONTS=(
[InterVariable]=https://rsms.me/inter/font-files/InterVariable.woff2
[InterVariable-Italic]=https://rsms.me/inter/font-files/InterVariable-Italic.woff2
[RobotoMono]=https://raw.githubusercontent.com/googlefonts/RobotoMono/main/fonts/variable/RobotoMono%5Bwght%5D.ttf
)
for name in "${!FONTS[@]}"
do
url="${FONTS[$name]}"
echo "Downloading $url"
TMP=$(mktemp)
curl "$url" -s --output "$TMP"
pyftsubset "$TMP" --output-file="../ui/css/$name.woff2" --flavor=woff2 --unicodes="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD" --layout-features+="tnum"
done
+4 -4
View File
@@ -1,8 +1,8 @@
import { complement } from "espresso-iisojs"; import { complement } from "espresso-iisojs";
import { Expression } from "../../types"; import { Expression } from "../../types.ts";
import normalize from "./normalize"; import normalize from "./normalize.ts";
import { Clause, SynthContext } from "./synth"; import { Clause, SynthContext } from "./synth.ts";
import { and, or } from "./util"; import { and, or } from "./util.ts";
type Bookmark = Record<string, null | boolean | number | string>; type Bookmark = Record<string, null | boolean | number | string>;
+12 -3
View File
@@ -105,6 +105,9 @@ router.post("/login", async (ctx) => {
const username = ctx.request.body.username; const username = ctx.request.body.username;
const password = ctx.request.body.password; const password = ctx.request.body.password;
const remember = ctx.request.body.remember;
const TWO_WEEKS_SECS = 1209600;
const ONE_DAY_SECS = 86400;
const log = { const log = {
message: "Log in", message: "Log in",
@@ -115,8 +118,14 @@ router.post("/login", async (ctx) => {
function success(authMethod): void { function success(authMethod): void {
log.method = authMethod; log.method = authMethod;
const token = jwt.sign({ username, authMethod }, JWT_SECRET); const expiresIn = remember ? TWO_WEEKS_SECS : ONE_DAY_SECS;
ctx.cookies.set(JWT_COOKIE, token, { sameSite: "lax" }); const token = jwt.sign({ username, authMethod }, JWT_SECRET, {
expiresIn,
});
ctx.cookies.set(JWT_COOKIE, token, {
sameSite: "lax",
maxAge: remember ? expiresIn * 1000 : null,
});
ctx.body = JSON.stringify(token); ctx.body = JSON.stringify(token);
logger.accessInfo(log); logger.accessInfo(log);
} }
@@ -219,7 +228,7 @@ router.get("/", async (ctx) => {
<link rel="shortcut icon" type="image/png" href="${FAVICON_PNG}" /> <link rel="shortcut icon" type="image/png" href="${FAVICON_PNG}" />
<link rel="stylesheet" href="${APP_CSS}"> <link rel="stylesheet" href="${APP_CSS}">
</head> </head>
<body> <body class="h-full bg-stone-100">
<noscript>GenieACS UI requires JavaScript to work. Please enable JavaScript in your browser.</noscript> <noscript>GenieACS UI requires JavaScript to work. Please enable JavaScript in your browser.</noscript>
<script> <script>
window.clientConfig = ${JSON.stringify({ window.clientConfig = ${JSON.stringify({
+1906 -86
View File
File diff suppressed because it is too large. Load diff
+9 -2
View File
@@ -42,7 +42,14 @@
"seedrandom": "^3.0.5" "seedrandom": "^3.0.5"
}, },
"devDependencies": { "devDependencies": {
"@types/codemirror": "^5.60.17", "@codemirror/commands": "^6.10.2",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.2",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.16",
"@tailwindcss/cli": "^4.2.1",
"@tailwindcss/forms": "^0.5.11",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/koa": "^2.15.0", "@types/koa": "^2.15.0",
"@types/koa-compress": "^4.0.7", "@types/koa-compress": "^4.0.7",
@@ -52,7 +59,6 @@
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",
"codemirror": "^5.65.21",
"esbuild": "^0.27.4", "esbuild": "^0.27.4",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.2", "eslint-config-prettier": "^9.1.2",
@@ -60,6 +66,7 @@
"prettier": "^3.8.1", "prettier": "^3.8.1",
"sql.js": "^1.14.1", "sql.js": "^1.14.1",
"svgo": "^3.3.3", "svgo": "^3.3.3",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"yaml": "^1.10.2" "yaml": "^1.10.2"
}, },
+3 -1
View File
@@ -12,7 +12,9 @@
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"skipLibCheck": true "skipLibCheck": true,
"jsx": "react",
"jsxFactory": "m"
}, },
"include": [ "include": [
"./bin/*.ts", "./bin/*.ts",
-88
View File
@@ -1,88 +0,0 @@
import m, { ClosureComponent, Component } from "mithril";
const component: ClosureComponent = (): Component => {
return {
view: (vnode) => {
const active = { [vnode.attrs["page"]]: "active" };
const tabs = [];
if (window.authorizer.hasAccess("presets", 1)) {
tabs.push(
m(
"li",
{ class: active["presets"] },
m("a", { href: "#!/admin/presets" }, "Presets"),
),
);
}
if (window.authorizer.hasAccess("provisions", 1)) {
tabs.push(
m(
"li",
{ class: active["provisions"] },
m("a", { href: "#!/admin/provisions" }, "Provisions"),
),
);
}
if (window.authorizer.hasAccess("virtualParameters", 1)) {
tabs.push(
m(
"li",
{ class: active["virtualParameters"] },
m(
"a",
{ href: "#!/admin/virtualParameters" },
"Virtual Parameters",
),
),
);
}
if (window.authorizer.hasAccess("files", 1)) {
tabs.push(
m(
"li",
{ class: active["files"] },
m("a", { href: "#!/admin/files" }, "Files"),
),
);
}
if (window.authorizer.hasAccess("config", 1)) {
tabs.push(
m(
"li",
{ class: active["config"] },
m("a", { href: "#!/admin/config" }, "Config"),
),
);
}
if (window.authorizer.hasAccess("permissions", 1)) {
tabs.push(
m(
"li",
{ class: active["permissions"] },
m("a", { href: "#!/admin/permissions" }, "Permissions"),
),
);
}
if (window.authorizer.hasAccess("users", 1)) {
tabs.push(
m(
"li",
{ class: active["users"] },
m("a", { href: "#!/admin/users" }, "Users"),
),
);
}
return m("nav#side-menu", m("ul", tabs));
},
};
};
export default component;
+23 -47
View File
@@ -1,8 +1,8 @@
import m, { RouteResolver } from "mithril"; import m, { RouteResolver } from "mithril";
import layout from "./layout.ts"; import layout from "./layout.tsx";
import * as store from "./store.ts"; import * as store from "./store.ts";
import * as wizardPage from "./wizard-page.ts"; import * as wizardPage from "./wizard-page.ts";
import * as loginPage from "./login-page.ts"; import * as loginPage from "./login-page.tsx";
import * as overviewPage from "./overview-page.ts"; import * as overviewPage from "./overview-page.ts";
import * as devicesPage from "./devices-page.ts"; import * as devicesPage from "./devices-page.ts";
import * as devicePage from "./device-page.ts"; import * as devicePage from "./device-page.ts";
@@ -16,9 +16,10 @@ import * as configPage from "./config-page.ts";
import * as permissionsPage from "./permissions-page.ts"; import * as permissionsPage from "./permissions-page.ts";
import * as usersPage from "./users-page.ts"; import * as usersPage from "./users-page.ts";
import Authorizer from "../lib//common/authorizer.ts"; import Authorizer from "../lib//common/authorizer.ts";
import * as notifications from "./notifications.ts";
import { contextifyComponent } from "./components.ts"; import { contextifyComponent } from "./components.ts";
import { PermissionSet, UiConfig } from "../lib/types.ts"; import { PermissionSet, UiConfig } from "../lib/types.ts";
import drawerComponent from "./drawer-component.ts";
import { render as renderOverlay } from "./overlay.ts";
declare global { declare global {
interface Window { interface Window {
@@ -33,16 +34,6 @@ declare global {
window.authorizer = new Authorizer(window.permissionSets); window.authorizer = new Authorizer(window.permissionSets);
const adminPages = [
"presets",
"provisions",
"virtualParameters",
"files",
"config",
"users",
"permissions",
];
let state; let state;
function pagify(pageName, page): RouteResolver { function pagify(pageName, page): RouteResolver {
@@ -52,10 +43,14 @@ function pagify(pageName, page): RouteResolver {
let p; let p;
if (state?.error) p = m(errorPage.component, state); if (state?.error) p = m(errorPage.component, state);
else p = m(contextifyComponent(page.component), state); else p = m(contextifyComponent(page.component), state);
const attrs = {}; const attrs = {
attrs["page"] = pageName; page: pageName,
attrs["oncreate"] = attrs["onupdate"] = () => { oncreate: () => {
store.fulfill(lastRenderTimestamp); store.fulfill(lastRenderTimestamp);
},
onupdate: () => {
store.fulfill(lastRenderTimestamp);
},
}; };
return m(layout, attrs, p); return m(layout, attrs, p);
}, },
@@ -78,10 +73,8 @@ function pagify(pageName, page): RouteResolver {
resolve(); resolve();
}) })
.catch((err) => { .catch((err) => {
if (!window.username && err.message.indexOf("authorized") >= 0) { if (!window.username && err.message.indexOf("authorized") >= 0)
notifications.push("error", err.message);
m.route.set("/login", { continue: requestedPath }); m.route.set("/login", { continue: requestedPath });
}
state = { error: err.message }; state = { error: err.message };
resolve(); resolve();
}); });
@@ -91,37 +84,20 @@ function pagify(pageName, page): RouteResolver {
return component; return component;
} }
function redirectAdminPage(): RouteResolver {
const component: RouteResolver = {
onmatch: () => {
for (const page of adminPages) {
if (window.authorizer.hasAccess(page, 2)) {
m.route.set(`/admin/${page}`);
return null;
}
}
return null;
},
};
return component;
}
m.route(document.body, "/overview", { m.route(document.body, "/overview", {
"/wizard": pagify("wizard", wizardPage), "/wizard": pagify("wizard", wizardPage),
"/login": pagify("login", loginPage),
"/overview": pagify("overview", overviewPage), "/overview": pagify("overview", overviewPage),
"/devices": pagify("devices", devicesPage), "/devices": pagify("devices", devicesPage),
"/devices/:id": pagify("devices", devicePage), "/devices/:id": pagify("devices", devicePage),
"/faults": pagify("faults", faultsPage), "/faults": pagify("faults", faultsPage),
"/admin": redirectAdminPage(), "/presets": pagify("presets", presetsPage),
"/admin/presets": pagify("presets", presetsPage), "/provisions": pagify("provisions", provisionsPage),
"/admin/provisions": pagify("provisions", provisionsPage), "/virtualParameters": pagify("virtualParameters", virtualParametersPage),
"/admin/virtualParameters": pagify( "/files": pagify("files", filesPage),
"virtualParameters", "/config": pagify("config", configPage),
virtualParametersPage, "/users": pagify("users", usersPage),
), "/permissions": pagify("permissions", permissionsPage),
"/admin/files": pagify("files", filesPage), "/login": {
"/admin/config": pagify("config", configPage), render: () => [m(loginPage.component), renderOverlay(), m(drawerComponent)],
"/admin/users": pagify("users", usersPage), },
"/admin/permissions": pagify("permissions", permissionsPage),
}); });
+6 -4
View File
@@ -12,7 +12,7 @@ export default class Autocomplete {
declare private selection: number; declare private selection: number;
declare private container: HTMLElement; declare private container: HTMLElement;
public constructor(className: string, callback: AutocompleteCallback) { public constructor(callback: AutocompleteCallback) {
this.callback = callback; this.callback = callback;
this.element = null; this.element = null;
this.hideTimeout = null; this.hideTimeout = null;
@@ -24,7 +24,8 @@ export default class Autocomplete {
this.container.style.position = "absolute"; this.container.style.position = "absolute";
this.container.style.display = "block"; this.container.style.display = "block";
this.container.style.opacity = "0"; this.container.style.opacity = "0";
this.container.className = className; this.container.className =
"absolute py-1 mt-2 rounded-md shadow-lg bg-white";
} }
public attach(el: HTMLInputElement): void { public attach(el: HTMLInputElement): void {
@@ -140,9 +141,10 @@ export default class Autocomplete {
for (const [idx, suggestion] of suggestions.entries()) { for (const [idx, suggestion] of suggestions.entries()) {
const e = document.createElement("div"); const e = document.createElement("div");
if (suggestion.tip) e.title = suggestion.tip; if (suggestion.tip) e.title = suggestion.tip;
e.classList.add("suggestion"); e.className =
"text-stone-700 block px-4 py-2 text-sm hover:bg-stone-100 hover:text-stone-900";
if (idx === this.selection) { if (idx === this.selection) {
e.classList.add("selected"); e.classList.add("bg-stone-100", "text-stone-900");
selectedElement = e; selectedElement = e;
} }
+37 -23
View File
@@ -21,19 +21,26 @@ const component: ClosureComponent<Attrs> = () => {
const form = [ const form = [
m( m(
"p", "p",
m("label", { for: "username" }, "Username"), m(
m("input", { "label.block text-sm font-semibold text-stone-700 mt-2 mb-1",
name: "username", { for: "username" },
type: "text", "Username",
value: vnode.state["username"], ),
disabled: !!username, m(
oninput: (e) => { "input.shadow-sm focus:ring-cyan-500 focus:border-cyan-500 block sm:text-sm border-stone-300 rounded-md",
vnode.state["username"] = e.target.value; {
name: "username",
type: "text",
value: vnode.state["username"],
disabled: !!username,
oninput: (e) => {
vnode.state["username"] = e.target.value;
},
oncreate: (_vnode) => {
(_vnode.dom as HTMLSelectElement).focus();
},
}, },
oncreate: (_vnode) => { ),
(_vnode.dom as HTMLSelectElement).focus();
},
}),
), ),
]; ];
@@ -48,31 +55,38 @@ const component: ClosureComponent<Attrs> = () => {
form.push( form.push(
m( m(
"p", "p",
m("label", { for: f }, l), m(
m("input", { "label.block text-sm font-semibold text-stone-700 mt-2 mb-1",
name: f, { for: f },
type: "password", l,
value: vnode.state[f], ),
oninput: (e) => { m(
vnode.state[f] = e.target.value; "input.shadow-sm focus:ring-cyan-500 focus:border-cyan-500 block sm:text-sm border-stone-300 rounded-md",
{
name: f,
type: "password",
value: vnode.state[f],
oninput: (e) => {
vnode.state[f] = e.target.value;
},
}, },
}), ),
), ),
); );
} }
const submit = m( const submit = m(
"button.primary", "button.ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-xs text-sm font-medium rounded-md text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500",
{ {
type: "submit", type: "submit",
}, },
"Change password", "Change password",
) as VnodeDOM; ) as VnodeDOM;
form.push(m(".actions-bar", submit)); form.push(m("div.flex justify-end mt-5", submit));
const children = [ const children = [
m("h1", "Change password"), m("h2.text-lg leading-6 font-medium text-stone-900", "Change password"),
m( m(
"form", "form",
{ {
+50 -25
View File
@@ -1,4 +1,3 @@
import { Editor } from "codemirror";
import { ClosureComponent } from "mithril"; import { ClosureComponent } from "mithril";
import { m } from "./components.ts"; import { m } from "./components.ts";
import { codeMirror } from "./dynamic-loader.ts"; import { codeMirror } from "./dynamic-loader.ts";
@@ -11,41 +10,67 @@ interface Attrs {
focus?: boolean; focus?: boolean;
onSubmit?: (dom: Element) => void; onSubmit?: (dom: Element) => void;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onReady?: (editor: Editor) => void;
} }
const component: ClosureComponent<Attrs> = () => { const component: ClosureComponent<Attrs> = () => {
return { return {
view: (vnode) => { view: (vnode) => {
return m("textarea", { return m("div.font-mono text-sm", {
name: vnode.attrs.id,
value: vnode.attrs.value,
oncreate: (_vnode) => { oncreate: (_vnode) => {
const editor = codeMirror.fromTextArea( const theme = codeMirror.EditorView.theme({
_vnode.dom as HTMLTextAreaElement, "&.cm-editor": {
{ display: "block",
mode: vnode.attrs.mode, width: "50rem",
lineNumbers: true, height: "30rem",
readOnly: vnode.attrs.readOnly, "max-width": "100%",
extraKeys: { "border-radius": "0.375rem",
"Ctrl-Enter": () => { "border-width": "1px",
if (vnode.attrs.onSubmit) vnode.attrs.onSubmit(_vnode.dom); "border-color": "var(--color-stone-300)",
}, "box-shadow":
"Cmd-Enter": () => { "var(--tw-ring-shadow, 0 0 #0000), 0 1px 2px 0 rgb(0 0 0 / 0.05)",
if (vnode.attrs.onSubmit) vnode.attrs.onSubmit(_vnode.dom); overflow: "hidden",
}, "& > .cm-scroller": {
"font-family": "inherit",
"line-height": "inherit",
}, },
}, },
); "&.cm-editor.cm-focused": {
outline: "none",
"border-color": "var(--color-cyan-500)",
"--tw-ring-shadow": "0 0 0 1px var(--color-cyan-500)",
},
});
if (vnode.attrs.onChange) { const extensions = [
editor.on("change", (e) => { theme,
vnode.attrs.onChange(e.getValue()); codeMirror.lineNumbers(),
}); codeMirror.history(),
} codeMirror.syntaxHighlighting(codeMirror.defaultHighlightStyle),
codeMirror.keymap.of([
...codeMirror.defaultKeymap,
...codeMirror.historyKeymap,
]),
codeMirror.EditorState.readOnly.of(!!vnode.attrs.readOnly),
codeMirror.EditorView.updateListener.of((update) => {
if (update.docChanged && vnode.attrs.onChange)
vnode.attrs.onChange(update.state.doc.toString());
}),
];
if (vnode.attrs.mode === "javascript")
extensions.push(codeMirror.javascript());
else if (vnode.attrs.mode === "yaml")
extensions.push(codeMirror.yaml());
const editor = new codeMirror.EditorView({
state: codeMirror.EditorState.create({
doc: vnode.attrs.value,
extensions,
}),
parent: _vnode.dom as HTMLTextAreaElement,
});
if (vnode.attrs.focus) editor.focus(); if (vnode.attrs.focus) editor.focus();
if (vnode.attrs.onReady) vnode.attrs.onReady(editor);
}, },
}); });
}, },
+9
View File
@@ -0,0 +1,9 @@
export { EditorView, keymap, lineNumbers } from "@codemirror/view";
export { EditorState } from "@codemirror/state";
export { history, historyKeymap, defaultKeymap } from "@codemirror/commands";
export {
syntaxHighlighting,
defaultHighlightStyle,
} from "@codemirror/language";
export { javascript } from "@codemirror/lang-javascript";
export { yaml } from "@codemirror/lang-yaml";
+1 -1
View File
@@ -127,7 +127,7 @@ export function contextifyComponent(component: ComponentTypes): ComponentTypes {
return res; return res;
} catch (err) { } catch (err) {
return m( return m(
"p.error", "p.text-sm font-bold text-red-500 cursor-pointer",
{ {
title: "Click to print stack trace to console", title: "Click to print stack trace to console",
onclick: () => console.error(err), onclick: () => console.error(err),
+55 -32
View File
@@ -3,7 +3,7 @@ import { m } from "../components.ts";
import * as taskQueue from "../task-queue.ts"; import * as taskQueue from "../task-queue.ts";
import { parse } from "../../lib/common/expression/parser.ts"; import { parse } from "../../lib/common/expression/parser.ts";
import memoize from "../../lib/common/memoize.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 { QueryResponse, evaluateExpression } from "../store.ts";
import debounce from "../../lib/common/debounce.ts"; import debounce from "../../lib/common/debounce.ts";
import { Expression } from "../../lib/types.ts"; import { Expression } from "../../lib/types.ts";
@@ -55,14 +55,17 @@ const component: ClosureComponent<Attrs> = () => {
const limit = const limit =
(evaluateExpression(vnode.attrs.limit, device) as number) || 100; (evaluateExpression(vnode.attrs.limit, device) as number) || 100;
const search = m("input", { const search = m(
type: "text", "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",
placeholder: "Search parameters", {
oninput: (e) => { type: "text",
formQueryString(e.target.value); placeholder: "Search parameters",
e.redraw = false; oninput: (e) => {
formQueryString(e.target.value);
e.redraw = false;
},
}, },
}); );
const instanceRegex = /\.[0-9]+$/; const instanceRegex = /\.[0-9]+$/;
let re; let re;
@@ -92,8 +95,6 @@ const component: ClosureComponent<Attrs> = () => {
const rows = filteredKeys.map((k) => { const rows = filteredKeys.map((k) => {
const p = device[k]; const p = device[k];
const val = []; const val = [];
const attrs = { key: k };
if (p.object === false) { if (p.object === false) {
val.push( val.push(
m( 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 { } 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( return m(
"tr", "tr",
attrs, m("td.pl-4 pr-2 py-2 truncate", m("long-text", { text: k })),
m("td.left", m("long-text", { text: k })), m("td.pr-4 py-2 text-right flex justify-end", val),
m("td.right", val),
); );
}); });
@@ -168,25 +179,37 @@ const component: ClosureComponent<Attrs> = () => {
"loading", "loading",
{ queries: [vnode.attrs.deviceQuery] }, { queries: [vnode.attrs.deviceQuery] },
m( m(
".all-parameters", ".bg-white shadow-sm rounded-lg",
m(
"a.download-csv",
{
href: `api/devices/${encodeURIComponent(
device["DeviceID.ID"].value[0],
)}.csv`,
download: "",
style: "float: right;",
},
"Download",
),
search, search,
m( m(
".parameter-list", ".overflow-hidden",
m("table", m("tbody", rows)),
m( m(
"m", ".overflow-y-scroll h-96 shadow-inner",
`Displaying ${filteredKeys.length} out of ${count} parameters.`, 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( buttons.push(
m( 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", title: "Reboot device",
onclick: () => { onclick: () => {
@@ -29,7 +29,7 @@ const component: ClosureComponent = (): Component => {
buttons.push( buttons.push(
m( 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", title: "Factory reset device",
onclick: () => { onclick: () => {
@@ -45,7 +45,7 @@ const component: ClosureComponent = (): Component => {
buttons.push( buttons.push(
m( 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", title: "Push a firmware or a config file",
onclick: () => { onclick: () => {
@@ -61,7 +61,7 @@ const component: ClosureComponent = (): Component => {
buttons.push( buttons.push(
m( 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", title: "Delete device",
onclick: () => { 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 { m } from "../components.ts";
import * as store from "../store.ts"; import * as store from "../store.ts";
import * as notifications from "../notifications.ts"; import * as notifications from "../notifications.ts";
import { getIcon } from "../icons.ts";
import { stringify } from "../../lib/common/yaml.ts"; import { stringify } from "../../lib/common/yaml.ts";
const component: ClosureComponent = (): Component => { const component: ClosureComponent = (): Component => {
@@ -23,63 +22,111 @@ const component: ClosureComponent = (): Component => {
"Detail", "Detail",
"Retries", "Retries",
"Timestamp", "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 = []; const rows = [];
for (const f of faults.value) { for (const f of faults.value) {
rows.push([ 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()),
m( m(
"td", "tr",
m( m(
"button", "td.whitespace-nowrap pl-6 pr-3 py-4 text-sm text-stone-900",
{ f["channel"],
title: "Delete fault", ),
onclick: (e) => { m(
e.redraw = false; "td.whitespace-nowrap px-3 py-4 text-sm text-stone-900",
store f["code"],
.deleteResource("faults", f["_id"]) ),
.then(() => { m(
notifications.push("success", "Fault deleted"); "td.whitespace-nowrap px-3 py-4 text-sm text-stone-900",
store.setTimestamp(Date.now()); m("long-text", { text: f["message"], class: "max-w-xs" }),
m.redraw(); ),
}) m(
.catch((err) => { "td.whitespace-nowrap px-3 py-4 text-sm text-stone-900",
notifications.push("error", err.message); m("long-text", {
store.setTimestamp(Date.now()); 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());
});
},
}, },
}, "Delete",
getIcon("remove"), ),
), ),
), ),
]); );
} }
let tbody; if (!rows.length) {
if (rows.length) { rows.push(
tbody = m( m(
"tbody", "tr",
rows.map((r) => m("tr", r)), m(
); "td.bg-stripes text-sm font-medium text-center text-stone-500 p-4",
} else { { colspan: headers.length },
tbody = m( "No faults",
"tbody", ),
m("tr.empty", m("td", { colspan: headers.length }, "No faults")), ),
); );
} }
return m( return m(
"loading", "loading",
{ queries: [faults] }, { 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) { if (deviceId) {
return m( return m(
"a", "a.text-cyan-700 hover:text-cyan-900 font-medium",
{ href: `#!/devices/${encodeURIComponent(deviceId)}` }, { href: `#!/devices/${encodeURIComponent(deviceId)}` },
children, children,
); );
+4 -7
View File
@@ -16,25 +16,22 @@ const component: ClosureComponent = (): Component => {
const filter = slice["filter"]; const filter = slice["filter"];
if (evaluateExpression(filter, device || {})) { if (evaluateExpression(filter, device || {})) {
const dot = m( const dot = m(
"svg", "svg.inline",
{ {
width: "1em", width: "1em",
height: "1em", height: "1em",
style: "margin: 0 0.2em 0.2em",
xmlns: "http://www.w3.org/2000/svg", xmlns: "http://www.w3.org/2000/svg",
"xmlns:xlink": "http://www.w3.org/1999/xlink", "xmlns:xlink": "http://www.w3.org/1999/xlink",
}, },
m("circle", { m("circle.stroke-stone-200 stroke-1", {
cx: "0.5em", cx: "0.5em",
cy: "0.5em", cy: "0.5em",
r: "0.4em", r: "0.4em",
fill: evaluateExpression(slice["color"], null), fill: evaluateExpression(slice["color"], null),
}), }),
); );
return m( return m("span", dot, evaluateExpression(slice["label"], null));
"span.overview-dot",
dot,
evaluateExpression(slice["label"], null),
);
} }
} }
return null; return null;
+11 -4
View File
@@ -30,7 +30,7 @@ const component: ClosureComponent<Attrs> = () => {
); );
return m( return m(
"tr", "div.py-3 grid grid-cols-3 gap-4 px-6",
{ {
oncreate: (vn) => { oncreate: (vn) => {
(vn.dom as HTMLElement).style.display = (p as VnodeDOM).dom (vn.dom as HTMLElement).style.display = (p as VnodeDOM).dom
@@ -43,15 +43,22 @@ const component: ClosureComponent<Attrs> = () => {
: "none"; : "none";
}, },
}, },
m("th", evaluateExpression(parameter.label, device)), m(
m("td", p), "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( return m(
"loading", "loading",
{ queries: [vnode.attrs.deviceQuery] }, { 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 * as taskQueue from "../task-queue.ts";
import { QueryResponse, evaluateExpression } from "../store.ts"; import { QueryResponse, evaluateExpression } from "../store.ts";
import * as expressionParser from "../../lib/common/expression/parser.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 { FlatDevice } from "../../lib/ui/db.ts";
import { Expression } from "../../lib/types.ts"; import { Expression } from "../../lib/types.ts";
@@ -42,11 +42,24 @@ const component: ClosureComponent<Attrs> = () => {
} }
} }
const headers = Object.values(parameters).map((p) => const headers = Object.values(parameters).map((p, i) => {
m("th", evaluateExpression(p["label"], device)), 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 = []; const rows = [];
for (const i of instances) { for (const i of instances) {
@@ -60,7 +73,9 @@ const component: ClosureComponent<Attrs> = () => {
if (!evaluateExpression(filter, device)) continue; 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) => { const param = expressionParser.map(p.parameter, (e) => {
if (Array.isArray(e) && e[0] === "PARAM") if (Array.isArray(e) && e[0] === "PARAM")
return ["PARAM", ["||", i, ".", e[1]]]; return ["PARAM", ["||", i, ".", e[1]]];
@@ -68,6 +83,9 @@ const component: ClosureComponent<Attrs> = () => {
}); });
return m( return m(
"td", "td",
{
class: "whitespace-nowrap py-4 text-sm text-stone-900 " + padding,
},
m.context( m.context(
{ {
device: device, device: device,
@@ -87,6 +105,10 @@ const component: ClosureComponent<Attrs> = () => {
row.push( row.push(
m( m(
"td", "td",
{
class:
"whitespace-nowrap pl-3 pr-6 py-4 text-sm text-stone-900",
},
m( m(
"button", "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)); rows.push(m("tr", row));
} }
if (!rows.length) { if (!rows.length) {
rows.push( 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( m(
"loading", "loading",
{ queries: [vnode.attrs.deviceQuery] }, { 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 { evaluate } from "../../lib/common/expression/util.ts";
import memoize from "../../lib/common/memoize.ts"; import memoize from "../../lib/common/memoize.ts";
import timeAgo from "../timeago.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) => { const evaluateParam = memoize((exp, obj, now: number) => {
let timestamp = now; 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}` }); const el = m("long-text", { text: `${value}` });
return m( return m(
"span", "span.inline-flex overflow-hidden align-top",
{ {
class: "parameter-value",
onmouseover: (e) => { onmouseover: (e) => {
e.redraw = false; e.redraw = false;
// Don't update any child element // Don't update any child element
@@ -104,7 +106,7 @@ const component: ClosureComponent = (): Component => {
} }
}, },
}, },
el, m("span.truncate", el),
edit, 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"]; const device = vnode.attrs["device"];
return m( 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", title: "Initiate session and refresh basic parameters",
onclick: (e) => { onclick: (e) => {
e.target.disabled = true; e.target.disabled = true;
+40 -9
View File
@@ -2,7 +2,7 @@ import { ClosureComponent } from "mithril";
import { m } from "../components.ts"; import { m } from "../components.ts";
import * as notifications from "../notifications.ts"; import * as notifications from "../notifications.ts";
import * as store from "../store.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 { decodeTag } from "../../lib/util.ts";
import { Expression } from "../../lib/types.ts"; import { Expression } from "../../lib/types.ts";
import { FlatDevice } from "../../lib/ui/db.ts"; import { FlatDevice } from "../../lib/ui/db.ts";
@@ -28,20 +28,36 @@ const component: ClosureComponent<Attrs> = () => {
if (!writable) { if (!writable) {
return m( return m(
".tags", "div",
tags.map((t) => m("span.tag", t)), 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( return m(
".tags", "div",
tags.map((tag) => tags.map((tag) =>
m( 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, tag,
m( m(
"button", "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) => { onclick: (e) => {
e.target.disabled = true; e.target.disabled = true;
const deviceId = device["DeviceID.ID"].value[0] as string; 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( m(
"span.tag.writable", "span",
m.trust("&nbsp;"), {
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( m(
"button", "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) => { onclick: (e) => {
e.target.disabled = true; e.target.disabled = true;
const deviceId = device["DeviceID.ID"].value[0] as string; 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",
}),
), ),
), ),
); );
+41 -26
View File
@@ -7,7 +7,7 @@ import uiConfigComponent from "./ui-config-component.ts";
import * as overlay from "./overlay.ts"; import * as overlay from "./overlay.ts";
import { parse, stringify } from "../lib/common/expression/parser.ts"; import { parse, stringify } from "../lib/common/expression/parser.ts";
import { loadCodeMirror, loadYaml } from "./dynamic-loader.ts"; import { loadCodeMirror, loadYaml } from "./dynamic-loader.ts";
import { getIcon } from "./icons.ts"; import { icon } from "./tailwind-utility-components.ts";
const attributes = [ const attributes = [
{ id: "_id", label: "Key" }, { id: "_id", label: "Key" },
@@ -64,6 +64,7 @@ function putActionHandler(action, _object, isNew?): Promise<ValidationErrors> {
}) })
.catch(reject); .catch(reject);
} else if (action === "delete") { } else if (action === "delete") {
if (!confirm("Deleting config. Are you sure?")) return void resolve(null);
store store
.deleteResource("config", object["_id"]) .deleteResource("config", object["_id"])
.then(() => { .then(() => {
@@ -166,7 +167,10 @@ function renderTable(confsResponse, searchString): Children {
); );
}, },
}, },
getIcon("edit"), m(icon, {
name: "edit",
class: "inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900",
}),
); );
const del = m( const del = m(
@@ -181,26 +185,32 @@ function renderTable(confsResponse, searchString): Children {
}); });
}, },
}, },
getIcon("remove"), m(icon, {
name: "remove",
class: "inline h-4 w-4 ml-1 text-cyan-700 hover:text-cyan-900",
}),
); );
rows.push( rows.push(
m( m(
"tr", "tr",
attrs, attrs,
m("td.left", m("long-text", { text: conf._id })), m("td.pl-4 pr-2 py-2 truncate", m("long-text", { text: conf._id })),
m( m(
"td.right", "td.px-2 py-2 text-right truncate",
m("span", [m("long-text", { text: `${conf.value}` }), edit, del]), m("long-text", { text: `${conf.value}` }),
), ),
m("td.pl-2 pr-4 py-2 w-max", edit, del),
), ),
); );
} }
if (!rows.length) if (!rows.length) rows.push(m("tr", m("td", { colspan: 3 }, "No config")));
rows.push(m("tr.empty", m("td", { colspan: 2 }, "No config")));
return m("table", m("tbody", rows)); return m(
"table.w-full table-fixed font-mono text-sm text-stone-700",
m("tbody.bg-white divide-y divide-stone-200", rows),
);
} }
export const component: ClosureComponent = (): Component => { export const component: ClosureComponent = (): Component => {
@@ -208,16 +218,19 @@ export const component: ClosureComponent = (): Component => {
view: (vnode) => { view: (vnode) => {
document.title = "Config - GenieACS"; document.title = "Config - GenieACS";
const search = m("input", { const search = m(
type: "text", "input.appearance-none block w-full px-3 py-2 border-stone-300 placeholder-stone-500 text-stone-900 focus:ring-cyan-500 focus:border-cyan-500 sm:text-sm rounded-md mt-1 max-w-screen-sm shadow-sm mb-5",
placeholder: "Search config", {
oninput: (e) => { type: "text",
vnode.state["searchString"] = e.target.value; placeholder: "Search config",
e.redraw = false; oninput: (e) => {
clearTimeout(vnode.state["timeout"]); vnode.state["searchString"] = e.target.value;
vnode.state["timeout"] = setTimeout(m.redraw, 250); e.redraw = false;
clearTimeout(vnode.state["timeout"]);
vnode.state["timeout"] = setTimeout(m.redraw, 250);
},
}, },
}); );
const confs = store.fetch("config", true); const confs = store.fetch("config", true);
@@ -226,7 +239,7 @@ export const component: ClosureComponent = (): Component => {
if (window.authorizer.hasAccess("config", 3)) { if (window.authorizer.hasAccess("config", 3)) {
newConfig = m( newConfig = m(
"button.primary", "button.mr-4 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",
{ {
title: "Create new config", title: "Create new config",
onclick: () => { onclick: () => {
@@ -295,7 +308,7 @@ export const component: ClosureComponent = (): Component => {
const attrs = { prefix: sub.prefix, name: sub.name, data: sub.data }; const attrs = { prefix: sub.prefix, name: sub.name, data: sub.data };
subs.push( subs.push(
m( m(
"button", "button.mr-4 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",
{ {
onclick: () => { onclick: () => {
let cb: () => Children = null; let cb: () => Children = null;
@@ -345,19 +358,21 @@ export const component: ClosureComponent = (): Component => {
} }
return [ return [
m("h1", "Listing config"), m("h1.text-xl font-medium text-stone-900 mb-5", "Listing config"),
m( m(
"loading", "loading",
{ queries: [confs] }, { queries: [confs] },
m( m(
".all-parameters", "div",
search, search,
m( m(
".parameter-list", ".shadow-sm overflow-hidden border-b border-stone-200 rounded-lg bg-white",
{ style: "height: 400px" }, m(
renderTable(confs, vnode.state["searchString"]), ".overflow-y-scroll h-96",
renderTable(confs, vnode.state["searchString"]),
),
), ),
m(".actions-bar", [newConfig].concat(subs)), m(".mt-5", [newConfig].concat(subs)),
), ),
), ),
]; ];
Binary file not shown.
Binary file not shown.
Binary file not shown.
+140 -779
View File
@@ -1,805 +1,166 @@
@import "normalize.css"; @import "tailwindcss";
@import "codemirror/lib/codemirror.css"; @plugin "@tailwindcss/forms";
:root { @source inline("h-full bg-stone-100");
--base-font-size: 13px; @source "../../ui/**/*.{ts,tsx}";
--base-line-height: 16px;
--color1: #ebe0bf; @theme {
--color2: #b9b098; --font-sans:
--color3: #f2ebd5; "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
--color4: #046380; "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--color5: #002f2f; --font-mono:
"Roboto Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
--fade: 0.2s; /* Reset default color palette to match v3 exclusive palette */
--color-*: initial;
--disabled: #666; --color-black: #000;
--color-white: #fff;
--status-red: 0 80% 80%; --color-cyan-50: #edfdfe;
--status-green: 125 80% 80%; --color-cyan-100: #d3f7fa;
--status-gray: 58 3% 80%; --color-cyan-200: #aceef5;
--status-yellow: 46 79% 80%; --color-cyan-300: #72e0ee;
--color-cyan-400: #31c8df;
--color-cyan-500: #15abc5;
--color-cyan-600: #1589a5;
--color-cyan-700: #186e86;
--color-cyan-800: #1c5a6e;
--color-cyan-900: #1c4b5d;
--color-stone-50: #faf8f8;
--color-stone-100: #f5f2f0;
--color-stone-200: #e7e4e2;
--color-stone-300: #d6d3d1;
--color-stone-400: #a8a29e;
--color-stone-500: #78716c;
--color-stone-600: #57534e;
--color-stone-700: #44403c;
--color-stone-800: #292524;
--color-stone-900: #1c1917;
--color-red-50: #fdf3f3;
--color-red-100: #fce4e4;
--color-red-200: #facece;
--color-red-300: #f5acac;
--color-red-400: #ee7b7b;
--color-red-500: #e25151;
--color-red-600: #ce3434;
--color-red-700: #ad2828;
--color-red-800: #902424;
--color-red-900: #782424;
--color-emerald-50: #edfcf5;
--color-emerald-100: #d4f7e5;
--color-emerald-200: #adedd0;
--color-emerald-300: #77deb5;
--color-emerald-400: #40c796;
--color-emerald-500: #1dac7d;
--color-emerald-600: #108b65;
--color-emerald-700: #0d6f53;
--color-emerald-800: #0d5843;
--color-emerald-900: #0b4938;
--color-yellow-50: #fcfbea;
--color-yellow-100: #faf5c7;
--color-yellow-200: #f5e993;
--color-yellow-300: #efd755;
--color-yellow-400: #e9c226;
--color-yellow-500: #d9aa19;
--color-yellow-600: #bb8513;
--color-yellow-700: #956013;
--color-yellow-800: #7c4c17;
--color-yellow-900: #6a3f19;
--color-blue-50: #f0f6fe;
--color-blue-100: #deeafb;
--color-blue-200: #c4dcf9;
--color-blue-300: #9bc5f5;
--color-blue-400: #6ca6ee;
--color-blue-500: #4985e8;
--color-blue-600: #3469dc;
--color-blue-700: #2b55ca;
--color-blue-800: #2946a4;
--color-blue-900: #263e82;
--background-image-stripes: repeating-linear-gradient(
-45deg,
transparent,
transparent 10px,
rgba(0, 0, 0, 0.02) 10px,
rgba(0, 0, 0, 0.02) 20px
);
} }
body, @supports (font-variation-settings: normal) {
input, @font-face {
button, font-family: "Roboto Mono";
select, src: url("RobotoMono.woff2") format("woff2-variations");
option, font-weight: 100 700;
textarea { font-style: normal;
outline-color: var(--color4); unicode-range:
font-family: sans-serif; U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
color: var(--color5); U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
} U+FEFF, U+FFFD;
font-display: swap;
}
a { @font-face {
color: var(--color4); font-family: "Inter";
cursor: pointer; font-weight: 100 900;
transition: font-display: swap;
background-color var(--fade), font-style: normal;
color var(--fade); font-named-instance: "Regular";
&:visited { src: url("InterVariable.woff2") format("woff2-variations");
color: var(--color4); unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
} }
&:hover {
color: white; @font-face {
background-color: var(--color4); font-family: "Inter";
} font-weight: 100 900;
/* text-decoration does not propagate to inline-block children */ font-display: swap;
& span { font-style: italic;
text-decoration: inherit; font-named-instance: "Italic";
src: url("InterVariable-Italic.woff2") format("woff2-variations");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
} }
} }
strong, .device-page {
b { h1 {
font-weight: bold; @apply text-xl font-medium text-stone-900 mb-5;
}
i {
font-style: italic;
}
@keyframes pending-blink {
from {
background-color: hsl(var(--status-green) / 0.9);
}
to {
background-color: transparent;
}
}
body {
height: 100vh;
display: flex;
flex-direction: column;
}
#content-wrapper {
flex-grow: 1;
display: flex;
overflow-x: auto;
overflow-y: auto;
}
#header {
background-color: #f2eddf;
border-bottom: 1px solid var(--color1);
overflow: visible;
position: relative;
& > .drawer-wrapper {
position: fixed;
z-index: 10;
width: 720px;
margin-left: -360px;
top: 0;
left: 50%;
& > .drawer {
background-color: rgba(255, 255, 255, 0.9);
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
border-bottom: 1px solid var(--color1);
border-left: 1px solid var(--color1);
border-right: 1px solid var(--color1);
margin-top: -1px;
box-shadow: 0 0 10px #ccc;
overflow: hidden;
transition:
height var(--fade),
opacity var(--fade);
& > div {
padding: 0.3em;
}
& > .status {
display: flex;
& > span {
margin-right: 1em;
padding-left: 0.2em;
padding-right: 0.2em;
border-radius: 0.4em;
}
& > .pending.active {
&.active {
animation: 0.5s linear 0s infinite alternate pending-blink;
}
}
& > .stale.active {
background-color: hsl(var(--status-gray) / 0.9);
}
& > .fault.active {
background-color: hsl(var(--status-red) / 0.9);
}
& > .queued.active {
font-weight: bold;
}
& > .actions {
flex-grow: 1;
text-align: right;
}
}
& > .staging {
& .parameter {
font-style: italic;
max-width: 500px;
text-overflow: ellipsis;
display: inline-block;
overflow: hidden;
vertical-align: text-bottom;
}
& input {
width: 100%;
box-sizing: border-box;
padding: 0.3em;
margin-top: 0.3em;
margin-bottom: 0.3em;
}
& .actions {
text-align: right;
}
}
& > .queue {
max-height: 400px;
overflow-y: auto;
& > .pending {
animation: 0.5s linear 0s infinite alternate pending-blink;
border-radius: 0.4em;
}
& > .stale {
background-color: hsl(var(--status-gray) / 0.9);
border-radius: 0.4em;
}
& > .fault {
background-color: hsl(var(--status-red) / 0.9);
border-radius: 0.4em;
}
& .parameter {
font-style: italic;
max-width: 400px;
text-overflow: ellipsis;
display: inline-block;
overflow: hidden;
vertical-align: text-bottom;
}
& .value {
max-width: 200px;
text-overflow: ellipsis;
display: inline-block;
overflow: hidden;
vertical-align: text-bottom;
white-space: nowrap;
}
& > div {
display: flex;
& > .actions {
flex-grow: 1;
text-align: right;
}
}
}
}
& > .notifications-wrapper {
& > .notification {
padding: 0.3em;
left: 0;
right: 0;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid var(--color1);
border-radius: 6px;
box-shadow: 0 0 10px #ccc;
transition:
opacity var(--fade),
top var(--fade);
&.error {
background-color: hsl(var(--status-red) / 0.9);
border: 1px solid hsl(var(--status-red));
}
&.success {
background-color: hsl(var(--status-green) / 0.9);
border: 1px solid hsl(var(--status-green));
}
&.warning {
background-color: hsl(var(--status-yellow) / 0.9);
border: 1px solid hsl(var(--status-yellow));
}
}
}
}
& > nav {
display: inline-block;
overflow: visible;
position: absolute;
bottom: -1px;
& > ul {
font-weight: bold;
display: inline-block;
margin: 0 10px;
padding: 0;
& > li {
margin: 0 2px;
background-color: var(--color1);
display: inline-block;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border-top: 1px solid var(--color1);
border-left: 1px solid var(--color1);
border-right: 1px solid var(--color1);
transition: background-color var(--fade);
&.active,
&:hover {
background-color: white;
}
& > a,
& > a:visited,
& > a:hover {
display: inline-block;
min-width: 140px;
color: var(--color4);
text-decoration: none;
background: none;
padding: 8px;
}
}
}
} }
& > .logo { h2 {
display: inline-block; @apply text-lg font-semibold text-stone-900 mb-5 mt-8;
position: relative;
& > img {
margin: 10px;
height: 56px;
vertical-align: top;
}
& > .version {
position: absolute;
bottom: 5px;
right: 10px;
font-family: monospace;
font-size: 10px;
color: #666;
}
} }
& > .user-menu { h3 {
float: right; @apply text-lg font-medium text-stone-900 mb-5 mt-8;
margin: 5px;
} }
}
#side-menu { span.inform {
width: 200px; @apply flex gap-3;
min-width: 200px; & > button {
& > ul { @apply -my-1.5;
padding: 0;
list-style: none;
& > li {
& > a {
display: block;
text-decoration: none;
margin: 5px 0;
padding: 8px;
border-bottom-right-radius: 6px;
border-top-right-radius: 6px;
background-color: var(--color3);
&:hover {
background-color: var(--color4);
color: var(--color3);
}
}
&.active {
font-weight: bold;
}
} }
} }
} }
#content { @layer base {
flex-grow: 1; button:not(:disabled),
margin: 10px; [role="button"]:not(:disabled) {
min-height: 250px; cursor: pointer;
/* Workaround for Chrome ignoring bottom margin when flex container overflows */
&::after {
content: "";
display: block;
padding-bottom: 1px;
}
}
table.table {
border-spacing: 0;
text-align: left;
overflow-x: auto;
& table.table {
font-size: 90%;
} }
& th { :focus-visible {
white-space: nowrap; @apply outline-2 outline-cyan-500;
color: var(--color4);
font-weight: bold;
border-bottom: 2px solid var(--color4);
margin: 0;
padding: 0.2em 0.5em;
}
& td {
margin: 0;
padding: 0.2em 0.5em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400px;
}
& tfoot td > a {
margin-left: 1em;
}
&.highlight > tbody > tr {
& > td {
border-bottom: 1px dotted var(--color1);
}
transition: background-color var(--fade);
&:hover {
background-color: var(--color3);
}
}
& tbody > tr.empty {
text-align: center;
color: var(--disabled);
&:hover {
background-color: initial;
}
} }
} }
h1 {
color: var(--color4);
font-size: 130%;
font-weight: bold;
}
h2 {
color: var(--color5);
font-weight: bold;
font-size: 120%;
}
h3 {
color: var(--color5);
font-size: 110%;
}
p.error {
color: #a00;
}
/* Remove button padding in FF */
input::-moz-focus-inner {
border: 0;
padding: 0;
}
input[type="button"],
button {
background: transparent;
border: 0;
color: var(--color4);
border-radius: 4px;
transition:
background-color var(--fade),
color var(--fade);
display: inline-block;
vertical-align: middle;
&.primary {
border: 1px solid var(--color1);
background-color: var(--color3);
}
&.critical {
border: 1px solid var(--color1);
background-color: var(--color3);
color: #700;
}
&:hover:enabled {
background-color: var(--color4);
color: var(--color3);
}
&:disabled {
color: var(--disabled);
cursor: not-allowed;
}
}
.CodeMirror,
textarea,
select,
input {
display: block;
border: 1px solid var(--color1);
background-color: var(--color3);
border-radius: 4px;
transition:
border-color var(--fade),
background-color var(--fade);
&.error {
background-color: #efcbcf;
border-color: #e6b2b7;
}
&:focus,
&:hover {
border-color: var(--color4);
}
}
.filter {
margin-bottom: 0.5em;
& > input {
font-size: 90%;
width: 35em;
margin: 2px;
padding: 2px;
}
}
.page-overview {
& > h1 {
text-align: center;
}
}
.overview-chart-group {
display: flex;
justify-content: center;
& h2 {
text-align: center;
}
}
table.parameter-list {
border-spacing: 0;
text-align: left;
& th {
padding: 0;
padding-right: 1em;
}
}
.overview-dot {
& > svg {
margin-bottom: -0.125em;
margin-left: 0.2em;
margin-right: 0.2em;
& > circle {
stroke: var(--color5);
stroke-width: 0.08em;
}
}
}
span.inform {
& > * {
margin-right: 0.5em;
}
}
.pie-chart {
margin: 20px;
text-align: center;
& > svg {
& > path {
stroke: var(--color1);
stroke-width: 1px;
}
& > a {
transition: opacity var(--fade);
opacity: 0;
& > text {
font-weight: bold;
opacity: 0.4;
}
& > path {
stroke: var(--color4);
}
&:hover {
opacity: 1;
}
}
}
& > .legend {
text-align: left;
margin: 15px;
& > .legend-line {
& > .color {
height: 12px;
width: 12px;
margin-right: 5px;
border: 1px solid var(--color1);
display: inline-block;
}
}
}
}
span.na {
color: var(--disabled);
}
.all-parameters {
& > input {
font-family: monospace;
font-size: 14px;
margin-bottom: 2px;
padding: 4px;
width: 700px;
}
& > .parameter-list {
font-family: monospace;
font-size: 14px;
& button {
font-size: 16px;
}
overflow-y: scroll;
height: 200px;
& > table {
width: 100%;
table-layout: fixed;
border-spacing: 0;
& > tbody {
& > tr {
& > td {
overflow: hidden;
white-space: nowrap;
border-bottom: 1px dotted var(--color1);
}
transition: background-color var(--fade);
&:hover {
background-color: var(--color3);
}
& > td.left {
& > * {
max-width: 100%;
}
}
& > td.right {
text-align: right;
padding-right: 0.5em;
}
}
}
}
}
}
.container-right {
float: right;
clear: right;
}
.container-full-width {
clear: both;
}
.actions-bar {
margin-top: 10px;
margin-bottom: 10px;
& > button {
margin-right: 10px;
}
}
span.tag {
display: inline-block;
background-image: url('data:image/svg+xml;utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="190" height="16"><path d="M6.59 15.42l-6-6a2 2 0 0 1 0-2.83l6-6A2 2 0 0 1 8 0h180a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H8a2 2 0 0 1-1.41-.58zM4.35 6.59A2 2 0 1 0 7.17 9.4 2 2 0 0 0 4.35 6.6z" fill="%23e8d5b2"></path></svg>');
background-size: cover;
background-repeat: no-repeat;
border-radius: 0.2em;
padding-left: 1em;
padding-right: 0.2em;
}
.tags {
& > .tag {
margin-right: 0.5em;
}
}
.overlay-wrapper {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.2);
z-index: 100;
transition:
opacity var(--fade),
top var(--fade);
display: flex;
align-items: center;
justify-content: center;
& > .overlay {
max-width: 90vw;
max-height: 90vh;
background-color: white;
border: 1px solid var(--color1);
border-radius: 10px;
padding: 10px;
box-shadow: 0 0 15px #aaa;
overflow: auto;
}
}
.overlay > .put-form {
min-width: 700px;
}
.autocomplete {
box-shadow: 0 0 10px #ccc;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid var(--color1);
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
transition: opacity var(--fade);
& > .suggestion {
padding: 5px;
&.selected,
&:hover {
background-color: var(--color3);
}
}
}
span.long-text {
display: inline-block;
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400px;
/*
"overflow: hidden" changes the baseline as explained here:
https://stackoverflow.com/questions/9273016/why-is-this-inline-block-element-pushed-downward
*/
vertical-align: top;
}
span.long-text-overflowed {
cursor: pointer;
&:hover {
text-decoration: underline;
text-decoration-style: dotted;
}
}
textarea.long-text {
font-family: monospace;
font-size: 14px;
min-width: 10vw;
min-height: 10vh;
}
span.parameter-value {
& > span:empty::before {
content: "blank";
color: #aaa;
}
}
.wizard-dialog {
max-width: 720px;
margin: 10px auto;
padding: 10px;
border-radius: 6px;
border: 1px solid var(--color1);
box-shadow: 0 0 10px #ccc;
}
.icon {
display: block;
width: 1em;
height: 1em;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
&.icon-unsorted {
opacity: 0.3;
}
}
.loading {
opacity: 0.6;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-overlay {
display: flex;
align-items: center;
justify-content: center;
&::after {
background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCI+PHBhdGggZD0iTTEwIDUwYTQwIDQwIDAgMDA4MCAwIDQwIDQyIDAgMDEtODAgMCIgZmlsbD0iI2IxMmQ1YyIgLz48L3N2Zz4=);
content: "";
width: 48px;
height: 48px;
animation: spin 1s linear infinite;
background-repeat: no-repeat;
background-size: 100% 100%;
}
}
.progress {
background-color: #eee;
overflow: hidden;
height: 3px;
& > .progress-bar {
height: 100%;
background-color: #b12d5c;
}
}
/* To keep consistent style after changing from Quirks to Standards mode */
td,
th {
line-height: normal;
}
-450
View File
@@ -1,450 +0,0 @@
/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in
* IE on Windows Phone and in iOS.
*/
html {
line-height: 1.15; /* 1 */
-ms-text-size-adjust: 100%; /* 2 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers (opinionated).
*/
body {
margin: 0;
}
/**
* Add the correct display in IE 9-.
*/
article,
aside,
footer,
header,
nav,
section {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* Add the correct display in IE 9-.
* 1. Add the correct display in IE.
*/
figcaption,
figure,
main {
/* 1 */
display: block;
}
/**
* Add the correct margin in IE 8.
*/
figure {
margin: 1em 40px;
}
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* 1. Remove the gray background on active links in IE 10.
* 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
*/
a {
background-color: transparent; /* 1 */
-webkit-text-decoration-skip: objects; /* 2 */
}
/**
* 1. Remove the bottom border in Chrome 57- and Firefox 39-.
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Prevent the duplicate application of `bolder` by the next rule in Safari 6.
*/
b,
strong {
font-weight: inherit;
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font style in Android 4.3-.
*/
dfn {
font-style: italic;
}
/**
* Add the correct background and color in IE 9-.
*/
mark {
background-color: #ff0;
color: #000;
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Add the correct display in IE 9-.
*/
audio,
video {
display: inline-block;
}
/**
* Add the correct display in iOS 4-7.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Remove the border on images inside links in IE 10-.
*/
img {
border-style: none;
}
/**
* Hide the overflow in IE.
*/
svg:not(:root) {
overflow: hidden;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers (opinionated).
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: sans-serif; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input {
/* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select {
/* 1 */
text-transform: none;
}
/**
* 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
* controls in Android 4.
* 2. Correct the inability to style clickable types in iOS and Safari.
*/
button,
html [type="button"],
/* 1 */ [type="reset"],
[type="submit"] {
-webkit-appearance: button; /* 2 */
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* 1. Add the correct display in IE 9-.
* 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
display: inline-block; /* 1 */
vertical-align: baseline; /* 2 */
}
/**
* Remove the default vertical scrollbar in IE.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10-.
* 2. Remove the padding in IE 10-.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-cancel-button,
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in IE 9-.
* 1. Add the correct display in Edge, IE, and Firefox.
*/
details,
/* 1 */ menu {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Scripting
========================================================================== */
/**
* Add the correct display in IE 9-.
*/
canvas {
display: inline-block;
}
/**
* Add the correct display in IE.
*/
template {
display: none;
}
/* Hidden
========================================================================== */
/**
* Add the correct display in IE 10-.
*/
[hidden] {
display: none;
}
+7 -3
View File
@@ -25,8 +25,12 @@ export const component: ClosureComponent = (): Component => {
const dev = store.fetch("devices", vnode.attrs["deviceFilter"]); const dev = store.fetch("devices", vnode.attrs["deviceFilter"]);
if (!dev.value.length) { if (!dev.value.length) {
if (!dev.fulfilling) if (!dev.fulfilling) {
return m("p.error", `No such device ${vnode.attrs["deviceId"]}`); return m(
"p.text-sm font-bold text-red-500",
`No such device ${vnode.attrs["deviceId"]}`,
);
}
return m( return m(
"loading", "loading",
{ queries: [dev] }, { queries: [dev] },
@@ -47,7 +51,7 @@ export const component: ClosureComponent = (): Component => {
); );
} }
return [m("h1", vnode.attrs["deviceId"]), cmps]; return m("div.device-page", m("h1", vnode.attrs["deviceId"]), cmps);
}, },
}; };
}; };
+8 -8
View File
@@ -69,7 +69,7 @@ function renderActions(selected: Set<string>): Children {
buttons.push( buttons.push(
m( 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 selected devices", title: "Reboot selected devices",
disabled: !selected.size, disabled: !selected.size,
@@ -87,7 +87,7 @@ function renderActions(selected: Set<string>): Children {
buttons.push( buttons.push(
m( 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 selected devices", title: "Factory reset selected devices",
disabled: !selected.size, disabled: !selected.size,
@@ -105,7 +105,7 @@ function renderActions(selected: Set<string>): Children {
buttons.push( buttons.push(
m( 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", title: "Push a firmware or a config file",
disabled: !selected.size, disabled: !selected.size,
@@ -122,7 +122,7 @@ function renderActions(selected: Set<string>): Children {
buttons.push( buttons.push(
m( 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 selected devices", title: "Delete selected devices",
disabled: !selected.size, disabled: !selected.size,
@@ -153,7 +153,7 @@ function renderActions(selected: Set<string>): Children {
buttons.push( buttons.push(
m( 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: "Tag selected devices", title: "Tag selected devices",
disabled: !selected.size, disabled: !selected.size,
@@ -185,7 +185,7 @@ function renderActions(selected: Set<string>): Children {
buttons.push( buttons.push(
m( 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: "Untag selected devices", title: "Untag selected devices",
disabled: !selected.size, disabled: !selected.size,
@@ -299,7 +299,7 @@ export const component: ClosureComponent = (): Component => {
attrs["valueCallback"] = valueCallback; attrs["valueCallback"] = valueCallback;
attrs["recordActionsCallback"] = (device): Children => { attrs["recordActionsCallback"] = (device): Children => {
return m( return m(
"a", "a.text-cyan-700 hover:text-cyan-900",
{ {
href: `#!/devices/${encodeURIComponent( href: `#!/devices/${encodeURIComponent(
device["DeviceID.ID"].value[0], device["DeviceID.ID"].value[0],
@@ -319,7 +319,7 @@ export const component: ClosureComponent = (): Component => {
}; };
return [ return [
m("h1", "Listing devices"), m("h1.text-xl font-medium text-stone-900 mb-5", "Listing devices"),
m(filterComponent, filterAttrs), m(filterComponent, filterAttrs),
m("loading", { queries: [devs, count] }, m(indexTableComponent, attrs)), m("loading", { queries: [devs, count] }, m(indexTableComponent, attrs)),
]; ];
+239 -118
View File
@@ -7,7 +7,7 @@ import m, {
} from "mithril"; } from "mithril";
import * as store from "./store.ts"; import * as store from "./store.ts";
import * as notifications from "./notifications.ts"; import * as notifications from "./notifications.ts";
import { getIcon } from "./icons.ts"; import { icon } from "./tailwind-utility-components.ts";
import { import {
clear, clear,
commit, commit,
@@ -21,14 +21,6 @@ import {
const invalid: WeakSet<StageTask> = new WeakSet(); const invalid: WeakSet<StageTask> = new WeakSet();
function mparam(param): Children {
return m("span.parameter", { title: param }, `${param}`);
}
function mval(val): Children {
return m("span.value", { title: val }, `${val}`);
}
function renderStagingSpv(task: StageTask, queueFunc, cancelFunc): Children { function renderStagingSpv(task: StageTask, queueFunc, cancelFunc): Children {
function keydown(e: KeyboardEvent): void { function keydown(e: KeyboardEvent): void {
if (e.key === "Enter") queueFunc(); if (e.key === "Enter") queueFunc();
@@ -39,7 +31,7 @@ function renderStagingSpv(task: StageTask, queueFunc, cancelFunc): Children {
let input; let input;
if (task.parameterValues[0][2] === "xsd:boolean") { if (task.parameterValues[0][2] === "xsd:boolean") {
input = m( input = m(
"select", "select.mt-1 w-full block pl-3 pr-10 py-2 text-base border-stone-300 focus:outline-hidden focus:ring-cyan-500 focus:border-cyan-500 sm:text-sm rounded-md",
{ {
value: task.parameterValues[0][1].toString(), value: task.parameterValues[0][1].toString(),
onchange: (e) => { onchange: (e) => {
@@ -61,25 +53,43 @@ function renderStagingSpv(task: StageTask, queueFunc, cancelFunc): Children {
let value = task.parameterValues[0][1]; let value = task.parameterValues[0][1];
if (type === "xsd:dateTime" && typeof value === "number") if (type === "xsd:dateTime" && typeof value === "number")
value = new Date(value).toJSON() || value; value = new Date(value).toJSON() || value;
input = m("input", { input = m(
type: ["xsd:int", "xsd:unsignedInt"].includes(type) ? "number" : "text", "input.mt-1 w-full shadow-xs focus:ring-cyan-500 focus:border-cyan-500 block sm:text-sm border-stone-300 rounded-md",
value: value, {
oninput: (e) => { type: ["xsd:int", "xsd:unsignedInt"].includes(type) ? "number" : "text",
e.redraw = false; value: value,
task.parameterValues[0][1] = input.dom.value; oninput: (e) => {
e.redraw = false;
task.parameterValues[0][1] = input.dom.value;
},
onkeydown: keydown,
oncreate: (vnode) => {
(vnode.dom as HTMLInputElement).focus();
(vnode.dom as HTMLInputElement).select();
// Need to prevent scrolling on focus because
// we're animating height and using overflow: hidden
(vnode.dom.parentNode.parentNode as Element).scrollTop = 0;
},
}, },
onkeydown: keydown, );
oncreate: (vnode) => {
(vnode.dom as HTMLInputElement).focus();
(vnode.dom as HTMLInputElement).select();
// Need to prevent scrolling on focus because
// we're animating height and using overflow: hidden
(vnode.dom.parentNode.parentNode as Element).scrollTop = 0;
},
});
} }
return [m("span", "Editing ", mparam(task.parameterValues[0][0])), input]; return [
m(
"span.text-sm text-stone-700 inline-flex max-w-full gap-2",
"Editing",
m(
"span",
{
title: task.parameterValues[0][0],
dir: "rtl",
class: "italic pr-1 min-w-0 truncate",
},
task.parameterValues[0][0],
),
),
input,
];
} }
function renderStagingDownload(task: StageTask): Children { function renderStagingDownload(task: StageTask): Children {
@@ -138,11 +148,13 @@ function renderStagingDownload(task: StageTask): Children {
), ),
); );
return [ return m("div.flex items-center gap-2 text-sm text-stone-700 max-w-full", [
"Push ", "Push",
m( m(
"select", "select",
{ {
class:
"min-w-0 pl-3 pr-10 py-2 text-base border-stone-300 focus:outline-hidden focus:ring-cyan-500 focus:border-cyan-500 sm:text-sm rounded-md",
onchange: (e) => { onchange: (e) => {
const f = e.target.value; const f = e.target.value;
task.fileName = f; task.fileName = f;
@@ -151,21 +163,22 @@ function renderStagingDownload(task: StageTask): Children {
if (file._id === f) task.fileType = file["metadata.fileType"]; if (file._id === f) task.fileType = file["metadata.fileType"];
}, },
disabled: files.fulfilling, disabled: files.fulfilling,
style: "width: 350px",
}, },
filesList, filesList,
), ),
" as ", "as",
m( m(
"select", "select",
{ {
class:
"min-w-0 pl-3 pr-10 py-2 text-base border-stone-300 focus:outline-hidden focus:ring-cyan-500 focus:border-cyan-500 sm:text-sm rounded-md",
onchange: (e) => { onchange: (e) => {
task.fileType = e.target.value; task.fileType = e.target.value;
}, },
}, },
typesList, typesList,
), ),
]; ]);
} }
function renderStaging(staging: Set<StageTask>): Child[] { function renderStaging(staging: Set<StageTask>): Child[] {
@@ -190,17 +203,34 @@ function renderStaging(staging: Set<StageTask>): Child[] {
else if (s.name === "download") elms = renderStagingDownload(s); else if (s.name === "download") elms = renderStagingDownload(s);
const queue = m( const queue = m(
"button.primary", "button",
{ title: "Queue task", onclick: queueFunc, disabled: invalid.has(s) }, {
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 disabled:cursor-not-allowed disabled:opacity-50",
title: "Queue task",
onclick: queueFunc,
disabled: invalid.has(s),
},
"Queue", "Queue",
); );
const cancel = m( const cancel = m(
"button", "button",
{ title: "Cancel edit", onclick: cancelFunc }, {
class:
"px-2.5 py-1.5 border border-stone-300 shadow-xs text-xs font-medium rounded-sm text-stone-700 bg-white hover:bg-stone-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:cursor-not-allowed disabled:opacity-50",
title: "Cancel edit",
onclick: cancelFunc,
},
"Cancel", "Cancel",
); );
elements.push(m(".staging", elms, m("div.actions", queue, cancel))); elements.push(
m(
"div.p-4",
elms,
m("div.flex mt-4 justify-center gap-4", cancel, queue),
),
);
} }
return elements; return elements;
} }
@@ -214,9 +244,10 @@ function renderQueue(queue: Set<QueueTask>): Child[] {
} }
for (const [k, v] of Object.entries(devices)) { for (const [k, v] of Object.entries(devices)) {
details.push(m("strong", k)); details.push(m("h3.font-semibold text-stone-700", k));
for (const t of v) { for (const t of v) {
const actions = []; const actions: ReturnType<typeof m>[] = [];
let task: ReturnType<typeof m>;
if (t.status === "fault" || t.status === "stale") { if (t.status === "fault" || t.status === "stale") {
actions.push( actions.push(
@@ -228,7 +259,10 @@ function renderQueue(queue: Set<QueueTask>): Child[] {
queueTask(t); queueTask(t);
}, },
}, },
getIcon("retry"), m(icon, {
name: "retry",
class: "inline h-4 w-4 text-cyan-700 hover:text-cyan-900",
}),
), ),
); );
} }
@@ -242,74 +276,116 @@ function renderQueue(queue: Set<QueueTask>): Child[] {
deleteTask(t); deleteTask(t);
}, },
}, },
getIcon("remove"), m(icon, {
name: "remove",
class: "inline h-4 w-4 text-cyan-700 hover:text-cyan-900",
}),
), ),
); );
if (t.name === "setParameterValues") { if (t.name === "setParameterValues") {
details.push( task = m(
"span.text-stone-900 inline-flex max-w-full gap-2",
"Set",
m( m(
`div.${t.status}`, "span",
m( {
"span", title: t.parameterValues[0][0],
"Set ", dir: "rtl",
mparam(t.parameterValues[0][0]), class: "italic pr-1 min-w-0 truncate",
" to '", },
mval(t.parameterValues[0][1]), t.parameterValues[0][0],
"'", ),
), "to",
m(".actions", actions), m(
"span",
{
title: t.parameterValues[0][1],
class: "min-w-0 truncate",
},
t.parameterValues[0][1],
), ),
); );
} else if (t.name === "refreshObject") { } else if (t.name === "refreshObject") {
details.push( task = m(
"span.text-stone-900 inline-flex max-w-full gap-2",
"Refresh",
m( m(
`div.${t.status}`, "span",
m("span", "Refresh ", mparam(t.parameterName)), {
m(".actions", actions), title: t.parameterName,
dir: "rtl",
class: "italic pr-1 min-w-0 truncate",
},
t.parameterName,
), ),
); );
} else if (t.name === "reboot") { } else if (t.name === "reboot") {
details.push(m(`div.${t.status}`, "Reboot", m(".actions", actions))); task = m("span.text-stone-900", "Reboot");
} else if (t.name === "factoryReset") { } else if (t.name === "factoryReset") {
details.push( task = m("span.text-stone-900", "Factory reset");
m(`div.${t.status}`, "Factory reset", m(".actions", actions)),
);
} else if (t.name === "addObject") { } else if (t.name === "addObject") {
details.push( task = m(
"span.text-stone-900 inline-flex max-w-full gap-2",
"Add",
m( m(
`div.${t.status}`, "span",
m("span", "Add ", mparam(t.objectName)), {
m(".actions", actions), title: t.objectName,
dir: "rtl",
class: "italic pr-1 min-w-0 truncate",
},
t.objectName,
), ),
); );
} else if (t.name === "deleteObject") { } else if (t.name === "deleteObject") {
details.push( task = m(
"span.text-stone-900 inline-flex max-w-full gap-2",
"Delete",
m( m(
`div.${t.status}`, "span",
m("span", "Delete ", mparam(t.objectName)), {
m(".actions", actions), title: t.objectName,
dir: "rtl",
class: "italic pr-1 min-w-0 truncate",
},
t.objectName,
), ),
); );
} else if (t.name === "getParameterValues") { } else if (t.name === "getParameterValues") {
details.push( task = m(
m( "span.text-stone-900",
`div.${t.status}`, `Refresh ${t.parameterNames.length} parameters`,
`Refresh ${t.parameterNames.length} parameters`,
m(".actions", actions),
),
); );
} else if (t.name === "download") { } else if (t.name === "download") {
details.push( task = m(
m( "span.text-stone-900",
`div.${t.status}`, `Push file: ${t.fileName} (${t.fileType})`,
`Push file: ${t.fileName} (${t.fileType})`,
m(".actions", actions),
),
); );
} else { } else {
details.push(m(`div.${t.status}`, t.name, m(".actions", actions))); task = m("span.text-stone-900", t.name);
} }
let bgDiv: ReturnType<typeof m>;
if (t.status === "pending") {
bgDiv = m(
"div.block absolute inset-0 bg-emerald-200 rounded-sm animate-pulse",
"",
);
} else if (t.status === "fault") {
bgDiv = m("div.block absolute inset-0 bg-red-200 rounded-sm", "");
} else if (t.status === "stale") {
bgDiv = m("div.block absolute inset-0 bg-stone-200 rounded-sm", "");
}
details.push(
m(
"div.flex justify-between w-full rounded-sm items-center relative",
bgDiv,
m("div.overflow-hidden relative", task),
m("div.flex whitespace-nowrap gap-2 ml-2 relative", actions),
),
);
} }
} }
@@ -320,20 +396,47 @@ function renderNotifications(notifs): Child[] {
const notificationElements: Child[] = []; const notificationElements: Child[] = [];
for (const n of notifs) { for (const n of notifs) {
let notifColors = "",
buttonColors = "";
if (n.type === "success") {
notifColors = "bg-emerald-50 text-emerald-800 border-emerald-100";
buttonColors =
"hover:bg-emerald-100 text-emerald-800 focus:ring-offset-emerald-50 focus:ring-emerald-600";
} else if (n.type === "error") {
notifColors = "bg-red-50 text-red-800 border-red-100";
buttonColors =
"hover:bg-red-100 text-red-800 focus:ring-offset-red-50 focus:ring-red-600";
} else if (n.type === "warning") {
notifColors = "bg-yellow-50 text-yellow-800 border-yellow-100";
buttonColors =
"hover:bg-yellow-100 text-yellow-800 focus:ring-offset-yellow-50 focus:ring-yellow-600";
}
let buttons; let buttons;
if (n.actions) { if (n.actions) {
const btns = Object.entries(n.actions).map(([label, onclick]) => const btns = Object.entries(n.actions).map(([label, onclick]) =>
m("button.primary", { onclick: onclick }, label), m(
"button",
{
class:
"ml-2 px-2 py-1.5 -my-1.5 rounded-md text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-offset-2 " +
buttonColors,
onclick: onclick,
},
label,
),
); );
if (btns.length) buttons = m("div", { style: "float: right" }, btns); if (btns.length) buttons = m("div", btns);
} }
notificationElements.push( notificationElements.push(
m( m(
"div.notification", "div",
{ {
class: n.type, class:
style: "position: absolute;opacity: 0", "absolute flex justify-between rounded-md w-full text-sm shadow-md p-4 border transition-[top,opacity] " +
notifColors,
style: "opacity: 0",
oncreate: (vnode) => { oncreate: (vnode) => {
(vnode.dom as HTMLDivElement).style.opacity = "1"; (vnode.dom as HTMLDivElement).style.opacity = "1";
}, },
@@ -347,7 +450,8 @@ function renderNotifications(notifs): Child[] {
}, },
key: n.timestamp, key: n.timestamp,
}, },
m("div", buttons, n.message), n.message,
buttons,
), ),
); );
} }
@@ -367,10 +471,10 @@ const component: ClosureComponent = (): Component => {
const queueElements = renderQueue(queue); const queueElements = renderQueue(queue);
function repositionNotifications(): void { function repositionNotifications(): void {
let top = 10; let top = 16;
for (const c of notificationElements as VnodeDOM[]) { for (const c of notificationElements as VnodeDOM[]) {
(c.dom as HTMLDivElement).style.top = `${top}px`; (c.dom as HTMLDivElement).style.top = `${top}px`;
top += (c.dom as HTMLDivElement).offsetHeight + 10; top += (c.dom as HTMLDivElement).offsetHeight + 16;
} }
} }
@@ -397,10 +501,23 @@ const component: ClosureComponent = (): Component => {
for (const t of queue) statusCount[t["status"]] += 1; for (const t of queue) statusCount[t["status"]] += 1;
const actions = m( const actions = m(
".actions", "div.flex ml-auto gap-2",
m( m(
"button.primary", "button",
{ {
class:
"px-2.5 py-1.5 -my-1.5 border border-stone-300 shadow-xs text-xs font-medium rounded-sm 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: "Clear tasks",
onclick: clear,
disabled: !queueElements.length,
},
"Clear",
),
m(
"button",
{
class:
"px-2.5 py-1.5 -my-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 disabled:opacity-50 disabled:cursor-not-allowed",
title: "Commit queued tasks", title: "Commit queued tasks",
disabled: !statusCount.queued, disabled: !statusCount.queued,
onclick: () => { onclick: () => {
@@ -458,45 +575,47 @@ const component: ClosureComponent = (): Component => {
}, },
"Commit", "Commit",
), ),
m(
"button",
{
title: "Clear tasks",
onclick: clear,
disabled: !queueElements.length,
},
"Clear",
),
); );
statusElement = m( statusElement = m(
".status", "div.flex p-4 gap-5 items-center text-sm",
m( m(
"span.queued", "span.text-stone-700 -mx-1 px-1",
{ class: statusCount.queued ? "active" : "" }, { class: statusCount.queued ? "font-semibold" : "" },
`Queued: ${statusCount.queued}`, `Queued: ${statusCount.queued}`,
), ),
m( m(
"span.pending", "span.text-stone-700 relative",
{ class: statusCount.pending ? "active" : "" }, statusCount.pending
`Pending: ${statusCount.pending}`, ? m(
"div.block absolute -inset-x-1 inset-y-0 rounded-sm bg-emerald-200 animate-pulse",
"",
)
: null,
m("span.relative", `Pending: ${statusCount.pending}`),
), ),
m( m(
"span.fault", "span.text-stone-700 relative",
{ class: statusCount.fault ? "active" : "" }, m("span.relative", `Fault: ${statusCount.fault}`),
`Fault: ${statusCount.fault}`,
), ),
m( m(
"span.stale", "span.text-stone-700 relative",
{ class: statusCount.stale ? "active" : "" }, statusCount.stale
`Stale: ${statusCount.stale}`, ? m(
"div.block absolute -inset-x-1 inset-y-0 rounded-sm bg-stone-200",
"",
)
: null,
m("span.relative", `Stale: ${statusCount.stale}`),
), ),
actions, actions,
); );
drawerElement = m( drawerElement = m(
".drawer", "div",
{ {
class:
"w-[48rem] mx-auto pointer-events-auto bg-white rounded-b-lg border-stone-300 border-x border-b shadow-md overflow-hidden transition-[height] -mt-px",
key: "drawer", key: "drawer",
style: "opacity: 0;height: 0;", style: "opacity: 0;height: 0;",
oncreate: (vnode2) => { oncreate: (vnode2) => {
@@ -504,20 +623,20 @@ const component: ClosureComponent = (): Component => {
(vnode2.dom as HTMLDivElement).style.opacity = "1"; (vnode2.dom as HTMLDivElement).style.opacity = "1";
resizeDrawer(); resizeDrawer();
}, },
onmouseover: (e) => { onmouseenter: (e) => {
if (drawerElement.dom.style.opacity === "0") return;
vnode.state["mouseIn"] = true; vnode.state["mouseIn"] = true;
resizeDrawer(); resizeDrawer();
e.redraw = false; e.redraw = false;
}, },
onmouseleave: (e) => { onmouseleave: (e) => {
if (drawerElement.dom.style.opacity === "0") return;
vnode.state["mouseIn"] = false; vnode.state["mouseIn"] = false;
resizeDrawer(); resizeDrawer();
e.redraw = false; e.redraw = false;
}, },
onupdate: resizeDrawer, onupdate: resizeDrawer,
onbeforeremove: (vnode2) => { onbeforeremove: (vnode2) => {
(vnode2.dom as HTMLDivElement).onmouseover = null;
(vnode2.dom as HTMLDivElement).onmouseleave = null;
(vnode2.dom as HTMLDivElement).style.opacity = "0"; (vnode2.dom as HTMLDivElement).style.opacity = "0";
(vnode2.dom as HTMLDivElement).style.height = "0"; (vnode2.dom as HTMLDivElement).style.height = "0";
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -526,18 +645,20 @@ const component: ClosureComponent = (): Component => {
}, },
}, },
statusElement, statusElement,
stagingElements.length ? stagingElements : m(".queue", queueElements), stagingElements.length
? stagingElements
: m("div.px-4 pb-4 text-sm", queueElements),
); );
} }
return m( return m(
"div.drawer-wrapper", "div.fixed pointer-events-none inset-0 z-30",
drawerElement, drawerElement,
m( m(
"div.notifications-wrapper", "div",
{ {
class: "relative w-[48rem] mx-auto pointer-events-auto",
key: "notifications", key: "notifications",
style: "position: relative;",
onupdate: repositionNotifications, onupdate: repositionNotifications,
oncreate: repositionNotifications, oncreate: repositionNotifications,
}, },
+7 -12
View File
@@ -1,7 +1,7 @@
import * as notifications from "./notifications.ts"; import * as notifications from "./notifications.ts";
export let codeMirror: typeof import("codemirror"); export let codeMirror: typeof import("./codemirror-loader");
export let yaml: typeof import("yaml"); export let yaml: typeof import("./yaml-loader");
let note; let note;
@@ -23,14 +23,9 @@ export function loadCodeMirror(): Promise<void> {
if (codeMirror) return Promise.resolve(); if (codeMirror) return Promise.resolve();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const promises = [ import("./codemirror-loader")
import("codemirror"), .then((module) => {
import("codemirror/mode/javascript/javascript"), codeMirror = module;
import("codemirror/mode/yaml/yaml"),
];
Promise.all(promises)
.then((modules) => {
codeMirror = modules[0].default;
resolve(); resolve();
}) })
.catch((err) => { .catch((err) => {
@@ -44,9 +39,9 @@ export function loadYaml(): Promise<void> {
if (yaml) return Promise.resolve(); if (yaml) return Promise.resolve();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
import("yaml") import("./yaml-loader")
.then((module) => { .then((module) => {
yaml = module.default; yaml = module;
resolve(); resolve();
}) })
.catch((err) => { .catch((err) => {
+1 -1
View File
@@ -5,7 +5,7 @@ export const component: ClosureComponent = (): Component => {
return { return {
view: function (vnode) { view: function (vnode) {
document.title = "Error! - GenieACS"; document.title = "Error! - GenieACS";
return m("p.error", vnode.attrs["error"]); return m("p.text-sm font-bold text-red-500", vnode.attrs["error"]);
}, },
}; };
}; };
+14 -6
View File
@@ -127,14 +127,22 @@ export const component: ClosureComponent = (): Component => {
fault["device"], fault["device"],
)}`; )}`;
return m("a", { href: deviceHref }, fault["device"]); return m(
"a.text-cyan-700 hover:text-cyan-900 font-medium",
{ href: deviceHref },
fault["device"],
);
} }
if (attr.id === "message") if (attr.id === "message")
return m("long-text", { text: fault["message"] }); return m("long-text", { text: fault["message"], class: "max-w-xs" });
if (attr.id === "detail") if (attr.id === "detail") {
return m("long-text", { text: yamlStringify(fault["detail"]) }); return m("long-text", {
text: yamlStringify(fault["detail"]),
class: "max-w-xs",
});
}
if (attr.id === "timestamp") if (attr.id === "timestamp")
return new Date(fault["timestamp"]).toLocaleString(); return new Date(fault["timestamp"]).toLocaleString();
@@ -155,7 +163,7 @@ export const component: ClosureComponent = (): Component => {
if (window.authorizer.hasAccess("faults", 3)) { if (window.authorizer.hasAccess("faults", 3)) {
attrs["actionsCallback"] = (selected: Set<string>): Children => { attrs["actionsCallback"] = (selected: Set<string>): Children => {
return m( return 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",
{ {
disabled: selected.size === 0, disabled: selected.size === 0,
title: "Delete selected faults", title: "Delete selected faults",
@@ -190,7 +198,7 @@ export const component: ClosureComponent = (): Component => {
}; };
return [ return [
m("h1", "Listing faults"), m("h1.text-xl font-medium text-stone-900 mb-5", "Listing faults"),
m(filterComponent, filterAttrs), m(filterComponent, filterAttrs),
m( m(
"loading", "loading",
+12 -6
View File
@@ -120,7 +120,7 @@ export const component: ClosureComponent = (): Component => {
function onFilterChanged(filter): void { function onFilterChanged(filter): void {
const ops = { filter }; const ops = { filter };
if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"]; if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"];
m.route.set("/admin/files", ops); m.route.set("/files", ops);
} }
const sort = vnode.attrs["sort"] const sort = vnode.attrs["sort"]
@@ -137,7 +137,7 @@ export const component: ClosureComponent = (): Component => {
_sort[attributes[Math.abs(index) - 1].id] = Math.sign(index); _sort[attributes[Math.abs(index) - 1].id] = Math.sign(index);
const ops = { sort: JSON.stringify(_sort) }; const ops = { sort: JSON.stringify(_sort) };
if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"];
m.route.set("/admin/files", ops); m.route.set("/files", ops);
} }
let filter = vnode.attrs["filter"] let filter = vnode.attrs["filter"]
@@ -163,14 +163,20 @@ export const component: ClosureComponent = (): Component => {
attrs["onSortChange"] = onSortChange; attrs["onSortChange"] = onSortChange;
attrs["downloadUrl"] = downloadUrl; attrs["downloadUrl"] = downloadUrl;
attrs["recordActionsCallback"] = (file) => { attrs["recordActionsCallback"] = (file) => {
return [m("a", { href: "api/blob/files/" + file["_id"] }, "Download")]; return [
m(
"a.text-cyan-700 hover:text-cyan-900",
{ href: "api/blob/files/" + file["_id"] },
"Download",
),
];
}; };
if (window.authorizer.hasAccess("files", 3)) { if (window.authorizer.hasAccess("files", 3)) {
attrs["actionsCallback"] = (selected: Set<string>): Children => { attrs["actionsCallback"] = (selected: Set<string>): Children => {
return [ return [
m( 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: "Create new file", title: "Create new file",
onclick: () => { onclick: () => {
@@ -257,7 +263,7 @@ export const component: ClosureComponent = (): Component => {
"New", "New",
), ),
m( 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 selected files", title: "Delete selected files",
disabled: !selected.size, disabled: !selected.size,
@@ -300,7 +306,7 @@ export const component: ClosureComponent = (): Component => {
}; };
return [ return [
m("h1", "Listing files"), m("h1.text-xl font-medium text-stone-900 mb-5", "Listing files"),
m(filterComponent, filterAttrs), m(filterComponent, filterAttrs),
m( m(
"loading", "loading",
+34 -25
View File
@@ -8,7 +8,7 @@ import { Expression } from "../lib/types.ts";
const getAutocomplete = memoize((resource) => { const getAutocomplete = memoize((resource) => {
const labels = smartQuery.getLabels(resource); const labels = smartQuery.getLabels(resource);
const autocomplete = new Autocomplete("autocomplete", (txt, cb) => { const autocomplete = new Autocomplete((txt, cb) => {
txt = txt.toLowerCase(); txt = txt.toLowerCase();
cb( cb(
labels labels
@@ -111,32 +111,41 @@ const component: ClosureComponent<Attrs> = (initialVnode) => {
attrs = vnode.attrs; attrs = vnode.attrs;
return m("div.filter", [ return m("div.mb-5", [
m("b", "Filter"), m("label.text-sm font-semibold text-stone-700", "Filter"),
...filterList.map((fltr, idx) => { m(
return m("input", { "div.shadow-sm rounded-md mt-1 max-w-screen-sm -space-y-px",
type: "text", ...filterList.map((fltr, idx) => {
class: `${(filterInvalid >> idx) & 1 ? "error" : ""}`, let classNames =
value: fltr, "appearance-none rounded-none relative block w-full px-3 py-2 border-stone-300 placeholder-stone-500 text-stone-900 focus:ring-cyan-500 focus:border-cyan-500 focus:z-10 sm:text-sm";
oninput: (e) => { if (idx === 0) classNames += " rounded-t-md";
e.redraw = false; if (idx === filterList.length - 1) classNames += " rounded-b-md";
filterList[idx] = e.target.value; if (filterInvalid & (1 << idx)) classNames += " text-red-700";
filterTouched = true;
},
oncreate: (vn) => {
const el = vn.dom as HTMLInputElement;
getAutocomplete(vnode.attrs.resource).attach(el);
el.addEventListener("blur", () => { return m(`input`, {
if (filterTouched) onChange(); type: "text",
}); class: classNames,
value: fltr,
oninput: (e) => {
e.redraw = false;
filterList[idx] = e.target.value;
filterTouched = true;
},
oncreate: (vn) => {
const el = vn.dom as HTMLInputElement;
getAutocomplete(vnode.attrs.resource).attach(el);
el.addEventListener("keydown", (e) => { el.addEventListener("blur", () => {
if (e.key === "Enter" && filterTouched) onChange(); if (filterTouched) onChange();
}); });
},
}); el.addEventListener("keydown", (e) => {
}), if (e.key === "Enter" && filterTouched) onChange();
});
},
});
}),
),
]); ]);
}, },
}; };
-10
View File
@@ -1,10 +0,0 @@
import m, { Children } from "mithril";
import { ICONS_SVG } from "../build/assets.ts";
export function getIcon(name: string): Children {
return m(
`svg.icon.icon-${name}`,
{ key: `icon-${name}` },
m("use", { href: `${ICONS_SVG}#icon-${name}` }),
);
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 18L18 6M6 6l12 12"/></svg>

After

Width:  |  Height:  |  Size: 219 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-bar-chart"><path d="M4 6h16M4 12h16M4 18h16"/></svg>

After

Width:  |  Height:  |  Size: 257 B

+179 -56
View File
@@ -1,6 +1,6 @@
import { ClosureComponent, Component, Children } from "mithril"; import { ClosureComponent, Component, Children } from "mithril";
import { m } from "./components.ts"; import { m } from "./components.ts";
import { getIcon } from "./icons.ts"; import { icon } from "./tailwind-utility-components.ts";
import debounce from "../lib/common/debounce.ts"; import debounce from "../lib/common/debounce.ts";
interface Attribute { interface Attribute {
@@ -11,6 +11,22 @@ interface Attribute {
const MAX_PAGE_SIZE = 200; const MAX_PAGE_SIZE = 200;
function getExcerpt(text: string, maxLength = 80, maxLines = 10): string[] {
let lines: string[] = text?.split("\n", maxLines + 1) ?? [""];
if (lines.length > maxLines) {
lines.pop();
lines[maxLines - 1] = "\ufe19";
}
lines = lines.map((l) => {
if (l.length <= maxLength) return l;
return l.slice(0, maxLength - 1) + "\u2026";
});
return lines;
}
function renderTable( function renderTable(
attributes: Attribute[], attributes: Attribute[],
data: Record<string, any>[], data: Record<string, any>[],
@@ -40,32 +56,66 @@ function renderTable(
// Table header // Table header
const labels = []; const labels = [];
if (buttons.length) { if (buttons.length) {
const selectAll = m("input", { const selectAll = m(
type: "checkbox", "input.focus:ring-cyan-500 h-4 w-4 text-cyan-700 border-stone-300 rounded-sm",
checked: records.length && selected.size === records.length, {
onchange: (e) => { type: "checkbox",
for (const record of records) { checked: records.length && selected.size === records.length,
const id = record["_id"] || record["DeviceID.ID"].value[0]; onchange: (e) => {
if (e.target.checked) selected.add(id); for (const record of records) {
else selected.delete(id); const id = record["_id"] || record["DeviceID.ID"].value[0];
} if (e.target.checked) selected.add(id);
else selected.delete(id);
}
},
disabled: !total,
}, },
disabled: !total, );
}); labels.push(
labels.push(m("th", selectAll)); m(
"th",
{ class: "px-6 py-3.5 w-0", scope: "col" },
m("span.sr-only", "Select"),
selectAll,
),
);
} }
for (let i = 0; i < attributes.length; i++) { for (const [i, attr] of attributes.entries()) {
const attr = attributes[i]; let padding: string;
if (i === 0) padding = buttons.length ? "pr-3" : "pl-6 pr-3";
else if (i === attributes.length - +!recordActionsCallback)
padding = "pl-3 pr-6";
else padding = "px-3";
const label = attr.label; const label = attr.label;
if (!sortAttributes.hasOwnProperty(i)) { if (!sortAttributes.hasOwnProperty(i)) {
labels.push(m("th", label)); labels.push(
m(
"th",
{
class:
"py-3.5 text-left text-sm font-semibold text-stone-500 " +
padding,
scope: "col",
},
label,
),
);
continue; continue;
} }
let symbol = getIcon("unsorted"); let symbol: Children;
if (sortAttributes[i] > 0) symbol = getIcon("sorted-asc"); if (sortAttributes[i] > 0) {
else if (sortAttributes[i] < 0) symbol = getIcon("sorted-dsc"); symbol = m(icon, { name: "sorted-asc", class: "inline h-4 w-4 ml-1" });
} else if (sortAttributes[i] < 0) {
symbol = m(icon, { name: "sorted-dsc", class: "inline h-4 w-4 ml-1" });
} else {
symbol = m(icon, {
name: "unsorted",
class: "inline h-4 w-4 ml-1 opacity-50 hover:opacity-100",
});
}
const sortable = m( const sortable = m(
"button", "button",
@@ -78,41 +128,71 @@ function renderTable(
symbol, symbol,
); );
labels.push(m("th", [label, sortable])); labels.push(
m(
"th",
{
class:
"py-3.5 text-left text-sm font-semibold text-stone-500 whitespace-nowrap " +
padding,
scope: "col",
},
[label, sortable],
),
);
} }
if (recordActionsCallback)
labels.push(m("th", { class: "pl-3 pr-6 py-3.5 w-0", scope: "col" }));
// Table rows // Table rows
const rows = []; const rows = [];
for (const record of records) { for (const record of records) {
const id = record["_id"] || record["DeviceID.ID"].value[0]; const id = record["_id"] || record["DeviceID.ID"].value[0];
const tds = []; const tds = [];
const isSelected = selected.has(id);
if (buttons.length) { if (buttons.length) {
const checkbox = m("input", { const checkbox = m(
type: "checkbox", "input.focus:ring-cyan-500 h-4 w-4 text-cyan-700 border-stone-300 rounded-sm",
checked: selected.has(id), {
onchange: (e) => { type: "checkbox",
if (e.target.checked) selected.add(id); checked: isSelected,
else selected.delete(id); onchange: (e) => {
if (e.target.checked) selected.add(id);
else selected.delete(id);
},
onclick: (e) => {
e.stopPropagation();
e.redraw = false;
},
}, },
onclick: (e) => { );
e.stopPropagation(); tds.push(
e.redraw = false; m("td.px-6 py-4 whitespace-nowrap text-sm text-stone-500", checkbox),
}, );
});
tds.push(m("td", checkbox));
} }
for (const attr of attributes) { for (const [i, attr] of attributes.entries()) {
const attrs = {}; let padding: string;
if (i === 0) padding = buttons.length ? "pr-3" : "pl-6 pr-3";
else if (i === attributes.length - +!recordActionsCallback)
padding = "pl-3 pr-6";
else padding = "px-3";
const attrs = {
class: "py-4 whitespace-nowrap text-sm text-stone-900 " + padding,
};
let valueComponent; let valueComponent;
if (typeof valueCallback === "function") { if (typeof valueCallback === "function") {
valueComponent = valueCallback(attr, record); valueComponent = valueCallback(attr, record);
} else if (attr.type === "code") { } else if (attr.type === "code") {
const firstLines = record[attr.id].split("\n", 11); const excerpt = getExcerpt(record[attr.id]);
if (firstLines.length > 10) firstLines[10] = ["\ufe19"]; valueComponent = m(
if (attrs["title"] == null) attrs["title"] = firstLines.join("\n"); "span.font-mono",
valueComponent = firstLines[0] || ""; { title: excerpt.join("\n") },
excerpt[0],
);
} else { } else {
valueComponent = record[attr.id]; valueComponent = record[attr.id];
} }
@@ -129,13 +209,20 @@ function renderTable(
recordButtons = recordActionsCallback; recordButtons = recordActionsCallback;
} }
for (const button of recordButtons) for (const button of recordButtons) {
tds.push(m("td.table-row-links", button)); tds.push(
m(
"td.pl-3 pr-6 py-4 whitespace-nowrap text-right text-sm font-medium",
button,
),
);
}
rows.push( rows.push(
m( m(
"tr", "tr",
{ {
class: isSelected ? "bg-stone-50" : "",
onclick: (e) => { onclick: (e) => {
if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) { if (["INPUT", "BUTTON", "A"].includes(e.target.nodeName)) {
e.redraw = false; e.redraw = false;
@@ -150,17 +237,27 @@ function renderTable(
); );
} }
if (!rows.length) if (!rows.length) {
rows.push(m("tr.empty", m("td", { colspan: labels.length }, "No records"))); rows.push(
m(
"tr",
m(
"td.bg-stripes text-sm font-medium text-center text-stone-500 p-4",
{ colspan: labels.length },
"No records",
),
),
);
}
// Table footer // Table footer
const footerElements = []; const pagination = [];
if (total != null) footerElements.push(`${records.length}/${total}`); if (total != null) pagination.push(`${records.length} / ${total}`);
else footerElements.push(`${records.length}`); else pagination.push(`${records.length}`);
footerElements.push( pagination.push(
m( m(
"button", "button.px-4 py-2 border border-stone-300 rounded-md text-stone-700 bg-white hover:bg-stone-50 ml-4 disabled:opacity-50 disabled:cursor-not-allowed",
{ {
title: "Show more records", title: "Show more records",
onclick: showMoreCallback, onclick: showMoreCallback,
@@ -171,27 +268,53 @@ function renderTable(
), ),
); );
let download: Children;
if (downloadUrl) { if (downloadUrl) {
footerElements.push( download = m(
m("a.download-csv", { href: downloadUrl, download: "" }, "Download"), "a.text-cyan-700 hover:text-cyan-900",
{ href: downloadUrl, download: "" },
"Download",
); );
} }
const tfoot = m( const tfoot = m(
"tfoot", "tfoot.bg-white",
m("tr", m("td", { colspan: labels.length }, footerElements)), m(
"tr",
m(
"td.px-6 py-3 text-sm font-medium text-stone-700",
{ colspan: labels.length },
m(
"div.flex items-center justify-between",
m("div", pagination),
download,
),
),
),
); );
const children = [ const children = [
m( m(
"table.table.highlight", "div.flex flex-col",
m("thead", m("tr", labels)), m(
m("tbody", rows), "div.-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8",
tfoot, m(
"div.py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8",
m(
"div.shadow-sm overflow-hidden border-b border-stone-200 sm:rounded-lg",
m(
"table.min-w-full divide-y divide-stone-200",
m("thead.bg-stone-50", m("tr", labels)),
m("tbody.bg-white divide-y divide-stone-200", rows),
tfoot,
),
),
),
),
), ),
]; ];
if (buttons.length) children.push(m("div.actions-bar", buttons)); if (buttons.length) children.push(m("div.flex gap-3 mt-4", buttons));
return children; return children;
} }
-61
View File
@@ -1,61 +0,0 @@
import m, { ClosureComponent, Component } from "mithril";
import menu from "./menu.ts";
import drawerComponent from "./drawer-component.ts";
import userMenu from "./user-menu.ts";
import adminMenu from "./admin-menu.ts";
import * as overlay from "./overlay.ts";
import { version as VERSION } from "../package.json";
import datalist from "./datalist.ts";
import { LOGO_SVG } from "../build/assets.ts";
const adminPages = [
"presets",
"provisions",
"virtualParameters",
"files",
"config",
"users",
"permissions",
];
const component: ClosureComponent = (): Component => {
return {
view: (vnode) => {
let sideMenu, group;
if (adminPages.includes(vnode.attrs["page"])) {
group = "admin";
const attrs = {};
attrs["page"] = vnode.attrs["page"];
sideMenu = m(adminMenu, attrs);
}
const attrs = {};
attrs["page"] = group || vnode.attrs["page"];
return [
m("#header", [
m(
"div.logo",
m("img", { src: LOGO_SVG }),
m("span.version", "v" + VERSION),
),
m(userMenu),
m(menu, attrs),
m(drawerComponent),
]),
m(
"#content-wrapper",
sideMenu,
m("#content", { class: `page-${vnode.attrs["page"]}` }, [
vnode.children,
]),
),
overlay.render(),
m(datalist),
];
},
};
};
export default component;
+317
View File
@@ -0,0 +1,317 @@
import m, { ClosureComponent } from "mithril";
import drawerComponent from "./drawer-component.ts";
import * as overlay from "./overlay.ts";
import { version as VERSION } from "../package.json";
import datalist from "./datalist.ts";
import {
transitionRoot,
transitionChild,
dialog,
dialogOverlay,
icon,
} from "./tailwind-utility-components.ts";
import * as store from "./store.ts";
import * as notifications from "./notifications.ts";
import { LOGO_SVG } from "../build/assets.ts";
function tsxComponent<T>(
c: ClosureComponent<T>,
): (attrs: T) => ReturnType<typeof m> {
return c as any;
}
const TransitionRoot = tsxComponent(transitionRoot);
const TransitionChild = tsxComponent(transitionChild);
const Dialog = tsxComponent(dialog);
const DialogOverlay = tsxComponent(dialogOverlay);
const Icon = tsxComponent(icon);
function classNames(...classes: string[]): string {
return classes.filter(Boolean).join(" ");
}
interface Attrs {
page: string;
}
const component: ClosureComponent<Attrs> = () => {
let sidebarOpen = false;
function setSidebarOpen(open: boolean): void {
sidebarOpen = open;
setTimeout(m.redraw);
}
return {
view: (vnode) => {
const navigation = [
{
name: "Overview",
href: "#!/overview",
enabled: window.authorizer.hasAccess("devices", 1),
},
{
name: "Devices",
href: "#!/devices",
enabled: window.authorizer.hasAccess("devices", 2),
},
{
name: "Faults",
href: "#!/faults",
enabled: window.authorizer.hasAccess("faults", 2),
},
{
name: "Presets",
href: "#!/presets",
enabled: window.authorizer.hasAccess("presets", 2),
},
{
name: "Provisions",
href: "#!/provisions",
enabled: window.authorizer.hasAccess("provisions", 2),
},
{
name: "Virtual Parameters",
href: "#!/virtualParameters",
enabled: window.authorizer.hasAccess("virtualParameters", 2),
},
{
name: "Files",
href: "#!/files",
enabled: window.authorizer.hasAccess("files", 2),
},
{
name: "Config",
href: "#!/config",
enabled: window.authorizer.hasAccess("config", 2),
},
{
name: "Permissions",
href: "#!/permissions",
enabled: window.authorizer.hasAccess("permissions", 2),
},
{
name: "Users",
href: "#!/users",
enabled: window.authorizer.hasAccess("users", 2),
},
]
.filter((item) => item.enabled)
.map(({ name, href }) => {
const n = href.slice(3);
return { name, href, active: vnode.attrs["page"] === n };
});
return [
<div>
<TransitionRoot show={!!sidebarOpen} duration={300}>
<Dialog
as="div"
class="fixed inset-0 flex z-40 md:hidden"
onClose={() => setSidebarOpen(false)}
>
<TransitionChild
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<DialogOverlay class="fixed inset-0 bg-black/50" />
</TransitionChild>
<TransitionChild
enter="transition ease-in-out duration-300 transform"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>
<div class="relative flex-1 flex flex-col max-w-xs w-full bg-white">
<TransitionChild
enter="ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div class="absolute top-0 right-0 -mr-12 pt-2">
<button
type="button"
class="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-hidden focus:ring-2 focus:ring-inset focus:ring-white"
onclick={(e) => {
e.redraw = false;
setSidebarOpen(false);
}}
>
<span class="sr-only">Close sidebar</span>
<Icon name="close" class="h-6 w-6 text-white" />
</button>
</div>
</TransitionChild>
<div class="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
<div class="flex-shrink-0 flex items-center px-4">
<img class="h-10 w-auto" src={LOGO_SVG} alt="GenieACS" />
</div>
<nav class="mt-5 px-2 flex flex-col gap-1">
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
class={classNames(
item.active
? "bg-stone-100 text-stone-900"
: "text-stone-600 hover:bg-stone-50 hover:text-stone-900",
"group flex items-center px-2 py-2 text-base font-medium rounded-md",
)}
>
{item.name}
</a>
))}
</nav>
</div>
<div class="p-2">
{window.username ? (
<div class="flex items-center px-2 text-stone-600 text-base">
{window.username}
<button
class="ml-auto text-base font-medium text-cyan-600 hover:text-cyan-500"
onclick={(e) => {
e.target.disabled = true;
store
.logOut()
.then(() => {
location.hash = "";
location.reload();
})
.catch((err) => {
e.target.disabled = false;
notifications.push("error", err.message);
});
return false;
}}
>
Log out
</button>
</div>
) : (
<div class="px-2">
<a
class="text-base font-medium text-cyan-700 hover:text-cyan-900"
href=""
>
Log in
</a>
</div>
)}
</div>
<div class="text-sm font-mono text-stone-400 text-right p-2">
v{VERSION}
</div>
</div>
</TransitionChild>
<div class="flex-shrink-0 w-14">
{/* Force sidebar to shrink to fit close icon */}
</div>
</Dialog>
</TransitionRoot>
{/* Static sidebar for desktop */}
<div class="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0">
<div class="flex-1 flex flex-col min-h-0 border-r border-stone-200 bg-white">
<div class="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<div class="flex items-center flex-shrink-0 px-4">
<img class="h-10 w-auto" src={LOGO_SVG} alt="GenieACS" />
</div>
<nav class="mt-5 flex-1 px-2 bg-white flex flex-col gap-1">
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
class={classNames(
item.active
? "bg-stone-100 text-stone-900"
: "text-stone-600 hover:bg-stone-50 hover:text-stone-900",
"group flex items-center px-2 py-2 text-sm font-medium rounded-md",
)}
>
{item.name}
</a>
))}
</nav>
</div>
<div class="p-2">
{window.username ? (
<div class="flex items-center px-2 text-stone-600 text-sm">
{window.username}
<button
class="ml-auto text-sm font-medium text-cyan-700 hover:text-cyan-900"
onclick={(e) => {
e.target.disabled = true;
store
.logOut()
.then(() => {
location.hash = "";
location.reload();
})
.catch((err) => {
e.target.disabled = false;
notifications.push("error", err.message);
});
return false;
}}
>
Log out
</button>
</div>
) : (
<div class="px-2">
<a
class="text-sm font-medium text-cyan-700 hover:text-cyan-900"
href=""
>
Log in
</a>
</div>
)}
</div>
<div class="text-xs font-mono text-stone-400 text-right p-2">
v{VERSION}
</div>
</div>
</div>
<div class="md:pl-64 flex flex-col flex-1">
<div class="sticky top-0 z-10 md:hidden pl-1 pt-1 sm:pl-3 sm:pt-3 bg-stone-100">
<button
type="button"
class="-ml-0.5 -mt-0.5 h-12 w-12 inline-flex items-center justify-center rounded-md text-stone-500 hover:text-stone-900 focus:outline-hidden focus:ring-2 focus:ring-inset focus:ring-cyan-500"
onclick={(e) => {
setSidebarOpen(true);
e.redraw = false;
return false;
}}
>
<span class="sr-only">Open sidebar</span>
<Icon name="menu" class="h-6 w-6" />
</button>
</div>
<main class="flex-1">
<div class="py-6">
<div class="px-4 sm:px-6 md:px-8">
{m(drawerComponent)}
{vnode.children}
</div>
</div>
</main>
</div>
</div>,
overlay.render(),
m(datalist),
];
},
};
};
export default component;
-96
View File
@@ -1,96 +0,0 @@
import { ClosureComponent, Component, Children } from "mithril";
import { m } from "./components.ts";
import * as store from "./store.ts";
import * as notifications from "./notifications.ts";
import * as overlay from "./overlay.ts";
import changePasswordComponent from "./change-password-component.ts";
export function init(
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return Promise.resolve(args);
}
export const component: ClosureComponent = (): Component => {
return {
view: (vnode) => {
if (window.username) m.route.set(vnode.attrs["continue"] || "/");
document.title = "Login - GenieACS";
return [
m("h1", "Log in"),
m(
"form",
m(
"p",
m("label", { for: "username" }, "Username"),
m("input", {
name: "username",
type: "text",
value: vnode.state["username"],
oncreate: (vnode2) => {
(vnode2.dom as HTMLInputElement).focus();
},
oninput: (e) => {
vnode.state["username"] = e.target.value;
},
}),
),
m(
"p",
m("label", { for: "password" }, "Password"),
m("input", {
name: "password",
type: "password",
value: vnode.state["password"],
oninput: (e) => {
vnode.state["password"] = e.target.value;
},
}),
),
m(
"p",
m(
"button.primary",
{
type: "submit",
onclick: (e) => {
e.target.disabled = true;
store
.logIn(vnode.state["username"], vnode.state["password"])
.then(() => {
location.reload();
})
.catch((err) => {
notifications.push("error", err.response || err.message);
e.target.disabled = false;
});
return false;
},
},
"Login",
),
),
),
m(
"a",
{
onclick: () => {
const cb = (): Children => {
const attrs = {
onPasswordChange: () => {
overlay.close(cb);
m.redraw();
},
};
return m(changePasswordComponent, attrs);
};
overlay.open(cb);
},
},
"Change password",
),
];
},
};
};
+188
View File
@@ -0,0 +1,188 @@
import { ClosureComponent, Component, Children } from "mithril";
import { m } from "./components.ts";
import * as store from "./store.ts";
import * as notifications from "./notifications.ts";
import * as overlay from "./overlay.ts";
import changePasswordComponent from "./change-password-component.ts";
export function init(
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
return Promise.resolve(args);
}
export const component: ClosureComponent = (): Component => {
let username = "";
let password = "";
let remember = false;
function logIn(e: MouseEvent): boolean {
e.target["disabled"] = true;
store
.logIn(username, password, remember)
.then(() => {
location.reload();
})
.catch((err) => {
notifications.push("error", err.response || err.message);
e.target["disabled"] = false;
});
return false;
}
function changePassword(): void {
const cb = (): Children => {
const attrs = {
onPasswordChange: () => {
overlay.close(cb);
m.redraw();
},
};
return m(changePasswordComponent, attrs);
};
overlay.open(cb);
}
return {
view: (vnode) => {
if (window.username) m.route.set(vnode.attrs["continue"] || "/");
document.title = "Login - GenieACS";
return (
<div class="min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full flex flex-col gap-8">
<div>
<svg
class="mx-auto h-14 w-auto"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 64 64"
>
<defs>
<linearGradient id="a">
<stop offset="0" stop-color="#b72f5f" />
<stop offset="1" stop-color="#872346" />
</linearGradient>
<linearGradient
xlink:href="#a"
id="b"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(.28375 0 0 -.28375 27.92 37.384)"
x1="16.045"
y1="132.803"
x2="16.045"
y2="-81.164"
/>
</defs>
<path
d="m27.92 37.38 2.6 4.94c-1.15 2.01-2.71 3.74-4.68 5.21-3.22 1.9-5.83 1.59-7.85-.92 3.9-1.76 7.21-4.83 9.93-9.22M18.74 27.2c-.27 2.86-.93 5.67-1.96 8.36-.79 2.08-1.33 4.25-1.6 6.45-.19 1.55.03 3.12.64 4.55.62 1.49 1.73 2.76 3.33 3.82-1.73.52-3.24.66-4.54.43-2.27-.44-3.75-1.81-4.44-4.1a13.48 13.48 0 0 1-.35-6.76c.4-2.11.95-4.18 1.64-6.21a43.64 43.64 0 0 0 1.63-6.46c.14-.83.16-1.7.04-2.6-.14-.96-.49-1.87-1.04-2.67a5.838 5.838 0 0 0-2.27-1.91c.93-.17 1.84-.31 2.75-.43.94-.08 1.89.08 2.75.47 1.12.49 1.95 1.3 2.51 2.41.75 1.42 1.06 3.04.89 4.64zm29.81-10.63-6.98 6.34 2.1 2.1 6.34-6.98.82.84-6.97 6.34 1.97 1.97 6.34-6.97.84.82-6.98 6.34 2.1 2.1 6.67-6.67 1.42 1.39-5.98 5.99-7.8 7.79-4.74 1.94-6.8-5.08 4.49 6.03-1.5.61-6.61-6.61 1.68-4.09 6.18 4.64.01.01.04-.03-.01-.02-5.25-6.99.85-2.09 6.39-6.38 7.38-7.38 1.41 1.39-6.68 6.68 2.09 2.09 6.34-6.97zm4.67 36.63C59.07 47.35 62 40.28 62 31.99c0-8.26-2.93-15.33-8.78-21.21C47.34 4.93 40.27 2 32.01 2c-8.29 0-15.36 2.93-21.21 8.78-3.6 3.61-6.08 7.65-7.47 12.15l.6-.33c2.98-1.57 5.62-.77 7.92 2.41-2.08.43-3.75.87-5.01 1.35-.69.27-1.33.63-1.9 1.09-1.28 1.01-2.08 2.3-2.41 3.86-.48 2.19-.47 4.47.04 6.65 1.08 5.77 3.82 10.85 8.23 15.24 5.85 5.87 12.92 8.8 21.21 8.8 8.28 0 15.35-2.93 21.21-8.8"
fill="url(#b)"
/>
</svg>
<h2 class="mt-6 text-center text-3xl font-extrabold text-stone-900">
Log in to continue
</h2>
</div>
<form class="mt-8 flex flex-col gap-6">
<div class="rounded-md shadow-xs -space-y-px">
<div>
<label for="username" class="sr-only">
Username
</label>
<input
id="username"
name="username"
type="text"
value={username}
autocomplete="username"
required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-stone-300 placeholder-stone-500 text-stone-900 rounded-t-md focus:outline-hidden focus:ring-cyan-500 focus:border-cyan-500 focus:z-10 sm:text-sm"
placeholder="Username"
oninput={(e) => {
username = e.target.value;
}}
/>
</div>
<div>
<label for="password" class="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
value={password}
autocomplete="current-password"
required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-stone-300 placeholder-stone-500 text-stone-900 rounded-b-md focus:outline-hidden focus:ring-cyan-500 focus:border-cyan-500 focus:z-10 sm:text-sm"
placeholder="Password"
oninput={(e) => {
password = e.target.value;
}}
/>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember"
name="remember"
type="checkbox"
value={remember}
class="h-4 w-4 text-cyan-700 focus:ring-cyan-500 border-stone-300 rounded-sm"
onchange={(e) => {
remember = e.target.checked;
}}
/>
<label
for="remember"
class="ml-2 block text-sm text-stone-900"
>
Remember me
</label>
</div>
<div class="text-sm">
<button
type="button"
class="font-medium text-cyan-700 hover:text-cyan-900"
onclick={changePassword}
>
Change password
</button>
</div>
</div>
<div>
<button
type="submit"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500"
onclick={logIn}
>
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
<svg
class="h-5 w-5 text-cyan-500 group-hover:text-cyan-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
clip-rule="evenodd"
/>
</svg>
</span>
Log in
</button>
</div>
</form>
</div>
</div>
);
},
};
};
+22 -16
View File
@@ -6,21 +6,25 @@ const component: ClosureComponent = (): Component => {
view: (vnode) => { view: (vnode) => {
const text = vnode.attrs["text"]; const text = vnode.attrs["text"];
const element = vnode.attrs["element"] || "span"; const element = vnode.attrs["element"] || "span";
const className = vnode.attrs["class"] || "";
function overflowed(_vnode): void { function overflowed(_vnode): void {
_vnode.dom.classList.add("long-text-overflowed"); _vnode.dom.classList.add("cursor-pointer", "hover:underline");
_vnode.dom.setAttribute("title", text);
_vnode.dom.onclick = (e) => { _vnode.dom.onclick = (e) => {
overlay.open(() => { overlay.open(() => {
return m("textarea.long-text", { return m(
value: text, "textarea.font-mono text-sm focus:ring-cyan-500 focus:border-cyan-500 border border-stone-300 rounded-md",
cols: 80, {
rows: 24, value: text,
readonly: "", cols: 80,
oncreate: (vnode2) => { rows: 24,
(vnode2.dom as HTMLTextAreaElement).focus(); readonly: "",
(vnode2.dom as HTMLTextAreaElement).select(); oncreate: (vnode2) => {
(vnode2.dom as HTMLTextAreaElement).focus();
},
}, },
}); );
}); });
// prevent index page selection // prevent index page selection
e.stopPropagation(); e.stopPropagation();
@@ -32,21 +36,23 @@ const component: ClosureComponent = (): Component => {
element, element,
{ {
oncreate: (vnode2) => { oncreate: (vnode2) => {
if (vnode2.dom.clientWidth !== vnode2.dom.scrollWidth) const w = Math.round(vnode2.dom.getBoundingClientRect().width);
overflowed(vnode2); if (w !== vnode2.dom.scrollWidth) overflowed(vnode2);
}, },
onupdate: (vnode2) => { onupdate: (vnode2) => {
if (vnode2.dom.clientWidth === vnode2.dom.scrollWidth) { const w = Math.round(vnode2.dom.getBoundingClientRect().width);
if (w === vnode2.dom.scrollWidth) {
(vnode2.dom as HTMLElement).classList.remove( (vnode2.dom as HTMLElement).classList.remove(
"long-text-overflowed", "cursor-pointer",
"hover:underline",
); );
(vnode2.dom as HTMLElement).onclick = null; (vnode2.dom as HTMLElement).onclick = null;
(vnode2.dom as HTMLElement).removeAttribute("title");
} else { } else {
overflowed(vnode2); overflowed(vnode2);
} }
}, },
class: "long-text", class: "block truncate decoration-dotted max-w-full " + className,
title: text,
}, },
text, text,
); );
-63
View File
@@ -1,63 +0,0 @@
import m, { ClosureComponent, Component } from "mithril";
const component: ClosureComponent = (): Component => {
return {
view: (vnode) => {
const active = { [vnode.attrs["page"]]: "active" };
const tabs = [];
if (window.authorizer.hasAccess("devices", 1)) {
tabs.push(
m(
"li",
{ class: active["overview"] },
m("a", { href: "#!/overview" }, "Overview"),
),
);
}
if (window.authorizer.hasAccess("devices", 2)) {
tabs.push(
m(
"li",
{ class: active["devices"] },
m("a", { href: "#!/devices" }, "Devices"),
),
);
}
if (window.authorizer.hasAccess("faults", 2)) {
tabs.push(
m(
"li",
{ class: active["faults"] },
m("a", { href: "#!/faults" }, "Faults"),
),
);
}
const adminPages = [
"presets",
"provisions",
"virtualParameters",
"files",
];
for (const page of adminPages) {
if (window.authorizer.hasAccess(page, 2)) {
tabs.push(
m(
"li",
{ class: active["admin"] },
m("a", { href: "#!/admin" }, "Admin"),
),
);
break;
}
}
return m("nav", m("ul", tabs));
},
};
};
export default component;
+26 -27
View File
@@ -1,4 +1,5 @@
import m, { Children } from "mithril"; import m, { Children } from "mithril";
import { dialog, dialogOverlay, icon } from "./tailwind-utility-components.ts";
type OverlayCallback = () => Children; type OverlayCallback = () => Children;
type CloseCallback = () => boolean; type CloseCallback = () => boolean;
@@ -28,35 +29,33 @@ export function close(callback: OverlayCallback, force = true): boolean {
export function render(): Children { export function render(): Children {
if (overlayCallback) { if (overlayCallback) {
return m( return m(
".overlay-wrapper", dialog,
{ {
tabindex: 0, as: "div",
onclick: () => { class: "fixed z-10 inset-0 overflow-y-auto",
close(overlayCallback, false); onClose: () => close(overlayCallback, false),
},
style: "opacity: 0",
oncreate: (vnode) => {
(vnode.dom as HTMLDivElement).focus();
(vnode.dom as HTMLDivElement).style.opacity = "1";
},
onbeforeremove: (vnode) => {
(vnode.dom as HTMLDivElement).style.opacity = "0";
return new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 500);
});
},
}, },
m( m("div.flex items-center justify-center min-h-screen p-4 text-center", [
".overlay", m(dialogOverlay, {
{ class: "fixed inset-0 bg-black/50",
onclick: (e) => { }),
e.stopPropagation(); m(
}, "div.relative z-10 bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform max-w-full",
}, m(
overlayCallback(), "div.block absolute top-0 right-0 pt-4 pr-4",
), m(
"button.bg-white rounded-md text-stone-400 hover:text-stone-500 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500",
{
type: "button",
onclick: () => close(overlayCallback, false),
},
m("span.sr-only", "Close"),
m(icon, { name: "close", class: "h-6 w-6" }),
),
),
overlayCallback(),
),
]),
); );
} }
+21 -6
View File
@@ -41,28 +41,43 @@ export const component: ClosureComponent = (): Component => {
document.title = "Overview - GenieACS"; document.title = "Overview - GenieACS";
const children = []; const children = [];
for (const group of Object.values(GROUPS)) { for (const group of Object.values(GROUPS)) {
if (group["label"]) if (group["label"]) {
children.push( children.push(
m("h1", store.evaluateExpression(group["label"], null)), m(
"h1.text-xl font-medium text-stone-900 mb-5",
store.evaluateExpression(group["label"], null),
),
); );
}
const groupChildren = []; const groupChildren = [];
for (const chartName of Object.values(group["charts"]) as string[]) { for (const chartName of Object.values(group["charts"]) as string[]) {
const chart = vnode.attrs["charts"][chartName]; const chart = vnode.attrs["charts"][chartName];
const chartChildren = []; const chartChildren = [];
if (chart.label) if (chart.label) {
chartChildren.push( chartChildren.push(
m("h2", store.evaluateExpression(chart.label, null)), m(
"h2.text-lg font-semibold text-stone-700 truncate mb-5 text-center",
store.evaluateExpression(chart.label, null),
),
); );
}
const attrs = {}; const attrs = {};
attrs["chart"] = chart; attrs["chart"] = chart;
chartChildren.push(m(pieChartComponent, attrs)); chartChildren.push(m(pieChartComponent, attrs));
groupChildren.push(m(".overview-chart", chartChildren)); groupChildren.push(
m(
"div.p-4 bg-white shadow-sm rounded-lg sm:p-6 sm:px-8",
chartChildren,
),
);
} }
children.push(m(".overview-chart-group", groupChildren)); children.push(
m("div.flex justify-center mt-5 mb-10 gap-x-10", groupChildren),
);
} }
return children; return children;
+27 -6
View File
@@ -44,6 +44,22 @@ const attributes = [
{ id: "validate", label: "Validate", type: "textarea" }, { id: "validate", label: "Validate", type: "textarea" },
]; ];
function getExcerpt(text: string, maxLength = 80, maxLines = 10): string[] {
let lines: string[] = text?.split("\n", maxLines + 1) ?? [""];
if (lines.length > maxLines) {
lines.pop();
lines[maxLines - 1] = "\ufe19";
}
lines = lines.map((l) => {
if (l.length <= maxLength) return l;
return l.slice(0, maxLength - 1) + "\u2026";
});
return lines;
}
const unpackSmartQuery = memoize((query) => { const unpackSmartQuery = memoize((query) => {
return map(query, (e) => { return map(query, (e) => {
if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q") if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "Q")
@@ -119,6 +135,8 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
}) })
.catch(reject); .catch(reject);
} else if (action === "delete") { } else if (action === "delete") {
if (!confirm("Deleting permission. Are you sure?"))
return void resolve(null);
store store
.deleteResource("permissions", object["_id"]) .deleteResource("permissions", object["_id"])
.then(() => { .then(() => {
@@ -177,7 +195,7 @@ export const component: ClosureComponent = (): Component => {
function onFilterChanged(filter): void { function onFilterChanged(filter): void {
const ops = { filter }; const ops = { filter };
if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"]; if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"];
m.route.set("/admin/permissions", ops); m.route.set("/permissions", ops);
} }
const sort = vnode.attrs["sort"] const sort = vnode.attrs["sort"]
@@ -197,7 +215,7 @@ export const component: ClosureComponent = (): Component => {
_sort[attributes[Math.abs(index) - 1].id] = Math.sign(index); _sort[attributes[Math.abs(index) - 1].id] = Math.sign(index);
const ops = { sort: JSON.stringify(_sort) }; const ops = { sort: JSON.stringify(_sort) };
if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"];
m.route.set("/admin/permissions", ops); m.route.set("/permissions", ops);
} }
let filter = vnode.attrs["filter"] let filter = vnode.attrs["filter"]
@@ -221,6 +239,9 @@ export const component: ClosureComponent = (): Component => {
else if (val === 2) return "2: read"; else if (val === 2) return "2: read";
else if (val === 3) return "3: write"; else if (val === 3) return "3: write";
return val; return val;
} else if (attr.id === "validate" || attr.id === "filter") {
const except = getExcerpt(permission[attr.id], 80, 1);
return m("span.font-mono", { title: permission[attr.id] }, except[0]);
} }
return permission[attr.id]; return permission[attr.id];
@@ -244,7 +265,7 @@ export const component: ClosureComponent = (): Component => {
if (val === 3) permission["access"] = "3: write"; if (val === 3) permission["access"] = "3: write";
return [ return [
m( m(
"a", "button.text-cyan-700 hover:text-cyan-900 font-medium",
{ {
onclick: () => { onclick: () => {
let cb: () => Children = null; let cb: () => Children = null;
@@ -306,7 +327,7 @@ export const component: ClosureComponent = (): Component => {
attrs["actionsCallback"] = (selected: Set<string>): Children => { attrs["actionsCallback"] = (selected: Set<string>): Children => {
return [ return [
m( 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: "Create new permission", title: "Create new permission",
onclick: () => { onclick: () => {
@@ -352,7 +373,7 @@ export const component: ClosureComponent = (): Component => {
"New", "New",
), ),
m( 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 selected permissions", title: "Delete selected permissions",
disabled: !selected.size, disabled: !selected.size,
@@ -397,7 +418,7 @@ export const component: ClosureComponent = (): Component => {
}; };
return [ return [
m("h1", "Listing permissions"), m("h1.text-xl font-medium text-stone-900 mb-5", "Listing permissions"),
m(filterComponent, filterAttrs), m(filterComponent, filterAttrs),
m( m(
"loading", "loading",
+38 -22
View File
@@ -23,21 +23,30 @@ function drawChart(chartData): Children {
for (const slice of Object.values(slices)) { for (const slice of Object.values(slices)) {
const percent = total > 0 ? (slice["count"]["value"] || 0) / total : 0; const percent = total > 0 ? (slice["count"]["value"] || 0) / total : 0;
legend.push( legend.push(
m(".legend-line", [ m("tr", [
m("span.color", {
style: `background-color: ${store.evaluateExpression(slice["color"], null)} !important;`,
}),
`${store.evaluateExpression(slice["label"], null)}: `,
m( m(
"a", "td",
{ m("span.inline-block w-3 h-3 border border-stone-200 mr-1", {
href: `#!/devices/?${m.buildQueryString({ style: `background-color: ${store.evaluateExpression(slice["color"], null)} !important;`,
filter: memoizedStringify(slice["filter"]), }),
})}`, ),
}, m("td.w-full", store.evaluateExpression(slice["label"], null)),
slice["count"]["value"] || 0, m(
"td.text-stone-500 text-right tabular-nums",
`${Math.round(percent * 100)}%`,
),
m(
"td.text-right tabular-nums",
m(
"a.text-cyan-700 hover:text-cyan-900 font-medium ml-2",
{
href: `#!/devices/?${m.buildQueryString({
filter: memoizedStringify(slice["filter"]),
})}`,
},
slice["count"]["value"] || 0,
),
), ),
` (${(percent * 100).toFixed(2)}%)`,
]), ]),
); );
@@ -56,7 +65,7 @@ function drawChart(chartData): Children {
startY = endY; startY = endY;
paths.push( paths.push(
m("path", { m("path.stroke-white stroke-1", {
d: sketch, d: sketch,
fill: store.evaluateExpression(slice["color"], null), fill: store.evaluateExpression(slice["color"], null),
}), }),
@@ -69,26 +78,26 @@ function drawChart(chartData): Children {
links.push( links.push(
m( m(
"a", "a.opacity-0 hover:opacity-100 focus-visible:opacity-100 outline-hidden",
{ {
"xlink:href": `#!/devices/?${m.buildQueryString({ "xlink:href": `#!/devices/?${m.buildQueryString({
filter: memoizedStringify(slice["filter"]), filter: memoizedStringify(slice["filter"]),
})}`, })}`,
}, },
[ [
m("path", { m("path.stroke-cyan-500 stroke-1", {
d: sketch, d: sketch,
"fill-opacity": 0, "fill-opacity": 0,
}), }),
m( m(
"text", "text.opacity-40 font-medium fill-black",
{ {
x: percentageX, x: percentageX,
y: percentageY, y: percentageY,
"dominant-baseline": "middle", "dominant-baseline": "middle",
"text-anchor": "middle", "text-anchor": "middle",
}, },
`${(percent * 100).toFixed(2)}%`, `${Math.round(percent * 100)}%`,
), ),
], ],
), ),
@@ -96,16 +105,23 @@ function drawChart(chartData): Children {
} }
} }
legend.push(m("span.legend-total", `Total: ${total}`)); legend.push(
m(
"tr",
m("td", ""),
m("td", { colspan: 2 }, "Total"),
m("td.text-right tabular-nums", total),
),
);
return m( return m(
"loading", "loading",
{ {
queries: Object.values(chartData.slices).map((s) => s["count"]), queries: Object.values(chartData.slices).map((s) => s["count"]),
}, },
m("div", { class: "pie-chart" }, [ m("div", [
m( m(
"svg", "svg.m-4",
{ {
// Adding 2 as padding; strokes must not be more than 2 // Adding 2 as padding; strokes must not be more than 2
viewBox: "-102 -102 204 204", viewBox: "-102 -102 204 204",
@@ -116,7 +132,7 @@ function drawChart(chartData): Children {
}, },
paths.concat(links), paths.concat(links),
), ),
m(".legend", legend), m("table.mt-8 text-sm", legend),
]), ]),
); );
} }
+34 -10
View File
@@ -39,6 +39,22 @@ interface ValidationErrors {
[prop: string]: string; [prop: string]: string;
} }
function getExcerpt(text: string, maxLength = 80, maxLines = 10): string[] {
let lines: string[] = text?.split("\n", maxLines + 1) ?? [""];
if (lines.length > maxLines) {
lines.pop();
lines[maxLines - 1] = "\ufe19";
}
lines = lines.map((l) => {
if (l.length <= maxLength) return l;
return l.slice(0, maxLength - 1) + "\u2026";
});
return lines;
}
function putActionHandler(action, _object, isNew): Promise<ValidationErrors> { function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const object = Object.assign({}, _object); const object = Object.assign({}, _object);
@@ -90,6 +106,8 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
}) })
.catch(reject); .catch(reject);
} else if (action === "delete") { } else if (action === "delete") {
if (!confirm("Deleting preset. Are you sure?")) return void resolve(null);
store store
.deleteResource("presets", object["_id"]) .deleteResource("presets", object["_id"])
.then(() => { .then(() => {
@@ -149,7 +167,7 @@ export const component: ClosureComponent = (): Component => {
function onFilterChanged(filter): void { function onFilterChanged(filter): void {
const ops = { filter }; const ops = { filter };
if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"]; if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"];
m.route.set("/admin/presets", ops); m.route.set("/presets", ops);
} }
const sort = vnode.attrs["sort"] const sort = vnode.attrs["sort"]
@@ -176,7 +194,7 @@ export const component: ClosureComponent = (): Component => {
_sort[attributes[Math.abs(index) - 1].id] = Math.sign(index); _sort[attributes[Math.abs(index) - 1].id] = Math.sign(index);
const ops = { sort: JSON.stringify(_sort) }; const ops = { sort: JSON.stringify(_sort) };
if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"];
m.route.set("/admin/presets", ops); m.route.set("/presets", ops);
} }
let filter = vnode.attrs["filter"] let filter = vnode.attrs["filter"]
@@ -227,18 +245,24 @@ export const component: ClosureComponent = (): Component => {
} }
return m( return m(
"a", "a.text-cyan-700 hover:text-cyan-900 font-mono",
{ href: devicesUrl, title: preset["precondition"] }, { href: devicesUrl, title: preset["precondition"] },
preset["precondition"], getExcerpt(preset["precondition"], 80, 1)[0],
);
} else if (attr.id === "provisionArgs") {
return m(
"span.font-mono",
{ title: preset["provisionArgs"] },
getExcerpt(preset["provisionArgs"], 80, 1)[0],
); );
} else if ( } else if (
attr.id === "provision" && attr.id === "provision" &&
userDefinedProvisions.has(preset[attr.id]) userDefinedProvisions.has(preset[attr.id])
) { ) {
return m( return m(
"a", "a.text-cyan-700 hover:text-cyan-900",
{ {
href: `#!/admin/provisions?${m.buildQueryString({ href: `#!/provisions?${m.buildQueryString({
filter: `Q("ID", "${preset["provision"]}")`, filter: `Q("ID", "${preset["provision"]}")`,
})}`, })}`,
}, },
@@ -261,7 +285,7 @@ export const component: ClosureComponent = (): Component => {
attrs["recordActionsCallback"] = (preset) => { attrs["recordActionsCallback"] = (preset) => {
return [ return [
m( m(
"a", "button.text-cyan-700 hover:text-cyan-900 font-medium",
{ {
onclick: () => { onclick: () => {
let cb: () => Children = null; let cb: () => Children = null;
@@ -322,7 +346,7 @@ export const component: ClosureComponent = (): Component => {
attrs["actionsCallback"] = (selected: Set<string>): Children => { attrs["actionsCallback"] = (selected: Set<string>): Children => {
return [ return [
m( 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: "Create new preset", title: "Create new preset",
onclick: () => { onclick: () => {
@@ -368,7 +392,7 @@ export const component: ClosureComponent = (): Component => {
"New", "New",
), ),
m( 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 selected presets", title: "Delete selected presets",
disabled: !selected.size, disabled: !selected.size,
@@ -411,7 +435,7 @@ export const component: ClosureComponent = (): Component => {
}; };
return [ return [
m("h1", "Listing presets"), m("h1.text-xl font-medium text-stone-900 mb-5", "Listing presets"),
m(filterComponent, filterAttrs), m(filterComponent, filterAttrs),
m( m(
"loading", "loading",
+8 -6
View File
@@ -76,6 +76,8 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
}) })
.catch(reject); .catch(reject);
} else if (action === "delete") { } else if (action === "delete") {
if (!confirm("Deleting provision. Are you sure?"))
return void resolve(null);
store store
.deleteResource("provisions", object["_id"]) .deleteResource("provisions", object["_id"])
.then(() => { .then(() => {
@@ -142,7 +144,7 @@ export const component: ClosureComponent = (): Component => {
function onFilterChanged(filter): void { function onFilterChanged(filter): void {
const ops = { filter }; const ops = { filter };
if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"]; if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"];
m.route.set("/admin/provisions", ops); m.route.set("/provisions", ops);
} }
const sort = vnode.attrs["sort"] const sort = vnode.attrs["sort"]
@@ -159,7 +161,7 @@ export const component: ClosureComponent = (): Component => {
_sort[attributes[Math.abs(index) - 1].id] = Math.sign(index); _sort[attributes[Math.abs(index) - 1].id] = Math.sign(index);
const ops = { sort: JSON.stringify(_sort) }; const ops = { sort: JSON.stringify(_sort) };
if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"];
m.route.set("/admin/provisions", ops); m.route.set("/provisions", ops);
} }
let filter = vnode.attrs["filter"] let filter = vnode.attrs["filter"]
@@ -187,7 +189,7 @@ export const component: ClosureComponent = (): Component => {
attrs["recordActionsCallback"] = (provision) => { attrs["recordActionsCallback"] = (provision) => {
return [ return [
m( m(
"a", "button.text-cyan-700 hover:text-cyan-900 font-medium",
{ {
onclick: () => { onclick: () => {
let cb: () => Children = null; let cb: () => Children = null;
@@ -239,7 +241,7 @@ export const component: ClosureComponent = (): Component => {
attrs["actionsCallback"] = (selected: Set<string>): Children => { attrs["actionsCallback"] = (selected: Set<string>): Children => {
return [ return [
m( 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: "Create new provision", title: "Create new provision",
onclick: () => { onclick: () => {
@@ -285,7 +287,7 @@ export const component: ClosureComponent = (): Component => {
"New", "New",
), ),
m( 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 selected provisions", title: "Delete selected provisions",
disabled: !selected.size, disabled: !selected.size,
@@ -330,7 +332,7 @@ export const component: ClosureComponent = (): Component => {
}; };
return [ return [
m("h1", "Listing provisions"), m("h1.text-xl font-medium text-stone-900 mb-5", "Listing provisions"),
m(filterComponent, filterAttrs), m(filterComponent, filterAttrs),
m( m(
"loading", "loading",
+75 -54
View File
@@ -27,7 +27,7 @@ function createField(current, attr, focus): Children {
options.push(m("option", { value: op }, op)); options.push(m("option", { value: op }, op));
return m( return m(
"select", "select.mt-1 block pl-3 pr-10 py-2 text-base border-stone-300 focus:outline-hidden focus:ring-cyan-500 focus:border-cyan-500 sm:text-sm rounded-md",
{ {
name: attr.id, name: attr.id,
value: selected, value: selected,
@@ -68,7 +68,16 @@ function createField(current, attr, focus): Children {
}, },
}; };
return m("tr", [m("td", m("input", opts)), m("td", op)]); return m("tr", [
m(
"td",
m(
"input.focus:ring-cyan-500 h-4 w-4 text-cyan-700 border-stone-300 rounded-sm",
opts,
),
),
m("td", op),
]);
}); });
return m("table", options); return m("table", options);
@@ -102,18 +111,55 @@ function createField(current, attr, focus): Children {
}, },
}); });
} else if (attr.type === "textarea") { } else if (attr.type === "textarea") {
return m("textarea", { return m(
"textarea.shadow-xs block focus:ring-cyan-500 focus:border-cyan-500 sm:text-sm border border-stone-300 rounded-md",
{
name: attr.id,
value: current.object[attr.id],
readonly: attr.id === "_id" && !current.isNew,
cols: attr.cols || 80,
rows: attr.rows || 4,
style: "resize: none;",
oncreate: focus
? (_vnode) => {
const dom = _vnode.dom as HTMLInputElement;
dom.focus();
dom.setSelectionRange(dom.value.length, dom.value.length);
}
: null,
oninput: (e) => {
current.object[attr.id] = e.target.value;
current.modified = true;
e.redraw = false;
},
onkeypress: (e) => {
e.redraw = false;
if (e.which === 13 && !e.shiftKey) {
const dom = e.target;
dom.form.querySelector("button[type=submit]").click();
return false;
}
return true;
},
},
);
}
let datalist: string = null;
if (attr.options) datalist = getDatalistId(attr.options);
return m(
"input.shadow-xs focus:ring-cyan-500 focus:border-cyan-500 block sm:text-sm border-stone-300 rounded-md",
{
type: attr.type === "password" ? "password" : "text",
name: attr.id, name: attr.id,
list: datalist,
autocomplete: datalist ? "off" : null,
disabled: attr.id === "_id" && !current.isNew,
value: current.object[attr.id], value: current.object[attr.id],
readonly: attr.id === "_id" && !current.isNew,
cols: attr.cols || 80,
rows: attr.rows || 4,
style: "resize: none;",
oncreate: focus oncreate: focus
? (_vnode) => { ? (_vnode) => {
const dom = _vnode.dom as HTMLInputElement; (_vnode.dom as HTMLInputElement).focus();
dom.focus();
dom.setSelectionRange(dom.value.length, dom.value.length);
} }
: null, : null,
oninput: (e) => { oninput: (e) => {
@@ -121,39 +167,8 @@ function createField(current, attr, focus): Children {
current.modified = true; current.modified = true;
e.redraw = false; e.redraw = false;
}, },
onkeypress: (e) => {
e.redraw = false;
if (e.which === 13 && !e.shiftKey) {
const dom = e.target;
dom.form.querySelector("button[type=submit]").click();
return false;
}
return true;
},
});
}
let datalist: string = null;
if (attr.options) datalist = getDatalistId(attr.options);
return m("input", {
type: attr.type === "password" ? "password" : "text",
name: attr.id,
list: datalist,
autocomplete: datalist ? "off" : null,
disabled: attr.id === "_id" && !current.isNew,
value: current.object[attr.id],
oncreate: focus
? (_vnode) => {
(_vnode.dom as HTMLInputElement).focus();
}
: null,
oninput: (e) => {
current.object[attr.id] = e.target.value;
current.modified = true;
e.redraw = false;
}, },
}); );
} }
interface Attrs { interface Attrs {
@@ -195,24 +210,22 @@ const component: ClosureComponent<Attrs> = () => {
form.push( form.push(
m( m(
"p", "p",
m("label", { for: attr.id }, attr.label || attr.id), m(
m("br"), "label.block text-sm font-semibold text-stone-700 mt-2 mb-1",
{ for: attr.id },
attr.label || attr.id,
),
createField(current, attr, focus), createField(current, attr, focus),
), ),
); );
} }
const submit = m( const buttons: VnodeDOM[] = [];
"button.primary",
{ type: "submit" },
"Save",
) as VnodeDOM;
const buttons = [submit];
if (!current.isNew) { if (!current.isNew) {
buttons.push( buttons.push(
m( m(
"button.primary", "button.ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-xs text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-red-500",
{ {
type: "button", type: "button",
title: `Delete ${singular[resource] || resource}`, title: `Delete ${singular[resource] || resource}`,
@@ -229,11 +242,19 @@ const component: ClosureComponent<Attrs> = () => {
); );
} }
form.push(m(".actions-bar", buttons)); const submit = m(
"button.ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-xs text-sm font-medium rounded-md text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500",
{ type: "submit" },
"Save",
) as VnodeDOM;
buttons.push(submit);
form.push(m("div.flex justify-end mt-5", buttons));
const children = [ const children = [
m( m(
"h1", "h2.text-lg leading-6 font-medium text-stone-900",
`${current.isNew ? "New" : "Editing"} ${ `${current.isNew ? "New" : "Editing"} ${
singular[resource] || resource singular[resource] || resource
}`, }`,
@@ -259,7 +280,7 @@ const component: ClosureComponent<Attrs> = () => {
), ),
]; ];
return m("div.put-form", children); return m("div", children);
}, },
}; };
}; };
+6 -2
View File
@@ -657,12 +657,16 @@ export function changePassword(
}); });
} }
export function logIn(username: string, password: string): Promise<void> { export function logIn(
username: string,
password: string,
remember = false,
): Promise<void> {
return xhrRequest({ return xhrRequest({
method: "POST", method: "POST",
url: "login", url: "login",
background: true, background: true,
body: { username, password }, body: { username, password, remember },
}); });
} }
+200
View File
@@ -0,0 +1,200 @@
import m, {
ChildArrayOrPrimitive,
ClosureComponent,
mount,
Vnode,
VnodeDOM,
} from "mithril";
import { ICONS_SVG } from "../build/assets.ts";
export const portal: ClosureComponent<void> = () => {
let rootElement: HTMLElement;
let children: ChildArrayOrPrimitive;
return {
oncreate: (vnode) => {
children = vnode.children;
rootElement = document.createElement("div");
document.body.appendChild(rootElement);
mount(rootElement, { view: () => children });
},
onupdate: (vnode) => {
children = vnode.children;
},
onremove: () => {
if (document.body.contains(rootElement)) {
mount(rootElement, null);
document.body.removeChild(rootElement);
}
},
view: () => {
return null;
},
};
};
interface DialogAttrs {
onClose?: () => void;
as: string;
class?: string;
}
export const dialog: ClosureComponent<DialogAttrs> = () => {
return {
view(vnode) {
return m(
portal,
m(
vnode.attrs.as,
{ class: vnode.attrs.class, role: "dialog", "aria-modal": "true" },
vnode.children,
),
);
},
};
};
interface DialogOverlayAttrs {
class?: string;
}
export const dialogOverlay: ClosureComponent<DialogOverlayAttrs> = () => {
return {
view(vnode) {
return m(
"div",
{ class: vnode.attrs.class, "aria-hidden": "true" },
vnode.children,
);
},
};
};
let transitionShow = false;
let transitionTimeout = 0;
interface TransitionRootAttrs {
show: boolean;
duration: number;
}
export const transitionRoot: ClosureComponent<TransitionRootAttrs> = (
initialVnode,
) => {
let show = initialVnode.attrs.show;
let transitionTimestamp = 0;
let timeout: ReturnType<typeof setTimeout>;
return {
view(vnode: Vnode<TransitionRootAttrs>) {
if (show !== vnode.attrs.show) {
const now = Date.now();
transitionTimestamp = now;
show = vnode.attrs.show;
clearTimeout(timeout);
timeout = setTimeout(() => m.redraw(), vnode.attrs.duration);
}
transitionShow = show;
transitionTimeout = Math.max(
0,
vnode.attrs.duration - (Date.now() - transitionTimestamp),
);
if (!transitionShow && !transitionTimeout) return null;
return vnode.children;
},
};
};
interface TransitionChildAttrs {
enter: string;
enterFrom: string;
enterTo: string;
leave: string;
leaveFrom: string;
leaveTo: string;
}
export const transitionChild: ClosureComponent<TransitionChildAttrs> = () => {
let show: boolean;
let timeout: number;
let firstFrame: boolean;
function updateCssClasses(vnode: VnodeDOM<TransitionChildAttrs>): void {
const dom = vnode.dom as HTMLElement;
const enter = vnode.attrs.enter.split(" ");
const enterFrom = vnode.attrs.enterFrom.split(" ");
const enterTo = vnode.attrs.enterTo.split(" ");
const leave = vnode.attrs.leave.split(" ");
const leaveFrom = vnode.attrs.leaveFrom.split(" ");
const leaveTo = vnode.attrs.leaveTo.split(" ");
if (show) {
dom.classList.remove(...leave, ...leaveFrom, ...leaveTo);
if (firstFrame) {
dom.classList.add(...enterFrom);
void dom.getBoundingClientRect();
dom.classList.remove(...enterFrom);
}
if (timeout) dom.classList.add(...enter);
else dom.classList.remove(...enter);
dom.classList.add(...enterTo);
} else {
dom.classList.remove(...enter, ...enterFrom, ...enterTo);
if (firstFrame) {
dom.classList.add(...leaveFrom);
void dom.getBoundingClientRect();
dom.classList.remove(...leaveFrom);
}
if (timeout) dom.classList.add(...leave);
else dom.classList.remove(...leave);
dom.classList.add(...leaveTo);
}
}
return {
view(vnode: Vnode<TransitionChildAttrs>) {
firstFrame = show !== transitionShow;
show = transitionShow;
timeout = transitionTimeout;
return vnode.children;
},
oncreate(vnode: VnodeDOM<TransitionChildAttrs>) {
updateCssClasses(vnode);
},
onupdate(vnode: VnodeDOM<TransitionChildAttrs>) {
updateCssClasses(vnode);
},
};
};
interface IconAttrs {
name: string;
class?: string;
}
export const icon: ClosureComponent<IconAttrs> = () => {
return {
view(vnode) {
return m(
`svg`,
{
xmlns: "http://www.w3.org/2000/svg",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
class: vnode.attrs.class,
"aria-hidden": "true",
},
m("use", { href: `${ICONS_SVG}#icon-${vnode.attrs.name}` }),
);
},
};
};
+11 -4
View File
@@ -110,10 +110,17 @@ const component: ClosureComponent<Attrs> = () => {
}; };
const code = m(codeEditorComponent, attrs); const code = m(codeEditorComponent, attrs);
const submit = m("button.primary", { type: "submit" }, "Save"); const submit = m(
"button.ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-xs text-sm font-medium rounded-md text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500",
{ type: "submit" },
"Save",
);
return m("div.put-form", [ return m("div", [
m("h1", `Editing ${name}`), m(
"h2.mb-5 text-lg leading-6 font-medium text-stone-900",
`Editing ${name}`,
),
m( m(
"form", "form",
{ {
@@ -128,7 +135,7 @@ const component: ClosureComponent<Attrs> = () => {
.catch(vnode.attrs.onError); .catch(vnode.attrs.onError);
}, },
}, },
[code, m(".actions-bar", [submit])], [code, m(".flex justify-end mt-5", [submit])],
), ),
]); ]);
}, },
-50
View File
@@ -1,50 +0,0 @@
import m, { ClosureComponent, Component } from "mithril";
import * as store from "./store.ts";
import * as notifications from "./notifications.ts";
const component: ClosureComponent = (): Component => {
return {
view: () => {
if (window.username) {
return m(
"div.user-menu",
window.username,
m(
"button",
{
onclick: (e) => {
e.target.disabled = true;
store
.logOut()
.then(() => {
location.hash = "";
location.reload();
})
.catch((err) => {
e.target.disabled = false;
notifications.push("error", err.message);
});
return false;
},
},
"Log out",
),
);
} else {
return m(
"div.user-menu",
m(
"a",
{
href:
"#!/login?" + m.buildQueryString({ continue: m.route.get() }),
},
"Log in",
),
);
}
},
};
};
export default component;
+7 -6
View File
@@ -97,6 +97,7 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
}) })
.catch(reject); .catch(reject);
} else if (action === "delete") { } else if (action === "delete") {
if (!confirm("Deleting user. Are you sure?")) return void resolve(null);
store store
.deleteResource("users", object["_id"]) .deleteResource("users", object["_id"])
.then(() => { .then(() => {
@@ -152,7 +153,7 @@ export const component: ClosureComponent = (): Component => {
function onFilterChanged(filter): void { function onFilterChanged(filter): void {
const ops = { filter }; const ops = { filter };
if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"]; if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"];
m.route.set("/admin/users", ops); m.route.set("/users", ops);
} }
const sort = vnode.attrs["sort"] const sort = vnode.attrs["sort"]
@@ -172,7 +173,7 @@ export const component: ClosureComponent = (): Component => {
_sort[attributes[Math.abs(index) - 1].id] = Math.sign(index); _sort[attributes[Math.abs(index) - 1].id] = Math.sign(index);
const ops = { sort: JSON.stringify(_sort) }; const ops = { sort: JSON.stringify(_sort) };
if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"];
m.route.set("/admin/users", ops); m.route.set("/users", ops);
} }
let filter = vnode.attrs["filter"] let filter = vnode.attrs["filter"]
@@ -211,7 +212,7 @@ export const component: ClosureComponent = (): Component => {
attrs["recordActionsCallback"] = (user) => { attrs["recordActionsCallback"] = (user) => {
return [ return [
m( m(
"a", "button.text-cyan-700 hover:text-cyan-900 font-medium",
{ {
onclick: () => { onclick: () => {
let cb: () => Children = null; let cb: () => Children = null;
@@ -296,7 +297,7 @@ export const component: ClosureComponent = (): Component => {
attrs["actionsCallback"] = (selected: Set<string>): Children => { attrs["actionsCallback"] = (selected: Set<string>): Children => {
return [ return [
m( 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: "Create new user", title: "Create new user",
onclick: () => { onclick: () => {
@@ -342,7 +343,7 @@ export const component: ClosureComponent = (): Component => {
"New", "New",
), ),
m( 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 selected users", title: "Delete selected users",
disabled: !selected.size, disabled: !selected.size,
@@ -385,7 +386,7 @@ export const component: ClosureComponent = (): Component => {
}; };
return [ return [
m("h1", "Listing users"), m("h1.text-xl font-medium text-stone-900 mb-5", "Listing users"),
m(filterComponent, filterAttrs), m(filterComponent, filterAttrs),
m( m(
"loading", "loading",
+11 -6
View File
@@ -76,6 +76,8 @@ function putActionHandler(action, _object, isNew): Promise<ValidationErrors> {
}) })
.catch(reject); .catch(reject);
} else if (action === "delete") { } else if (action === "delete") {
if (!confirm("Deleting virtual parameter. Are you sure?"))
return void resolve(null);
store store
.deleteResource("virtualParameters", object["_id"]) .deleteResource("virtualParameters", object["_id"])
.then(() => { .then(() => {
@@ -142,7 +144,7 @@ export const component: ClosureComponent = (): Component => {
function onFilterChanged(filter): void { function onFilterChanged(filter): void {
const ops = { filter }; const ops = { filter };
if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"]; if (vnode.attrs["sort"]) ops["sort"] = vnode.attrs["sort"];
m.route.set("/admin/virtualParameters", ops); m.route.set("/virtualParameters", ops);
} }
const sort = vnode.attrs["sort"] const sort = vnode.attrs["sort"]
@@ -159,7 +161,7 @@ export const component: ClosureComponent = (): Component => {
_sort[attributes[Math.abs(index) - 1].id] = Math.sign(index); _sort[attributes[Math.abs(index) - 1].id] = Math.sign(index);
const ops = { sort: JSON.stringify(_sort) }; const ops = { sort: JSON.stringify(_sort) };
if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"]; if (vnode.attrs["filter"]) ops["filter"] = vnode.attrs["filter"];
m.route.set("/admin/virtualParameters", ops); m.route.set("/virtualParameters", ops);
} }
let filter = vnode.attrs["filter"] let filter = vnode.attrs["filter"]
@@ -187,7 +189,7 @@ export const component: ClosureComponent = (): Component => {
attrs["recordActionsCallback"] = (virtualParameter) => { attrs["recordActionsCallback"] = (virtualParameter) => {
return [ return [
m( m(
"a", "button.text-cyan-700 hover:text-cyan-900 font-medium",
{ {
onclick: () => { onclick: () => {
let cb: () => Children = null; let cb: () => Children = null;
@@ -239,7 +241,7 @@ export const component: ClosureComponent = (): Component => {
attrs["actionsCallback"] = (selected: Set<string>): Children => { attrs["actionsCallback"] = (selected: Set<string>): Children => {
return [ return [
m( 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: "Create new virtual parameter", title: "Create new virtual parameter",
onclick: () => { onclick: () => {
@@ -285,7 +287,7 @@ export const component: ClosureComponent = (): Component => {
"New", "New",
), ),
m( 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 selected virtual parameters", title: "Delete selected virtual parameters",
disabled: !selected.size, disabled: !selected.size,
@@ -330,7 +332,10 @@ export const component: ClosureComponent = (): Component => {
}; };
return [ return [
m("h1", "Listing virtual parameters"), m(
"h1.text-xl font-medium text-stone-900 mb-5",
"Listing virtual parameters",
),
m(filterComponent, filterAttrs), m(filterComponent, filterAttrs),
m( m(
"loading", "loading",
+88 -81
View File
@@ -1,4 +1,5 @@
import m, { ClosureComponent, Component } from "mithril"; import { ClosureComponent, Component } from "mithril";
import { m } from "./components.ts";
import * as notifications from "./notifications.ts"; import * as notifications from "./notifications.ts";
export async function init(): Promise<Record<string, unknown>> { export async function init(): Promise<Record<string, unknown>> {
@@ -14,93 +15,99 @@ export const component: ClosureComponent = (vnode): Component => {
view: () => { view: () => {
document.title = "Initialization wizard - GenieACS"; document.title = "Initialization wizard - GenieACS";
const checkboxes = [ const items = [
"users", { key: "users", label: "Users, roles and permissions" },
"presets", { key: "presets", label: "Presets and provisions" },
"filters", { key: "filters", label: "Devices predefined search filters" },
"device", { key: "device", label: "Device details page" },
"index", { key: "index", label: "Devices listing page" },
"overview", { key: "overview", label: "Overview page" },
].map((s) => { ];
if (!options[s]) selected.delete(s);
return m("input", {
type: "checkbox",
checked: selected.has(s),
disabled: !options[s],
style: "display: inline; margin-right: 0.5em;",
onclick: (e) => {
if (e.target.checked) selected.add(s);
else selected.delete(s);
},
});
});
return m(".wizard-dialog", [ return [
m("h1", "Initialization wizard"),
m( m(
"p", "h1.text-xl font-medium text-stone-900 mb-5",
"This wizard will seed the database with a minimal initial configuration to serve as a starting point. Select what you want to initialize and click 'ABRACADABRA!'.", "Initialization wizard",
), ),
m("div", m("label", checkboxes[0], "Users, roles and permissions")), m(".bg-white shadow-sm rounded-lg p-6 sm:p-8 max-w-lg", [
m("div", m("label", checkboxes[1], "Presets and provisions")), m(
m( "p.text-sm text-stone-600 mb-6",
"div", "This wizard will seed the database with a minimal initial configuration to serve as a starting point. Select what you want to initialize and click the button below.",
m("label", checkboxes[2], "Devices predefined search filters"), ),
), m(
m("div", m("label", checkboxes[3], "Device details page")), ".flex flex-col gap-3 mb-6",
m("div", m("label", checkboxes[4], "Devices listing page")), items.map((item) => {
m("div", m("label", checkboxes[5], "Overview page")), if (!options[item.key]) selected.delete(item.key);
m( return m(
"button.primary", "label.flex items-center text-sm text-stone-700",
{ { class: options[item.key] ? "" : "opacity-50" },
style: "margin: 10px;", m(
disabled: selected.size === 0, "input.focus:ring-cyan-500 h-4 w-4 text-cyan-700 border-stone-300 rounded-sm",
onclick: (e) => { {
e.target.disabled = true; type: "checkbox",
checked: selected.has(item.key),
disabled: !options[item.key],
onclick: (e) => {
if (e.target.checked) selected.add(item.key);
else selected.delete(item.key);
},
},
),
m("span.ml-2", item.label),
);
}),
),
m(
"button.inline-flex justify-center py-2 px-4 border border-transparent shadow-xs text-sm font-medium rounded-md text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed",
{
disabled: selected.size === 0,
onclick: (e) => {
e.target.disabled = true;
const opts = {}; const opts = {};
for (const s of selected) opts[s] = true; for (const s of selected) opts[s] = true;
m.request({ m.request({
method: "POST", method: "POST",
url: "init", url: "init",
body: opts, body: opts,
})
.then(() => {
setTimeout(() => {
m.request({ url: "init" })
.then((o) => {
e.target.disabled = false;
options = o;
notifications.push(
"success",
"Initialization complete",
{
"Open Sesame!": () => {
m.route.set("/login");
window.location.reload();
},
},
);
})
.catch((err) => {
notifications.push("error", err.message);
});
}, 3000);
if (opts["users"]) {
alert(
"An administrator user has been created for you. Use admin/admin to log in. Don't forget to change the default password.",
);
}
}) })
.catch((err) => { .then(() => {
notifications.push("error", err.message); setTimeout(() => {
}); m.request({ url: "init" })
.then((o) => {
e.target.disabled = false;
options = o;
notifications.push(
"success",
"Initialization complete",
{
"Open Sesame!": () => {
m.route.set("/login");
window.location.reload();
},
},
);
})
.catch((err) => {
notifications.push("error", err.message);
});
}, 3000);
if (opts["users"]) {
alert(
"An administrator user has been created for you. Use admin/admin to log in. Don't forget to change the default password.",
);
}
})
.catch((err) => {
notifications.push("error", err.message);
});
},
}, },
}, "ABRACADABRA!",
"ABRACADABRA!", ),
), ]),
]); ];
}, },
}; };
}; };
+1
View File
@@ -0,0 +1 @@
export { parse, stringify } from "yaml";