agent-zero/webui/components/notifications/notification-store.js
Alessandro d1827e6c66
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions
Refactor: use user locale for time displays
Add user-configurable timezone and 12/24-hour preferences, then wire them through settings, runtime snapshots, scheduler payloads, wait handling, notifications, backups, memory, plugin metadata, and frontend formatters.

Keep UTC as the boundary for absolute instants while serializing user-facing dates in the configured or browser-resolved timezone. Preserve scheduler wall-clock inputs in the selected timezone, propagate TZ into desktop/runtime process environments, and restart active desktop sessions when the runtime timezone changes.

Cover the risky paths with timezone regression tests for settings normalization, auto and fixed timezone resolution, scheduler round-trips, memory timestamp conversion, and desktop timezone sync.
2026-05-21 15:26:00 +02:00

872 lines
23 KiB
JavaScript

import { createStore } from "/js/AlpineStore.js";
import * as API from "/js/api.js";
import { openModal } from "/js/modals.js";
import { formatDateTime, getCurrentUserISOString } from "/js/time-utils.js";
export const NotificationType = {
INFO: "info",
SUCCESS: "success",
WARNING: "warning",
ERROR: "error",
PROGRESS: "progress",
};
export const NotificationPriority = {
NORMAL: 10,
HIGH: 20,
};
export const defaultPriority = NotificationPriority.NORMAL;
const maxNotifications = 100;
const maxToasts = 5;
const model = {
notifications: Array(),
loading: false,
lastNotificationVersion: 0,
lastNotificationGuid: "",
unreadCount: 0,
unreadPrioCount: 0,
// NEW: Toast stack management
toastStack: Array(),
init() {
this.initialize();
},
// Initialize the notification store
initialize() {
this.loading = true;
this.updateUnreadCount();
// this.removeOldNotifications();
this.toastStack = [];
// // Auto-cleanup old notifications and toasts
// setInterval(() => {
// this.removeOldNotifications();
// this.cleanupExpiredToasts();
// }, 5 * 60 * 1000); // Every 5 minutes
},
// Update notifications from polling data
updateFromPoll(pollData) {
if (!pollData) return;
// Check if GUID changed (system restart)
if (pollData.notifications_guid !== this.lastNotificationGuid) {
this.lastNotificationVersion = 0;
this.notifications = [];
this.toastStack = []; // Clear toast stack on restart
this.lastNotificationGuid = pollData.notifications_guid || "";
}
// Process new notifications and add to toast stack
if (pollData.notifications && pollData.notifications.length > 0) {
pollData.notifications.forEach((notification) => {
// should we toast the notification?
const shouldToast = !notification.read;
// adjust notification data before adding
this.adjustNotificationData(notification);
const existingNotificationIndex = this.notifications.findIndex(
(n) => n.id === notification.id
);
const existingNotification =
existingNotificationIndex >= 0
? this.notifications[existingNotificationIndex]
: null;
const isNew = !existingNotification;
const shouldRetoast =
!!existingNotification &&
shouldToast &&
(existingNotification.timestamp !== notification.timestamp ||
existingNotification.title !== notification.title ||
existingNotification.message !== notification.message ||
existingNotification.detail !== notification.detail);
this.addOrUpdateNotification(notification);
// Add new unread notifications to toast stack, and also re-toast updated unread notifications
if ((isNew && shouldToast) || shouldRetoast) {
this.addToToastStack(notification);
}
});
}
// Update version tracking
this.lastNotificationVersion = pollData.notifications_version || 0;
this.lastNotificationGuid = pollData.notifications_guid || "";
// Update UI state
this.updateUnreadCount();
// this.removeOldNotifications();
},
adjustNotificationData(notification) {
// set default priority if not set
if (!notification.priority) {
notification.priority = defaultPriority;
}
},
getToastDisplayTime(toast) {
const displayTime = Number(toast?.display_time);
return Number.isFinite(displayTime) ? displayTime : 3;
},
isPersistentToast(toast) {
return this.getToastDisplayTime(toast) <= 0;
},
// NEW: Add notification to toast stack
addToToastStack(notification) {
// If notification has a group, remove any existing toasts with the same group
if (notification.group && notification.group.trim() !== "") {
const existingToast = this.toastStack.find(
(t) => t.group === notification.group
);
if (existingToast && existingToast.toastId)
this.removeFromToastStack(existingToast.toastId);
}
// Create toast object with auto-dismiss timer
const toast = {
...notification,
toastId: `toast-${notification.id}`,
addedAt: Date.now(),
autoRemoveTimer: null,
};
// Add to bottom of stack (newest at bottom)
this.toastStack.push(toast);
// Enforce max stack limit (remove oldest)
while (this.toastStack.length > maxToasts) {
const oldest = this.toastStack[0];
if (oldest && oldest.toastId) this.removeFromToastStack(oldest.toastId);
}
// Set auto-dismiss timer
this.restartToastTimer(toast.toastId);
},
clearToastTimer(toastId) {
const toastIndex = this.toastStack.findIndex((t) => t.toastId === toastId);
if (toastIndex < 0) return;
const toast = this.toastStack[toastIndex];
if (this.isPersistentToast(toast)) return;
if (toast.autoRemoveTimer) {
clearTimeout(toast.autoRemoveTimer);
toast.autoRemoveTimer = null;
}
},
restartToastTimer(toastId) {
const toastIndex = this.toastStack.findIndex((t) => t.toastId === toastId);
if (toastIndex < 0) return;
const toast = this.toastStack[toastIndex];
if (this.isPersistentToast(toast)) return;
this.clearToastTimer(toastId);
toast.autoRemoveTimer = setTimeout(() => {
this.removeFromToastStack(toast.toastId);
}, this.getToastDisplayTime(toast) * 1000);
},
// NEW: Remove toast from stack
removeFromToastStack(toastId, removedByUser = false) {
const index = this.toastStack.findIndex((t) => t.toastId === toastId);
if (index >= 0) {
const toast = this.toastStack[index];
if (toast.autoRemoveTimer) {
clearTimeout(toast.autoRemoveTimer);
toast.autoRemoveTimer = null;
}
this.toastStack.splice(index, 1);
// execute after toast removed callback
this.afterToastRemoved(toast, removedByUser);
}
},
// called by UI
dismissToast(toastId) {
this.removeFromToastStack(toastId, true);
},
async afterToastRemoved(toast, removedByUser = false) {
// if the toast is closed by the user OR timed out with normal priority, mark it as read
if (removedByUser || toast.priority <= NotificationPriority.NORMAL) {
this.markAsRead(toast.id);
}
},
// NEW: Clear entire toast stack
clearToastStack(withCallback = true, removedByUser = false) {
this.toastStack.forEach((toast) => {
if (toast.autoRemoveTimer) {
clearTimeout(toast.autoRemoveTimer);
toast.autoRemoveTimer = null;
}
if (withCallback) this.afterToastRemoved(toast, removedByUser);
});
this.toastStack = [];
},
// NEW: Clean up expired toasts (backup cleanup)
cleanupExpiredToasts() {
const now = Date.now();
this.toastStack = this.toastStack.filter((toast) => {
if (this.isPersistentToast(toast)) {
return true;
}
const age = now - toast.addedAt;
const maxAge = this.getToastDisplayTime(toast) * 1000;
if (age > maxAge) {
if (toast.autoRemoveTimer) {
clearTimeout(toast.autoRemoveTimer);
}
return false;
}
return true;
});
},
// NEW: Handle toast click (opens modal)
async handleToastClick(toastId, event) {
const target = event?.target;
const toast = this.toastStack.find((t) => t.toastId === toastId);
if (
target instanceof Element &&
target.closest(
'button, a, input, select, textarea, summary, label, [role="button"], [data-toast-interactive]'
)
) {
if (toast?.id) {
this.markAsRead(toast.id);
}
return;
}
await this.openModal();
// Modal opening will clear toast stack via markAllAsRead
},
// Add or update a notification
addOrUpdateNotification(notification) {
const existingIndex = this.notifications.findIndex(
(n) => n.id === notification.id
);
if (existingIndex >= 0) {
// Update existing notification
this.notifications[existingIndex] = notification;
} else {
// Add new notification at the beginning (most recent first)
this.notifications.unshift(notification);
}
// Limit notifications to prevent memory issues (keep most recent)
if (this.notifications.length > maxNotifications) {
this.notifications = this.notifications.slice(0, maxNotifications);
}
},
// Update unread count
updateUnreadCount() {
const unread = this.notifications.filter((n) => !n.read).length;
const unreadPrio = this.notifications.filter(
(n) => !n.read && n.priority > NotificationPriority.NORMAL
).length;
if (this.unreadCount !== unread) this.unreadCount = unread;
if (this.unreadPrioCount !== unreadPrio) this.unreadPrioCount = unreadPrio;
},
// Mark notification as read
async markAsRead(notificationId) {
const notification = this.notifications.find(
(n) => n.id === notificationId
);
if (notification && !notification.read) {
notification.read = true;
this.updateUnreadCount();
// Sync with backend (non-blocking)
try {
await API.callJsonApi("notifications_mark_read", {
notification_ids: [notificationId],
});
} catch (error) {
console.error("Failed to sync notification read status:", error);
// Don't revert the UI change - user experience should not be affected
}
}
},
// Enhanced: Mark all as read and clear toast stack
async markAllAsRead() {
const unreadNotifications = this.notifications.filter((n) => !n.read);
if (unreadNotifications.length === 0) return;
// Update UI immediately
this.notifications.forEach((notification) => {
notification.read = true;
});
this.updateUnreadCount();
// Clear toast stack when marking all as read
this.clearToastStack(false);
// Sync with backend (non-blocking)
try {
await API.callJsonApi("notifications_mark_read", {
mark_all: true,
});
} catch (error) {
console.error("Failed to sync mark all as read:", error);
}
},
// Clear all notifications
async clearAll(syncBackend = true) {
this.notifications = [];
this.unreadCount = 0;
this.clearToastStack(false); // Also clear toast stack
this.clearBackendNotifications();
},
async clearBackendNotifications() {
try {
await API.callJsonApi("notifications_clear", null);
} catch (error) {
console.error("Failed to clear notifications:", error);
}
},
// Get notifications by type
getNotificationsByType(type) {
return this.notifications.filter((n) => n.type === type);
},
// Get notifications for display: ALL unread + read from last 5 minutes
getDisplayNotifications() {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
return this.notifications.filter((notification) => {
// Always show unread notifications
if (!notification.read) {
return true;
}
// Show read notifications only if they're from the last 5 minutes
const notificationDate = new Date(notification.timestamp);
return notificationDate > fiveMinutesAgo;
});
},
// Get recent notifications (last 5) - kept for backwards compatibility
getRecentNotifications() {
return this.notifications.slice(0, 5);
},
// Get notification by ID
getNotificationById(id) {
return this.notifications.find((n) => n.id === id);
},
// Remove old notifications (older than 1 hour)
removeOldNotifications() {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
const initialCount = this.notifications.length;
this.notifications = this.notifications.filter(
(n) => new Date(n.timestamp) > oneHourAgo
);
if (this.notifications.length !== initialCount) {
this.updateUnreadCount();
}
},
// Format timestamp for display
formatTimestamp(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now - date;
const diffMins = diffMs / 60000;
const diffHours = diffMs / 3600000;
const diffDays = diffMs / 86400000;
if (diffMins < 0.15) return "Just now";
else if (diffMins < 1) return "Less than a minute ago";
else if (diffMins < 60) return `${Math.round(diffMins)}m ago`;
else if (diffHours < 24) return `${Math.round(diffHours)}h ago`;
else if (diffDays < 7) return `${Math.round(diffDays)}d ago`;
return formatDateTime(timestamp, "date");
},
// Get CSS class for notification type
getNotificationClass(type) {
const classes = {
info: "notification-info",
success: "notification-success",
warning: "notification-warning",
error: "notification-error",
progress: "notification-progress",
};
return classes[type] || "notification-info";
},
// Get CSS class for notification item including read state
getNotificationItemClass(notification) {
const typeClass = this.getNotificationClass(notification.type);
const readClass = notification.read ? "read" : "unread";
return `notification-item ${typeClass} ${readClass}`;
},
// Get icon for notification type (Google Material Icons)
getNotificationIcon(type) {
const icons = {
info: "info",
success: "check_circle",
warning: "warning",
error: "error",
progress: "hourglass_empty",
};
const iconName = icons[type] || "info";
return `<span class="material-symbols-outlined">${iconName}</span>`;
},
// Create notification via backend (will appear via polling)
async createNotification(
type,
message,
title = "",
detail = "",
display_time = 3,
group = "",
priority = defaultPriority
) {
try {
const response = await globalThis.sendJsonData("/notification_create", {
type: type,
message: message,
title: title,
detail: detail,
display_time: display_time,
group: group,
priority: priority,
});
if (response.success) {
return response.notification_id;
} else {
console.error("Failed to create notification:", response.error);
return null;
}
} catch (error) {
console.error("Error creating notification:", error);
return null;
}
},
// Convenience methods for different notification types
async info(
message,
title = "",
detail = "",
display_time = 3,
group = "",
priority = defaultPriority
) {
return await this.createNotification(
NotificationType.INFO,
message,
title,
detail,
display_time,
group,
priority
);
},
async success(
message,
title = "",
detail = "",
display_time = 3,
group = "",
priority = defaultPriority
) {
return await this.createNotification(
NotificationType.SUCCESS,
message,
title,
detail,
display_time,
group,
priority
);
},
async warning(
message,
title = "",
detail = "",
display_time = 3,
group = "",
priority = defaultPriority
) {
return await this.createNotification(
NotificationType.WARNING,
message,
title,
detail,
display_time,
group,
priority
);
},
async error(
message,
title = "",
detail = "",
display_time = 3,
group = "",
priority = defaultPriority
) {
return await this.createNotification(
NotificationType.ERROR,
message,
title,
detail,
display_time,
group,
priority
);
},
async progress(
message,
title = "",
detail = "",
display_time = 3,
group = "",
priority = defaultPriority
) {
return await this.createNotification(
NotificationType.PROGRESS,
message,
title,
detail,
display_time,
group,
priority
);
},
// Enhanced: Open modal and clear toast stack
async openModal() {
// Clear toast stack when modal opens
this.clearToastStack(false);
// open modal
await openModal("notifications/notification-modal.html");
// mark all as read when modal closes
this.markAllAsRead();
},
// Legacy method for backward compatibility
toggleNotifications() {
this.openModal();
},
// NEW: Check if backend connection is available
isConnected() {
// Use the global connection status from index.js, but default to true if undefined
// This handles the case where polling hasn't run yet but backend is actually available
const pollingStatus =
typeof globalThis.getConnectionStatus === "function"
? globalThis.getConnectionStatus()
: undefined;
// If polling status is explicitly false, respect that
if (pollingStatus === false) {
return false;
}
// If polling status is undefined/true, assume backend is available
// (since the page loaded successfully, backend must be working)
return true;
},
// NEW: Add frontend-only toast directly to stack (renamed from original addFrontendToast)
addFrontendToastOnly(
type,
message,
title = "",
display_time = 5,
group = "",
priority = defaultPriority
) {
const timestamp = getCurrentUserISOString();
const notification = {
id: `frontend-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: type,
title: title,
message: message,
detail: "",
timestamp: timestamp,
display_time: display_time,
read: false,
frontend: true, // Mark as frontend-only
group: group,
priority: priority,
};
//adjust data before using
this.adjustNotificationData(notification);
// If notification has a group, remove any existing toasts with the same group
if (group && String(group).trim() !== "") {
const existingToastIndex = this.toastStack.findIndex(
(t) => t.group === group
);
if (existingToastIndex >= 0) {
const existingToast = this.toastStack[existingToastIndex];
this.removeFromToastStack(existingToast.toastId);
}
}
// Create toast object with auto-dismiss timer
const toast = {
...notification,
toastId: `toast-${notification.id}`,
addedAt: Date.now(),
autoRemoveTimer: null,
hoverTimer: null,
isHovered: false,
};
// Add to bottom of stack (newest at bottom)
this.toastStack.push(toast);
// Enforce max stack limit (remove oldest).
while (this.toastStack.length > maxToasts) {
const removed = this.toastStack.shift();
if (removed?.autoRemoveTimer) {
clearTimeout(removed.autoRemoveTimer);
}
}
// Set auto-dismiss timer
this.restartToastTimer(toast.toastId);
return notification.id;
},
// NEW: Enhanced frontend toast that tries backend first, falls back to frontend-only
async addFrontendToast(
type,
message,
title = "",
display_time = 5,
group = "",
priority = defaultPriority,
frontendOnly = false
) {
// Try to send to backend first if connected
if (!frontendOnly) {
if (this.isConnected()) {
try {
const notificationId = await this.createNotification(
type,
message,
title,
"",
display_time,
group,
priority
);
if (notificationId) {
// Backend handled it, notification will arrive via polling
return notificationId;
}
} catch (error) {
console.log(
`Backend unavailable for notification, showing as frontend-only: ${
error.message || error
}`
);
}
} else {
console.log("Backend disconnected, showing as frontend-only toast");
}
}
// Fallback to frontend-only toast
return this.addFrontendToastOnly(
type,
message,
title,
display_time,
group,
priority
);
},
// NEW: Convenience methods for frontend notifications (updated to use new backend-first logic)
async frontendError(
message,
title = "Connection Error",
display_time = 8,
group = "",
priority = defaultPriority,
frontendOnly = false
) {
return await this.addFrontendToast(
NotificationType.ERROR,
message,
title,
display_time,
group,
priority,
frontendOnly
);
},
async frontendWarning(
message,
title = "Warning",
display_time = 5,
group = "",
priority = defaultPriority,
frontendOnly = false
) {
return await this.addFrontendToast(
NotificationType.WARNING,
message,
title,
display_time,
group,
priority,
frontendOnly
);
},
async frontendInfo(
message,
title = "Info",
display_time = 3,
group = "",
priority = defaultPriority,
frontendOnly = false
) {
return await this.addFrontendToast(
NotificationType.INFO,
message,
title,
display_time,
group,
priority,
frontendOnly
);
},
async frontendSuccess(
message,
title = "Success",
display_time = 3,
group = "",
priority = defaultPriority,
frontendOnly = false
) {
return await this.addFrontendToast(
NotificationType.SUCCESS,
message,
title,
display_time,
group,
priority,
frontendOnly
);
},
async frontendProgress(
message,
title = "Progress",
display_time = 3,
group = "",
priority = defaultPriority,
frontendOnly = false
) {
return await this.addFrontendToast(
NotificationType.PROGRESS,
message,
title,
display_time,
group,
priority,
frontendOnly
);
},
// NEW: Enhanced frontend toast with object parameters and type annotations
/**
* Adds a frontend toast notification with object parameters.
* @param {Object} options - The options for the toast notification.
* @param {string} options.type - The type of notification (e.g., info, success, error).
* @param {string} options.message - The message content of the notification.
* @param {string} [options.title=''] - The title of the notification.
* @param {number} [options.displayTime=5] - The display duration in seconds.
* @param {string} [options.group=''] - The group identifier for the notification.
* @param {string} [options.priority='medium'] - The priority of the notification.
* @param {boolean} [options.frontendOnly=false] - Whether to show only on frontend.
* @returns {Promise<string>} The ID of the added notification.
*/
async frontendNotification({
type,
message,
title = '',
displayTime = 5,
group = '',
priority = defaultPriority,
frontendOnly = false
}) {
return await this.addFrontendToast(type, message, title, displayTime, group, priority, frontendOnly);
},
};
// Create and export the store
const store = createStore("notificationStore", model);
export { store };
// export toast functions
const toastFrontendInfo = store.frontendInfo.bind(store);
const toastFrontendSuccess = store.frontendSuccess.bind(store);
const toastFrontendWarning = store.frontendWarning.bind(store);
const toastFrontendError = store.frontendError.bind(store);
const toastFrontendProgress = store.frontendProgress.bind(store);
export {
toastFrontendInfo,
toastFrontendSuccess,
toastFrontendWarning,
toastFrontendError,
toastFrontendProgress,
};
// add toasts to global for backward compatibility with older scripts
globalThis.toastFrontendInfo = toastFrontendInfo;
globalThis.toastFrontendSuccess = toastFrontendSuccess;
globalThis.toastFrontendWarning = toastFrontendWarning;
globalThis.toastFrontendError = toastFrontendError;
globalThis.toastFrontendProgress = toastFrontendProgress;