feat: entity cache system, nav UX improvements, split CLAUDE.md
- Add $state-based entity cache layer with 30s TTL, request deduplication, and local mutation helpers (entity-cache.svelte.ts + caches.svelte.ts) - Wire all 10 page components to use shared caches for cross-page data - Add slide animation for nav tree expand/collapse with rotating chevron - Remove aggregate count badges from container nav nodes (keep on leaves) - Convert Targets from flat leaf to group with per-type children (Telegram, Webhook, Email, Discord, Slack, ntfy, Matrix) - Add URL-based type filtering on Targets page with per-type descriptions - Add Bots group children for Email and Matrix alongside Telegram - Tab-based routing for bots page (?tab=telegram/email/matrix) - Add per-type target counts and email/matrix bot counts to /status/counts - Split CLAUDE.md into focused context files under .claude/docs/ - Fix .gitignore: scope lib/ to root, allow .claude/docs/ tracking - Clear all caches on logout - Reset form state when switching target type tabs
This commit is contained in:
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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
|
||||||
@@ -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>('/foo');` to `caches.svelte.ts`
|
||||||
|
3. Add `fooCache.clear()` to `clearAllCaches()`
|
||||||
|
4. In page components: replace `let foo = $state<Foo[]>([])` with `let foo = $derived(fooCache.items)` and replace `api('/foo')` with `fooCache.fetch()`
|
||||||
@@ -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.
|
||||||
+4
-3
@@ -10,8 +10,8 @@ dist/
|
|||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
/lib/
|
||||||
lib64/
|
/lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
@@ -43,7 +43,8 @@ Thumbs.db
|
|||||||
htmlcov/
|
htmlcov/
|
||||||
|
|
||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/settings.json
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
# Data
|
# Data
|
||||||
test-data/
|
test-data/
|
||||||
|
|||||||
@@ -1,76 +1,18 @@
|
|||||||
# Project Guidelines
|
# 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:
|
| Area | File | Key rules |
|
||||||
```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
|
| 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:
|
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.
|
||||||
```bash
|
2. **Overlays** MUST use `position: fixed` with inline styles and `z-index: 9999` — see [frontend-architecture.md](.claude/docs/frontend-architecture.md).
|
||||||
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/
|
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).
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -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<User | null>(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<User>('/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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,14 @@
|
|||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"matrix": "Matrix",
|
"matrix": "Matrix",
|
||||||
"common": "Common"
|
"common": "Common",
|
||||||
|
"targetTelegram": "Telegram",
|
||||||
|
"targetWebhook": "Webhook",
|
||||||
|
"targetEmail": "Email",
|
||||||
|
"targetDiscord": "Discord",
|
||||||
|
"targetSlack": "Slack",
|
||||||
|
"targetNtfy": "ntfy",
|
||||||
|
"targetMatrix": "Matrix"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"signIn": "Sign in",
|
"signIn": "Sign in",
|
||||||
@@ -188,7 +195,14 @@
|
|||||||
},
|
},
|
||||||
"targets": {
|
"targets": {
|
||||||
"title": "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",
|
"addTarget": "Add Target",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
|
|||||||
@@ -26,7 +26,14 @@
|
|||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"matrix": "Matrix",
|
"matrix": "Matrix",
|
||||||
"common": "Общие"
|
"common": "Общие",
|
||||||
|
"targetTelegram": "Telegram",
|
||||||
|
"targetWebhook": "Webhook",
|
||||||
|
"targetEmail": "Email",
|
||||||
|
"targetDiscord": "Discord",
|
||||||
|
"targetSlack": "Slack",
|
||||||
|
"targetNtfy": "ntfy",
|
||||||
|
"targetMatrix": "Matrix"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"signIn": "Войти",
|
"signIn": "Войти",
|
||||||
@@ -188,7 +195,14 @@
|
|||||||
},
|
},
|
||||||
"targets": {
|
"targets": {
|
||||||
"title": "Получатели",
|
"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": "Добавить получателя",
|
"addTarget": "Добавить получателя",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"type": "Тип",
|
"type": "Тип",
|
||||||
|
|||||||
@@ -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<ServiceProvider>('/providers');
|
||||||
|
|
||||||
|
/** Notification targets — used by Trackers, Targets page. */
|
||||||
|
export const targetsCache = createEntityCache<NotificationTarget>('/targets');
|
||||||
|
|
||||||
|
/** Tracking configs — used by Trackers, Tracking Configs page. */
|
||||||
|
export const trackingConfigsCache = createEntityCache<TrackingConfig>('/tracking-configs');
|
||||||
|
|
||||||
|
/** Template configs — used by Trackers, Template Configs page. */
|
||||||
|
export const templateConfigsCache = createEntityCache<TemplateConfig>('/template-configs');
|
||||||
|
|
||||||
|
/** Telegram bots — used by Targets, Command Trackers, Bots page. */
|
||||||
|
export const telegramBotsCache = createEntityCache<TelegramBot>('/telegram-bots');
|
||||||
|
|
||||||
|
/** Email bots — used by Targets, Bots page. */
|
||||||
|
export const emailBotsCache = createEntityCache<EmailBot>('/email-bots');
|
||||||
|
|
||||||
|
/** Matrix bots — used by Targets, Bots page. */
|
||||||
|
export const matrixBotsCache = createEntityCache<MatrixBot>('/matrix-bots');
|
||||||
|
|
||||||
|
// Command-specific caches (less shared but still benefit from caching)
|
||||||
|
|
||||||
|
export const commandConfigsCache = createEntityCache<any>('/command-configs');
|
||||||
|
|
||||||
|
export const commandTemplateConfigsCache = createEntityCache<any>('/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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Generic reactive entity cache with TTL, deduplication, and local mutations.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const providers = createEntityCache<ServiceProvider>('/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<T extends { id: number }> {
|
||||||
|
/** 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<T[]>;
|
||||||
|
/** 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<string, Promise<any>>();
|
||||||
|
|
||||||
|
export function createEntityCache<T extends { id: number }>(
|
||||||
|
endpoint: string,
|
||||||
|
ttlMs: number = DEFAULT_TTL_MS,
|
||||||
|
): EntityCache<T> {
|
||||||
|
let _items = $state<T[]>([]);
|
||||||
|
let _loading = $state(false);
|
||||||
|
let _fetchedAt = $state(0);
|
||||||
|
|
||||||
|
function isFresh(): boolean {
|
||||||
|
return _fetchedAt > 0 && Date.now() - _fetchedAt < ttlMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetch(force = false): Promise<T[]> {
|
||||||
|
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<T[]>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,16 @@ export interface ServiceProvider {
|
|||||||
created_at: string;
|
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 {
|
export interface EmailBot {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
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 { api } from '$lib/api';
|
||||||
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
|
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
|
||||||
import { t, getLocale, setLocale, type Locale } from '$lib/i18n';
|
import { t, getLocale, setLocale, type Locale } from '$lib/i18n';
|
||||||
@@ -50,7 +51,6 @@
|
|||||||
key: string;
|
key: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
children: NavItem[];
|
children: NavItem[];
|
||||||
countKeys?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type NavEntry = NavItem | NavGroup;
|
type NavEntry = NavItem | NavGroup;
|
||||||
@@ -64,7 +64,6 @@
|
|||||||
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer', countKey: 'providers' },
|
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer', countKey: 'providers' },
|
||||||
{
|
{
|
||||||
key: 'nav.notification', icon: 'mdiBellOutline',
|
key: 'nav.notification', icon: 'mdiBellOutline',
|
||||||
countKeys: ['notification_trackers', 'tracking_configs', 'template_configs'],
|
|
||||||
children: [
|
children: [
|
||||||
{ href: '/notification-trackers', key: 'nav.trackers', icon: 'mdiRadar', countKey: 'notification_trackers' },
|
{ href: '/notification-trackers', key: 'nav.trackers', icon: 'mdiRadar', countKey: 'notification_trackers' },
|
||||||
{ href: '/tracking-configs', key: 'nav.configs', icon: 'mdiCog', countKey: 'tracking_configs' },
|
{ href: '/tracking-configs', key: 'nav.configs', icon: 'mdiCog', countKey: 'tracking_configs' },
|
||||||
@@ -73,7 +72,6 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'nav.commands', icon: 'mdiConsoleLine',
|
key: 'nav.commands', icon: 'mdiConsoleLine',
|
||||||
countKeys: ['command_trackers', 'command_configs', 'command_template_configs'],
|
|
||||||
children: [
|
children: [
|
||||||
{ href: '/command-trackers', key: 'nav.trackers', icon: 'mdiRadar', countKey: 'command_trackers' },
|
{ href: '/command-trackers', key: 'nav.trackers', icon: 'mdiRadar', countKey: 'command_trackers' },
|
||||||
{ href: '/command-configs', key: 'nav.configs', icon: 'mdiCog', countKey: 'command_configs' },
|
{ href: '/command-configs', key: 'nav.configs', icon: 'mdiCog', countKey: 'command_configs' },
|
||||||
@@ -82,12 +80,24 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'nav.bots', icon: 'mdiRobot',
|
key: 'nav.bots', icon: 'mdiRobot',
|
||||||
countKeys: ['telegram_bots'],
|
|
||||||
children: [
|
children: [
|
||||||
{ href: '/telegram-bots', key: 'nav.telegram', icon: 'mdiSendCircle', countKey: 'telegram_bots' },
|
{ 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<NavEntry[]>(auth.isAdmin
|
const navEntries = $derived<NavEntry[]>(auth.isAdmin
|
||||||
@@ -115,12 +125,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isGroupActive(group: NavGroup): boolean {
|
function isGroupActive(group: NavGroup): boolean {
|
||||||
return group.children.some(c => page.url.pathname === c.href);
|
return group.children.some(c => {
|
||||||
}
|
const path = c.href.split('?')[0];
|
||||||
|
return page.url.pathname === path;
|
||||||
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
|
// Mobile: flatten nav for bottom bar
|
||||||
@@ -182,6 +190,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
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;
|
return page.url.pathname === href;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -241,15 +258,14 @@
|
|||||||
<MdiIcon name={entry.icon} size={18} />
|
<MdiIcon name={entry.icon} size={18} />
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<span class="truncate flex-1">{t(entry.key)}</span>
|
<span class="truncate flex-1">{t(entry.key)}</span>
|
||||||
{#if groupCount(entry) > 0}
|
<span class="nav-chevron" style="display: inline-flex; transition: transform 0.2s ease; transform: rotate({expandedGroups[entry.key] ? '90deg' : '0deg'});">
|
||||||
<span class="nav-badge">{groupCount(entry)}</span>
|
<MdiIcon name="mdiChevronRight" size={14} />
|
||||||
{/if}
|
</span>
|
||||||
<MdiIcon name={expandedGroups[entry.key] ? 'mdiChevronDown' : 'mdiChevronRight'} size={14} />
|
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<!-- Group children -->
|
<!-- Group children -->
|
||||||
{#if expandedGroups[entry.key] && !collapsed}
|
{#if expandedGroups[entry.key] && !collapsed}
|
||||||
<div class="ml-3 pl-3 space-y-0.5" style="border-left: 1px solid var(--color-border);">
|
<div transition:slide={{ duration: 200, easing: cubicOut }} class="ml-3 pl-3 space-y-0.5" style="border-left: 1px solid var(--color-border);">
|
||||||
{#each entry.children as child}
|
{#each entry.children as child}
|
||||||
<a
|
<a
|
||||||
href={child.href}
|
href={child.href}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
import { providersCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -9,7 +10,7 @@
|
|||||||
import EventChart from '$lib/components/EventChart.svelte';
|
import EventChart from '$lib/components/EventChart.svelte';
|
||||||
|
|
||||||
let status = $state<any>(null);
|
let status = $state<any>(null);
|
||||||
let providers = $state<any[]>([]);
|
let providers = $derived(providersCache.items);
|
||||||
let chartDays = $state<any[]>([]);
|
let chartDays = $state<any[]>([]);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
@@ -130,13 +131,12 @@
|
|||||||
|
|
||||||
async function loadInitial() {
|
async function loadInitial() {
|
||||||
try {
|
try {
|
||||||
const [statusRes, providersRes, chartRes] = await Promise.all([
|
const [statusRes, , chartRes] = await Promise.all([
|
||||||
api<any>(`/status?limit=${eventsLimit}`),
|
api<any>(`/status?limit=${eventsLimit}`),
|
||||||
api<any[]>('/providers'),
|
providersCache.fetch(),
|
||||||
api<any>('/status/chart'),
|
api<any>('/status/chart'),
|
||||||
]);
|
]);
|
||||||
status = statusRes;
|
status = statusRes;
|
||||||
providers = providersRes;
|
|
||||||
chartDays = chartRes.days || [];
|
chartDays = chartRes.days || [];
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
animateCount(0, status.providers, (v) => displayProviders = v);
|
animateCount(0, status.providers, (v) => displayProviders = v);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
import { commandConfigsCache, commandTemplateConfigsCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -12,8 +13,8 @@
|
|||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
|
|
||||||
let configs = $state<any[]>([]);
|
let configs = $derived(commandConfigsCache.items);
|
||||||
let cmdTemplateConfigs = $state<any[]>([]);
|
let cmdTemplateConfigs = $derived(commandTemplateConfigsCache.items);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
@@ -54,9 +55,9 @@
|
|||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
[configs, cmdTemplateConfigs] = await Promise.all([
|
await Promise.all([
|
||||||
api('/command-configs'),
|
commandConfigsCache.fetch(true),
|
||||||
api('/command-template-configs'),
|
commandTemplateConfigsCache.fetch(),
|
||||||
]);
|
]);
|
||||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||||
finally { loaded = true; }
|
finally { loaded = true; }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
import { commandTemplateConfigsCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [cfgs, caps, vars] = await Promise.all([
|
const [cfgs, caps, vars] = await Promise.all([
|
||||||
api('/command-template-configs'),
|
commandTemplateConfigsCache.fetch(true),
|
||||||
api('/providers/capabilities'),
|
api('/providers/capabilities'),
|
||||||
api('/command-template-configs/variables'),
|
api('/command-template-configs/variables'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
import { providersCache, telegramBotsCache, commandConfigsCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -15,9 +16,9 @@
|
|||||||
import type { ServiceProvider, TelegramBot } from '$lib/types';
|
import type { ServiceProvider, TelegramBot } from '$lib/types';
|
||||||
|
|
||||||
let trackers = $state<any[]>([]);
|
let trackers = $state<any[]>([]);
|
||||||
let providers = $state<ServiceProvider[]>([]);
|
let providers = $derived(providersCache.items);
|
||||||
let commandConfigs = $state<any[]>([]);
|
let commandConfigs = $derived(commandConfigsCache.items);
|
||||||
let telegramBots = $state<TelegramBot[]>([]);
|
let telegramBots = $derived(telegramBotsCache.items);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
@@ -53,11 +54,10 @@
|
|||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
[trackers, providers, commandConfigs, telegramBots] = await Promise.all([
|
[trackers] = await Promise.all([
|
||||||
api('/command-trackers'),
|
api('/command-trackers'),
|
||||||
api('/providers'),
|
providersCache.fetch(), commandConfigsCache.fetch(),
|
||||||
api('/command-configs'),
|
telegramBotsCache.fetch(),
|
||||||
api('/telegram-bots'),
|
|
||||||
]);
|
]);
|
||||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||||
finally { loaded = true; }
|
finally { loaded = true; }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
|
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -19,10 +20,10 @@
|
|||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let loadError = $state('');
|
let loadError = $state('');
|
||||||
let notificationTrackers = $state<Tracker[]>([]);
|
let notificationTrackers = $state<Tracker[]>([]);
|
||||||
let providers = $state<ServiceProvider[]>([]);
|
let providers = $derived(providersCache.items);
|
||||||
let targets = $state<NotificationTarget[]>([]);
|
let targets = $derived(targetsCache.items);
|
||||||
let trackingConfigs = $state<TrackingConfig[]>([]);
|
let trackingConfigs = $derived(trackingConfigsCache.items);
|
||||||
let templateConfigs = $state<TemplateConfig[]>([]);
|
let templateConfigs = $derived(templateConfigsCache.items);
|
||||||
let collections = $state<any[]>([]);
|
let collections = $state<any[]>([]);
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
@@ -59,9 +60,10 @@
|
|||||||
async function load() {
|
async function load() {
|
||||||
loadError = '';
|
loadError = '';
|
||||||
try {
|
try {
|
||||||
[notificationTrackers, providers, targets, trackingConfigs, templateConfigs] = await Promise.all([
|
[notificationTrackers] = await Promise.all([
|
||||||
api('/notification-trackers'), api('/providers'), api('/targets'),
|
api('/notification-trackers'),
|
||||||
api('/tracking-configs'), api('/template-configs'),
|
providersCache.fetch(), targetsCache.fetch(),
|
||||||
|
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
|
||||||
]);
|
]);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
loadError = err.message || 'Failed to load data';
|
loadError = err.message || 'Failed to load data';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
import { providersCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import type { ServiceProvider } from '$lib/types';
|
import type { ServiceProvider } from '$lib/types';
|
||||||
|
|
||||||
let providers = $state<ServiceProvider[]>([]);
|
let providers = $derived(providersCache.items);
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', external_domain: '', icon: '' });
|
let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', external_domain: '', icon: '' });
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
providers = await api('/providers');
|
await providersCache.fetch(true);
|
||||||
loadError = '';
|
loadError = '';
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
loadError = err.message || t('providers.loadError');
|
loadError = err.message || t('providers.loadError');
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
config.api_key = form.api_key; // required on create
|
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 }) });
|
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'));
|
snackSuccess(t('snack.providerSaved'));
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||||
submitting = false;
|
submitting = false;
|
||||||
@@ -76,7 +77,7 @@
|
|||||||
if (!confirmDelete) return;
|
if (!confirmDelete) return;
|
||||||
const id = confirmDelete.id;
|
const id = confirmDelete.id;
|
||||||
confirmDelete = null;
|
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); }
|
catch (err: any) { error = err.message; snackError(err.message); }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
|
import { page } from '$app/state';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
|
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -21,11 +23,17 @@
|
|||||||
telegram: 'mdiSend', webhook: 'mdiWebhook', email: 'mdiEmailOutline',
|
telegram: 'mdiSend', webhook: 'mdiWebhook', email: 'mdiEmailOutline',
|
||||||
discord: 'mdiChat', slack: 'mdiSlack', ntfy: 'mdiBell', matrix: 'mdiMatrix',
|
discord: 'mdiChat', slack: 'mdiSlack', ntfy: 'mdiBell', matrix: 'mdiMatrix',
|
||||||
};
|
};
|
||||||
|
const TYPE_DESC_KEYS: Record<string, string> = {
|
||||||
|
telegram: 'targets.descTelegram', webhook: 'targets.descWebhook', email: 'targets.descEmail',
|
||||||
|
discord: 'targets.descDiscord', slack: 'targets.descSlack', ntfy: 'targets.descNtfy', matrix: 'targets.descMatrix',
|
||||||
|
};
|
||||||
|
|
||||||
let targets = $state<NotificationTarget[]>([]);
|
let allTargets = $derived(targetsCache.items);
|
||||||
let bots = $state<TelegramBot[]>([]);
|
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
|
||||||
let emailBots = $state<EmailBot[]>([]);
|
let targets = $derived(activeType ? allTargets.filter(t => t.type === activeType) : allTargets);
|
||||||
let matrixBots = $state<MatrixBot[]>([]);
|
let bots = $derived(telegramBotsCache.items);
|
||||||
|
let emailBots = $derived(emailBotsCache.items);
|
||||||
|
let matrixBots = $derived(matrixBotsCache.items);
|
||||||
let botChats = $state<Record<number, TelegramChat[]>>({});
|
let botChats = $state<Record<number, TelegramChat[]>>({});
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
@@ -51,11 +59,20 @@
|
|||||||
let showTelegramSettings = $state(false);
|
let showTelegramSettings = $state(false);
|
||||||
let confirmDelete = $state<NotificationTarget | null>(null);
|
let confirmDelete = $state<NotificationTarget | null>(null);
|
||||||
|
|
||||||
|
// Reset form when switching target type tabs
|
||||||
|
$effect(() => {
|
||||||
|
activeType; // track
|
||||||
|
showForm = false;
|
||||||
|
editing = null;
|
||||||
|
error = '';
|
||||||
|
});
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
[targets, bots, emailBots, matrixBots] = await Promise.all([
|
await Promise.all([
|
||||||
api('/targets'), api('/telegram-bots'), api('/email-bots'), api('/matrix-bots'),
|
targetsCache.fetch(true), telegramBotsCache.fetch(),
|
||||||
|
emailBotsCache.fetch(), matrixBotsCache.fetch(),
|
||||||
]);
|
]);
|
||||||
loadError = '';
|
loadError = '';
|
||||||
} catch (err: any) { loadError = err.message || t('common.loadError'); snackError(loadError); } finally { loaded = true; }
|
} catch (err: any) { loadError = err.message || t('common.loadError'); snackError(loadError); } finally { loaded = true; }
|
||||||
@@ -66,7 +83,7 @@
|
|||||||
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {}
|
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showTelegramSettings = false; showForm = true; }
|
function openNew() { form = defaultForm(); formType = activeType || 'telegram'; editing = null; showTelegramSettings = false; showForm = true; }
|
||||||
async function edit(tgt: any) {
|
async function edit(tgt: any) {
|
||||||
formType = tgt.type;
|
formType = tgt.type;
|
||||||
const c = tgt.config || {};
|
const c = tgt.config || {};
|
||||||
@@ -152,7 +169,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader title={t('targets.title')} description={t('targets.description')}>
|
<PageHeader title={activeType ? `${t('targets.title')} — ${activeType.charAt(0).toUpperCase() + activeType.slice(1)}` : t('targets.title')} description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')}>
|
||||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||||
{showForm ? t('targets.cancel') : t('targets.addTarget')}
|
{showForm ? t('targets.cancel') : t('targets.addTarget')}
|
||||||
@@ -170,6 +187,7 @@
|
|||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||||
<form onsubmit={save} class="space-y-4">
|
<form onsubmit={save} class="space-y-4">
|
||||||
|
{#if !activeType}
|
||||||
<div>
|
<div>
|
||||||
<label for="tgt-type" class="block text-sm font-medium mb-1">{t('targets.type')}</label>
|
<label for="tgt-type" class="block text-sm font-medium mb-1">{t('targets.type')}</label>
|
||||||
<select id="tgt-type" bind:value={formType}
|
<select id="tgt-type" bind:value={formType}
|
||||||
@@ -179,6 +197,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
<div>
|
<div>
|
||||||
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
|
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -359,7 +378,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
|
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
|
||||||
<p class="font-medium">{target.name}</p>
|
<p class="font-medium">{target.name}</p>
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>
|
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
|
||||||
{#if target.receiver_count}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.receiver_count} receiver(s)</span>{/if}
|
{#if target.receiver_count}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.receiver_count} receiver(s)</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
|
import { page } from '$app/state';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
|
import { telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -14,9 +16,12 @@
|
|||||||
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
|
||||||
import type { TelegramBot, TelegramChat, EmailBot, MatrixBot } from '$lib/types';
|
import type { TelegramBot, TelegramChat, EmailBot, MatrixBot } from '$lib/types';
|
||||||
|
|
||||||
let bots = $state<TelegramBot[]>([]);
|
type BotTab = 'telegram' | 'email' | 'matrix';
|
||||||
let emailBots = $state<EmailBot[]>([]);
|
let activeTab = $derived((page.url.searchParams.get('tab') as BotTab | null) || 'telegram');
|
||||||
let matrixBots = $state<MatrixBot[]>([]);
|
|
||||||
|
let bots = $derived(telegramBotsCache.items);
|
||||||
|
let emailBots = $derived(emailBotsCache.items);
|
||||||
|
let matrixBots = $derived(matrixBotsCache.items);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
@@ -39,11 +44,11 @@
|
|||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
[bots, settings, emailBots, matrixBots] = await Promise.all([
|
[, settings] = await Promise.all([
|
||||||
api('/telegram-bots'),
|
telegramBotsCache.fetch(true),
|
||||||
api('/settings'),
|
api('/settings'),
|
||||||
api('/email-bots'),
|
emailBotsCache.fetch(true),
|
||||||
api('/matrix-bots'),
|
matrixBotsCache.fetch(true),
|
||||||
]);
|
]);
|
||||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||||
finally { loaded = true; }
|
finally { loaded = true; }
|
||||||
@@ -341,6 +346,9 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if !loaded}<Loading />{:else}
|
||||||
|
|
||||||
|
{#if activeTab === 'telegram'}
|
||||||
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')}>
|
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')}>
|
||||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||||
@@ -348,8 +356,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if !loaded}<Loading />{:else}
|
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||||
@@ -559,8 +565,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
|
||||||
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- ======= Email Bots Section ======= -->
|
<!-- ======= Email Bots Section ======= -->
|
||||||
<div class="mt-8">
|
{#if activeTab === 'email'}
|
||||||
<PageHeader title={t('emailBot.title')} description={t('emailBot.description')}>
|
<PageHeader title={t('emailBot.title')} description={t('emailBot.description')}>
|
||||||
<button onclick={() => { showEmailForm ? (showEmailForm = false, editingEmail = null) : openNewEmail(); }}
|
<button onclick={() => { showEmailForm ? (showEmailForm = false, editingEmail = null) : openNewEmail(); }}
|
||||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||||
@@ -653,18 +663,13 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
|
|
||||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
|
||||||
|
|
||||||
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
|
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
|
||||||
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
|
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- ======= Matrix Bots Section ======= -->
|
<!-- ======= Matrix Bots Section ======= -->
|
||||||
<div class="mt-8">
|
{#if activeTab === 'matrix'}
|
||||||
<PageHeader title={t('matrixBot.title')} description={t('matrixBot.description')}>
|
<PageHeader title={t('matrixBot.title')} description={t('matrixBot.description')}>
|
||||||
<button onclick={() => { showMatrixForm ? (showMatrixForm = false, editingMatrix = null) : openNewMatrix(); }}
|
<button onclick={() => { showMatrixForm ? (showMatrixForm = false, editingMatrix = null) : openNewMatrix(); }}
|
||||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||||
@@ -740,7 +745,9 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')}
|
<ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')}
|
||||||
onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} />
|
onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
import { templateConfigsCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import type { TemplateConfig } from '$lib/types';
|
import type { TemplateConfig } from '$lib/types';
|
||||||
|
|
||||||
let configs = $state<TemplateConfig[]>([]);
|
let configs = $derived(templateConfigsCache.items);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let varsRef = $state<Record<string, any>>({});
|
let varsRef = $state<Record<string, any>>({});
|
||||||
let showVarsFor = $state<string | null>(null);
|
let showVarsFor = $state<string | null>(null);
|
||||||
@@ -133,8 +134,8 @@
|
|||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
[configs, varsRef, allCapabilities] = await Promise.all([
|
[, varsRef, allCapabilities] = await Promise.all([
|
||||||
api('/template-configs'),
|
templateConfigsCache.fetch(true),
|
||||||
api('/template-configs/variables'),
|
api('/template-configs/variables'),
|
||||||
api('/providers/capabilities'),
|
api('/providers/capabilities'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
import { trackingConfigsCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import type { TrackingConfig } from '$lib/types';
|
import type { TrackingConfig } from '$lib/types';
|
||||||
|
|
||||||
let configs = $state<TrackingConfig[]>([]);
|
let configs = $derived(trackingConfigsCache.items);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() {
|
async function load() {
|
||||||
try { configs = await api('/tracking-configs'); }
|
try { await trackingConfigsCache.fetch(true); }
|
||||||
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||||
finally { loaded = true; }
|
finally { loaded = true; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ from ..database.models import (
|
|||||||
CommandConfig,
|
CommandConfig,
|
||||||
CommandTemplateConfig,
|
CommandTemplateConfig,
|
||||||
CommandTracker,
|
CommandTracker,
|
||||||
|
EmailBot,
|
||||||
EventLog,
|
EventLog,
|
||||||
|
MatrixBot,
|
||||||
NotificationTarget,
|
NotificationTarget,
|
||||||
NotificationTracker,
|
NotificationTracker,
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
@@ -119,6 +121,8 @@ async def get_nav_counts(
|
|||||||
(TemplateConfig, "template_configs"),
|
(TemplateConfig, "template_configs"),
|
||||||
(NotificationTarget, "targets"),
|
(NotificationTarget, "targets"),
|
||||||
(TelegramBot, "telegram_bots"),
|
(TelegramBot, "telegram_bots"),
|
||||||
|
(EmailBot, "email_bots"),
|
||||||
|
(MatrixBot, "matrix_bots"),
|
||||||
(CommandTracker, "command_trackers"),
|
(CommandTracker, "command_trackers"),
|
||||||
(CommandConfig, "command_configs"),
|
(CommandConfig, "command_configs"),
|
||||||
(CommandTemplateConfig, "command_template_configs"),
|
(CommandTemplateConfig, "command_template_configs"),
|
||||||
@@ -138,6 +142,16 @@ async def get_nav_counts(
|
|||||||
)).one()
|
)).one()
|
||||||
counts[key] += system_count
|
counts[key] += system_count
|
||||||
|
|
||||||
|
# Per-type target counts for nav badges
|
||||||
|
for target_type in ("telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix"):
|
||||||
|
type_count = (await session.exec(
|
||||||
|
select(func.count()).select_from(NotificationTarget).where(
|
||||||
|
NotificationTarget.user_id == user.id,
|
||||||
|
NotificationTarget.type == target_type,
|
||||||
|
)
|
||||||
|
)).one()
|
||||||
|
counts[f"targets_{target_type}"] = type_count
|
||||||
|
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user