feat: grouped nav tree with badges, dashboard events section with filtered chart
Navigation: - Restructure flat nav into grouped tree: Notification (Trackers, Configs, Templates), Commands (same), Bots (Telegram), Settings (Common, Users) - Collapsible groups with expand/collapse state persisted in localStorage - Auto-expand group containing the active page - Counter badges on groups (sum of children) and individual items - New /api/status/counts endpoint for nav badge data - Mobile bottom nav uses flattened key pages Dashboard: - Rename "Recent Events" to "Events" - Move chart under Events section (after filters, before event list) - Filters (event type, provider, search) now affect both the event list AND the chart simultaneously - Add event_type, provider_id, search filter params to /api/status/chart
This commit is contained in:
@@ -16,7 +16,17 @@
|
|||||||
"cmdTemplateConfigs": "Cmd Templates",
|
"cmdTemplateConfigs": "Cmd Templates",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"logout": "Logout"
|
"logout": "Logout",
|
||||||
|
"notification": "Notification",
|
||||||
|
"commands": "Commands",
|
||||||
|
"bots": "Bots",
|
||||||
|
"trackers": "Trackers",
|
||||||
|
"configs": "Configs",
|
||||||
|
"templates": "Templates",
|
||||||
|
"telegram": "Telegram",
|
||||||
|
"email": "Email",
|
||||||
|
"matrix": "Matrix",
|
||||||
|
"common": "Common"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"signIn": "Sign in",
|
"signIn": "Sign in",
|
||||||
@@ -39,7 +49,7 @@
|
|||||||
"providers": "Providers",
|
"providers": "Providers",
|
||||||
"activeTrackers": "Active Trackers",
|
"activeTrackers": "Active Trackers",
|
||||||
"targets": "Targets",
|
"targets": "Targets",
|
||||||
"recentEvents": "Recent Events",
|
"recentEvents": "Events",
|
||||||
"noEvents": "No events yet. Create a tracker to start monitoring.",
|
"noEvents": "No events yet. Create a tracker to start monitoring.",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"justNow": "just now",
|
"justNow": "just now",
|
||||||
@@ -253,7 +263,7 @@
|
|||||||
"enabledCommands": "Enabled commands",
|
"enabledCommands": "Enabled commands",
|
||||||
"defaultCount": "Default count",
|
"defaultCount": "Default count",
|
||||||
"responseMode": "Response mode",
|
"responseMode": "Response mode",
|
||||||
"modeMedia": "Media (photos)",
|
"modeMedia": "Media (files)",
|
||||||
"modeText": "Text only",
|
"modeText": "Text only",
|
||||||
"botLocale": "Bot language",
|
"botLocale": "Bot language",
|
||||||
"rateLimits": "Rate Limits",
|
"rateLimits": "Rate Limits",
|
||||||
@@ -396,13 +406,27 @@
|
|||||||
"invalidFormat": "Invalid format string"
|
"invalidFormat": "Invalid format string"
|
||||||
},
|
},
|
||||||
"templateVars": {
|
"templateVars": {
|
||||||
"message_assets_added": { "description": "Notification when new assets are added to an album" },
|
"message_assets_added": {
|
||||||
"message_assets_removed": { "description": "Notification when assets are removed from an album" },
|
"description": "Notification when new assets are added to an album"
|
||||||
"message_album_renamed": { "description": "Notification when an album is renamed" },
|
},
|
||||||
"message_album_deleted": { "description": "Notification when an album is deleted" },
|
"message_assets_removed": {
|
||||||
"periodic_summary_message": { "description": "Periodic album summary (scheduler not yet implemented)" },
|
"description": "Notification when assets are removed from an album"
|
||||||
"scheduled_assets_message": { "description": "Scheduled asset delivery (scheduler not yet implemented)" },
|
},
|
||||||
"memory_mode_message": { "description": "\"On This Day\" memories (scheduler not yet implemented)" },
|
"message_album_renamed": {
|
||||||
|
"description": "Notification when an album is renamed"
|
||||||
|
},
|
||||||
|
"message_album_deleted": {
|
||||||
|
"description": "Notification when an album is deleted"
|
||||||
|
},
|
||||||
|
"periodic_summary_message": {
|
||||||
|
"description": "Periodic album summary (scheduler not yet implemented)"
|
||||||
|
},
|
||||||
|
"scheduled_assets_message": {
|
||||||
|
"description": "Scheduled asset delivery (scheduler not yet implemented)"
|
||||||
|
},
|
||||||
|
"memory_mode_message": {
|
||||||
|
"description": "\"On This Day\" memories (scheduler not yet implemented)"
|
||||||
|
},
|
||||||
"album_id": "Album ID (UUID)",
|
"album_id": "Album ID (UUID)",
|
||||||
"album_name": "Album name",
|
"album_name": "Album name",
|
||||||
"album_url": "Public share URL (empty if not shared)",
|
"album_url": "Public share URL (empty if not shared)",
|
||||||
@@ -544,7 +568,7 @@
|
|||||||
"enabledCommands": "Enabled Commands",
|
"enabledCommands": "Enabled Commands",
|
||||||
"locale": "Locale",
|
"locale": "Locale",
|
||||||
"responseMode": "Response Mode",
|
"responseMode": "Response Mode",
|
||||||
"modeMedia": "Media (photos)",
|
"modeMedia": "Media (files)",
|
||||||
"modeText": "Text only",
|
"modeText": "Text only",
|
||||||
"defaultCount": "Default Count",
|
"defaultCount": "Default Count",
|
||||||
"rateLimits": "Rate Limits",
|
"rateLimits": "Rate Limits",
|
||||||
@@ -662,4 +686,4 @@
|
|||||||
"line": "line",
|
"line": "line",
|
||||||
"add": "Add"
|
"add": "Add"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,17 @@
|
|||||||
"cmdTemplateConfigs": "Шаблоны команд",
|
"cmdTemplateConfigs": "Шаблоны команд",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"logout": "Выход"
|
"logout": "Выход",
|
||||||
|
"notification": "Уведомления",
|
||||||
|
"commands": "Команды",
|
||||||
|
"bots": "Боты",
|
||||||
|
"trackers": "Трекеры",
|
||||||
|
"configs": "Настройки",
|
||||||
|
"templates": "Шаблоны",
|
||||||
|
"telegram": "Telegram",
|
||||||
|
"email": "Email",
|
||||||
|
"matrix": "Matrix",
|
||||||
|
"common": "Общие"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"signIn": "Войти",
|
"signIn": "Войти",
|
||||||
@@ -39,7 +49,7 @@
|
|||||||
"providers": "Провайдеры",
|
"providers": "Провайдеры",
|
||||||
"activeTrackers": "Активные трекеры",
|
"activeTrackers": "Активные трекеры",
|
||||||
"targets": "Получатели",
|
"targets": "Получатели",
|
||||||
"recentEvents": "Последние события",
|
"recentEvents": "События",
|
||||||
"noEvents": "Событий пока нет. Создайте трекер для отслеживания.",
|
"noEvents": "Событий пока нет. Создайте трекер для отслеживания.",
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
"justNow": "только что",
|
"justNow": "только что",
|
||||||
@@ -253,7 +263,7 @@
|
|||||||
"enabledCommands": "Включённые команды",
|
"enabledCommands": "Включённые команды",
|
||||||
"defaultCount": "Кол-во по умолчанию",
|
"defaultCount": "Кол-во по умолчанию",
|
||||||
"responseMode": "Режим ответа",
|
"responseMode": "Режим ответа",
|
||||||
"modeMedia": "Медиа (фото)",
|
"modeMedia": "Медиа (файлы)",
|
||||||
"modeText": "Только текст",
|
"modeText": "Только текст",
|
||||||
"botLocale": "Язык бота",
|
"botLocale": "Язык бота",
|
||||||
"rateLimits": "Ограничения частоты",
|
"rateLimits": "Ограничения частоты",
|
||||||
@@ -396,13 +406,27 @@
|
|||||||
"invalidFormat": "Некорректная строка формата"
|
"invalidFormat": "Некорректная строка формата"
|
||||||
},
|
},
|
||||||
"templateVars": {
|
"templateVars": {
|
||||||
"message_assets_added": { "description": "Уведомление о добавлении файлов в альбом" },
|
"message_assets_added": {
|
||||||
"message_assets_removed": { "description": "Уведомление об удалении файлов из альбома" },
|
"description": "Уведомление о добавлении файлов в альбом"
|
||||||
"message_album_renamed": { "description": "Уведомление о переименовании альбома" },
|
},
|
||||||
"message_album_deleted": { "description": "Уведомление об удалении альбома" },
|
"message_assets_removed": {
|
||||||
"periodic_summary_message": { "description": "Периодическая сводка альбомов (планировщик не реализован)" },
|
"description": "Уведомление об удалении файлов из альбома"
|
||||||
"scheduled_assets_message": { "description": "Запланированная подборка фото (планировщик не реализован)" },
|
},
|
||||||
"memory_mode_message": { "description": "«В этот день» — воспоминания (планировщик не реализован)" },
|
"message_album_renamed": {
|
||||||
|
"description": "Уведомление о переименовании альбома"
|
||||||
|
},
|
||||||
|
"message_album_deleted": {
|
||||||
|
"description": "Уведомление об удалении альбома"
|
||||||
|
},
|
||||||
|
"periodic_summary_message": {
|
||||||
|
"description": "Периодическая сводка альбомов (планировщик не реализован)"
|
||||||
|
},
|
||||||
|
"scheduled_assets_message": {
|
||||||
|
"description": "Запланированная подборка фото (планировщик не реализован)"
|
||||||
|
},
|
||||||
|
"memory_mode_message": {
|
||||||
|
"description": "«В этот день» — воспоминания (планировщик не реализован)"
|
||||||
|
},
|
||||||
"album_id": "ID альбома (UUID)",
|
"album_id": "ID альбома (UUID)",
|
||||||
"album_name": "Название альбома",
|
"album_name": "Название альбома",
|
||||||
"album_url": "Публичная ссылка (пусто, если не расшарен)",
|
"album_url": "Публичная ссылка (пусто, если не расшарен)",
|
||||||
@@ -544,7 +568,7 @@
|
|||||||
"enabledCommands": "Включённые команды",
|
"enabledCommands": "Включённые команды",
|
||||||
"locale": "Язык",
|
"locale": "Язык",
|
||||||
"responseMode": "Режим ответа",
|
"responseMode": "Режим ответа",
|
||||||
"modeMedia": "Медиа (фото)",
|
"modeMedia": "Медиа (файлы)",
|
||||||
"modeText": "Только текст",
|
"modeText": "Только текст",
|
||||||
"defaultCount": "Кол-во по умолчанию",
|
"defaultCount": "Кол-во по умолчанию",
|
||||||
"rateLimits": "Ограничения частоты",
|
"rateLimits": "Ограничения частоты",
|
||||||
@@ -662,4 +686,4 @@
|
|||||||
"line": "строка",
|
"line": "строка",
|
||||||
"add": "Добавить"
|
"add": "Добавить"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
|
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
|
||||||
try {
|
try {
|
||||||
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
|
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
|
||||||
pwdMsg = t('common.passwordChanged');
|
pwdMsg = t('common.changePassword');
|
||||||
pwdSuccess = true;
|
pwdSuccess = true;
|
||||||
pwdCurrent = ''; pwdNew = '';
|
pwdCurrent = ''; pwdNew = '';
|
||||||
snackSuccess(t('snack.passwordChanged'));
|
snackSuccess(t('snack.passwordChanged'));
|
||||||
@@ -36,23 +36,102 @@
|
|||||||
|
|
||||||
let collapsed = $state(false);
|
let collapsed = $state(false);
|
||||||
|
|
||||||
const baseNavItems = [
|
// Nav counts for badges
|
||||||
|
let navCounts = $state<Record<string, number>>({});
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
href: string;
|
||||||
|
key: string;
|
||||||
|
icon: string;
|
||||||
|
countKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavGroup {
|
||||||
|
key: string;
|
||||||
|
icon: string;
|
||||||
|
children: NavItem[];
|
||||||
|
countKeys?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavEntry = NavItem | NavGroup;
|
||||||
|
|
||||||
|
function isGroup(entry: NavEntry): entry is NavGroup {
|
||||||
|
return 'children' in entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseNavEntries: NavEntry[] = [
|
||||||
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
|
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
|
||||||
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' },
|
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer', countKey: 'providers' },
|
||||||
{ href: '/notification-trackers', key: 'nav.notificationTrackers', icon: 'mdiRadar' },
|
{
|
||||||
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: 'mdiCog' },
|
key: 'nav.notification', icon: 'mdiBellOutline',
|
||||||
{ href: '/template-configs', key: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit' },
|
countKeys: ['notification_trackers', 'tracking_configs', 'template_configs'],
|
||||||
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: 'mdiRobot' },
|
children: [
|
||||||
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
{ href: '/notification-trackers', key: 'nav.trackers', icon: 'mdiRadar', countKey: 'notification_trackers' },
|
||||||
{ href: '/command-trackers', key: 'nav.commandTrackers', icon: 'mdiConsoleLine' },
|
{ href: '/tracking-configs', key: 'nav.configs', icon: 'mdiCog', countKey: 'tracking_configs' },
|
||||||
{ href: '/command-configs', key: 'nav.commandConfigs', icon: 'mdiCog' },
|
{ href: '/template-configs', key: 'nav.templates', icon: 'mdiFileDocumentEdit', countKey: 'template_configs' },
|
||||||
{ href: '/command-template-configs', key: 'nav.cmdTemplateConfigs', icon: 'mdiCodeBracesBox' },
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'nav.commands', icon: 'mdiConsoleLine',
|
||||||
|
countKeys: ['command_trackers', 'command_configs', 'command_template_configs'],
|
||||||
|
children: [
|
||||||
|
{ href: '/command-trackers', key: 'nav.trackers', icon: 'mdiRadar', countKey: 'command_trackers' },
|
||||||
|
{ href: '/command-configs', key: 'nav.configs', icon: 'mdiCog', countKey: 'command_configs' },
|
||||||
|
{ href: '/command-template-configs', key: 'nav.templates', icon: 'mdiCodeBracesBox', countKey: 'command_template_configs' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'nav.bots', icon: 'mdiRobot',
|
||||||
|
countKeys: ['telegram_bots'],
|
||||||
|
children: [
|
||||||
|
{ href: '/telegram-bots', key: 'nav.telegram', icon: 'mdiSendCircle', countKey: 'telegram_bots' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget', countKey: 'targets' },
|
||||||
];
|
];
|
||||||
const navItems = $derived(auth.isAdmin
|
|
||||||
? [...baseNavItems, { href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' }, { href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' }]
|
const navEntries = $derived<NavEntry[]>(auth.isAdmin
|
||||||
: baseNavItems
|
? [
|
||||||
|
...baseNavEntries,
|
||||||
|
{
|
||||||
|
key: 'nav.settings', icon: 'mdiCogOutline',
|
||||||
|
children: [
|
||||||
|
{ href: '/settings', key: 'nav.common', icon: 'mdiCogOutline' },
|
||||||
|
{ href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: baseNavEntries
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Track which groups are expanded (persisted in localStorage)
|
||||||
|
let expandedGroups = $state<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
function toggleGroup(key: string) {
|
||||||
|
expandedGroups = { ...expandedGroups, [key]: !expandedGroups[key] };
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem('nav_expanded', JSON.stringify(expandedGroups));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGroupActive(group: NavGroup): boolean {
|
||||||
|
return group.children.some(c => page.url.pathname === c.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupCount(group: NavGroup): number {
|
||||||
|
if (!group.countKeys) return 0;
|
||||||
|
return group.countKeys.reduce((s, k) => s + (navCounts[k] || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile: flatten nav for bottom bar
|
||||||
|
const mobileNavItems = $derived<NavItem[]>([
|
||||||
|
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
|
||||||
|
{ href: '/notification-trackers', key: 'nav.notification', icon: 'mdiBellOutline' },
|
||||||
|
{ href: '/command-trackers', key: 'nav.commands', icon: 'mdiConsoleLine' },
|
||||||
|
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||||
|
{ href: '/telegram-bots', key: 'nav.bots', icon: 'mdiRobot' },
|
||||||
|
]);
|
||||||
|
|
||||||
const isAuthPage = $derived(
|
const isAuthPage = $derived(
|
||||||
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
||||||
);
|
);
|
||||||
@@ -61,11 +140,28 @@
|
|||||||
initTheme();
|
initTheme();
|
||||||
if (typeof localStorage !== 'undefined') {
|
if (typeof localStorage !== 'undefined') {
|
||||||
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('nav_expanded');
|
||||||
|
if (saved) expandedGroups = JSON.parse(saved);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
await loadUser();
|
await loadUser();
|
||||||
if (!auth.user && !isAuthPage) {
|
if (!auth.user && !isAuthPage) {
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
// Load nav counts
|
||||||
|
if (auth.user) {
|
||||||
|
try { navCounts = await api('/status/counts'); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-expand group containing the active page
|
||||||
|
$effect(() => {
|
||||||
|
for (const entry of navEntries) {
|
||||||
|
if (isGroup(entry) && isGroupActive(entry) && !expandedGroups[entry.key]) {
|
||||||
|
expandedGroups = { ...expandedGroups, [entry.key]: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function cycleTheme() {
|
function cycleTheme() {
|
||||||
@@ -128,21 +224,74 @@
|
|||||||
|
|
||||||
<!-- Nav -->
|
<!-- Nav -->
|
||||||
<nav class="flex-1 p-2 space-y-0.5 overflow-y-auto">
|
<nav class="flex-1 p-2 space-y-0.5 overflow-y-auto">
|
||||||
{#each navItems as item}
|
{#each navEntries as entry}
|
||||||
<a
|
{#if isGroup(entry)}
|
||||||
href={item.href}
|
<!-- Group header -->
|
||||||
class="nav-item group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative"
|
<button
|
||||||
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(item.href) ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isActive(item.href) ? '500' : '400'};"
|
onclick={() => collapsed ? null : toggleGroup(entry.key)}
|
||||||
onmouseenter={(e) => { if (!isActive(item.href)) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
|
class="nav-item group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 w-full text-left relative"
|
||||||
onmouseleave={(e) => { if (!isActive(item.href)) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
|
style="color: {isGroupActive(entry) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isGroupActive(entry) && !expandedGroups[entry.key] ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isGroupActive(entry) ? '500' : '400'};"
|
||||||
title={collapsed ? t(item.key) : ''}
|
onmouseenter={(e) => { if (!isGroupActive(entry)) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
|
||||||
>
|
onmouseleave={(e) => { if (!isGroupActive(entry)) { e.currentTarget.style.background = isGroupActive(entry) && !expandedGroups[entry.key] ? 'var(--color-sidebar-active)' : 'transparent'; e.currentTarget.style.color = isGroupActive(entry) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'; } }}
|
||||||
{#if isActive(item.href)}
|
title={collapsed ? t(entry.key) : ''}
|
||||||
<div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
>
|
||||||
|
{#if isGroupActive(entry) && !expandedGroups[entry.key]}
|
||||||
|
<div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
||||||
|
{/if}
|
||||||
|
<MdiIcon name={entry.icon} size={18} />
|
||||||
|
{#if !collapsed}
|
||||||
|
<span class="truncate flex-1">{t(entry.key)}</span>
|
||||||
|
{#if groupCount(entry) > 0}
|
||||||
|
<span class="nav-badge">{groupCount(entry)}</span>
|
||||||
|
{/if}
|
||||||
|
<MdiIcon name={expandedGroups[entry.key] ? 'mdiChevronDown' : 'mdiChevronRight'} size={14} />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<!-- Group children -->
|
||||||
|
{#if expandedGroups[entry.key] && !collapsed}
|
||||||
|
<div class="ml-3 pl-3 space-y-0.5" style="border-left: 1px solid var(--color-border);">
|
||||||
|
{#each entry.children as child}
|
||||||
|
<a
|
||||||
|
href={child.href}
|
||||||
|
class="nav-item group flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition-all duration-200 relative"
|
||||||
|
style="color: {isActive(child.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(child.href) ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isActive(child.href) ? '500' : '400'};"
|
||||||
|
onmouseenter={(e) => { if (!isActive(child.href)) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
|
||||||
|
onmouseleave={(e) => { if (!isActive(child.href)) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
|
||||||
|
>
|
||||||
|
{#if isActive(child.href)}
|
||||||
|
<div class="active-indicator" style="position: absolute; left: -13px; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
||||||
|
{/if}
|
||||||
|
<MdiIcon name={child.icon} size={15} />
|
||||||
|
<span class="truncate flex-1">{t(child.key)}</span>
|
||||||
|
{#if child.countKey && navCounts[child.countKey]}
|
||||||
|
<span class="nav-badge-sm">{navCounts[child.countKey]}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<MdiIcon name={item.icon} size={18} />
|
{:else}
|
||||||
{#if !collapsed}<span class="truncate">{t(item.key)}</span>{/if}
|
<!-- Top-level item -->
|
||||||
</a>
|
<a
|
||||||
|
href={entry.href}
|
||||||
|
class="nav-item group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative"
|
||||||
|
style="color: {isActive(entry.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(entry.href) ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isActive(entry.href) ? '500' : '400'};"
|
||||||
|
onmouseenter={(e) => { if (!isActive(entry.href)) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
|
||||||
|
onmouseleave={(e) => { if (!isActive(entry.href)) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
|
||||||
|
title={collapsed ? t(entry.key) : ''}
|
||||||
|
>
|
||||||
|
{#if isActive(entry.href)}
|
||||||
|
<div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
||||||
|
{/if}
|
||||||
|
<MdiIcon name={entry.icon} size={18} />
|
||||||
|
{#if !collapsed}
|
||||||
|
<span class="truncate flex-1">{t(entry.key)}</span>
|
||||||
|
{#if entry.countKey && navCounts[entry.countKey]}
|
||||||
|
<span class="nav-badge">{navCounts[entry.countKey]}</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -217,7 +366,7 @@
|
|||||||
|
|
||||||
<!-- Mobile bottom nav -->
|
<!-- Mobile bottom nav -->
|
||||||
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0; backdrop-filter: blur(12px);">
|
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0; backdrop-filter: blur(12px);">
|
||||||
{#each navItems.slice(0, 5) as item}
|
{#each mobileNavItems as item}
|
||||||
<a href={item.href} aria-label={t(item.key)}
|
<a href={item.href} aria-label={t(item.key)}
|
||||||
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
|
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
|
||||||
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
|
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
|
||||||
@@ -280,4 +429,28 @@
|
|||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.mobile-nav { display: flex !important; }
|
.mobile-nav { display: flex !important; }
|
||||||
}
|
}
|
||||||
|
.nav-badge {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-primary-foreground);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
line-height: 1.2;
|
||||||
|
min-width: 1.2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.nav-badge-sm {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.05rem 0.35rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
line-height: 1.2;
|
||||||
|
min-width: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -34,8 +34,8 @@
|
|||||||
/** Calculate how many event rows fit in the remaining viewport space. */
|
/** Calculate how many event rows fit in the remaining viewport space. */
|
||||||
function calcPageSize(): number {
|
function calcPageSize(): number {
|
||||||
if (typeof window === 'undefined') return 8;
|
if (typeof window === 'undefined') return 8;
|
||||||
const EVENT_ROW_HEIGHT = 50; // px per event row (content + gap)
|
const EVENT_ROW_HEIGHT = 50;
|
||||||
const FIXED_OVERHEAD = 600; // header + stats + chart + events header + filters + paginator + padding
|
const FIXED_OVERHEAD = 700; // slightly more for chart in Events section
|
||||||
const available = window.innerHeight - FIXED_OVERHEAD;
|
const available = window.innerHeight - FIXED_OVERHEAD;
|
||||||
return Math.max(3, Math.floor(available / EVENT_ROW_HEIGHT));
|
return Math.max(3, Math.floor(available / EVENT_ROW_HEIGHT));
|
||||||
}
|
}
|
||||||
@@ -53,13 +53,19 @@
|
|||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build filter query string (shared by events list + chart). */
|
||||||
|
function buildFilterParams(): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filterEventType) params.set('event_type', filterEventType);
|
||||||
|
if (filterProviderId) params.set('provider_id', filterProviderId);
|
||||||
|
if (filterSearch) params.set('search', filterSearch);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadEvents() {
|
async function loadEvents() {
|
||||||
eventsLoading = true;
|
eventsLoading = true;
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = buildFilterParams();
|
||||||
if (filterEventType) params.set('event_type', filterEventType);
|
|
||||||
if (filterProviderId) params.set('provider_id', filterProviderId);
|
|
||||||
if (filterSearch) params.set('search', filterSearch);
|
|
||||||
params.set('sort', filterSort);
|
params.set('sort', filterSort);
|
||||||
params.set('limit', String(eventsLimit));
|
params.set('limit', String(eventsLimit));
|
||||||
params.set('offset', String(eventsOffset));
|
params.set('offset', String(eventsOffset));
|
||||||
@@ -72,9 +78,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadChart() {
|
||||||
|
try {
|
||||||
|
const params = buildFilterParams();
|
||||||
|
const qs = params.toString();
|
||||||
|
const chartRes = await api<any>(`/status/chart${qs ? '?' + qs : ''}`);
|
||||||
|
chartDays = chartRes.days || [];
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
eventsOffset = 0;
|
eventsOffset = 0;
|
||||||
loadEvents();
|
loadEvents();
|
||||||
|
loadChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToPage(page: number) {
|
function goToPage(page: number) {
|
||||||
@@ -216,8 +232,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EventChart days={chartDays} />
|
<!-- Events section -->
|
||||||
|
|
||||||
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
|
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
|
||||||
<MdiIcon name="mdiPulse" size={18} />
|
<MdiIcon name="mdiPulse" size={18} />
|
||||||
{t('dashboard.recentEvents')}
|
{t('dashboard.recentEvents')}
|
||||||
@@ -253,6 +268,9 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart (now inside Events section, affected by filters) -->
|
||||||
|
<EventChart days={chartDays} />
|
||||||
|
|
||||||
{#if eventsLoading}
|
{#if eventsLoading}
|
||||||
<Card><p class="text-sm text-center py-4" style="color: var(--color-muted-foreground);">{t('dashboard.loadingEvents')}</p></Card>
|
<Card><p class="text-sm text-center py-4" style="color: var(--color-muted-foreground);">{t('dashboard.loadingEvents')}</p></Card>
|
||||||
{:else if status.recent_events.length === 0}
|
{:else if status.recent_events.length === 0}
|
||||||
|
|||||||
@@ -8,7 +8,19 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||||||
|
|
||||||
from ..auth.dependencies import get_current_user
|
from ..auth.dependencies import get_current_user
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import NotificationTarget, NotificationTracker, ServiceProvider, EventLog, User
|
from ..database.models import (
|
||||||
|
CommandConfig,
|
||||||
|
CommandTemplateConfig,
|
||||||
|
CommandTracker,
|
||||||
|
EventLog,
|
||||||
|
NotificationTarget,
|
||||||
|
NotificationTracker,
|
||||||
|
ServiceProvider,
|
||||||
|
TelegramBot,
|
||||||
|
TemplateConfig,
|
||||||
|
TrackingConfig,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/status", tags=["status"])
|
router = APIRouter(prefix="/api/status", tags=["status"])
|
||||||
|
|
||||||
@@ -93,13 +105,52 @@ async def get_status(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/counts")
|
||||||
|
async def get_nav_counts(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Return entity counts for sidebar navigation badges."""
|
||||||
|
counts = {}
|
||||||
|
for model, key in [
|
||||||
|
(ServiceProvider, "providers"),
|
||||||
|
(NotificationTracker, "notification_trackers"),
|
||||||
|
(TrackingConfig, "tracking_configs"),
|
||||||
|
(TemplateConfig, "template_configs"),
|
||||||
|
(NotificationTarget, "targets"),
|
||||||
|
(TelegramBot, "telegram_bots"),
|
||||||
|
(CommandTracker, "command_trackers"),
|
||||||
|
(CommandConfig, "command_configs"),
|
||||||
|
(CommandTemplateConfig, "command_template_configs"),
|
||||||
|
]:
|
||||||
|
count = (await session.exec(
|
||||||
|
select(func.count()).select_from(model).where(model.user_id == user.id)
|
||||||
|
)).one()
|
||||||
|
counts[key] = count
|
||||||
|
|
||||||
|
# System-owned templates (user_id=0) count as well
|
||||||
|
for model, key in [
|
||||||
|
(TemplateConfig, "template_configs"),
|
||||||
|
(CommandTemplateConfig, "command_template_configs"),
|
||||||
|
]:
|
||||||
|
system_count = (await session.exec(
|
||||||
|
select(func.count()).select_from(model).where(model.user_id == 0)
|
||||||
|
)).one()
|
||||||
|
counts[key] += system_count
|
||||||
|
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
@router.get("/chart")
|
@router.get("/chart")
|
||||||
async def get_event_chart(
|
async def get_event_chart(
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
days: int = Query(14, ge=1, le=90),
|
days: int = Query(14, ge=1, le=90),
|
||||||
|
event_type: str | None = Query(None),
|
||||||
|
provider_id: int | None = Query(None),
|
||||||
|
search: str | None = Query(None),
|
||||||
):
|
):
|
||||||
"""Return daily event counts by type for the last N days."""
|
"""Return daily event counts by type for the last N days, with optional filters."""
|
||||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||||
|
|
||||||
day_col = func.date(EventLog.created_at)
|
day_col = func.date(EventLog.created_at)
|
||||||
@@ -112,10 +163,21 @@ async def get_event_chart(
|
|||||||
)
|
)
|
||||||
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id)
|
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id)
|
||||||
.where(NotificationTracker.user_id == user.id, EventLog.created_at >= cutoff)
|
.where(NotificationTracker.user_id == user.id, EventLog.created_at >= cutoff)
|
||||||
.group_by(day_col, EventLog.event_type)
|
|
||||||
.order_by(day_col)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if event_type:
|
||||||
|
query = query.where(EventLog.event_type == event_type)
|
||||||
|
if provider_id is not None:
|
||||||
|
query = query.where(EventLog.provider_id == provider_id)
|
||||||
|
if search:
|
||||||
|
query = query.where(
|
||||||
|
EventLog.collection_name.contains(search)
|
||||||
|
| EventLog.tracker_name.contains(search)
|
||||||
|
| EventLog.provider_name.contains(search)
|
||||||
|
)
|
||||||
|
|
||||||
|
query = query.group_by(day_col, EventLog.event_type).order_by(day_col)
|
||||||
|
|
||||||
rows = (await session.exec(query)).all()
|
rows = (await session.exec(query)).all()
|
||||||
|
|
||||||
# Build a dict: { "2026-03-15": { "assets_added": 18, ... }, ... }
|
# Build a dict: { "2026-03-15": { "assets_added": 18, ... }, ... }
|
||||||
|
|||||||
Reference in New Issue
Block a user