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:
2026-03-21 23:35:50 +03:00
parent 2c740ff2d2
commit 563716fa76
25 changed files with 551 additions and 155 deletions
+66
View File
@@ -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';
}
}
+16 -2
View File
@@ -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",
+16 -2
View File
@@ -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": "Тип",
+59
View File
@@ -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,
};
}
+10
View File
@@ -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;