diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 6562e3b..9bb422f 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -59,7 +59,11 @@ "oldestFirst": "Oldest first", "loadingEvents": "Loading events...", "asset": "asset", - "assets": "assets" + "assets": "assets", + "eventActivity": "Event Activity", + "last14days": "Last 14 days", + "events": "events", + "noChartData": "No event data yet" }, "providers": { "title": "Providers", @@ -126,7 +130,11 @@ "batchDuration": "Batch duration (seconds)", "linkedTargets": "targets", "noLinkedTargets": "No targets linked. Add a target below.", - "addTarget": "Add target" + "addTarget": "Add target", + "testBasic": "Send test message", + "testPeriodic": "Test periodic summary", + "testScheduled": "Test scheduled assets", + "testMemory": "Test memory / On This Day" }, "templates": { "title": "Templates", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index b9461a0..9ebd439 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -59,7 +59,11 @@ "oldestFirst": "Сначала старые", "loadingEvents": "Загрузка событий...", "asset": "файл", - "assets": "файлов" + "assets": "файлов", + "eventActivity": "Активность событий", + "last14days": "Последние 14 дней", + "events": "событий", + "noChartData": "Нет данных о событиях" }, "providers": { "title": "Провайдеры", @@ -126,7 +130,11 @@ "batchDuration": "Длительность пакета (секунды)", "linkedTargets": "получатели", "noLinkedTargets": "Нет привязанных получателей. Добавьте получателя ниже.", - "addTarget": "Добавить получателя" + "addTarget": "Добавить получателя", + "testBasic": "Отправить тестовое сообщение", + "testPeriodic": "Тест периодической сводки", + "testScheduled": "Тест запланированных фото", + "testMemory": "Тест воспоминаний" }, "templates": { "title": "Шаблоны", diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 7c2d7c3..f2bede5 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -6,9 +6,11 @@ import Card from '$lib/components/Card.svelte'; import Loading from '$lib/components/Loading.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte'; + import EventChart from '$lib/components/EventChart.svelte'; let status = $state(null); let providers = $state([]); + let chartDays = $state([]); let loaded = $state(false); let error = $state(''); @@ -33,7 +35,7 @@ function calcPageSize(): number { if (typeof window === 'undefined') return 8; const EVENT_ROW_HEIGHT = 50; // px per event row (content + gap) - const FIXED_OVERHEAD = 390; // header + stats + events header + filters + paginator + padding + const FIXED_OVERHEAD = 600; // header + stats + chart + events header + filters + paginator + padding const available = window.innerHeight - FIXED_OVERHEAD; return Math.max(3, Math.floor(available / EVENT_ROW_HEIGHT)); } @@ -108,10 +110,14 @@ async function loadInitial() { try { - [status, providers] = await Promise.all([ + const [statusRes, providersRes, chartRes] = await Promise.all([ api(`/status?limit=${eventsLimit}`), api('/providers'), + api('/status/chart'), ]); + status = statusRes; + providers = providersRes; + chartDays = chartRes.days || []; setTimeout(() => { animateCount(0, status.providers, (v) => displayProviders = v); animateCount(0, status.trackers.active, (v) => displayActive = v); @@ -200,6 +206,8 @@ {/each} + +

{t('dashboard.recentEvents')} diff --git a/frontend/src/routes/providers/+page.svelte b/frontend/src/routes/providers/+page.svelte index 4d732dd..770a10c 100644 --- a/frontend/src/routes/providers/+page.svelte +++ b/frontend/src/routes/providers/+page.svelte @@ -161,7 +161,9 @@

{provider.name}

{provider.type} -

{provider.config?.url || ''}

+ {#if provider.config?.url} + {provider.config.url} + {/if}
diff --git a/frontend/src/routes/trackers/+page.svelte b/frontend/src/routes/trackers/+page.svelte index 1a7d538..2765156 100644 --- a/frontend/src/routes/trackers/+page.svelte +++ b/frontend/src/routes/trackers/+page.svelte @@ -176,13 +176,23 @@ } catch (err: any) { error = err.message; snackError(err.message); } confirmDelete = null; } + let testMenuOpen = $state(null); + let testMenuStyle = $state(''); + + const testTypes = [ + { key: 'basic', icon: 'mdiSend', labelKey: 'trackers.testBasic' }, + { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'trackers.testPeriodic' }, + { key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'trackers.testScheduled' }, + { key: 'memory', icon: 'mdiHistory', labelKey: 'trackers.testMemory' }, + ]; + async function testTrackerTarget(trackerId: number, ttId: number, testType: string) { + testMenuOpen = null; const key = `${ttId}_${testType}`; if (ttTesting[key]) return; ttTesting = { ...ttTesting, [key]: testType }; try { - const endpoint = testType === 'basic' ? 'test' : `test-${testType}`; - await api(`/trackers/${trackerId}/targets/${ttId}/${endpoint}?locale=${getLocale()}`, { method: 'POST' }); + await api(`/trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' }); snackSuccess(t('snack.targetTestSent')); } catch (err: any) { snackError(err.message); @@ -190,6 +200,13 @@ ttTesting = { ...ttTesting, [key]: '' }; } } + + function openTestMenu(ttId: number, event: MouseEvent) { + const btn = event.currentTarget as HTMLElement; + const rect = btn.getBoundingClientRect(); + testMenuStyle = `position:fixed; z-index:9999; top:${rect.bottom + 4}px; right:${window.innerWidth - rect.right}px;`; + testMenuOpen = testMenuOpen === String(ttId) ? null : String(ttId); + } function toggleCollection(collectionId: string) { form.collection_ids = form.collection_ids.includes(collectionId) ? form.collection_ids.filter(id => id !== collectionId) : [...form.collection_ids, collectionId]; } function formatDate(dateStr: string): string { @@ -390,15 +407,11 @@ {#each templateConfigs as tc}{/each} - testTrackerTarget(tracker.id, tt.id, 'basic')} - disabled={!!ttTesting[`${tt.id}_basic`]} /> - testTrackerTarget(tracker.id, tt.id, 'periodic')} - disabled={!!ttTesting[`${tt.id}_periodic`]} /> - testTrackerTarget(tracker.id, tt.id, 'memory')} - disabled={!!ttTesting[`${tt.id}_memory`]} /> +
+ openTestMenu(tt.id, e)} + disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} /> +
updateTargetLink(tracker.id, tt, 'enabled', !tt.enabled)} /> @@ -442,6 +455,29 @@ {/if} {/if} +{#if testMenuOpen} + +
testMenuOpen = null} + onkeydown={(e) => { if (e.key === 'Escape') testMenuOpen = null; }}> +
+
+ {#each testTypes as tt} + {@const trackerId = trackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === testMenuOpen))?.id} + + {/each} +
+{/if} + {#if linkWarning}
dict[str, Any]: bot_token = target.config.get("bot_token") chat_id = target.config.get("chat_id") + disable_preview = target.config.get("disable_url_preview", False) + max_media = target.config.get("max_media_to_send", 50) + max_group = target.config.get("max_media_per_group", 10) + chunk_delay = target.config.get("media_delay", 500) + max_size = target.config.get("max_asset_size") + if max_size: + max_size = max_size * 1024 * 1024 # MB to bytes + send_large_as_docs = target.config.get("send_large_photos_as_documents", False) if not bot_token or not chat_id: return {"success": False, "error": "Missing bot_token or chat_id"} @@ -98,15 +106,23 @@ class NotificationDispatcher: async with aiohttp.ClientSession() as session: client = TelegramClient(session, bot_token) - # Build asset list for media sending - # Attach API key header for URLs pointing to the provider (internal or external) + # Step 1: Send the text message first + text_result = await client.send_message( + chat_id=str(chat_id), + text=message, + disable_web_page_preview=disable_preview or None, + ) + if not text_result.get("success"): + return text_result + + # Step 2: Send assets as reply to the text message provider_urls = [] if target.provider_internal_url: provider_urls.append(target.provider_internal_url) if target.provider_external_url: provider_urls.append(target.provider_external_url) assets = [] - for asset in event.added_assets: + for asset in event.added_assets[:max_media]: url = asset.full_url or asset.thumbnail_url if url: asset_type = "video" if asset.type.value == "video" else "photo" @@ -115,11 +131,21 @@ class NotificationDispatcher: asset_headers["x-api-key"] = target.provider_api_key assets.append({"url": url, "type": asset_type, "headers": asset_headers}) - return await client.send_notification( - chat_id=str(chat_id), - caption=message, - assets=assets if assets else None, - ) + if assets: + reply_to = text_result.get("message_id") + media_result = await client.send_notification( + chat_id=str(chat_id), + assets=assets, + reply_to_message_id=reply_to, + max_group_size=max_group, + chunk_delay=chunk_delay, + max_asset_data_size=max_size, + send_large_photos_as_documents=send_large_as_docs, + ) + if not media_result.get("success"): + _LOGGER.warning("Text sent OK but media failed: %s", media_result.get("error")) + + return text_result async def _send_webhook( self, target: TargetConfig, message: str, event: ServiceEvent diff --git a/packages/server/src/notify_bridge_server/api/status.py b/packages/server/src/notify_bridge_server/api/status.py index 23985f1..34e1d36 100644 --- a/packages/server/src/notify_bridge_server/api/status.py +++ b/packages/server/src/notify_bridge_server/api/status.py @@ -1,5 +1,7 @@ """Status/dashboard API route.""" +from datetime import datetime, timedelta, timezone + from fastapi import APIRouter, Depends, Query from sqlmodel import func, select from sqlmodel.ext.asyncio.session import AsyncSession @@ -89,3 +91,46 @@ async def get_status( for e in recent_events.all() ], } + + +@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), +): + """Return daily event counts by type for the last N days.""" + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + + day_col = func.date(EventLog.created_at) + + query = ( + select( + day_col.label("day"), + EventLog.event_type, + func.count().label("count"), + ) + .join(Tracker, EventLog.tracker_id == Tracker.id) + .where(Tracker.user_id == user.id, EventLog.created_at >= cutoff) + .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": 3, ... }, ... } + by_day: dict[str, dict[str, int]] = {} + for row in rows: + day_str = str(row.day) + if day_str not in by_day: + by_day[day_str] = {} + by_day[day_str][row.event_type] = row.count + + # Fill in missing days so the frontend gets a continuous series + result = [] + for i in range(days): + d = (datetime.now(timezone.utc) - timedelta(days=days - 1 - i)).strftime("%Y-%m-%d") + counts = by_day.get(d, {}) + result.append({"date": d, **counts}) + + return {"days": result} diff --git a/packages/server/src/notify_bridge_server/api/tracker_targets.py b/packages/server/src/notify_bridge_server/api/tracker_targets.py index 4735bd3..b4e9a78 100644 --- a/packages/server/src/notify_bridge_server/api/tracker_targets.py +++ b/packages/server/src/notify_bridge_server/api/tracker_targets.py @@ -11,6 +11,7 @@ from ..auth.dependencies import get_current_user from ..database.engine import get_session from ..database.models import ( NotificationTarget, + ServiceProvider, TemplateConfig, Tracker, TrackerTarget, @@ -148,16 +149,24 @@ async def delete_tracker_target( await session.commit() -@router.post("/{tracker_target_id}/test") +@router.post("/{tracker_target_id}/test/{test_type}") async def test_tracker_target( tracker_id: int, tracker_target_id: int, + test_type: str, locale: str = Query("en"), user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): - """Send a test notification to a specific linked target.""" - await _get_user_tracker(session, tracker_id, user.id) + """Send a test notification using real provider data. + + test_type: basic | periodic | scheduled | memory + """ + valid_types = {"basic", "periodic", "scheduled", "memory"} + if test_type not in valid_types: + raise HTTPException(status_code=400, detail=f"Invalid test type. Must be one of: {', '.join(valid_types)}") + + tracker = await _get_user_tracker(session, tracker_id, user.id) tt = await session.get(TrackerTarget, tracker_target_id) if not tt or tt.tracker_id != tracker_id: raise HTTPException(status_code=404, detail="Tracker-target link not found") @@ -166,62 +175,42 @@ async def test_tracker_target( if not target: raise HTTPException(status_code=404, detail="Target not found") - from ..services.notifier import send_test_notification - r = await send_test_notification(target, locale=locale) - return {"target": target.name, **r} - - -@router.post("/{tracker_target_id}/test-periodic") -async def test_periodic_tracker_target( - tracker_id: int, - tracker_target_id: int, - user: User = Depends(get_current_user), - session: AsyncSession = Depends(get_session), -): - """Send a test periodic summary to a specific linked target.""" - await _get_user_tracker(session, tracker_id, user.id) - tt = await session.get(TrackerTarget, tracker_target_id) - if not tt or tt.tracker_id != tracker_id: - raise HTTPException(status_code=404, detail="Tracker-target link not found") - - target = await session.get(NotificationTarget, tt.target_id) - if not target: - raise HTTPException(status_code=404, detail="Target not found") + if test_type == "basic": + from ..services.notifier import send_test_notification + r = await send_test_notification(target, locale=locale) + return {"target": target.name, **r} + # For periodic/scheduled/memory — fetch real data from provider template_config = None if tt.template_config_id: template_config = await session.get(TemplateConfig, tt.template_config_id) - template_str = (template_config.periodic_summary_message if template_config else "") or "" - from ..services.notifier import send_test_template_notification - r = await send_test_template_notification(target, "periodic_summary", template_str) - return {"target": target.name, **r} + slot_map = { + "periodic": "periodic_summary_message", + "scheduled": "scheduled_assets_message", + "memory": "memory_mode_message", + } + template_str = getattr(template_config, slot_map[test_type], "") if template_config else "" + # Load provider and tracker data eagerly before aiohttp context + provider = await session.get(ServiceProvider, tracker.provider_id) + if not provider: + raise HTTPException(status_code=404, detail="Provider not found") + provider_config = dict(provider.config) + collection_ids = list(tracker.collection_ids or []) -@router.post("/{tracker_target_id}/test-memory") -async def test_memory_tracker_target( - tracker_id: int, - tracker_target_id: int, - user: User = Depends(get_current_user), - session: AsyncSession = Depends(get_session), -): - """Send a test memory/on-this-day notification to a specific linked target.""" - await _get_user_tracker(session, tracker_id, user.id) - tt = await session.get(TrackerTarget, tracker_target_id) - if not tt or tt.tracker_id != tracker_id: - raise HTTPException(status_code=404, detail="Tracker-target link not found") - - target = await session.get(NotificationTarget, tt.target_id) - if not target: - raise HTTPException(status_code=404, detail="Target not found") - - template_config = None - if tt.template_config_id: - template_config = await session.get(TemplateConfig, tt.template_config_id) - template_str = (template_config.memory_mode_message if template_config else "") or "" - - from ..services.notifier import send_test_template_notification - r = await send_test_template_notification(target, "memory_mode", template_str) + # Fetch real data from provider + from ..services.notifier import send_real_data_notification + r = await send_real_data_notification( + target=target, + template_str=template_str, + test_type=test_type, + provider_type=provider.type, + provider_config=provider_config, + collection_ids=collection_ids, + date_format=template_config.date_format if template_config else "%d.%m.%Y, %H:%M UTC", + date_only_format=template_config.date_only_format if template_config and hasattr(template_config, "date_only_format") else "%d.%m.%Y", + ) return {"target": target.name, **r} diff --git a/packages/server/src/notify_bridge_server/services/notifier.py b/packages/server/src/notify_bridge_server/services/notifier.py index 036a543..f3159a4 100644 --- a/packages/server/src/notify_bridge_server/services/notifier.py +++ b/packages/server/src/notify_bridge_server/services/notifier.py @@ -118,3 +118,210 @@ async def _test_webhook(target: NotificationTarget, locale: str = "en") -> dict: "message": _get_test_message(locale, "webhook"), "event_type": "test", }) + + +async def send_real_data_notification( + target: NotificationTarget, + template_str: str, + test_type: str, + provider_type: str, + provider_config: dict, + collection_ids: list[str], + date_format: str = "%d.%m.%Y, %H:%M UTC", + date_only_format: str = "%d.%m.%Y", +) -> dict: + """Fetch real data from provider, render template, and send notification.""" + from datetime import datetime, timezone + from jinja2.sandbox import SandboxedEnvironment + + if not template_str: + return {"success": False, "error": f"No template configured for {test_type}"} + + # Fetch real data from provider + ctx: dict = {} + try: + ctx = await _build_real_context( + provider_type, provider_config, collection_ids, + test_type, date_format, date_only_format, + ) + except Exception as e: + _LOGGER.error("Failed to fetch real data for test: %s", e) + return {"success": False, "error": f"Failed to fetch provider data: {e}"} + + ctx["target_type"] = target.type + ctx["date_format"] = date_format + ctx["date_only_format"] = date_only_format + + # Render template + try: + env = SandboxedEnvironment(autoescape=False) + tmpl = env.from_string(template_str) + message = tmpl.render(**ctx) + except Exception as e: + return {"success": False, "error": f"Template render error: {e}"} + + # Send + try: + if target.type == "telegram": + return await _test_telegram_with_message(target, message) + elif target.type == "webhook": + return await _test_webhook_with_message(target, message) + return {"success": False, "error": f"Unknown target type: {target.type}"} + except Exception as e: + _LOGGER.error("Test notification failed: %s", e) + return {"success": False, "error": str(e)} + + +async def _build_real_context( + provider_type: str, + provider_config: dict, + collection_ids: list[str], + test_type: str, + date_format: str, + date_only_format: str, +) -> dict: + """Build template context from real provider data.""" + from datetime import datetime, timezone + + if provider_type != "immich": + return {"date": datetime.now(timezone.utc).strftime(date_only_format)} + + from notify_bridge_core.providers.immich import ImmichServiceProvider + + async with aiohttp.ClientSession() as http_session: + immich = ImmichServiceProvider( + http_session, + provider_config.get("url", ""), + provider_config.get("api_key", ""), + provider_config.get("external_domain"), + "Immich", + ) + connected = await immich.connect() + if not connected: + raise RuntimeError("Failed to connect to Immich") + + # Fetch album data for all tracked collections + collections = [] + all_assets = [] + + for album_id in collection_ids: + album = await immich.client.get_album(album_id) + if not album: + continue + + # Get shared link for public URL + shared_links = await immich.client.get_shared_links(album_id) + ext_domain = provider_config.get("external_domain") or provider_config.get("url", "") + album_public_url = "" + for link in shared_links: + if link.is_accessible and not link.is_expired and not link.has_password: + album_public_url = f"{ext_domain.rstrip('/')}/share/{link.key}" + break + + collections.append({ + "name": album.name, + "url": album_public_url or f"{ext_domain.rstrip('/')}/albums/{album_id}", + "public_url": album_public_url, + "asset_count": album.asset_count, + "shared": album.shared, + "photo_count": album.photo_count, + "video_count": album.video_count, + "owner": album.owner, + }) + + # Collect assets (limited sample) + for asset_id, asset in list(album.assets.items())[:10]: + asset_public_url = f"{album_public_url}/photos/{asset_id}" if album_public_url else "" + all_assets.append({ + "id": asset.id, + "filename": asset.filename, + "type": asset.type.upper(), + "created_at": asset.created_at, + "owner": asset.owner_name or "", + "description": asset.description or "", + "people": list(asset.people), + "is_favorite": asset.is_favorite, + "rating": asset.rating, + "city": asset.city or "", + "state": asset.state or "", + "country": asset.country or "", + "public_url": asset_public_url, + "url": f"{ext_domain.rstrip('/')}/api/assets/{asset.id}/original", + "photo_url": f"{ext_domain.rstrip('/')}/api/assets/{asset.id}/thumbnail", + }) + + # Build context based on test type + now = datetime.now(timezone.utc) + ctx: dict = { + "date": now.strftime(date_only_format), + "timestamp": now.isoformat(), + "service_name": "Immich", + "service_type": "immich", + "collections": collections, + "albums": collections, # alias + "assets": all_assets, + } + + # Common date/location for assets + if len(all_assets) > 1: + dates = set() + for a in all_assets: + ca = a.get("created_at", "") + if ca: + dates.add(ca[:10]) + if len(dates) == 1: + try: + ctx["common_date"] = datetime.fromisoformat(dates.pop()).strftime(date_only_format) + except (ValueError, TypeError): + ctx["common_date"] = "" + else: + ctx["common_date"] = "" + + locations = set() + for a in all_assets: + city = a.get("city", "") + country = a.get("country", "") + if city: + locations.add(f"{city}, {country}" if country else city) + else: + locations.add("") + if len(locations) == 1 and "" not in locations: + ctx["common_location"] = locations.pop() + else: + ctx["common_location"] = "" + else: + ctx["common_date"] = "" + ctx["common_location"] = "" + + # Add first collection details as top-level for periodic-style templates + if collections: + first = collections[0] + ctx.update({ + "collection_name": first["name"], + "album_name": first["name"], + "public_url": first.get("public_url", ""), + "album_url": first.get("url", ""), + "shared": first.get("shared", False), + "photo_count": first.get("photo_count", 0), + "video_count": first.get("video_count", 0), + "owner": first.get("owner", ""), + }) + else: + ctx.update({ + "collection_name": "", "album_name": "", + "public_url": "", "album_url": "", + "shared": False, "photo_count": 0, "video_count": 0, "owner": "", + }) + + # People across all assets + people = set() + for a in all_assets: + people.update(a.get("people", [])) + ctx["people"] = list(people) + ctx["has_videos"] = any(a.get("type") == "VIDEO" for a in all_assets) + ctx["has_photos"] = any(a.get("type") == "IMAGE" for a in all_assets) + ctx["added_count"] = len(all_assets) + ctx["added_assets"] = all_assets + ctx["protected_url"] = "" + + return ctx