diff --git a/.claude/docs/backend-architecture.md b/.claude/docs/backend-architecture.md new file mode 100644 index 0000000..49e7f92 --- /dev/null +++ b/.claude/docs/backend-architecture.md @@ -0,0 +1,23 @@ +# Backend Architecture Notes + +## Stack + +- **FastAPI REST API + SQLite** — async SQLAlchemy via sqlmodel, auto-created on startup with migrations. +- **`packages/core`** (`notify_bridge_core`): Shared library — providers, models, notifications, templates. No DB dependency. Includes `jinja2` dependency. +- **`packages/server`** (`notify_bridge_server`): FastAPI REST API + SQLite. Depends on core. + +## Environment Variables + +- `NOTIFY_BRIDGE_DATA_DIR`, `NOTIFY_BRIDGE_SECRET_KEY`, `NOTIFY_BRIDGE_DATABASE_URL` + +## Key Constraints + +- **SQLAlchemy async + aiohttp**: Cannot nest `async with aiohttp.ClientSession()` inside a route that has an active SQLAlchemy async session — greenlet context breaks. Eagerly load all DB data before entering aiohttp context. +- **Jinja2 SandboxedEnvironment**: All template rendering MUST use `from jinja2.sandbox import SandboxedEnvironment`. +- **System-owned entities**: `user_id=0` means system-owned (e.g. default templates). +- **FastAPI route ordering**: Static path routes MUST be registered BEFORE parameterized routes. +- **`__pycache__`**: Add to `.gitignore`. Never commit. + +## API + +- All CRUD routes under `/api/`, auth via JWT Bearer, `NOTIFY_BRIDGE_` env prefix. diff --git a/.claude/docs/dev-servers.md b/.claude/docs/dev-servers.md new file mode 100644 index 0000000..82cb9d3 --- /dev/null +++ b/.claude/docs/dev-servers.md @@ -0,0 +1,15 @@ +# Development Servers + +**MANDATORY**: You MUST restart the backend server IMMEDIATELY after ANY backend code change (files in `packages/server/` or `packages/core/`). Do NOT wait for the user to ask — restart automatically every time. Failure to restart means the user will test against stale code and encounter bugs that don't exist. Use this one-liner: +```bash +PID=$(netstat -ano 2>/dev/null | grep ':8420.*LISTENING' | awk '{print $5}' | head -1) && [ -n "$PID" ] && taskkill //F //PID $PID 2>/dev/null; sleep 1 && cd packages/server && pip install -e . 2>&1 | tail -1 && cd ../.. && NOTIFY_BRIDGE_DATA_DIR=./test-data NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32chars nohup python -m uvicorn notify_bridge_server.main:app --host 0.0.0.0 --port 8420 > /dev/null 2>&1 & sleep 3 && curl -s http://localhost:8420/api/health +``` + +**IMPORTANT**: When the user requests it, restart the frontend dev server using this one-liner: +```bash +PID=$(netstat -ano 2>/dev/null | grep ':5173.*LISTENING' | awk '{print $5}' | head -1) && [ -n "$PID" ] && taskkill //F //PID $PID 2>/dev/null; sleep 1 && cd frontend && npx vite dev --port 5173 --host > /dev/null 2>&1 & sleep 4 && curl -s -o /dev/null -w "Frontend: %{http_code}" http://localhost:5173/ +``` + +## Test Credentials + +Default test account: username `admin`, password `admin1`. diff --git a/.claude/docs/entity-relationships.md b/.claude/docs/entity-relationships.md new file mode 100644 index 0000000..ea526df --- /dev/null +++ b/.claude/docs/entity-relationships.md @@ -0,0 +1,19 @@ +# Entity Relationships + +``` +ServiceProvider → type: "immich" (inferred capabilities: notifications, commands) +NotificationTracker → provider_id, collection_ids, scan_interval, batch_duration, enabled +NotificationTrackerTarget → notification_tracker_id, target_id, tracking_config_id, template_config_id, quiet_hours, enabled +TrackingConfig → provider_type, event flags, scheduling rules +TemplateConfig → provider_type, Jinja2 template slots per event type +NotificationTarget → type: "telegram"/"webhook", config JSON, chat_action (telegram only) +CommandConfig → provider_type, enabled_commands, locale, response_mode, default_count, rate_limits +CommandTracker → provider_id, command_config_id, enabled +CommandTrackerListener → command_tracker_id, listener_type ("telegram_bot"), listener_id +TelegramBot → token, update_mode, bot_username (used as notification target backend + commands listener) +``` + +- NotificationTrackerTarget links a tracker to a target with per-link tracking/template config and quiet hours +- CommandTrackerListener links a command tracker to a listener (e.g. TelegramBot) for slash-command handling +- `user_id=0` on TemplateConfig = system default (EN/RU seeded on first startup) +- DB: SQLite + async SQLAlchemy via sqlmodel, auto-created on startup with migrations diff --git a/.claude/docs/frontend-architecture.md b/.claude/docs/frontend-architecture.md new file mode 100644 index 0000000..8e721e1 --- /dev/null +++ b/.claude/docs/frontend-architecture.md @@ -0,0 +1,44 @@ +# Frontend Architecture Notes + +## Stack + +- **SvelteKit 2 + Svelte 5 + Tailwind CSS v4** — Static adapter with SPA fallback. Dev proxy to :8420. +- **Tailwind CSS v4**: Uses `@theme` directive in `app.css` for CSS variables. + +## Svelte 5 Runes + +- `$state` only works in `.svelte` and `.svelte.ts` files. Regular `.ts` files cannot use runes — use plain variables instead. + +## i18n + +- Uses `$state` rune in `.svelte.ts` file. Locale auto-detects from localStorage. +- `t()` is reactive via `$state`. `setLocale()` updates immediately without page reload. + +## Auth Flow + +- After login/setup, use `window.location.href = '/'` (hard redirect), NOT `goto('/')`. + +## Overlays + +**IMPORTANT**: Overlays (modals, dropdowns, pickers) MUST use `position: fixed` with inline styles and `z-index: 9999`. Tailwind CSS v4 `fixed`/`absolute` classes do NOT work reliably inside flex/overflow containers in this project. Always calculate position from `getBoundingClientRect()` for dropdowns, or use `top:0;left:0;right:0;bottom:0` for full-screen backdrops. + +## Entity Cache System + +Shared entities use a `$state`-based cache layer in `frontend/src/lib/stores/`: + +- **`entity-cache.svelte.ts`** — Generic cache factory with 30s TTL, request deduplication, and local mutation helpers (`upsert`, `remove`, `set`). +- **`caches.svelte.ts`** — Singleton caches for: `providersCache`, `targetsCache`, `trackingConfigsCache`, `templateConfigsCache`, `telegramBotsCache`, `emailBotsCache`, `matrixBotsCache`, `commandConfigsCache`, `commandTemplateConfigsCache`. + +### How pages use caches + +- **Cross-page references** (e.g. providers on the Trackers page): Use `$derived(providersCache.items)` and call `providersCache.fetch()` in `load()`. Cached data is returned instantly if fresh (<30s). +- **Owning pages** (e.g. Providers page itself): Use `$derived(providersCache.items)` and call `providersCache.fetch(true)` (force refresh) in `load()`. +- **After mutations** (create/update/delete): The owning page calls `cache.invalidate()` then `load()` to force-refresh. Other pages pick up changes on next navigation via TTL expiry. +- **On logout**: `clearAllCaches()` is called to wipe all cached data. + +### Adding a new cached entity + +1. Add a type to `frontend/src/lib/types.ts` +2. Add `export const fooCache = createEntityCache('/foo');` to `caches.svelte.ts` +3. Add `fooCache.clear()` to `clearAllCaches()` +4. In page components: replace `let foo = $state([])` with `let foo = $derived(fooCache.items)` and replace `api('/foo')` with `fooCache.fetch()` diff --git a/.claude/docs/template-system.md b/.claude/docs/template-system.md new file mode 100644 index 0000000..c913912 --- /dev/null +++ b/.claude/docs/template-system.md @@ -0,0 +1,11 @@ +# Template System Sync Rules + +**IMPORTANT**: When adding or changing template context variables, you MUST update ALL of these in sync: +1. **`packages/core/.../templates/context.py`** — `build_template_context()` where variables are computed +2. **`packages/server/.../api/template_configs.py`** — `_SAMPLE_CONTEXT` dict (for preview rendering) +3. **`packages/server/.../api/template_configs.py`** — `get_template_variables()` endpoint (`event_vars`, `asset_fields`, `album_fields`, `scheduled_vars`, per-slot variable dicts) +4. **`packages/core/.../templates/defaults/{en,ru}/*.jinja2`** — default template files using the new variables +5. **`packages/core/.../providers/immich/provider.py`** — `IMMICH_VARIABLES` list (provider-specific variable definitions) +6. **`packages/server/.../api/command_template_configs.py`** — `get_command_variables()` endpoint (for command response templates) + +**IMPORTANT**: Variable reference endpoints MUST document child/nested properties, not only top-level variables. When a variable is a list of dicts (e.g. `assets`, `albums`, `events`, `commands`), the endpoint MUST include a corresponding `*_fields` dict describing the child properties (e.g. `asset_fields: {"id": "...", "filename": "..."}`) so the frontend can show them (e.g. `{{ asset.id }}`, `{{ album.name }}`). Never list only `"assets": "List of asset dicts"` — always specify what fields each dict contains. \ No newline at end of file diff --git a/.gitignore b/.gitignore index c049b70..61f81b2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ @@ -43,7 +43,8 @@ Thumbs.db htmlcov/ # Claude Code -.claude/ +.claude/settings.json +.claude/settings.local.json # Data test-data/ diff --git a/CLAUDE.md b/CLAUDE.md index 1b7644e..ccf8920 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,76 +1,18 @@ # Project Guidelines -## Development Servers +Detailed context is split into focused documents under `.claude/docs/`. Read the relevant file when working in that area. -**MANDATORY**: You MUST restart the backend server IMMEDIATELY after ANY backend code change (files in `packages/server/` or `packages/core/`). Do NOT wait for the user to ask — restart automatically every time. Failure to restart means the user will test against stale code and encounter bugs that don't exist. Use this one-liner: -```bash -PID=$(netstat -ano 2>/dev/null | grep ':8420.*LISTENING' | awk '{print $5}' | head -1) && [ -n "$PID" ] && taskkill //F //PID $PID 2>/dev/null; sleep 1 && cd packages/server && pip install -e . 2>&1 | tail -1 && cd ../.. && NOTIFY_BRIDGE_DATA_DIR=./test-data NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32chars nohup python -m uvicorn notify_bridge_server.main:app --host 0.0.0.0 --port 8420 > /dev/null 2>&1 & sleep 3 && curl -s http://localhost:8420/api/health -``` +| Area | File | Key rules | +| --- | --- | --- | +| Dev servers & credentials | [dev-servers.md](.claude/docs/dev-servers.md) | **MUST restart backend after code changes**; frontend restart on request | +| Frontend architecture | [frontend-architecture.md](.claude/docs/frontend-architecture.md) | Svelte 5 runes, overlays, entity cache system, i18n, auth flow | +| Backend architecture | [backend-architecture.md](.claude/docs/backend-architecture.md) | SQLAlchemy async constraints, Jinja2 sandbox, route ordering | +| Entity relationships | [entity-relationships.md](.claude/docs/entity-relationships.md) | Full entity graph and DB conventions | +| Template system | [template-system.md](.claude/docs/template-system.md) | **6-file sync rule** for template variables | -**IMPORTANT**: Overlays (modals, dropdowns, pickers) MUST use `position: fixed` with inline styles and `z-index: 9999`. Tailwind CSS v4 `fixed`/`absolute` classes do NOT work reliably inside flex/overflow containers in this project. Always calculate position from `getBoundingClientRect()` for dropdowns, or use `top:0;left:0;right:0;bottom:0` for full-screen backdrops. +## Critical Rules (always apply) -**IMPORTANT**: When the user requests it, restart the frontend dev server using this one-liner: -```bash -PID=$(netstat -ano 2>/dev/null | grep ':5173.*LISTENING' | awk '{print $5}' | head -1) && [ -n "$PID" ] && taskkill //F //PID $PID 2>/dev/null; sleep 1 && cd frontend && npx vite dev --port 5173 --host > /dev/null 2>&1 & sleep 4 && curl -s -o /dev/null -w "Frontend: %{http_code}" http://localhost:5173/ -``` - -## Test Credentials - -Default test account: username `admin`, password `admin1`. - -## Frontend Architecture Notes - -- **i18n**: Uses `$state` rune in `.svelte.ts` file. Locale auto-detects from localStorage. `t()` is reactive via `$state`. `setLocale()` updates immediately without page reload. -- **Svelte 5 runes**: `$state` only works in `.svelte` and `.svelte.ts` files. Regular `.ts` files cannot use runes -- use plain variables instead. -- **Static adapter**: Frontend uses `@sveltejs/adapter-static` with SPA fallback. API calls proxied via Vite dev server config. -- **Auth flow**: After login/setup, use `window.location.href = '/'` (hard redirect), NOT `goto('/')`. -- **Tailwind CSS v4**: Uses `@theme` directive in `app.css` for CSS variables. - -## Backend Architecture Notes - -- **SQLAlchemy async + aiohttp**: Cannot nest `async with aiohttp.ClientSession()` inside a route that has an active SQLAlchemy async session -- greenlet context breaks. Eagerly load all DB data before entering aiohttp context. -- **Jinja2 SandboxedEnvironment**: All template rendering MUST use `from jinja2.sandbox import SandboxedEnvironment`. -- **System-owned entities**: `user_id=0` means system-owned (e.g. default templates). -- **FastAPI route ordering**: Static path routes MUST be registered BEFORE parameterized routes. -- **`__pycache__`**: Add to `.gitignore`. Never commit. - -## Project Structure (Phase 1) - -- **packages/core** (`notify_bridge_core`): Shared library — providers, models, notifications, templates. No DB dependency. -- **packages/server** (`notify_bridge_server`): FastAPI REST API + SQLite. Depends on core. -- **frontend**: SvelteKit 2 + Svelte 5 + Tailwind CSS v4. Static adapter with SPA fallback. Dev proxy to :8420. -- **Environment vars**: `NOTIFY_BRIDGE_DATA_DIR`, `NOTIFY_BRIDGE_SECRET_KEY`, `NOTIFY_BRIDGE_DATABASE_URL` -- Core package includes `jinja2` dependency (template rendering lives in core, not server). - -## Entity Relationships - -``` -ServiceProvider → type: "immich" (inferred capabilities: notifications, commands) -NotificationTracker → provider_id, collection_ids, scan_interval, batch_duration, enabled -NotificationTrackerTarget → notification_tracker_id, target_id, tracking_config_id, template_config_id, quiet_hours, enabled -TrackingConfig → provider_type, event flags, scheduling rules -TemplateConfig → provider_type, Jinja2 template slots per event type -NotificationTarget → type: "telegram"/"webhook", config JSON, chat_action (telegram only) -CommandConfig → provider_type, enabled_commands, locale, response_mode, default_count, rate_limits -CommandTracker → provider_id, command_config_id, enabled -CommandTrackerListener → command_tracker_id, listener_type ("telegram_bot"), listener_id -TelegramBot → token, update_mode, bot_username (used as notification target backend + commands listener) -``` - -- NotificationTrackerTarget links a tracker to a target with per-link tracking/template config and quiet hours -- CommandTrackerListener links a command tracker to a listener (e.g. TelegramBot) for slash-command handling -- `user_id=0` on TemplateConfig = system default (EN/RU seeded on first startup) -- DB: SQLite + async SQLAlchemy via sqlmodel, auto-created on startup with migrations -- API: All CRUD routes under `/api/`, auth via JWT Bearer, `NOTIFY_BRIDGE_` env prefix - -## Template System Sync Rules - -**IMPORTANT**: When adding or changing template context variables, you MUST update ALL of these in sync: -1. **`packages/core/.../templates/context.py`** — `build_template_context()` where variables are computed -2. **`packages/server/.../api/template_configs.py`** — `_SAMPLE_CONTEXT` dict (for preview rendering) -3. **`packages/server/.../api/template_configs.py`** — `get_template_variables()` endpoint (`event_vars`, `asset_fields`, `album_fields`, `scheduled_vars`, per-slot variable dicts) -4. **`packages/core/.../templates/defaults/{en,ru}/*.jinja2`** — default template files using the new variables -5. **`packages/core/.../providers/immich/provider.py`** — `IMMICH_VARIABLES` list (provider-specific variable definitions) -6. **`packages/server/.../api/command_template_configs.py`** — `get_command_variables()` endpoint (for command response templates) - -**IMPORTANT**: Variable reference endpoints MUST document child/nested properties, not only top-level variables. When a variable is a list of dicts (e.g. `assets`, `albums`, `events`, `commands`), the endpoint MUST include a corresponding `*_fields` dict describing the child properties (e.g. `asset_fields: {"id": "...", "filename": "..."}`) so the frontend can show them (e.g. `{{ asset.id }}`, `{{ album.name }}`). Never list only `"assets": "List of asset dicts"` — always specify what fields each dict contains. +1. **Restart backend** after ANY change to `packages/server/` or `packages/core/` — see [dev-servers.md](.claude/docs/dev-servers.md) for the one-liner. +2. **Overlays** MUST use `position: fixed` with inline styles and `z-index: 9999` — see [frontend-architecture.md](.claude/docs/frontend-architecture.md). +3. **Template variables** must be updated in 6 files simultaneously — see [template-system.md](.claude/docs/template-system.md). +4. **Entity cache** — shared entities use `$state`-based caches in `frontend/src/lib/stores/caches.svelte.ts`. Always use cache for cross-page data; invalidate after mutations — see [frontend-architecture.md](.claude/docs/frontend-architecture.md). diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts new file mode 100644 index 0000000..0c05e92 --- /dev/null +++ b/frontend/src/lib/auth.svelte.ts @@ -0,0 +1,66 @@ +/** + * Reactive auth state using Svelte 5 runes. + */ + +import { api, setTokens, clearTokens, isAuthenticated } from './api'; +import { clearAllCaches } from './stores/caches.svelte'; + +interface User { + id: number; + username: string; + role: string; +} + +let user = $state(null); +let loading = $state(true); + +export function getAuth() { + return { + get user() { return user; }, + get loading() { return loading; }, + get isAdmin() { return user?.role === 'admin'; }, + }; +} + +export async function loadUser() { + if (!isAuthenticated()) { + user = null; + loading = false; + return; + } + try { + user = await api('/auth/me'); + } catch { + user = null; + clearTokens(); + } finally { + loading = false; + } +} + +export async function login(username: string, password: string) { + const data = await api<{ access_token: string; refresh_token: string }>('/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }) + }); + setTokens(data.access_token, data.refresh_token); + await loadUser(); +} + +export async function setup(username: string, password: string) { + const data = await api<{ access_token: string; refresh_token: string }>('/auth/setup', { + method: 'POST', + body: JSON.stringify({ username, password }) + }); + setTokens(data.access_token, data.refresh_token); + await loadUser(); +} + +export function logout() { + clearTokens(); + clearAllCaches(); + user = null; + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } +} diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 41c143e..6d99ebf 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -26,7 +26,14 @@ "telegram": "Telegram", "email": "Email", "matrix": "Matrix", - "common": "Common" + "common": "Common", + "targetTelegram": "Telegram", + "targetWebhook": "Webhook", + "targetEmail": "Email", + "targetDiscord": "Discord", + "targetSlack": "Slack", + "targetNtfy": "ntfy", + "targetMatrix": "Matrix" }, "auth": { "signIn": "Sign in", @@ -188,7 +195,14 @@ }, "targets": { "title": "Targets", - "description": "Notification destinations (Telegram, Discord, Slack, Email, ntfy, Matrix, Webhooks)", + "description": "Notification delivery destinations", + "descTelegram": "Telegram chat destinations for notifications", + "descWebhook": "HTTP webhook endpoints for notification delivery", + "descEmail": "Email recipients for notifications", + "descDiscord": "Discord channel webhooks for notifications", + "descSlack": "Slack channel webhooks for notifications", + "descNtfy": "ntfy push notification topics", + "descMatrix": "Matrix room destinations for notifications", "addTarget": "Add Target", "cancel": "Cancel", "type": "Type", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index cf6e5d0..a046749 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -26,7 +26,14 @@ "telegram": "Telegram", "email": "Email", "matrix": "Matrix", - "common": "Общие" + "common": "Общие", + "targetTelegram": "Telegram", + "targetWebhook": "Webhook", + "targetEmail": "Email", + "targetDiscord": "Discord", + "targetSlack": "Slack", + "targetNtfy": "ntfy", + "targetMatrix": "Matrix" }, "auth": { "signIn": "Войти", @@ -188,7 +195,14 @@ }, "targets": { "title": "Получатели", - "description": "Адреса уведомлений (Telegram, Discord, Slack, Email, ntfy, Matrix, вебхуки)", + "description": "Адреса доставки уведомлений", + "descTelegram": "Чаты Telegram для доставки уведомлений", + "descWebhook": "HTTP вебхуки для доставки уведомлений", + "descEmail": "Email-адреса для доставки уведомлений", + "descDiscord": "Вебхуки каналов Discord для уведомлений", + "descSlack": "Вебхуки каналов Slack для уведомлений", + "descNtfy": "Топики ntfy для push-уведомлений", + "descMatrix": "Комнаты Matrix для доставки уведомлений", "addTarget": "Добавить получателя", "cancel": "Отмена", "type": "Тип", diff --git a/frontend/src/lib/stores/caches.svelte.ts b/frontend/src/lib/stores/caches.svelte.ts new file mode 100644 index 0000000..f3bf448 --- /dev/null +++ b/frontend/src/lib/stores/caches.svelte.ts @@ -0,0 +1,59 @@ +/** + * Singleton entity caches for all shared entities. + * + * Import from here in page components: + * import { providersCache, targetsCache } from '$lib/stores/caches.svelte'; + */ + +import { createEntityCache } from './entity-cache.svelte'; +import type { + ServiceProvider, + NotificationTarget, + TrackingConfig, + TemplateConfig, + TelegramBot, + EmailBot, + MatrixBot, +} from '$lib/types'; + +/** Service providers — used by Dashboard, Trackers, Command Trackers, Providers page. */ +export const providersCache = createEntityCache('/providers'); + +/** Notification targets — used by Trackers, Targets page. */ +export const targetsCache = createEntityCache('/targets'); + +/** Tracking configs — used by Trackers, Tracking Configs page. */ +export const trackingConfigsCache = createEntityCache('/tracking-configs'); + +/** Template configs — used by Trackers, Template Configs page. */ +export const templateConfigsCache = createEntityCache('/template-configs'); + +/** Telegram bots — used by Targets, Command Trackers, Bots page. */ +export const telegramBotsCache = createEntityCache('/telegram-bots'); + +/** Email bots — used by Targets, Bots page. */ +export const emailBotsCache = createEntityCache('/email-bots'); + +/** Matrix bots — used by Targets, Bots page. */ +export const matrixBotsCache = createEntityCache('/matrix-bots'); + +// Command-specific caches (less shared but still benefit from caching) + +export const commandConfigsCache = createEntityCache('/command-configs'); + +export const commandTemplateConfigsCache = createEntityCache('/command-template-configs'); + +/** + * Invalidate all entity caches. Useful on logout. + */ +export function clearAllCaches(): void { + providersCache.clear(); + targetsCache.clear(); + trackingConfigsCache.clear(); + templateConfigsCache.clear(); + telegramBotsCache.clear(); + emailBotsCache.clear(); + matrixBotsCache.clear(); + commandConfigsCache.clear(); + commandTemplateConfigsCache.clear(); +} diff --git a/frontend/src/lib/stores/entity-cache.svelte.ts b/frontend/src/lib/stores/entity-cache.svelte.ts new file mode 100644 index 0000000..b033910 --- /dev/null +++ b/frontend/src/lib/stores/entity-cache.svelte.ts @@ -0,0 +1,115 @@ +/** + * Generic reactive entity cache with TTL, deduplication, and local mutations. + * + * Usage: + * const providers = createEntityCache('/providers'); + * // In component: const list = providers.items; // reactive $state + * // await providers.fetch(); // returns cached if fresh + * // providers.upsert(updated); // patch single item + * // providers.remove(id); // remove single item + */ + +import { api } from '$lib/api'; + +const DEFAULT_TTL_MS = 30_000; // 30 seconds + +export interface EntityCache { + /** Reactive list of cached entities. */ + readonly items: T[]; + /** True only during the very first fetch (no cached data yet). */ + readonly loading: boolean; + /** Timestamp of last successful fetch. */ + readonly fetchedAt: number; + /** Fetch entities — returns cached data if fresh, else hits network. */ + fetch(force?: boolean): Promise; + /** Force next fetch() to hit network. */ + invalidate(): void; + /** Insert or update a single entity in the local cache. */ + upsert(entity: T): void; + /** Remove a single entity from the local cache by id. */ + remove(id: number): void; + /** Replace the entire cache (e.g. after a full reload). */ + set(entities: T[]): void; + /** Clear all cached data. */ + clear(): void; +} + +/** In-flight request deduplication map: endpoint → Promise */ +const inflightRequests = new Map>(); + +export function createEntityCache( + endpoint: string, + ttlMs: number = DEFAULT_TTL_MS, +): EntityCache { + let _items = $state([]); + let _loading = $state(false); + let _fetchedAt = $state(0); + + function isFresh(): boolean { + return _fetchedAt > 0 && Date.now() - _fetchedAt < ttlMs; + } + + async function fetch(force = false): Promise { + if (!force && isFresh()) return _items; + + // Deduplicate concurrent requests to the same endpoint + const existing = inflightRequests.get(endpoint); + if (existing) return existing; + + const isFirstLoad = _fetchedAt === 0; + if (isFirstLoad) _loading = true; + + const request = api(endpoint) + .then((data) => { + _items = data; + _fetchedAt = Date.now(); + return data; + }) + .finally(() => { + _loading = false; + inflightRequests.delete(endpoint); + }); + + inflightRequests.set(endpoint, request); + return request; + } + + function invalidate(): void { + _fetchedAt = 0; + } + + function upsert(entity: T): void { + const idx = _items.findIndex((e) => e.id === entity.id); + if (idx >= 0) { + _items = _items.map((e) => (e.id === entity.id ? entity : e)); + } else { + _items = [..._items, entity]; + } + } + + function remove(id: number): void { + _items = _items.filter((e) => e.id !== id); + } + + function set(entities: T[]): void { + _items = entities; + _fetchedAt = Date.now(); + } + + function clear(): void { + _items = []; + _fetchedAt = 0; + } + + return { + get items() { return _items; }, + get loading() { return _loading; }, + get fetchedAt() { return _fetchedAt; }, + fetch, + invalidate, + upsert, + remove, + set, + clear, + }; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 03c5d2a..2014026 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -9,6 +9,16 @@ export interface ServiceProvider { created_at: string; } +export interface MatrixBot { + id: number; + name: string; + icon: string; + homeserver_url: string; + access_token: string; + display_name: string; + created_at: string; +} + export interface EmailBot { id: number; name: string; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 0485739..119c747 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -2,7 +2,8 @@ import '../app.css'; import { page } from '$app/state'; import { onMount } from 'svelte'; - import { fade } from 'svelte/transition'; + import { fade, slide } from 'svelte/transition'; + import { cubicOut } from 'svelte/easing'; import { api } from '$lib/api'; import { getAuth, loadUser, logout } from '$lib/auth.svelte'; import { t, getLocale, setLocale, type Locale } from '$lib/i18n'; @@ -50,7 +51,6 @@ key: string; icon: string; children: NavItem[]; - countKeys?: string[]; } type NavEntry = NavItem | NavGroup; @@ -64,7 +64,6 @@ { 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' }, @@ -73,7 +72,6 @@ }, { 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' }, @@ -82,12 +80,24 @@ }, { key: 'nav.bots', icon: 'mdiRobot', - countKeys: ['telegram_bots'], children: [ { href: '/telegram-bots', key: 'nav.telegram', icon: 'mdiSendCircle', countKey: 'telegram_bots' }, + { href: '/telegram-bots?tab=email', key: 'nav.email', icon: 'mdiEmailOutline', countKey: 'email_bots' }, + { href: '/telegram-bots?tab=matrix', key: 'nav.matrix', icon: 'mdiMatrix', countKey: 'matrix_bots' }, + ], + }, + { + key: 'nav.targets', icon: 'mdiTarget', + children: [ + { href: '/targets?type=telegram', key: 'nav.targetTelegram', icon: 'mdiSend', countKey: 'targets_telegram' }, + { href: '/targets?type=webhook', key: 'nav.targetWebhook', icon: 'mdiWebhook', countKey: 'targets_webhook' }, + { href: '/targets?type=email', key: 'nav.targetEmail', icon: 'mdiEmailOutline', countKey: 'targets_email' }, + { href: '/targets?type=discord', key: 'nav.targetDiscord', icon: 'mdiChat', countKey: 'targets_discord' }, + { href: '/targets?type=slack', key: 'nav.targetSlack', icon: 'mdiSlack', countKey: 'targets_slack' }, + { href: '/targets?type=ntfy', key: 'nav.targetNtfy', icon: 'mdiBell', countKey: 'targets_ntfy' }, + { href: '/targets?type=matrix', key: 'nav.targetMatrix', icon: 'mdiMatrix', countKey: 'targets_matrix' }, ], }, - { href: '/targets', key: 'nav.targets', icon: 'mdiTarget', countKey: 'targets' }, ]; const navEntries = $derived(auth.isAdmin @@ -115,12 +125,10 @@ } 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); + return group.children.some(c => { + const path = c.href.split('?')[0]; + return page.url.pathname === path; + }); } // Mobile: flatten nav for bottom bar @@ -182,6 +190,15 @@ } function isActive(href: string): boolean { + if (href.includes('?')) { + const [path, qs] = href.split('?'); + if (page.url.pathname !== path) return false; + const params = new URLSearchParams(qs); + for (const [k, v] of params) { + if (page.url.searchParams.get(k) !== v) return false; + } + return true; + } return page.url.pathname === href; } @@ -241,15 +258,14 @@ {#if !collapsed} {t(entry.key)} - {#if groupCount(entry) > 0} - {groupCount(entry)} - {/if} - + + + {/if} {#if expandedGroups[entry.key] && !collapsed} -
+
{#each entry.children as child} (null); - let providers = $state([]); + let providers = $derived(providersCache.items); let chartDays = $state([]); let loaded = $state(false); let error = $state(''); @@ -130,13 +131,12 @@ async function loadInitial() { try { - const [statusRes, providersRes, chartRes] = await Promise.all([ + const [statusRes, , chartRes] = await Promise.all([ api(`/status?limit=${eventsLimit}`), - api('/providers'), + providersCache.fetch(), api('/status/chart'), ]); status = statusRes; - providers = providersRes; chartDays = chartRes.days || []; setTimeout(() => { animateCount(0, status.providers, (v) => displayProviders = v); diff --git a/frontend/src/routes/command-configs/+page.svelte b/frontend/src/routes/command-configs/+page.svelte index d8663fa..dfe6c51 100644 --- a/frontend/src/routes/command-configs/+page.svelte +++ b/frontend/src/routes/command-configs/+page.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { api } from '$lib/api'; import { t } from '$lib/i18n'; + import { commandConfigsCache, commandTemplateConfigsCache } from '$lib/stores/caches.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import Card from '$lib/components/Card.svelte'; import Loading from '$lib/components/Loading.svelte'; @@ -12,8 +13,8 @@ import IconButton from '$lib/components/IconButton.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; - let configs = $state([]); - let cmdTemplateConfigs = $state([]); + let configs = $derived(commandConfigsCache.items); + let cmdTemplateConfigs = $derived(commandTemplateConfigsCache.items); let loaded = $state(false); let showForm = $state(false); let editing = $state(null); @@ -54,9 +55,9 @@ onMount(load); async function load() { try { - [configs, cmdTemplateConfigs] = await Promise.all([ - api('/command-configs'), - api('/command-template-configs'), + await Promise.all([ + commandConfigsCache.fetch(true), + commandTemplateConfigsCache.fetch(), ]); } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } finally { loaded = true; } diff --git a/frontend/src/routes/command-template-configs/+page.svelte b/frontend/src/routes/command-template-configs/+page.svelte index 7e46c20..d8d8741 100644 --- a/frontend/src/routes/command-template-configs/+page.svelte +++ b/frontend/src/routes/command-template-configs/+page.svelte @@ -3,6 +3,7 @@ import { slide } from 'svelte/transition'; import { api } from '$lib/api'; import { t } from '$lib/i18n'; + import { commandTemplateConfigsCache } from '$lib/stores/caches.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import Card from '$lib/components/Card.svelte'; import Loading from '$lib/components/Loading.svelte'; @@ -66,7 +67,7 @@ async function load() { try { const [cfgs, caps, vars] = await Promise.all([ - api('/command-template-configs'), + commandTemplateConfigsCache.fetch(true), api('/providers/capabilities'), api('/command-template-configs/variables'), ]); diff --git a/frontend/src/routes/command-trackers/+page.svelte b/frontend/src/routes/command-trackers/+page.svelte index 291fab4..ce5d160 100644 --- a/frontend/src/routes/command-trackers/+page.svelte +++ b/frontend/src/routes/command-trackers/+page.svelte @@ -3,6 +3,7 @@ import { slide } from 'svelte/transition'; import { api } from '$lib/api'; import { t } from '$lib/i18n'; + import { providersCache, telegramBotsCache, commandConfigsCache } from '$lib/stores/caches.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import Card from '$lib/components/Card.svelte'; import Loading from '$lib/components/Loading.svelte'; @@ -15,9 +16,9 @@ import type { ServiceProvider, TelegramBot } from '$lib/types'; let trackers = $state([]); - let providers = $state([]); - let commandConfigs = $state([]); - let telegramBots = $state([]); + let providers = $derived(providersCache.items); + let commandConfigs = $derived(commandConfigsCache.items); + let telegramBots = $derived(telegramBotsCache.items); let loaded = $state(false); let showForm = $state(false); let editing = $state(null); @@ -53,11 +54,10 @@ onMount(load); async function load() { try { - [trackers, providers, commandConfigs, telegramBots] = await Promise.all([ + [trackers] = await Promise.all([ api('/command-trackers'), - api('/providers'), - api('/command-configs'), - api('/telegram-bots'), + providersCache.fetch(), commandConfigsCache.fetch(), + telegramBotsCache.fetch(), ]); } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } finally { loaded = true; } diff --git a/frontend/src/routes/notification-trackers/+page.svelte b/frontend/src/routes/notification-trackers/+page.svelte index 3ab160a..1d6f559 100644 --- a/frontend/src/routes/notification-trackers/+page.svelte +++ b/frontend/src/routes/notification-trackers/+page.svelte @@ -3,6 +3,7 @@ import { slide } from 'svelte/transition'; import { api } from '$lib/api'; import { t, getLocale } from '$lib/i18n'; + import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache } from '$lib/stores/caches.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import Card from '$lib/components/Card.svelte'; import Loading from '$lib/components/Loading.svelte'; @@ -19,10 +20,10 @@ let loaded = $state(false); let loadError = $state(''); let notificationTrackers = $state([]); - let providers = $state([]); - let targets = $state([]); - let trackingConfigs = $state([]); - let templateConfigs = $state([]); + let providers = $derived(providersCache.items); + let targets = $derived(targetsCache.items); + let trackingConfigs = $derived(trackingConfigsCache.items); + let templateConfigs = $derived(templateConfigsCache.items); let collections = $state([]); let showForm = $state(false); let editing = $state(null); @@ -59,9 +60,10 @@ async function load() { loadError = ''; try { - [notificationTrackers, providers, targets, trackingConfigs, templateConfigs] = await Promise.all([ - api('/notification-trackers'), api('/providers'), api('/targets'), - api('/tracking-configs'), api('/template-configs'), + [notificationTrackers] = await Promise.all([ + api('/notification-trackers'), + providersCache.fetch(), targetsCache.fetch(), + trackingConfigsCache.fetch(), templateConfigsCache.fetch(), ]); } catch (err: any) { loadError = err.message || 'Failed to load data'; diff --git a/frontend/src/routes/providers/+page.svelte b/frontend/src/routes/providers/+page.svelte index 9604f6a..f214d09 100644 --- a/frontend/src/routes/providers/+page.svelte +++ b/frontend/src/routes/providers/+page.svelte @@ -3,6 +3,7 @@ import { slide } from 'svelte/transition'; import { api } from '$lib/api'; import { t } from '$lib/i18n'; + import { providersCache } from '$lib/stores/caches.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import Card from '$lib/components/Card.svelte'; import Loading from '$lib/components/Loading.svelte'; @@ -14,7 +15,7 @@ import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import type { ServiceProvider } from '$lib/types'; - let providers = $state([]); + let providers = $derived(providersCache.items); let showForm = $state(false); let editing = $state(null); let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', external_domain: '', icon: '' }); @@ -29,7 +30,7 @@ onMount(load); async function load() { try { - providers = await api('/providers'); + await providersCache.fetch(true); loadError = ''; } catch (err: any) { loadError = err.message || t('providers.loadError'); @@ -65,7 +66,7 @@ config.api_key = form.api_key; // required on create await api('/providers', { method: 'POST', body: JSON.stringify({ type: form.type, name: form.name, icon: form.icon, config }) }); } - showForm = false; editing = null; await load(); + showForm = false; editing = null; providersCache.invalidate(); await load(); snackSuccess(t('snack.providerSaved')); } catch (err: any) { error = err.message; snackError(err.message); } submitting = false; @@ -76,7 +77,7 @@ if (!confirmDelete) return; const id = confirmDelete.id; confirmDelete = null; - try { await api(`/providers/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.providerDeleted')); } + try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); } catch (err: any) { error = err.message; snackError(err.message); } } diff --git a/frontend/src/routes/targets/+page.svelte b/frontend/src/routes/targets/+page.svelte index 75ee776..f880dc0 100644 --- a/frontend/src/routes/targets/+page.svelte +++ b/frontend/src/routes/targets/+page.svelte @@ -1,8 +1,10 @@ - +