mirror of
https://github.com/mickael-kerjean/filestash
synced 2025-12-06 08:22:24 +01:00
feature (admin): revamp admin ux
This commit is contained in:
parent
587793e376
commit
5a4571fb0e
7 changed files with 118 additions and 57 deletions
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
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<shift; i++) {
|
||||||
|
buckets.shift();
|
||||||
|
start += width;
|
||||||
|
}
|
||||||
|
end = times[times.length - 1];
|
||||||
}
|
}
|
||||||
return {
|
return { start, end, width, buckets, max, init };
|
||||||
bars,
|
}, {}),
|
||||||
start: new Date(start).toLocaleTimeString(),
|
rxjs.tap(({ buckets, start, end, max }) => {
|
||||||
end: new Date(end).toLocaleTimeString(),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
rxjs.tap(({ bars, start, end }) => {
|
|
||||||
const max = Math.max(1, ...bars);
|
|
||||||
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),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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 }) {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue