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:
2026-03-21 23:07:55 +03:00
parent ddcbfdaa0b
commit 2c740ff2d2
5 changed files with 366 additions and 65 deletions
+36 -12
View File
@@ -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"
}
}
}
+36 -12
View File
@@ -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": "Добавить"
}
}
}
+202 -29
View File
@@ -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>
+26 -8
View File
@@ -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, ... }, ... }