Web: add notification history pop-over
Add a notification pop-over, which can be opened by clicking the footer bar.
This commit is contained in:
parent
bc355d68ab
commit
40bed3db5e
@ -67,7 +67,7 @@ body {
|
|||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
font-family: var(--font-family-body);
|
font-family: var(--font-family-body);
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
height: 100vh;
|
height: calc(100vh);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@ -215,6 +215,8 @@ footer {
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
grid-area: footer;
|
grid-area: footer;
|
||||||
padding: var(--spacer-sm);
|
padding: var(--spacer-sm);
|
||||||
|
|
||||||
|
background-color: var(--color-background-column);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-bar {
|
.btn-bar {
|
||||||
@ -360,7 +362,7 @@ ul.status-filter-bar .status-filter-indicator .indicator {
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabulator-row {
|
.job-list .tabulator-row, .tasks-list .tabulator-row {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -371,3 +373,36 @@ ul.status-filter-bar .status-filter-indicator .indicator {
|
|||||||
.tabulator-row.active-row.tabulator-row-even {
|
.tabulator-row.active-row.tabulator-row-even {
|
||||||
background-color: var(--table-color-background-row-active-even);
|
background-color: var(--table-color-background-row-active-even);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
footer.window-footer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.footer-popup {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--grid-gap);
|
||||||
|
left: var(--grid-gap);
|
||||||
|
right: var(--grid-gap);
|
||||||
|
height: 20vh;
|
||||||
|
|
||||||
|
z-index: 42;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
|
||||||
|
background-color: var(--color-background-column);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
border: thin solid var(--color-border);
|
||||||
|
|
||||||
|
box-shadow: 0 0 2rem black;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.footer-popup header {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.footer-popup header h3 {
|
||||||
|
margin: 0 auto 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.footer-popup button {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
66
web/app/src/components/FooterPopup.vue
Normal file
66
web/app/src/components/FooterPopup.vue
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { TabulatorFull as Tabulator } from 'tabulator-tables';
|
||||||
|
import { useNotifs } from '@/stores/notifications'
|
||||||
|
import * as datetime from "@/datetime";
|
||||||
|
|
||||||
|
const notifs = useNotifs();
|
||||||
|
const emit = defineEmits(['clickClose'])
|
||||||
|
|
||||||
|
const tabOptions = {
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
title: 'Time', field: 'time',
|
||||||
|
sorter: 'alphanum', sorterParams: { alignEmptyValues: "top" },
|
||||||
|
formatter(cell) {
|
||||||
|
const cellValue = cell.getData().time;
|
||||||
|
return datetime.shortened(cellValue);
|
||||||
|
},
|
||||||
|
widthGrow: 1,
|
||||||
|
resizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Message',
|
||||||
|
field: 'msg',
|
||||||
|
sorter: 'string',
|
||||||
|
widthGrow: 100,
|
||||||
|
resizable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initialSort: [
|
||||||
|
{ column: "time", dir: "asc" },
|
||||||
|
],
|
||||||
|
layout: "fitDataStretch",
|
||||||
|
resizableColumnFit: true,
|
||||||
|
height: "calc(20vh - 3rem)", // Must be set in order for the virtual DOM to function correctly.
|
||||||
|
data: notifs.history,
|
||||||
|
selectable: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let tabulator = null;
|
||||||
|
onMounted(() => {
|
||||||
|
tabulator = new Tabulator('#notification_list', tabOptions);
|
||||||
|
tabulator.on("tableBuilt", _scrollToBottom);
|
||||||
|
tabulator.on("tableBuilt", _subscribeToPinia);
|
||||||
|
});
|
||||||
|
|
||||||
|
function _scrollToBottom() {
|
||||||
|
tabulator.scrollToRow(notifs.lastID, "bottom", false);
|
||||||
|
}
|
||||||
|
function _subscribeToPinia() {
|
||||||
|
notifs.$subscribe(() => {
|
||||||
|
tabulator.setData(notifs.history)
|
||||||
|
.then(_scrollToBottom)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="footer-popup">
|
||||||
|
<header>
|
||||||
|
<h3 class="sub-title">Notifications</h3>
|
||||||
|
<button class='close' @click="emit('clickClose')">X</button>
|
||||||
|
</header>
|
||||||
|
<div id="notification_list"></div>
|
||||||
|
</section>
|
||||||
|
</template>
|
@ -5,17 +5,25 @@ const relativeTimeDefaultOptions = {
|
|||||||
format: DateTime.DATE_MED_WITH_WEEKDAY,
|
format: DateTime.DATE_MED_WITH_WEEKDAY,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the given timestamp to a Luxon time object.
|
||||||
|
*
|
||||||
|
* @param {Date | string} timestamp either a Date object or an ISO time string.
|
||||||
|
* @returns Luxon time object.
|
||||||
|
*/
|
||||||
|
function parseTimestamp(timestamp) {
|
||||||
|
if (timestamp instanceof Date) {
|
||||||
|
return DateTime.fromJSDate(timestamp);
|
||||||
|
}
|
||||||
|
return DateTime.fromISO(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
// relativeTime parses the timestamp (can be ISO-formatted string or JS Date
|
// relativeTime parses the timestamp (can be ISO-formatted string or JS Date
|
||||||
// object) and returns it in string form. The returned string is either "xxx
|
// object) and returns it in string form. The returned string is either "xxx
|
||||||
// time ago" if it's a relatively short time ago, or the formatted absolute time
|
// time ago" if it's a relatively short time ago, or the formatted absolute time
|
||||||
// otherwise.
|
// otherwise.
|
||||||
export function relativeTime(timestamp, options) {
|
export function relativeTime(timestamp, options) {
|
||||||
let parsedTimestamp = null;
|
const parsedTimestamp = parseTimestamp(timestamp);
|
||||||
if (timestamp instanceof Date) {
|
|
||||||
parsedTimestamp = DateTime.fromJSDate(timestamp);
|
|
||||||
} else {
|
|
||||||
parsedTimestamp = DateTime.fromISO(timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options) options = relativeTimeDefaultOptions;
|
if (!options) options = relativeTimeDefaultOptions;
|
||||||
|
|
||||||
@ -25,3 +33,12 @@ export function relativeTime(timestamp, options) {
|
|||||||
return parsedTimestamp.toLocaleString(options.format);
|
return parsedTimestamp.toLocaleString(options.format);
|
||||||
return parsedTimestamp.toRelative({style: "narrow"});
|
return parsedTimestamp.toRelative({style: "narrow"});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shortened(timestamp) {
|
||||||
|
const parsedTimestamp = parseTimestamp(timestamp);
|
||||||
|
const now = DateTime.local();
|
||||||
|
const ageInHours = now.diff(parsedTimestamp).as('hours');
|
||||||
|
if (ageInHours < 24)
|
||||||
|
return parsedTimestamp.toLocaleString(DateTime.TIME_24_SIMPLE);
|
||||||
|
return parsedTimestamp.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY);
|
||||||
|
}
|
||||||
|
@ -8,12 +8,19 @@ const MESSAGE_HIDE_DELAY_MS = 5000;
|
|||||||
*/
|
*/
|
||||||
export const useNotifs = defineStore('notifications', {
|
export const useNotifs = defineStore('notifications', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
/** @type {{ msg: string, time: Date }[]} */
|
/**
|
||||||
|
* History of notifications.
|
||||||
|
*
|
||||||
|
* The 'id' is just for Tabulator to uniquely identify rows, in order to be
|
||||||
|
* able to scroll to them.
|
||||||
|
*
|
||||||
|
* @type {{ id: Number, msg: string, time: Date }[]} */
|
||||||
history: [],
|
history: [],
|
||||||
/** @type { msg: string, time: Date } */
|
/** @type { id: Number, msg: string, time: Date } */
|
||||||
last: "",
|
last: "",
|
||||||
|
|
||||||
hideTimerID: 0,
|
hideTimerID: 0,
|
||||||
|
lastID: 0,
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
/**
|
/**
|
||||||
@ -21,9 +28,10 @@ export const useNotifs = defineStore('notifications', {
|
|||||||
* @param {string} message
|
* @param {string} message
|
||||||
*/
|
*/
|
||||||
add(message) {
|
add(message) {
|
||||||
const notif = {msg: message, time: new Date()};
|
const notif = {id: this._generateID(), msg: message, time: new Date()};
|
||||||
this.history.push(notif);
|
this.history.push(notif);
|
||||||
this.last = notif;
|
this.last = notif;
|
||||||
|
console.log("New notification:", plain(notif));
|
||||||
this._prune();
|
this._prune();
|
||||||
this._restartHideTimer();
|
this._restartHideTimer();
|
||||||
},
|
},
|
||||||
@ -44,5 +52,8 @@ export const useNotifs = defineStore('notifications', {
|
|||||||
last: "",
|
last: "",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
_generateID() {
|
||||||
|
return ++this.lastID;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -9,12 +9,13 @@
|
|||||||
<div class="col col-3">
|
<div class="col col-3">
|
||||||
<task-details :taskData="tasks.activeTask" />
|
<task-details :taskData="tasks.activeTask" />
|
||||||
</div>
|
</div>
|
||||||
<footer>
|
|
||||||
<notification-bar />
|
<footer class="window-footer" v-if="!showFooterPopup" @click="showFooterPopup = true"><notification-bar /></footer>
|
||||||
<update-listener ref="updateListener" :websocketURL="websocketURL" :subscribedJob="jobID"
|
<footer-popup v-if="showFooterPopup" ref="footerPopup" @clickClose="showFooterPopup = false" />
|
||||||
@jobUpdate="onSioJobUpdate" @taskUpdate="onSioTaskUpdate" @message="onChatMessage"
|
|
||||||
@sioReconnected="onSIOReconnected" @sioDisconnected="onSIODisconnected" />
|
<update-listener ref="updateListener" :websocketURL="websocketURL" :subscribedJob="jobID"
|
||||||
</footer>
|
@jobUpdate="onSioJobUpdate" @taskUpdate="onSioTaskUpdate" @message="onChatMessage"
|
||||||
|
@sioReconnected="onSIOReconnected" @sioDisconnected="onSIODisconnected" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -24,6 +25,7 @@ import { useJobs } from '@/stores/jobs';
|
|||||||
import { useTasks } from '@/stores/tasks';
|
import { useTasks } from '@/stores/tasks';
|
||||||
import { apiClient } from '@/stores/api-query-count';
|
import { apiClient } from '@/stores/api-query-count';
|
||||||
|
|
||||||
|
import FooterPopup from '@/components/FooterPopup.vue'
|
||||||
import JobDetails from '@/components/JobDetails.vue'
|
import JobDetails from '@/components/JobDetails.vue'
|
||||||
import JobsTable from '@/components/JobsTable.vue'
|
import JobsTable from '@/components/JobsTable.vue'
|
||||||
import NotificationBar from '@/components/NotificationBar.vue'
|
import NotificationBar from '@/components/NotificationBar.vue'
|
||||||
@ -35,6 +37,7 @@ export default {
|
|||||||
name: 'JobsView',
|
name: 'JobsView',
|
||||||
props: ["jobID", "taskID"], // provided by Vue Router.
|
props: ["jobID", "taskID"], // provided by Vue Router.
|
||||||
components: {
|
components: {
|
||||||
|
FooterPopup,
|
||||||
JobDetails,
|
JobDetails,
|
||||||
JobsTable,
|
JobsTable,
|
||||||
NotificationBar,
|
NotificationBar,
|
||||||
@ -48,9 +51,11 @@ export default {
|
|||||||
|
|
||||||
jobs: useJobs(),
|
jobs: useJobs(),
|
||||||
tasks: useTasks(),
|
tasks: useTasks(),
|
||||||
|
showFooterPopup: false,
|
||||||
}),
|
}),
|
||||||
mounted() {
|
mounted() {
|
||||||
window.jobsView = this;
|
window.jobsView = this;
|
||||||
|
window.footerPopup = this.$refs.footerPopup;
|
||||||
|
|
||||||
// Useful for debugging:
|
// Useful for debugging:
|
||||||
// this.jobs.$subscribe((mutation, state) => {
|
// this.jobs.$subscribe((mutation, state) => {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user