feature (admin): revamp admin ux

This commit is contained in:
MickaelK 2025-10-08 21:02:26 +11:00
parent 587793e376
commit 5a4571fb0e
7 changed files with 118 additions and 57 deletions

View file

@ -1,23 +1,29 @@
.component_stats { .component_page_admin .component_stats {
clear: both; clear: both;
height: 100px;
z-index: 1;
margin-bottom: 30px;
}
.component_page_admin .component_logviewer {
z-index: 0;
} }
.component_stats .chart { .component_stats .chart {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
height: 100px; justify-content: end;
height: 100%;
} }
.component_stats .chart .bar { .component_stats .chart .bar {
max-width: 45px;
cursor: pointer;
display: flex; display: flex;
flex: 1; flex: 1;
cursor: pointer;
border-top-left-radius: 3px; border-top-left-radius: 3px;
border-top-right-radius: 3px; border-top-right-radius: 3px;
border-bottom: 1px solid var(--light); background: #ebebec;
background: var(--border);
border: 2px solid var(--border); 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-left-radius: 0px;
border-top-right-radius: 0px; border-top-right-radius: 0px;
} }
@ -27,3 +33,12 @@
color: var(--light); color: var(--light);
font-size: 0.8rem; font-size: 0.8rem;
} }
.component_stats .legend .title {
font-style: italic;
}
.component_stats .legend.invisible {
opacity: 0;
}
.component_stats .component_skeleton {
margin-bottom: 5px;
}

View file

@ -1,48 +1,83 @@
import { createElement } from "../../lib/skeleton/index.js"; import { createElement } from "../../lib/skeleton/index.js";
import rxjs, { effect } from "../../lib/rx.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 { 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) { export default async function(render) {
render(createElement(`<div>${generateSkeleton(3)}</div>`));
await loadCSS(import.meta.url, "./ctrl_activity_graph.css"); await loadCSS(import.meta.url, "./ctrl_activity_graph.css");
effect(getLogs().pipe( effect(getLogs(300).pipe(
rxjs.map((log) => { rxjs.first(),
const times = log.trim().split("\n").map((line) => new Date(line.substring(0, 19)).getTime()); rxjs.repeat({ delay: 2500 }),
const start = times[0]; rxjs.scan(({ start, end, width, max, init = true, buckets = Array(NUMBER_BUCKETS).fill(0) }, logfile) => {
const end = times[times.length - 1]; const times = logfile.trim().split("\n").map((line) => new Date(line.substring(0, 19)).getTime());
if (init === true) {
const size = 30 start = times[0];
const bars = Array(size).fill(0); end = times[times.length - 1];
const width = (end - start) / size; width = Math.max((end - start) / NUMBER_BUCKETS, MIN_TIME_WIDTH);
for (const t of times) { for (let i=times.length-1; i>=0; i--) {
const idx = Math.min(size - 1, Math.max(0, Math.floor((t - start) / width))); let idx = Math.floor((times[i] - start) / width);
bars[idx] += 1; if (idx === NUMBER_BUCKETS) idx -= 1;
buckets[idx] += 1;
} }
return { for (let i=buckets.length-1; i>=0; i--) {
bars, if (buckets[i] === 0) buckets[i] = -1;
start: new Date(start).toLocaleTimeString(), else break;
end: new Date(end).toLocaleTimeString(), }
}; max = Math.max(1, ...buckets);
}), init = false;
rxjs.tap(({ bars, start, end }) => { } else {
const max = Math.max(1, ...bars); 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<shift; i++) {
buckets.shift();
start += width;
}
end = times[times.length - 1];
}
return { start, end, width, buckets, max, init };
}, {}),
rxjs.tap(({ buckets, start, end, max }) => {
const $root = document.createDocumentFragment(); const $root = document.createDocumentFragment();
const $chart = createElement(`<div class="chart"></div>`); const $chart = createElement(`<div class="chart"></div>`);
for (let i = 0; i < bars.length; i++) { let display = true;
const $bar = createElement(`<div class="bar" title="${bars[i]}"></div>`) for (let i = 0; i < buckets.length; i++) {
$bar.style.height = Math.sqrt(bars[i]) / Math.sqrt(max) * 100 + "%"; if (buckets[i] < 0) {
display = false;
continue;
}
const $bar = createElement(`<div class="bar" title="${buckets[i]}"></div>`);
const height = Math.sqrt(buckets[i]) / Math.sqrt(max) * 100;
$bar.style.height = Math.min(height, 120) + "%";
$chart.appendChild($bar); $chart.appendChild($bar);
} }
$root.appendChild($chart); $root.appendChild($chart);
$root.appendChild(createElement(` $root.appendChild(createElement(`
<div class="legend"> <div class="legend">
<span>${start}</span> <span>${new Date(start).toLocaleTimeString()}</span>
<span>${end}</span> <span class="title">Log Events</span>
<span>${new Date(end).toLocaleTimeString()}</span>
</div> </div>
`)); `));
render($root); if (display) render($root);
}), }),
rxjs.catchError(() => rxjs.EMPTY), rxjs.catchError((err) => rxjs.EMPTY),
)); ));
} }

View file

@ -19,13 +19,15 @@ export default async function(render) {
const $log = qs($page, "pre"); const $log = qs($page, "pre");
render($page); 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"), rxjs.map((logData) => logData + "\n\n\n\n\n"),
stateMutation($log, "textContent"), stateMutation($log, "textContent"),
rxjs.tap(() => { rxjs.tap(() => {
if ($log?.scrollTop !== 0) return; if ($log?.scrollTop !== 0) return;
$log.scrollTop = $log.scrollHeight; $log.scrollTop = $log.scrollHeight;
}), }),
rxjs.repeat({ delay: 2500 }),
rxjs.catchError(() => rxjs.EMPTY), rxjs.catchError(() => rxjs.EMPTY),
)); ));
} }

View file

@ -54,7 +54,7 @@ function setupHOC(ctrlWrapped) {
function componentStep1(render) { function componentStep1(render) {
const $page = createElement(` const $page = createElement(`
<div id="step1"> <div id="step1">
<h4>Welcome Aboard, Captain!</h4> <h4>Welcome Aboard!</h4>
<div> <div>
<p>First thing first, setup your password: </p> <p>First thing first, setup your password: </p>
<form> <form>

View file

@ -104,8 +104,17 @@
/* list page */ /* list page */
.component_page_workflow h3.empty { .component_page_workflow h3.empty {
padding: 30px; padding: 30px;
border: 2px dashed var(--light); background: white;
color: var(--light); 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 */ /* Workflow creation modal */

View file

@ -20,8 +20,8 @@ export default async function(render, { workflows, triggers }) {
`); `);
render(transition($page)); render(transition($page));
const $workflows = qs($page, `[data-bind="workflows"]`); const $workflows = qs($page, `[data-bind="workflows"]`);
workflows.forEach((workflow) => $workflows.appendChild(createWorkflow(workflow))); workflows.forEach((workflow) => $workflows.appendChild(createWorkflow({ workflow })));
if (workflows.length === 0) $workflows.appendChild(createEmptyWorkflow()); if (workflows.length === 0) $workflows.appendChild(createEmptyWorkflow({ triggers }));
effect(onClick(qs($page, "h2 > a")).pipe( effect(onClick(qs($page, "h2 > a")).pipe(
rxjs.tap(($a) => animate($a, { rxjs.tap(($a) => animate($a, {
@ -32,14 +32,14 @@ export default async function(render, { workflows, triggers }) {
)); ));
} }
function createWorkflow(specs) { function createWorkflow({ workflow }) {
const { name, published, actions, trigger, updated_at } = specs; const { name, published, actions, trigger, updated_at } = workflow;
const summaryHTML = { const summaryHTML = {
trigger: `<button class="light">${safe(trigger.name)}</button>`, trigger: `<button class="light">${safe(trigger.name)}</button>`,
actions: (actions || []).map(({ name }) => `<button class="light">${safe(name.split("/")[0])}</button>`).join(""), actions: (actions || []).map(({ name }) => `<button class="light">${safe(name.split("/")[0])}</button>`).join(""),
}; };
const $workflow = createElement(` const $workflow = createElement(`
<a href="./admin/workflow?specs=${encodeURIComponent(btoa(JSON.stringify(specs)))}" class="box ${published ? "" : "disabled"}" data-link> <a href="./admin/workflow?specs=${encodeURIComponent(btoa(JSON.stringify(workflow)))}" class="box ${published ? "" : "disabled"}" data-link>
<h3 class="ellipsis"> <h3 class="ellipsis">
${safe(name)} ${safe(name)}
<span>(${Intl.DateTimeFormat(navigator.language).format(new Date(safe(updated_at)))})</span> <span>(${Intl.DateTimeFormat(navigator.language).format(new Date(safe(updated_at)))})</span>
@ -52,10 +52,14 @@ function createWorkflow(specs) {
return $workflow; return $workflow;
} }
function createEmptyWorkflow() { function createEmptyWorkflow({ triggers }) {
return createElement(` const $empty = createElement(`
<h3 class="center empty no-select">Add a new workflow to get started</h3> <h3 class="center empty no-select">Create your first workflow</h3>
`); `);
effect(onClick($empty).pipe(
rxjs.tap(() => ctrlModal(createModal(), { triggers })),
));
return $empty;
} }
function ctrlModal(render, { triggers }) { function ctrlModal(render, { triggers }) {

View file

@ -1,19 +1,15 @@
import rxjs from "../../lib/rx.js"; import rxjs from "../../lib/rx.js";
import ajax from "../../lib/ajax.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) { export function url(logSize = 0) {
return "admin/api/logs" + (logSize ? `?maxSize=${logSize}` : ""); return "admin/api/logs" + (logSize ? `?maxSize=${logSize}` : "");
} }
export function get() { export function get(t = 100) {
return log$.pipe( return ajax({
rxjs.repeat({ delay: 10000 }), url: url(1024 * t), // fetch the last 100KB by default
responseType: "text",
}).pipe(
rxjs.map(({ response }) => response),
); );
} }