From 5a4571fb0ee72d4dadab40706728c8360ea81fa7 Mon Sep 17 00:00:00 2001 From: MickaelK Date: Wed, 8 Oct 2025 21:02:26 +1100 Subject: [PATCH] feature (admin): revamp admin ux --- .../pages/adminpage/ctrl_activity_graph.css | 29 ++++-- .../pages/adminpage/ctrl_activity_graph.js | 91 +++++++++++++------ .../pages/adminpage/ctrl_activity_viewer.js | 4 +- public/assets/pages/adminpage/ctrl_setup.js | 2 +- .../assets/pages/adminpage/ctrl_workflow.css | 13 ++- .../pages/adminpage/ctrl_workflow_list.js | 20 ++-- public/assets/pages/adminpage/model_log.js | 16 ++-- 7 files changed, 118 insertions(+), 57 deletions(-) diff --git a/public/assets/pages/adminpage/ctrl_activity_graph.css b/public/assets/pages/adminpage/ctrl_activity_graph.css index 52f41f08..ff3bb15d 100644 --- a/public/assets/pages/adminpage/ctrl_activity_graph.css +++ b/public/assets/pages/adminpage/ctrl_activity_graph.css @@ -1,23 +1,29 @@ -.component_stats { +.component_page_admin .component_stats { clear: both; + height: 100px; + z-index: 1; + margin-bottom: 30px; +} +.component_page_admin .component_logviewer { + z-index: 0; } .component_stats .chart { display: flex; align-items: flex-end; - height: 100px; + justify-content: end; + height: 100%; } .component_stats .chart .bar { + max-width: 45px; + cursor: pointer; display: flex; flex: 1; - cursor: pointer; border-top-left-radius: 3px; border-top-right-radius: 3px; - border-bottom: 1px solid var(--light); - - background: var(--border); + background: #ebebec; border: 2px solid var(--border); } -.component_stats .chart .bar[title="0"], .component_stats .chart .bar[title="1"], .component_stats .chart .bar[title="2"] { +.component_stats .chart .bar[title="0"], .component_stats .chart .bar[title="1"] { border-top-left-radius: 0px; border-top-right-radius: 0px; } @@ -27,3 +33,12 @@ color: var(--light); font-size: 0.8rem; } +.component_stats .legend .title { + font-style: italic; +} +.component_stats .legend.invisible { + opacity: 0; +} +.component_stats .component_skeleton { + margin-bottom: 5px; +} diff --git a/public/assets/pages/adminpage/ctrl_activity_graph.js b/public/assets/pages/adminpage/ctrl_activity_graph.js index 684669e9..04b7bb80 100644 --- a/public/assets/pages/adminpage/ctrl_activity_graph.js +++ b/public/assets/pages/adminpage/ctrl_activity_graph.js @@ -1,48 +1,83 @@ import { createElement } from "../../lib/skeleton/index.js"; import rxjs, { effect } from "../../lib/rx.js"; -import { get as getLogs } from "./model_log.js"; +import { generateSkeleton } from "../../components/skeleton.js"; import { loadCSS } from "../../helpers/loader.js"; +import { get as getLogs } from "./model_log.js"; + +const NUMBER_BUCKETS = 30; +const MIN_TIME_WIDTH = 5000; + export default async function(render) { + render(createElement(`
${generateSkeleton(3)}
`)); await loadCSS(import.meta.url, "./ctrl_activity_graph.css"); - effect(getLogs().pipe( - rxjs.map((log) => { - const times = log.trim().split("\n").map((line) => new Date(line.substring(0, 19)).getTime()); - const start = times[0]; - const end = times[times.length - 1]; - - const size = 30 - const bars = Array(size).fill(0); - const width = (end - start) / size; - for (const t of times) { - const idx = Math.min(size - 1, Math.max(0, Math.floor((t - start) / width))); - bars[idx] += 1; + effect(getLogs(300).pipe( + rxjs.first(), + rxjs.repeat({ delay: 2500 }), + rxjs.scan(({ start, end, width, max, init = true, buckets = Array(NUMBER_BUCKETS).fill(0) }, logfile) => { + const times = logfile.trim().split("\n").map((line) => new Date(line.substring(0, 19)).getTime()); + if (init === true) { + start = times[0]; + end = times[times.length - 1]; + width = Math.max((end - start) / NUMBER_BUCKETS, MIN_TIME_WIDTH); + for (let i=times.length-1; i>=0; i--) { + let idx = Math.floor((times[i] - start) / width); + if (idx === NUMBER_BUCKETS) idx -= 1; + buckets[idx] += 1; + } + for (let i=buckets.length-1; i>=0; i--) { + if (buckets[i] === 0) buckets[i] = -1; + else break; + } + max = Math.max(1, ...buckets); + init = false; + } else { + for (let i=times.length-1; i>=0; i--) { + const current = times[i]; + // start end current + // | | | + // |=============|<-- new --> + if (current <= end) { + break; + } + const idx = Math.floor((times[i] - start) / width); + if (!buckets[idx]) buckets[idx] = 0; + buckets[idx] += 1; + } + const shift = buckets.length - NUMBER_BUCKETS; + for (let i=0; i { - const max = Math.max(1, ...bars); + return { start, end, width, buckets, max, init }; + }, {}), + rxjs.tap(({ buckets, start, end, max }) => { const $root = document.createDocumentFragment(); const $chart = createElement(`
`); - for (let i = 0; i < bars.length; i++) { - const $bar = createElement(`
`) - $bar.style.height = Math.sqrt(bars[i]) / Math.sqrt(max) * 100 + "%"; + let display = true; + for (let i = 0; i < buckets.length; i++) { + if (buckets[i] < 0) { + display = false; + continue; + } + const $bar = createElement(`
`); + const height = Math.sqrt(buckets[i]) / Math.sqrt(max) * 100; + $bar.style.height = Math.min(height, 120) + "%"; $chart.appendChild($bar); } $root.appendChild($chart); $root.appendChild(createElement(`
- ${start} - ${end} + ${new Date(start).toLocaleTimeString()} + Log Events + ${new Date(end).toLocaleTimeString()}
`)); - render($root); + if (display) render($root); }), - rxjs.catchError(() => rxjs.EMPTY), + rxjs.catchError((err) => rxjs.EMPTY), )); } diff --git a/public/assets/pages/adminpage/ctrl_activity_viewer.js b/public/assets/pages/adminpage/ctrl_activity_viewer.js index 25abe402..089ea378 100644 --- a/public/assets/pages/adminpage/ctrl_activity_viewer.js +++ b/public/assets/pages/adminpage/ctrl_activity_viewer.js @@ -19,13 +19,15 @@ export default async function(render) { const $log = qs($page, "pre"); render($page); - effect(getLogs().pipe( + effect(rxjs.of(null).pipe( + rxjs.mergeMap(() => $log.matches(":hover") ? rxjs.EMPTY : getLogs()), rxjs.map((logData) => logData + "\n\n\n\n\n"), stateMutation($log, "textContent"), rxjs.tap(() => { if ($log?.scrollTop !== 0) return; $log.scrollTop = $log.scrollHeight; }), + rxjs.repeat({ delay: 2500 }), rxjs.catchError(() => rxjs.EMPTY), )); } diff --git a/public/assets/pages/adminpage/ctrl_setup.js b/public/assets/pages/adminpage/ctrl_setup.js index 6d86f894..d1357208 100644 --- a/public/assets/pages/adminpage/ctrl_setup.js +++ b/public/assets/pages/adminpage/ctrl_setup.js @@ -54,7 +54,7 @@ function setupHOC(ctrlWrapped) { function componentStep1(render) { const $page = createElement(`
-

Welcome Aboard, Captain!

+

Welcome Aboard!

First thing first, setup your password:

diff --git a/public/assets/pages/adminpage/ctrl_workflow.css b/public/assets/pages/adminpage/ctrl_workflow.css index a2d3eb49..078e34be 100644 --- a/public/assets/pages/adminpage/ctrl_workflow.css +++ b/public/assets/pages/adminpage/ctrl_workflow.css @@ -104,8 +104,17 @@ /* list page */ .component_page_workflow h3.empty { padding: 30px; - border: 2px dashed var(--light); - color: var(--light); + background: white; + border: 2px dashed var(--color); + text-shadow: 0px 0px 1px rgba(0, 0, 0, 0.5); + border-radius: 5px; + font-weight: 600; + color: var(--color); + text-transform: uppercase; + font-size: 1.05rem; + letter-spacing: -0.5px; + opacity: 0.4; + cursor: pointer; } /* Workflow creation modal */ diff --git a/public/assets/pages/adminpage/ctrl_workflow_list.js b/public/assets/pages/adminpage/ctrl_workflow_list.js index eaa993cd..329df19c 100644 --- a/public/assets/pages/adminpage/ctrl_workflow_list.js +++ b/public/assets/pages/adminpage/ctrl_workflow_list.js @@ -20,8 +20,8 @@ export default async function(render, { workflows, triggers }) { `); render(transition($page)); const $workflows = qs($page, `[data-bind="workflows"]`); - workflows.forEach((workflow) => $workflows.appendChild(createWorkflow(workflow))); - if (workflows.length === 0) $workflows.appendChild(createEmptyWorkflow()); + workflows.forEach((workflow) => $workflows.appendChild(createWorkflow({ workflow }))); + if (workflows.length === 0) $workflows.appendChild(createEmptyWorkflow({ triggers })); effect(onClick(qs($page, "h2 > a")).pipe( rxjs.tap(($a) => animate($a, { @@ -32,14 +32,14 @@ export default async function(render, { workflows, triggers }) { )); } -function createWorkflow(specs) { - const { name, published, actions, trigger, updated_at } = specs; +function createWorkflow({ workflow }) { + const { name, published, actions, trigger, updated_at } = workflow; const summaryHTML = { trigger: ``, actions: (actions || []).map(({ name }) => ``).join(""), }; const $workflow = createElement(` - +

${safe(name)} (${Intl.DateTimeFormat(navigator.language).format(new Date(safe(updated_at)))}) @@ -52,10 +52,14 @@ function createWorkflow(specs) { return $workflow; } -function createEmptyWorkflow() { - return createElement(` -

Add a new workflow to get started

+function createEmptyWorkflow({ triggers }) { + const $empty = createElement(` +

Create your first workflow

`); + effect(onClick($empty).pipe( + rxjs.tap(() => ctrlModal(createModal(), { triggers })), + )); + return $empty; } function ctrlModal(render, { triggers }) { diff --git a/public/assets/pages/adminpage/model_log.js b/public/assets/pages/adminpage/model_log.js index ac5542a0..9a728a32 100644 --- a/public/assets/pages/adminpage/model_log.js +++ b/public/assets/pages/adminpage/model_log.js @@ -1,19 +1,15 @@ import rxjs from "../../lib/rx.js"; import ajax from "../../lib/ajax.js"; -const log$ = ajax({ - url: url(1024 * 100), // fetch the last 100kb by default - responseType: "text", -}).pipe( - rxjs.map(({ response }) => response), -); - export function url(logSize = 0) { return "admin/api/logs" + (logSize ? `?maxSize=${logSize}` : ""); } -export function get() { - return log$.pipe( - rxjs.repeat({ delay: 10000 }), +export function get(t = 100) { + return ajax({ + url: url(1024 * t), // fetch the last 100KB by default + responseType: "text", + }).pipe( + rxjs.map(({ response }) => response), ); }