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",
|
||||
"users": "Users",
|
||||
"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": {
|
||||
"signIn": "Sign in",
|
||||
@@ -39,7 +49,7 @@
|
||||
"providers": "Providers",
|
||||
"activeTrackers": "Active Trackers",
|
||||
"targets": "Targets",
|
||||
"recentEvents": "Recent Events",
|
||||
"recentEvents": "Events",
|
||||
"noEvents": "No events yet. Create a tracker to start monitoring.",
|
||||
"loading": "Loading...",
|
||||
"justNow": "just now",
|
||||
@@ -253,7 +263,7 @@
|
||||
"enabledCommands": "Enabled commands",
|
||||
"defaultCount": "Default count",
|
||||
"responseMode": "Response mode",
|
||||
"modeMedia": "Media (photos)",
|
||||
"modeMedia": "Media (files)",
|
||||
"modeText": "Text only",
|
||||
"botLocale": "Bot language",
|
||||
"rateLimits": "Rate Limits",
|
||||
@@ -396,13 +406,27 @@
|
||||
"invalidFormat": "Invalid format string"
|
||||
},
|
||||
"templateVars": {
|
||||
"message_assets_added": { "description": "Notification when new assets are added to an album" },
|
||||
"message_assets_removed": { "description": "Notification when assets are removed from an album" },
|
||||
"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)" },
|
||||
"message_assets_added": {
|
||||
"description": "Notification when new assets are added to an album"
|
||||
},
|
||||
"message_assets_removed": {
|
||||
"description": "Notification when assets are removed from an album"
|
||||
},
|
||||
"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_name": "Album name",
|
||||
"album_url": "Public share URL (empty if not shared)",
|
||||
@@ -544,7 +568,7 @@
|
||||
"enabledCommands": "Enabled Commands",
|
||||
"locale": "Locale",
|
||||
"responseMode": "Response Mode",
|
||||
"modeMedia": "Media (photos)",
|
||||
"modeMedia": "Media (files)",
|
||||
"modeText": "Text only",
|
||||
"defaultCount": "Default Count",
|
||||
"rateLimits": "Rate Limits",
|
||||
@@ -662,4 +686,4 @@
|
||||
"line": "line",
|
||||
"add": "Add"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,17 @@
|
||||
"cmdTemplateConfigs": "Шаблоны команд",
|
||||
"users": "Пользователи",
|
||||
"settings": "Настройки",
|
||||
"logout": "Выход"
|
||||
"logout": "Выход",
|
||||
"notification": "Уведомления",
|
||||
"commands": "Команды",
|
||||
"bots": "Боты",
|
||||
"trackers": "Трекеры",
|
||||
"configs": "Настройки",
|
||||
"templates": "Шаблоны",
|
||||
"telegram": "Telegram",
|
||||
"email": "Email",
|
||||
"matrix": "Matrix",
|
||||
"common": "Общие"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Войти",
|
||||
@@ -39,7 +49,7 @@
|
||||
"providers": "Провайдеры",
|
||||
"activeTrackers": "Активные трекеры",
|
||||
"targets": "Получатели",
|
||||
"recentEvents": "Последние события",
|
||||
"recentEvents": "События",
|
||||
"noEvents": "Событий пока нет. Создайте трекер для отслеживания.",
|
||||
"loading": "Загрузка...",
|
||||
"justNow": "только что",
|
||||
@@ -253,7 +263,7 @@
|
||||
"enabledCommands": "Включённые команды",
|
||||
"defaultCount": "Кол-во по умолчанию",
|
||||
"responseMode": "Режим ответа",
|
||||
"modeMedia": "Медиа (фото)",
|
||||
"modeMedia": "Медиа (файлы)",
|
||||
"modeText": "Только текст",
|
||||
"botLocale": "Язык бота",
|
||||
"rateLimits": "Ограничения частоты",
|
||||
@@ -396,13 +406,27 @@
|
||||
"invalidFormat": "Некорректная строка формата"
|
||||
},
|
||||
"templateVars": {
|
||||
"message_assets_added": { "description": "Уведомление о добавлении файлов в альбом" },
|
||||
"message_assets_removed": { "description": "Уведомление об удалении файлов из альбома" },
|
||||
"message_album_renamed": { "description": "Уведомление о переименовании альбома" },
|
||||
"message_album_deleted": { "description": "Уведомление об удалении альбома" },
|
||||
"periodic_summary_message": { "description": "Периодическая сводка альбомов (планировщик не реализован)" },
|
||||
"scheduled_assets_message": { "description": "Запланированная подборка фото (планировщик не реализован)" },
|
||||
"memory_mode_message": { "description": "«В этот день» — воспоминания (планировщик не реализован)" },
|
||||
"message_assets_added": {
|
||||
"description": "Уведомление о добавлении файлов в альбом"
|
||||
},
|
||||
"message_assets_removed": {
|
||||
"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_name": "Название альбома",
|
||||
"album_url": "Публичная ссылка (пусто, если не расшарен)",
|
||||
@@ -544,7 +568,7 @@
|
||||
"enabledCommands": "Включённые команды",
|
||||
"locale": "Язык",
|
||||
"responseMode": "Режим ответа",
|
||||
"modeMedia": "Медиа (фото)",
|
||||
"modeMedia": "Медиа (файлы)",
|
||||
"modeText": "Только текст",
|
||||
"defaultCount": "Кол-во по умолчанию",
|
||||
"rateLimits": "Ограничения частоты",
|
||||
@@ -662,4 +686,4 @@
|
||||
"line": "строка",
|
||||
"add": "Добавить"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
|
||||
try {
|
||||
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;
|
||||
pwdCurrent = ''; pwdNew = '';
|
||||
snackSuccess(t('snack.passwordChanged'));
|
||||
@@ -36,23 +36,102 @@
|
||||
|
||||
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: '/providers', key: 'nav.providers', icon: 'mdiServer' },
|
||||
{ href: '/notification-trackers', key: 'nav.notificationTrackers', icon: 'mdiRadar' },
|
||||
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: 'mdiCog' },
|
||||
{ href: '/template-configs', key: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit' },
|
||||
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: 'mdiRobot' },
|
||||
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||
{ href: '/command-trackers', key: 'nav.commandTrackers', icon: 'mdiConsoleLine' },
|
||||
{ href: '/command-configs', key: 'nav.commandConfigs', icon: 'mdiCog' },
|
||||
{ href: '/command-template-configs', key: 'nav.cmdTemplateConfigs', icon: 'mdiCodeBracesBox' },
|
||||
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer', countKey: 'providers' },
|
||||
{
|
||||
key: 'nav.notification', icon: 'mdiBellOutline',
|
||||
countKeys: ['notification_trackers', 'tracking_configs', 'template_configs'],
|
||||
children: [
|
||||
{ href: '/notification-trackers', key: 'nav.trackers', icon: 'mdiRadar', countKey: 'notification_trackers' },
|
||||
{ href: '/tracking-configs', key: 'nav.configs', icon: 'mdiCog', countKey: 'tracking_configs' },
|
||||
{ href: '/template-configs', key: 'nav.templates', icon: 'mdiFileDocumentEdit', countKey: 'template_configs' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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' }]
|
||||
: baseNavItems
|
||||
|
||||
const navEntries = $derived<NavEntry[]>(auth.isAdmin
|
||||
? [
|
||||
...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(
|
||||
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
||||
);
|
||||
@@ -61,11 +140,28 @@
|
||||
initTheme();
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
||||
try {
|
||||
const saved = localStorage.getItem('nav_expanded');
|
||||
if (saved) expandedGroups = JSON.parse(saved);
|
||||
} catch {}
|
||||
}
|
||||
await loadUser();
|
||||
if (!auth.user && !isAuthPage) {
|
||||
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() {
|
||||
@@ -128,21 +224,74 @@
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="flex-1 p-2 space-y-0.5 overflow-y-auto">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.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(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'};"
|
||||
onmouseenter={(e) => { if (!isActive(item.href)) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
|
||||
onmouseleave={(e) => { if (!isActive(item.href)) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
|
||||
title={collapsed ? t(item.key) : ''}
|
||||
>
|
||||
{#if isActive(item.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>
|
||||
{#each navEntries as entry}
|
||||
{#if isGroup(entry)}
|
||||
<!-- Group header -->
|
||||
<button
|
||||
onclick={() => collapsed ? null : toggleGroup(entry.key)}
|
||||
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"
|
||||
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'};"
|
||||
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)'; } }}
|
||||
title={collapsed ? t(entry.key) : ''}
|
||||
>
|
||||
{#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}
|
||||
<MdiIcon name={item.icon} size={18} />
|
||||
{#if !collapsed}<span class="truncate">{t(item.key)}</span>{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<!-- Top-level item -->
|
||||
<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}
|
||||
</nav>
|
||||
|
||||
@@ -217,7 +366,7 @@
|
||||
|
||||
<!-- 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);">
|
||||
{#each navItems.slice(0, 5) as item}
|
||||
{#each mobileNavItems as item}
|
||||
<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"
|
||||
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
|
||||
@@ -280,4 +429,28 @@
|
||||
@media (max-width: 767px) {
|
||||
.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>
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
/** Calculate how many event rows fit in the remaining viewport space. */
|
||||
function calcPageSize(): number {
|
||||
if (typeof window === 'undefined') return 8;
|
||||
const EVENT_ROW_HEIGHT = 50; // px per event row (content + gap)
|
||||
const FIXED_OVERHEAD = 600; // header + stats + chart + events header + filters + paginator + padding
|
||||
const EVENT_ROW_HEIGHT = 50;
|
||||
const FIXED_OVERHEAD = 700; // slightly more for chart in Events section
|
||||
const available = window.innerHeight - FIXED_OVERHEAD;
|
||||
return Math.max(3, Math.floor(available / EVENT_ROW_HEIGHT));
|
||||
}
|
||||
@@ -53,13 +53,19 @@
|
||||
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() {
|
||||
eventsLoading = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filterEventType) params.set('event_type', filterEventType);
|
||||
if (filterProviderId) params.set('provider_id', filterProviderId);
|
||||
if (filterSearch) params.set('search', filterSearch);
|
||||
const params = buildFilterParams();
|
||||
params.set('sort', filterSort);
|
||||
params.set('limit', String(eventsLimit));
|
||||
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() {
|
||||
eventsOffset = 0;
|
||||
loadEvents();
|
||||
loadChart();
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
@@ -216,8 +232,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<EventChart days={chartDays} />
|
||||
|
||||
<!-- Events section -->
|
||||
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
|
||||
<MdiIcon name="mdiPulse" size={18} />
|
||||
{t('dashboard.recentEvents')}
|
||||
@@ -253,6 +268,9 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Chart (now inside Events section, affected by filters) -->
|
||||
<EventChart days={chartDays} />
|
||||
|
||||
{#if eventsLoading}
|
||||
<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}
|
||||
|
||||
@@ -8,7 +8,19 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
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"])
|
||||
|
||||
@@ -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")
|
||||
async def get_event_chart(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
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)
|
||||
|
||||
day_col = func.date(EventLog.created_at)
|
||||
@@ -112,10 +163,21 @@ async def get_event_chart(
|
||||
)
|
||||
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id)
|
||||
.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()
|
||||
|
||||
# Build a dict: { "2026-03-15": { "assets_added": 18, ... }, ... }
|
||||
|
||||
Reference in New Issue
Block a user