feat: bridge_self bot commands — status, thresholds, reset, health
Adds bot commands for the bridge_self provider so operators can inspect and manage bridge health from chat: /status, /thresholds, /reset, /health. Includes Jinja2 templates for both locales, seed data, capability slots, and a handler that exposes pending deferred backlog plus per-counter reset. Also adds .claude/skills/ for project-scoped graph-aware skills.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Debug Issue
|
||||
description: Systematically debug issues using graph-powered code navigation
|
||||
---
|
||||
|
||||
## Debug Issue
|
||||
|
||||
Use the knowledge graph to systematically trace and debug issues.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Use `semantic_search_nodes` to find code related to the issue.
|
||||
2. Use `query_graph` with `callers_of` and `callees_of` to trace call chains.
|
||||
3. Use `get_flow` to see full execution paths through suspected areas.
|
||||
4. Run `detect_changes` to check if recent changes caused the issue.
|
||||
5. Use `get_impact_radius` on suspected files to see what else is affected.
|
||||
|
||||
### Tips
|
||||
|
||||
- Check both callers and callees to understand the full context.
|
||||
- Look at affected flows to find the entry point that triggers the bug.
|
||||
- Recent changes are the most common source of new issues.
|
||||
|
||||
## Token Efficiency Rules
|
||||
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Explore Codebase
|
||||
description: Navigate and understand codebase structure using the knowledge graph
|
||||
---
|
||||
|
||||
## Explore Codebase
|
||||
|
||||
Use the code-review-graph MCP tools to explore and understand the codebase.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Run `list_graph_stats` to see overall codebase metrics.
|
||||
2. Run `get_architecture_overview` for high-level community structure.
|
||||
3. Use `list_communities` to find major modules, then `get_community` for details.
|
||||
4. Use `semantic_search_nodes` to find specific functions or classes.
|
||||
5. Use `query_graph` with patterns like `callers_of`, `callees_of`, `imports_of` to trace relationships.
|
||||
6. Use `list_flows` and `get_flow` to understand execution paths.
|
||||
|
||||
### Tips
|
||||
|
||||
- Start broad (stats, architecture) then narrow down to specific areas.
|
||||
- Use `children_of` on a file to see all its functions and classes.
|
||||
- Use `find_large_functions` to identify complex code.
|
||||
|
||||
## Token Efficiency Rules
|
||||
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Refactor Safely
|
||||
description: Plan and execute safe refactoring using dependency analysis
|
||||
---
|
||||
|
||||
## Refactor Safely
|
||||
|
||||
Use the knowledge graph to plan and execute refactoring with confidence.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Use `refactor_tool` with mode="suggest" for community-driven refactoring suggestions.
|
||||
2. Use `refactor_tool` with mode="dead_code" to find unreferenced code.
|
||||
3. For renames, use `refactor_tool` with mode="rename" to preview all affected locations.
|
||||
4. Use `apply_refactor_tool` with the refactor_id to apply renames.
|
||||
5. After changes, run `detect_changes` to verify the refactoring impact.
|
||||
|
||||
### Safety Checks
|
||||
|
||||
- Always preview before applying (rename mode gives you an edit list).
|
||||
- Check `get_impact_radius` before major refactors.
|
||||
- Use `get_affected_flows` to ensure no critical paths are broken.
|
||||
- Run `find_large_functions` to identify decomposition targets.
|
||||
|
||||
## Token Efficiency Rules
|
||||
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: Review Changes
|
||||
description: Perform a structured code review using change detection and impact
|
||||
---
|
||||
|
||||
## Review Changes
|
||||
|
||||
Perform a thorough, risk-aware code review using the knowledge graph.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Run `detect_changes` to get risk-scored change analysis.
|
||||
2. Run `get_affected_flows` to find impacted execution paths.
|
||||
3. For each high-risk function, run `query_graph` with pattern="tests_for" to check test coverage.
|
||||
4. Run `get_impact_radius` to understand the blast radius.
|
||||
5. For any untested changes, suggest specific test cases.
|
||||
|
||||
### Output Format
|
||||
|
||||
Provide findings grouped by risk level (high/medium/low) with:
|
||||
- What changed and why it matters
|
||||
- Test coverage status
|
||||
- Suggested improvements
|
||||
- Overall merge recommendation
|
||||
|
||||
## Token Efficiency Rules
|
||||
- ALWAYS start with `get_minimal_context(task="<your task>")` before any other graph tool.
|
||||
- Use `detail_level="minimal"` on all calls. Only escalate to "standard" when minimal is insufficient.
|
||||
- Target: complete any review/debug/refactor task in ≤5 tool calls and ≤800 total output tokens.
|
||||
@@ -1127,6 +1127,18 @@
|
||||
"scopeInherit": "Inherit: derive from notification routing",
|
||||
"noCollections": "No albums available."
|
||||
},
|
||||
"commands": {
|
||||
"bridgeSelf": {
|
||||
"status": "Bridge status",
|
||||
"statusDesc": "Show current bridge health counters",
|
||||
"thresholds": "Bridge thresholds",
|
||||
"thresholdsDesc": "Show configured alert thresholds",
|
||||
"reset": "Reset counter",
|
||||
"resetDesc": "Manually reset a failure counter",
|
||||
"health": "Bridge health",
|
||||
"healthDesc": "Terse one-line health summary"
|
||||
}
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details"
|
||||
|
||||
@@ -1127,6 +1127,18 @@
|
||||
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
|
||||
"noCollections": "Нет доступных альбомов."
|
||||
},
|
||||
"commands": {
|
||||
"bridgeSelf": {
|
||||
"status": "Состояние моста",
|
||||
"statusDesc": "Показать счётчики состояния моста",
|
||||
"thresholds": "Пороги моста",
|
||||
"thresholdsDesc": "Показать настроенные пороги оповещений",
|
||||
"reset": "Сбросить счётчик",
|
||||
"resetDesc": "Вручную сбросить счётчик сбоев",
|
||||
"health": "Здоровье моста",
|
||||
"healthDesc": "Краткая однострочная сводка состояния"
|
||||
}
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Показать детали",
|
||||
"hideDetails": "Скрыть детали"
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
|
||||
// Immich command icons — used as fallback when capabilities don't specify icons
|
||||
// Immich command icons — used as fallback when capabilities don't specify icons
|
||||
const commandIcons: Record<string, string> = {
|
||||
help: 'mdiHelpCircle', status: 'mdiChartBox', albums: 'mdiImageMultiple',
|
||||
events: 'mdiPulse', summary: 'mdiFileDocumentEdit', latest: 'mdiImagePlus',
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
return {
|
||||
value: code,
|
||||
label: m.native,
|
||||
desc: i === 0 ? `${code.toUpperCase()} Р’В· ${t('locales.primary')}` : code.toUpperCase(),
|
||||
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
|
||||
};
|
||||
}));
|
||||
$effect(() => {
|
||||
@@ -144,10 +144,10 @@
|
||||
* Group command slots by purpose so the form mirrors how notification
|
||||
* templates are split (event vs scheduled vs settings).
|
||||
*
|
||||
* commandResponses — primary reply templates (/start, /help, /status, data slots)
|
||||
* commandErrors — fallback messages (rate_limited, no_results)
|
||||
* commandDescriptions — desc_* slots: short menu blurbs in Telegram's command picker
|
||||
* commandUsage — usage_* slots: invocation examples shown by /help
|
||||
* commandResponses — primary reply templates (/start, /help, /status, data slots)
|
||||
* commandErrors — fallback messages (rate_limited, no_results)
|
||||
* commandDescriptions — desc_* slots: short menu blurbs in Telegram's command picker
|
||||
* commandUsage — usage_* slots: invocation examples shown by /help
|
||||
*/
|
||||
let commandSlotGroups = $derived([
|
||||
{
|
||||
@@ -548,7 +548,7 @@
|
||||
{#each filteredSlots as slot}
|
||||
<CollapsibleSlot
|
||||
label={slot.name}
|
||||
description="/{slot.name} — {slot.description}"
|
||||
description="/{slot.name} — {slot.description}"
|
||||
expanded={expandedSlots.has(slot.name)}
|
||||
status={getSlotStatus(slot.name)}
|
||||
ontoggle={() => toggleSlot(slot.name)}
|
||||
@@ -587,7 +587,7 @@
|
||||
|
||||
{#if slotErrors[slot.name]}
|
||||
{#if slotErrorTypes[slot.name] === 'undefined'}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">вљ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">вњ• {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
|
||||
{/if}
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
// Test types: basic is always available; periodic/scheduled/memory only for providers
|
||||
// that have those notification slots in their capabilities AND have the feature
|
||||
// enabled on the tracker's default TrackingConfig. A disabled feature on the
|
||||
// default config means cron dispatch won't fire it in production either — so
|
||||
// default config means cron dispatch won't fire it in production either — so
|
||||
// the test button would just surface a silent skip.
|
||||
const allTestTypes: Record<string, {
|
||||
key: string; icon: string; labelKey: string;
|
||||
@@ -128,7 +128,7 @@
|
||||
base.push({
|
||||
key: tt.key, icon: tt.icon, labelKey: tt.labelKey,
|
||||
// When surfaced, the button still renders but is disabled and
|
||||
// shows *why* — users who land here via the test menu without
|
||||
// shows *why* — users who land here via the test menu without
|
||||
// having toggled the feature on Tracking Config see a clear
|
||||
// pointer to the missing setting instead of a silent failure.
|
||||
disabledReason: enabled ? undefined : 'notificationTracker.testDisabledHint',
|
||||
@@ -179,7 +179,7 @@
|
||||
|
||||
async function loadUsers() {
|
||||
if (!form.provider_id) { users = []; return; }
|
||||
// Skip the fetch when the descriptor has no user filters — saves a
|
||||
// Skip the fetch when the descriptor has no user filters — saves a
|
||||
// pointless round-trip for providers like Immich/Scheduler.
|
||||
const desc = getDescriptor(selectedProviderType);
|
||||
if (!desc?.userFilters || desc.userFilters.length === 0) { users = []; return; }
|
||||
@@ -383,7 +383,7 @@
|
||||
function trackerTiles(tracker: Tracker): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
const trkDesc = getDescriptor(getProviderType(tracker));
|
||||
// Status — armed/paused with color tone
|
||||
// Status — armed/paused with color tone
|
||||
tiles.push(tracker.enabled
|
||||
? { icon: 'mdiPulse', label: t('notificationTracker.armed'), tone: 'mint' }
|
||||
: { icon: 'mdiPauseCircleOutline', label: t('notificationTracker.paused'), tone: 'citrus' });
|
||||
@@ -393,7 +393,7 @@
|
||||
label: getProviderName(tracker.provider_id),
|
||||
tone: 'lavender',
|
||||
});
|
||||
// Collections — count + label (varies per provider descriptor)
|
||||
// Collections — count + label (varies per provider descriptor)
|
||||
const collCount = (tracker.collection_ids || []).length;
|
||||
if (collCount > 0 || !trkDesc?.webhookBased) {
|
||||
tiles.push({
|
||||
@@ -403,7 +403,7 @@
|
||||
tone: 'sky',
|
||||
});
|
||||
}
|
||||
// Scan interval — only meaningful for polling trackers
|
||||
// Scan interval — only meaningful for polling trackers
|
||||
if (!trkDesc?.webhookBased) {
|
||||
tiles.push({
|
||||
icon: 'mdiTimerOutline',
|
||||
@@ -593,8 +593,8 @@
|
||||
<div class="list-row__secondary mt-0.5">
|
||||
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} Р’В·
|
||||
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s Р’В·{/if}
|
||||
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
|
||||
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
|
||||
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -605,7 +605,7 @@
|
||||
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
|
||||
<button onclick={() => toggleExpand(tracker.id)}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('notificationTracker.linkedTargets')} {expandedTracker === tracker.id ? 'в–І' : 'в–С'}
|
||||
{t('notificationTracker.linkedTargets')} {expandedTracker === tracker.id ? '▲' : '▼'}
|
||||
</button>
|
||||
<IconButton icon="mdiDelete" title={t('notificationTracker.delete')} onclick={() => startDelete(tracker)} variant="danger" />
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
const tiles: MetaTile[] = [];
|
||||
const h = health[provider.id];
|
||||
const provDesc = getDescriptor(provider.type);
|
||||
// Status — first tile, color-coded
|
||||
// Status — first tile, color-coded
|
||||
if (h === true) {
|
||||
tiles.push({ icon: 'mdiCheckCircle', label: t('providers.online'), tone: 'mint' });
|
||||
} else if (h === false) {
|
||||
@@ -107,10 +107,10 @@
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const segments = u.pathname.split('/').filter(Boolean);
|
||||
const tail = segments.length ? `/${segments[0]}${segments.length > 1 ? '/…' : ''}` : '';
|
||||
const tail = segments.length ? `/${segments[0]}${segments.length > 1 ? '/…' : ''}` : '';
|
||||
return `${u.host}${tail}`;
|
||||
} catch {
|
||||
return url.length > 32 ? `${url.slice(0, 30)}…` : url;
|
||||
return url.length > 32 ? `${url.slice(0, 30)}…` : url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
|
||||
let health = $state<Record<number, boolean | null>>({});
|
||||
|
||||
// Status pill row for the page header — derived from health probes.
|
||||
// Status pill row for the page header — derived from health probes.
|
||||
const headerPills = $derived.by(() => {
|
||||
const onlineCount = Object.values(health).filter(v => v === true).length;
|
||||
const offlineCount = Object.values(health).filter(v => v === false).length;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
import ReceiverSection from './ReceiverSection.svelte';
|
||||
import BotGroupHeader from './BotGroupHeader.svelte';
|
||||
|
||||
// в”Ђв”Ђ Helpers в”Ђв”Ђ
|
||||
// ──── Helpers ────
|
||||
|
||||
function getBotName(target: NotificationTarget): string | null {
|
||||
if (target.type === 'telegram' && target.config?.bot_id) {
|
||||
@@ -74,7 +74,7 @@
|
||||
return recv.receiver_key || '?';
|
||||
}
|
||||
|
||||
// в”Ђв”Ђ Constants в”Ђв”Ђ
|
||||
// ──── Constants ────
|
||||
|
||||
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix', 'broadcast'] as const;
|
||||
type TargetType = typeof ALL_TYPES[number];
|
||||
@@ -97,7 +97,7 @@
|
||||
|
||||
function targetTiles(target: NotificationTarget): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
// Type tile — useful when the "all types" filter is active and rows
|
||||
// Type tile — useful when the "all types" filter is active and rows
|
||||
// from multiple types appear side-by-side. The receivers count is
|
||||
// already shown inside the `target-summary` button, so we don't repeat
|
||||
// it as a tile.
|
||||
@@ -115,8 +115,8 @@
|
||||
tone: 'sky',
|
||||
});
|
||||
}
|
||||
// Telegram targets expose a chat label in config — surface it so the
|
||||
// row reads "Telegram Р’В· @bot Р’В· Family chat" without expanding.
|
||||
// Telegram targets expose a chat label in config — surface it so the
|
||||
// row reads "Telegram · @bot · Family chat" without expanding.
|
||||
const cfg = (target.config || {}) as Record<string, any>;
|
||||
if (target.type === 'telegram' && cfg.chat_id) {
|
||||
tiles.push({
|
||||
@@ -126,7 +126,7 @@
|
||||
mono: true,
|
||||
});
|
||||
}
|
||||
// Webhook target — show host
|
||||
// Webhook target — show host
|
||||
if (target.type === 'webhook' && cfg.url) {
|
||||
let host = String(cfg.url);
|
||||
try { host = new URL(host).host; } catch { /* keep raw */ }
|
||||
@@ -142,7 +142,7 @@
|
||||
return tiles;
|
||||
}
|
||||
|
||||
// в”Ђв”Ђ Derived state в”Ђв”Ђ
|
||||
// ──── Derived state ────
|
||||
|
||||
let allTargets = $derived(targetsCache.items);
|
||||
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
|
||||
@@ -158,7 +158,7 @@
|
||||
const emailBotItems = $derived(emailBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiEmailOutline', desc: b.email })));
|
||||
const matrixBotItems = $derived(matrixBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiMatrix', desc: b.display_name || b.homeserver_url })));
|
||||
|
||||
// в”Ђв”Ђ Target form state в”Ђв”Ђ
|
||||
// ──── Target form state ────
|
||||
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
@@ -204,7 +204,7 @@
|
||||
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
// в”Ђв”Ђ Receiver inline form state в”Ђв”Ђ
|
||||
// ──── Receiver inline form state ────
|
||||
|
||||
let addingReceiverForTarget = $state<number | null>(null);
|
||||
let receiverForm = $state<Record<string, any>>({});
|
||||
@@ -228,7 +228,7 @@
|
||||
if (!expandedTargets.has(id)) expandedTargets.add(id);
|
||||
}
|
||||
|
||||
// в”Ђв”Ђ Effects в”Ђв”Ђ
|
||||
// ──── Effects ────
|
||||
|
||||
// Reset form when switching target type tabs
|
||||
$effect(() => {
|
||||
@@ -239,11 +239,11 @@
|
||||
addingReceiverForTarget = null;
|
||||
});
|
||||
|
||||
// в”Ђв”Ђ Data loading в”Ђв”Ђ
|
||||
// ──── Data loading ────
|
||||
|
||||
onMount(load);
|
||||
|
||||
// в”Ђв”Ђ Bot grouping в”Ђв”Ђ
|
||||
// ──── Bot grouping ────
|
||||
|
||||
type TargetGroup = {
|
||||
key: string;
|
||||
@@ -372,7 +372,7 @@
|
||||
} catch (e) { console.warn('Failed to load bot chats:', e); }
|
||||
}
|
||||
|
||||
// Active discovery — actually polls Telegram getUpdates and persists any new chats.
|
||||
// Active discovery — actually polls Telegram getUpdates and persists any new chats.
|
||||
// Fired when the chat picker opens so the user sees the freshest list without a manual click.
|
||||
async function discoverReceiverBotChats(botId: number) {
|
||||
if (!botId) return;
|
||||
@@ -382,7 +382,7 @@
|
||||
} catch (e) { console.warn('Failed to discover bot chats:', e); }
|
||||
}
|
||||
|
||||
// в”Ђв”Ђ Target CRUD в”Ђв”Ђ
|
||||
// ──── Target CRUD ────
|
||||
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
@@ -507,7 +507,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// в”Ђв”Ђ Receiver CRUD в”Ђв”Ђ
|
||||
// ──── Receiver CRUD ────
|
||||
|
||||
async function openReceiverForm(targetId: number, targetType: string) {
|
||||
// Force a remount of any picker palette when the same target is reopened
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
return {
|
||||
value: code,
|
||||
label: m.native,
|
||||
desc: i === 0 ? `${code.toUpperCase()} Р’В· ${t('locales.primary')}` : code.toUpperCase(),
|
||||
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
|
||||
};
|
||||
}));
|
||||
/**
|
||||
@@ -442,7 +442,7 @@
|
||||
label: t('templateConfig.slots'),
|
||||
tone: slotCount > 0 ? 'sky' : 'default',
|
||||
});
|
||||
// Locale coverage — count unique locales present across all slots
|
||||
// Locale coverage — count unique locales present across all slots
|
||||
const locales = new Set<string>();
|
||||
for (const s of Object.values(config.slots || {})) {
|
||||
for (const loc of Object.keys(s || {})) locales.add(loc);
|
||||
@@ -624,7 +624,7 @@
|
||||
|
||||
{#if slotErrors[slot.key]}
|
||||
{#if slotErrorTypes[slot.key] === 'undefined'}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">вљ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">вњ• {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
|
||||
{/if}
|
||||
|
||||
@@ -28,13 +28,13 @@
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
|
||||
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
||||
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
||||
const gridItemSources: Record<string, () => any[]> = {
|
||||
sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems,
|
||||
};
|
||||
|
||||
/**
|
||||
* HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron
|
||||
* HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron
|
||||
* dispatch accepts. Matched on blur for time-list fields; invalid values
|
||||
* are surfaced inline next to the input.
|
||||
*/
|
||||
@@ -43,7 +43,7 @@
|
||||
/** Per-field error messages surfaced inline under time-list inputs. */
|
||||
let timeListErrors = $state<Record<string, string>>({});
|
||||
|
||||
/** Normalize "9:0 , 18:30" в†’ "09:00,18:30" on blur, clear error when valid. */
|
||||
/** Normalize "9:0 , 18:30" → "09:00,18:30" on blur, clear error when valid. */
|
||||
function normalizeTimeList(key: string) {
|
||||
const raw = String(form[key] ?? '').trim();
|
||||
if (!raw) { timeListErrors = { ...timeListErrors, [key]: '' }; return; }
|
||||
@@ -74,8 +74,8 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiet-hours preview: "22:00 в†’ 07:00 next day (9h)" or "Quiet period is 0
|
||||
* minutes — adjust times" when start equals end. Handles overnight ranges
|
||||
* Quiet-hours preview: "22:00 → 07:00 next day (9h)" or "Quiet period is 0
|
||||
* minutes — adjust times" when start equals end. Handles overnight ranges
|
||||
* (start > end) correctly.
|
||||
*/
|
||||
function quietHoursPreview(start: string, end: string): string {
|
||||
@@ -92,8 +92,8 @@
|
||||
const m = span % 60;
|
||||
const dur = m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||
const arrow = overnight
|
||||
? `${start} в†’ ${end} ${t('trackingConfig.nextDay')}`
|
||||
: `${start} в†’ ${end}`;
|
||||
? `${start} → ${end} ${t('trackingConfig.nextDay')}`
|
||||
: `${start} → ${end}`;
|
||||
return `${arrow} (${dur})`;
|
||||
}
|
||||
|
||||
@@ -112,12 +112,12 @@
|
||||
/**
|
||||
* Inline preview of the shipped default template for a scheduled/periodic/
|
||||
* memory slot. Using the shipped default (not a tracker's current template)
|
||||
* keeps this scoped to the tracking-config page — which has no concept of
|
||||
* keeps this scoped to the tracking-config page — which has no concept of
|
||||
* which TemplateConfig a given tracker uses. Users who want to edit the
|
||||
* actual config can click "Edit template" in the modal footer.
|
||||
*
|
||||
* ``previewLocale`` is modal-scoped so switching tabs only refetches for
|
||||
* this preview — the user's UI locale (and other previews) are untouched.
|
||||
* this preview — the user's UI locale (and other previews) are untouched.
|
||||
*/
|
||||
let previewModal = $state<{ slotName: string; rendered: string; error: string; locale: string } | null>(null);
|
||||
let previewLoading = $state(false);
|
||||
@@ -262,7 +262,7 @@
|
||||
if (config.quiet_hours_start && config.quiet_hours_end) {
|
||||
tiles.push({
|
||||
icon: 'mdiWeatherNight',
|
||||
label: `${config.quiet_hours_start}–${config.quiet_hours_end}`,
|
||||
label: `${config.quiet_hours_start}–${config.quiet_hours_end}`,
|
||||
hint: t('trackingConfig.quietHoursStart'),
|
||||
tone: 'citrus',
|
||||
mono: true,
|
||||
@@ -344,7 +344,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Event tracking — driven by descriptor -->
|
||||
<!-- Event tracking — driven by descriptor -->
|
||||
{#if descriptor}
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.eventTracking')}</legend>
|
||||
@@ -377,7 +377,7 @@
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<!-- Feature sections (periodic, scheduled, memory) — driven by descriptor -->
|
||||
<!-- Feature sections (periodic, scheduled, memory) — driven by descriptor -->
|
||||
{#each descriptor.featureSections ?? [] as section (section.key)}
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">
|
||||
@@ -494,9 +494,9 @@
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">
|
||||
{(desc?.eventFields ?? []).filter(f => (config as Record<string, any>)[f.key]).map(f => t(f.label)).join(', ')}
|
||||
{config.periodic_enabled ? ` Р’В· ${t('trackingConfig.periodic')}` : ''}
|
||||
{config.scheduled_enabled ? ` Р’В· ${t('trackingConfig.scheduled')}` : ''}
|
||||
{config.memory_enabled ? ` Р’В· ${t('trackingConfig.memory')}` : ''}
|
||||
{config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''}
|
||||
{config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''}
|
||||
{config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<MetaStrip tiles={trackingConfigTiles(config)} />
|
||||
@@ -518,7 +518,7 @@
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<Modal open={previewModal !== null}
|
||||
title={previewModal ? `${t('trackingConfig.previewTemplate')} — ${previewModal.slotName}` : ''}
|
||||
title={previewModal ? `${t('trackingConfig.previewTemplate')} — ${previewModal.slotName}` : ''}
|
||||
onclose={() => previewModal = null}>
|
||||
{#if previewModal}
|
||||
{#if previewLocales.length > 1}
|
||||
@@ -537,7 +537,7 @@
|
||||
{t('trackingConfig.previewSampleNote')}
|
||||
</p>
|
||||
<!-- Keep the prior rendered/error box mounted while refetching on locale
|
||||
switch — just dim it. Unmounting and replacing with a small "…"
|
||||
switch — just dim it. Unmounting and replacing with a small "…"
|
||||
placeholder caused a one-frame layout jump as the modal shrank and
|
||||
then re-expanded. -->
|
||||
<div class="relative mb-3" style="opacity: {previewLoading ? 0.5 : 1}; transition: opacity 0.15s ease;">
|
||||
@@ -550,7 +550,7 @@
|
||||
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(previewModal.rendered)}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-3 text-xs" style="color: var(--color-muted-foreground);">…</div>
|
||||
<div class="p-3 text-xs" style="color: var(--color-muted-foreground);">…</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end mt-3">
|
||||
|
||||
@@ -542,8 +542,32 @@ BRIDGE_SELF_CAPABILITIES = ProviderCapabilities(
|
||||
{"name": "bridge_self_deferred_backlog", "description": "Deferred backlog high"},
|
||||
{"name": "bridge_self_target_failures", "description": "Target send failures"},
|
||||
],
|
||||
command_slots=[],
|
||||
commands=[],
|
||||
command_slots=[
|
||||
# Response templates
|
||||
{"name": "start", "description": "/start greeting message"},
|
||||
{"name": "help", "description": "/help command listing"},
|
||||
{"name": "status", "description": "/status full counter snapshot"},
|
||||
{"name": "thresholds", "description": "/thresholds configured alert thresholds"},
|
||||
{"name": "reset", "description": "/reset manual counter reset"},
|
||||
{"name": "health", "description": "/health terse one-line summary"},
|
||||
{"name": "rate_limited", "description": "Rate limit warning message"},
|
||||
{"name": "no_results", "description": "Empty results fallback"},
|
||||
# Description slots
|
||||
{"name": "desc_help", "description": "Menu description for /help"},
|
||||
{"name": "desc_status", "description": "Menu description for /status"},
|
||||
{"name": "desc_thresholds", "description": "Menu description for /thresholds"},
|
||||
{"name": "desc_reset", "description": "Menu description for /reset"},
|
||||
{"name": "desc_health", "description": "Menu description for /health"},
|
||||
# Usage examples
|
||||
{"name": "usage_reset", "description": "Usage example for /reset"},
|
||||
],
|
||||
commands=[
|
||||
{"name": "status", "description": "Show current bridge health counters"},
|
||||
{"name": "thresholds", "description": "Show configured alert thresholds"},
|
||||
{"name": "reset", "description": "Manually reset a failure counter"},
|
||||
{"name": "health", "description": "Terse one-line health summary"},
|
||||
{"name": "help", "description": "Show commands"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
Terse one-line health summary
|
||||
+1
@@ -0,0 +1 @@
|
||||
Show available commands
|
||||
+1
@@ -0,0 +1 @@
|
||||
Reset a failure counter (tracker:<id>, target:<id>, or all)
|
||||
+1
@@ -0,0 +1 @@
|
||||
Show current bridge health counters
|
||||
+1
@@ -0,0 +1 @@
|
||||
Show configured alert thresholds
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{%- if healthy -%}
|
||||
✅ {{ summary }}
|
||||
{%- else -%}
|
||||
🚨 {{ summary }}
|
||||
{%- endif %}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
🩺 <b>Available commands:</b>
|
||||
{%- for cmd in commands %}
|
||||
/{{ cmd.name }} — {{ cmd.description }}
|
||||
{%- if cmd.usage %} ↳ {{ cmd.usage }}{% endif %}
|
||||
{%- endfor %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
No results.
|
||||
+1
@@ -0,0 +1 @@
|
||||
⏳ Too many requests. Please wait {{ wait }}s before trying again.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{%- if success %}
|
||||
✅ <b>Counter reset</b>
|
||||
{%- if subject_type == 'all' %}
|
||||
Cleared {{ previous_count }} of your failure counters (trackers + targets).
|
||||
{%- else %}
|
||||
{{ subject_type|capitalize }} <b>{{ subject_name }}</b>{% if subject_id %} (id <code>{{ subject_id }}</code>){% endif %}
|
||||
Previous count: <b>{{ previous_count }}</b> → 0
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
❌ <b>Reset failed</b>
|
||||
{%- if error_message %}
|
||||
<i>{{ error_message }}</i>
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
👋 Hi! I'm your Notify Bridge bot for <b>Bridge Self-Monitoring</b>.
|
||||
Use /help to see available commands.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
🩺 <b>Bridge Status</b>
|
||||
{%- if poll_failures %}
|
||||
|
||||
🚨 <b>Tracker poll failures</b>
|
||||
{%- for f in poll_failures %}
|
||||
• <b>{{ f.tracker_name }}</b> (id <code>{{ f.tracker_id }}</code>) — {{ f.count }} consecutive
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{%- if deferred_pending is none %}
|
||||
|
||||
⏳ <b>Deferred backlog</b>
|
||||
Pending: <b>unknown</b> (DB unavailable) · Threshold: {{ deferred_threshold }}
|
||||
{%- elif deferred_pending %}
|
||||
|
||||
⏳ <b>Deferred backlog</b>
|
||||
Pending: <b>{{ deferred_pending }}</b> · Threshold: {{ deferred_threshold }}
|
||||
{%- endif %}
|
||||
{%- if target_failures %}
|
||||
|
||||
📡 <b>Target send failures</b>
|
||||
{%- for f in target_failures %}
|
||||
• <b>{{ f.target_name }}</b> (id <code>{{ f.target_id }}</code>) — {{ f.count }} consecutive
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{%- if not poll_failures and not target_failures and deferred_pending == 0 %}
|
||||
|
||||
✅ All counters at zero. Nothing to report.
|
||||
{%- endif %}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
⚙️ <b>Bridge Thresholds</b>
|
||||
Tracker poll failures: <b>{{ poll_failure_threshold }}</b>
|
||||
Deferred backlog: <b>{{ deferred_backlog_threshold }}</b>
|
||||
Target send failures: <b>{{ target_failure_threshold }}</b>
|
||||
+1
@@ -0,0 +1 @@
|
||||
/reset tracker:42 (or target:<id>, or all)
|
||||
@@ -73,6 +73,15 @@ PROVIDER_COMMAND_SLOTS: dict[str, list[str]] = {
|
||||
# Usage examples
|
||||
"usage_entities", "usage_state",
|
||||
],
|
||||
"bridge_self": [
|
||||
# Response templates
|
||||
"start", "help", "status", "thresholds", "reset", "health",
|
||||
"rate_limited", "no_results",
|
||||
# Description slots
|
||||
"desc_help", "desc_status", "desc_thresholds", "desc_reset", "desc_health",
|
||||
# Usage examples
|
||||
"usage_reset",
|
||||
],
|
||||
}
|
||||
|
||||
# Backward-compatible aliases
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
Краткая однострочная сводка состояния
|
||||
+1
@@ -0,0 +1 @@
|
||||
Показать доступные команды
|
||||
+1
@@ -0,0 +1 @@
|
||||
Сбросить счётчик сбоев (tracker:<id>, target:<id> или all)
|
||||
+1
@@ -0,0 +1 @@
|
||||
Показать счётчики состояния моста
|
||||
+1
@@ -0,0 +1 @@
|
||||
Показать настроенные пороги оповещений
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{%- if healthy -%}
|
||||
✅ {{ summary }}
|
||||
{%- else -%}
|
||||
🚨 {{ summary }}
|
||||
{%- endif %}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
🩺 <b>Доступные команды:</b>
|
||||
{%- for cmd in commands %}
|
||||
/{{ cmd.name }} — {{ cmd.description }}
|
||||
{%- if cmd.usage %} ↳ {{ cmd.usage }}{% endif %}
|
||||
{%- endfor %}
|
||||
+1
@@ -0,0 +1 @@
|
||||
Нет результатов.
|
||||
+1
@@ -0,0 +1 @@
|
||||
⏳ Слишком много запросов. Подождите {{ wait }}с и попробуйте снова.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{%- if success %}
|
||||
✅ <b>Счётчик сброшен</b>
|
||||
{%- if subject_type == 'all' %}
|
||||
Очищено {{ previous_count }} ваших счётчиков (трекеры + цели).
|
||||
{%- else %}
|
||||
{{ subject_type|capitalize }} <b>{{ subject_name }}</b>{% if subject_id %} (id <code>{{ subject_id }}</code>){% endif %}
|
||||
Было: <b>{{ previous_count }}</b> → 0
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
❌ <b>Не удалось сбросить</b>
|
||||
{%- if error_message %}
|
||||
<i>{{ error_message }}</i>
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
👋 Привет! Я бот Notify Bridge для <b>Самомониторинга моста</b>.
|
||||
Используйте /help, чтобы посмотреть доступные команды.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
🩺 <b>Состояние моста</b>
|
||||
{%- if poll_failures %}
|
||||
|
||||
🚨 <b>Сбои опроса трекеров</b>
|
||||
{%- for f in poll_failures %}
|
||||
• <b>{{ f.tracker_name }}</b> (id <code>{{ f.tracker_id }}</code>) — {{ f.count }} подряд
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{%- if deferred_pending is none %}
|
||||
|
||||
⏳ <b>Очередь отложенной отправки</b>
|
||||
В ожидании: <b>неизвестно</b> (БД недоступна) · Порог: {{ deferred_threshold }}
|
||||
{%- elif deferred_pending %}
|
||||
|
||||
⏳ <b>Очередь отложенной отправки</b>
|
||||
В ожидании: <b>{{ deferred_pending }}</b> · Порог: {{ deferred_threshold }}
|
||||
{%- endif %}
|
||||
{%- if target_failures %}
|
||||
|
||||
📡 <b>Сбои отправки в адресаты</b>
|
||||
{%- for f in target_failures %}
|
||||
• <b>{{ f.target_name }}</b> (id <code>{{ f.target_id }}</code>) — {{ f.count }} подряд
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{%- if not poll_failures and not target_failures and deferred_pending == 0 %}
|
||||
|
||||
✅ Все счётчики в нуле. Всё хорошо.
|
||||
{%- endif %}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
⚙️ <b>Пороги моста</b>
|
||||
Сбои опроса трекеров: <b>{{ poll_failure_threshold }}</b>
|
||||
Очередь отложенной отправки: <b>{{ deferred_backlog_threshold }}</b>
|
||||
Сбои отправки в адресаты: <b>{{ target_failure_threshold }}</b>
|
||||
+1
@@ -0,0 +1 @@
|
||||
/reset tracker:42 (или target:<id>, или all)
|
||||
@@ -413,6 +413,69 @@ async def get_command_variables(
|
||||
},
|
||||
}
|
||||
|
||||
# --- Bridge self-monitoring ---
|
||||
bridge_self_poll_failure_fields = {
|
||||
"tracker_id": "Tracker id (int)",
|
||||
"tracker_name": "Tracker display name",
|
||||
"count": "Consecutive poll failures",
|
||||
}
|
||||
bridge_self_target_failure_fields = {
|
||||
"target_id": "Target id (int)",
|
||||
"target_name": "Target display name",
|
||||
"count": "Consecutive send failures",
|
||||
}
|
||||
bridge_self = {
|
||||
"status": {
|
||||
"description": "/status snapshot of all bridge_self counters",
|
||||
"variables": {
|
||||
**common_vars,
|
||||
"poll_failures": "List of {tracker_id, tracker_name, count} dicts (use {% for f in poll_failures %})",
|
||||
"deferred_pending": "Pending deferred-dispatch row count for this user",
|
||||
"deferred_threshold": "Deferred backlog threshold from provider config",
|
||||
"target_failures": "List of {target_id, target_name, count} dicts (use {% for f in target_failures %})",
|
||||
},
|
||||
"poll_failure_fields": bridge_self_poll_failure_fields,
|
||||
"target_failure_fields": bridge_self_target_failure_fields,
|
||||
},
|
||||
"thresholds": {
|
||||
"description": "/thresholds configured alert thresholds",
|
||||
"variables": {
|
||||
**common_vars,
|
||||
"poll_failure_threshold": "Tracker poll failure threshold (int)",
|
||||
"deferred_backlog_threshold": "Deferred backlog threshold (int)",
|
||||
"target_failure_threshold": "Target send failure threshold (int)",
|
||||
},
|
||||
},
|
||||
"reset": {
|
||||
"description": "/reset result of a manual counter reset",
|
||||
"variables": {
|
||||
**common_vars,
|
||||
"subject_type": "'tracker', 'target', or 'all' (empty on parse error)",
|
||||
"subject_id": "Subject id (int) or None for 'all' / errors",
|
||||
"subject_name": "Display name of the subject (empty on error)",
|
||||
"previous_count": "Counter value before reset",
|
||||
"success": "Whether the reset succeeded (boolean)",
|
||||
"error_message": "Error message when success=False (None on success)",
|
||||
},
|
||||
},
|
||||
"health": {
|
||||
"description": "/health terse one-line summary",
|
||||
"variables": {
|
||||
**common_vars,
|
||||
"healthy": "Whether everything is at zero (boolean)",
|
||||
"summary": "Human-readable one-line summary",
|
||||
"tracker_count": "Total enabled tracker count for this user",
|
||||
"failing_tracker_count": "Number of trackers with non-zero poll failures",
|
||||
"deferred_pending": "Pending deferred-dispatch row count for this user",
|
||||
"failing_target_count": "Number of targets with non-zero send failures",
|
||||
},
|
||||
},
|
||||
"desc_thresholds": {"description": "Description for /thresholds command", "variables": common_vars},
|
||||
"desc_reset": {"description": "Description for /reset command", "variables": common_vars},
|
||||
"desc_health": {"description": "Description for /health command", "variables": common_vars},
|
||||
"usage_reset": {"description": "Usage example for /reset", "variables": common_vars},
|
||||
}
|
||||
|
||||
return {
|
||||
**shared,
|
||||
"immich": immich,
|
||||
@@ -421,6 +484,7 @@ async def get_command_variables(
|
||||
"nut": nut,
|
||||
"google_photos": google_photos,
|
||||
"webhook": webhook,
|
||||
"bridge_self": bridge_self,
|
||||
}
|
||||
|
||||
|
||||
@@ -625,6 +689,39 @@ async def preview_raw(
|
||||
{"area_id": "kitchen", "name": "Kitchen", "entity_count": 14},
|
||||
{"area_id": "entrance", "name": "Entrance", "entity_count": 4},
|
||||
],
|
||||
# --- Bridge self-monitoring ---
|
||||
# /status — three categories of failure (preview shows non-empty data
|
||||
# so operators can see how the template renders failures, not the
|
||||
# all-zero case)
|
||||
"poll_failures": [
|
||||
{"tracker_id": 12, "tracker_name": "Family Photos poller", "count": 3},
|
||||
{"tracker_id": 18, "tracker_name": "Vacation 2025 poller", "count": 5},
|
||||
],
|
||||
"deferred_pending": 47,
|
||||
"deferred_threshold": 100,
|
||||
"target_failures": [
|
||||
{"target_id": 4, "target_name": "Telegram - Family chat", "count": 7},
|
||||
],
|
||||
# /thresholds — user's configured thresholds
|
||||
"poll_failure_threshold": 3,
|
||||
"deferred_backlog_threshold": 100,
|
||||
"target_failure_threshold": 5,
|
||||
# /reset — sample success result
|
||||
"subject_type": "tracker",
|
||||
"subject_id": 12,
|
||||
"subject_name": "Family Photos poller",
|
||||
"previous_count": 3,
|
||||
"success": True,
|
||||
"error_message": None,
|
||||
# /health — sample "unhealthy" line that matches the failure data
|
||||
# above. Templates that only branch on ``healthy`` still preview
|
||||
# cleanly — operators wanting the healthy case can flip the flag
|
||||
# in their template editor.
|
||||
"healthy": False,
|
||||
"summary": "2 trackers failing, 47 deferred, 1 target failing",
|
||||
"tracker_count": 3,
|
||||
"failing_tracker_count": 2,
|
||||
"failing_target_count": 1,
|
||||
}
|
||||
|
||||
return render_template_preview(body.template, sample_ctx)
|
||||
|
||||
@@ -244,7 +244,7 @@ async def _test_provider_connection(provider: ServiceProvider) -> dict[str, Any]
|
||||
)
|
||||
return await ha.test_connection()
|
||||
|
||||
if provider.type in ("scheduler", "webhook"):
|
||||
if provider.type in ("scheduler", "webhook", "bridge_self"):
|
||||
return {"ok": True, "message": "Virtual provider — always available"}
|
||||
|
||||
return {"ok": False, "message": f"Unknown provider type: {provider.type}"}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Bridge self-monitoring bot command handler.
|
||||
|
||||
Read-only commands plus a ``/reset`` operator action that clears in-memory
|
||||
failure counters once the underlying issue has been fixed.
|
||||
|
||||
Counters are kept in module-level dicts inside
|
||||
``services.bridge_self`` — see that module for the increment / reset
|
||||
helpers used by the watcher / scheduler / dispatcher hot path. We only
|
||||
read snapshots from those dicts here so we never block the emission
|
||||
side or hold a lock across DB calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ..database.models import (
|
||||
CommandConfig,
|
||||
CommandTracker,
|
||||
CommandTrackerListener,
|
||||
ServiceProvider,
|
||||
TelegramBot,
|
||||
)
|
||||
from ..services import bridge_self as bs
|
||||
from .base import CommandResponse, ProviderCommandHandler
|
||||
from .handler import _render_cmd_template
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_BRIDGE_SELF_COMMANDS = {"status", "thresholds", "reset", "health"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context builders — one per command. Each returns the dict passed to the
|
||||
# template renderer.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _build_status_context(provider: ServiceProvider) -> dict[str, Any]:
|
||||
"""Snapshot the calling user's counters + pending deferred backlog.
|
||||
|
||||
User-scoped: ``get_user_*_failures`` filter the global counter dicts by
|
||||
ownership in a single batched query so one user cannot see another
|
||||
user's failing trackers/targets.
|
||||
"""
|
||||
thresholds = await bs.get_user_thresholds(provider.user_id)
|
||||
|
||||
poll_rows = await bs.get_user_poll_failures(provider.user_id)
|
||||
poll_failures = sorted(
|
||||
({"tracker_id": r["id"], "tracker_name": r["name"], "count": r["count"]} for r in poll_rows),
|
||||
key=lambda r: r["tracker_id"],
|
||||
)
|
||||
|
||||
target_rows = await bs.get_user_target_failures(provider.user_id)
|
||||
target_failures = sorted(
|
||||
({"target_id": r["id"], "target_name": r["name"], "count": r["count"]} for r in target_rows),
|
||||
key=lambda r: r["target_id"],
|
||||
)
|
||||
|
||||
deferred_pending = await bs.get_pending_deferred_count(provider.user_id)
|
||||
|
||||
return {
|
||||
"poll_failures": poll_failures,
|
||||
"deferred_pending": deferred_pending,
|
||||
"deferred_threshold": thresholds["deferred_backlog_threshold"],
|
||||
"target_failures": target_failures,
|
||||
}
|
||||
|
||||
|
||||
async def _build_thresholds_context(provider: ServiceProvider) -> dict[str, Any]:
|
||||
"""Render the user's configured alert thresholds."""
|
||||
thresholds = await bs.get_user_thresholds(provider.user_id)
|
||||
return {
|
||||
"poll_failure_threshold": thresholds["poll_failure_threshold"],
|
||||
"deferred_backlog_threshold": thresholds["deferred_backlog_threshold"],
|
||||
"target_failure_threshold": thresholds["target_failure_threshold"],
|
||||
}
|
||||
|
||||
|
||||
def _parse_reset_subject(args: str) -> tuple[str, int | None, str | None]:
|
||||
"""Parse a ``/reset`` argument into ``(subject_type, subject_id, error)``.
|
||||
|
||||
Accepted forms:
|
||||
* ``tracker:<id>`` — clear that tracker's poll-failure counter
|
||||
* ``target:<id>`` — clear that target's send-failure counter
|
||||
* ``all`` — clear every in-memory counter
|
||||
|
||||
On a parse error we return ``("", None, "<message>")`` so the handler can
|
||||
render a templated error reply instead of propagating an exception.
|
||||
"""
|
||||
raw = (args or "").strip()
|
||||
if not raw:
|
||||
return "", None, "Missing subject. Use tracker:<id>, target:<id>, or all."
|
||||
if raw.lower() == "all":
|
||||
return "all", None, None
|
||||
if ":" not in raw:
|
||||
return "", None, (
|
||||
f"Invalid subject '{raw}'. Use tracker:<id>, target:<id>, or all."
|
||||
)
|
||||
prefix, _, value = raw.partition(":")
|
||||
prefix = prefix.strip().lower()
|
||||
value = value.strip()
|
||||
if prefix not in ("tracker", "target"):
|
||||
return "", None, (
|
||||
f"Unknown subject type '{prefix}'. Use tracker, target, or all."
|
||||
)
|
||||
try:
|
||||
subject_id = int(value)
|
||||
except ValueError:
|
||||
return "", None, f"Invalid id '{value}'. Must be an integer."
|
||||
if subject_id <= 0:
|
||||
return "", None, f"Invalid id '{value}'. Must be a positive integer."
|
||||
return prefix, subject_id, None
|
||||
|
||||
|
||||
async def _build_reset_context(args: str, provider: ServiceProvider) -> dict[str, Any]:
|
||||
"""Reset a counter, enforcing that the subject belongs to ``provider.user_id``.
|
||||
|
||||
Without this ownership check, any authenticated user could reset another
|
||||
user's counters (or wipe global state via ``all``). For ``all`` we only
|
||||
clear counters tied to trackers/targets owned by the calling user.
|
||||
"""
|
||||
subject_type, subject_id, error = _parse_reset_subject(args)
|
||||
if error is not None:
|
||||
return {
|
||||
"subject_type": "",
|
||||
"subject_id": None,
|
||||
"subject_name": "",
|
||||
"previous_count": 0,
|
||||
"success": False,
|
||||
"error_message": error,
|
||||
}
|
||||
|
||||
if subject_type == "all":
|
||||
cleared = await bs.reset_user_counters(provider.user_id)
|
||||
return {
|
||||
"subject_type": "all",
|
||||
"subject_id": None,
|
||||
"subject_name": "your counters",
|
||||
"previous_count": cleared,
|
||||
"success": True,
|
||||
"error_message": None,
|
||||
}
|
||||
|
||||
# Verify the subject belongs to the calling user before touching the counter.
|
||||
owner_lookup = bs.find_tracker_owner if subject_type == "tracker" else bs.find_target_owner
|
||||
owner = await owner_lookup(subject_id) if subject_id is not None else None
|
||||
if owner is None or owner != provider.user_id:
|
||||
return {
|
||||
"subject_type": subject_type,
|
||||
"subject_id": subject_id,
|
||||
"subject_name": "",
|
||||
"previous_count": 0,
|
||||
"success": False,
|
||||
"error_message": f"{subject_type} {subject_id} not found",
|
||||
}
|
||||
|
||||
failure_type = "poll_failures" if subject_type == "tracker" else "target_failures"
|
||||
name_lookup = bs.get_tracker_name if subject_type == "tracker" else bs.get_target_name
|
||||
subject_name = await name_lookup(subject_id) if subject_id is not None else ""
|
||||
previous = bs.reset_counter(failure_type, subject_id)
|
||||
return {
|
||||
"subject_type": subject_type,
|
||||
"subject_id": subject_id,
|
||||
"subject_name": subject_name,
|
||||
"previous_count": previous,
|
||||
"success": True,
|
||||
"error_message": None,
|
||||
}
|
||||
|
||||
|
||||
async def _build_health_context(provider: ServiceProvider) -> dict[str, Any]:
|
||||
"""Build the terse one-line health summary context."""
|
||||
from ..database.models import NotificationTracker
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..database.engine import get_engine
|
||||
|
||||
engine = get_engine()
|
||||
try:
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(NotificationTracker).where(
|
||||
NotificationTracker.user_id == provider.user_id,
|
||||
NotificationTracker.enabled == True, # noqa: E712 — SQLModel needs ==
|
||||
)
|
||||
)
|
||||
tracker_count = len(list(result.all()))
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("health: failed to count trackers for user=%s", provider.user_id)
|
||||
tracker_count = 0
|
||||
|
||||
# User-scoped: only count failures that belong to THIS user's trackers/targets.
|
||||
failing_tracker_count = len(await bs.get_user_poll_failures(provider.user_id))
|
||||
failing_target_count = len(await bs.get_user_target_failures(provider.user_id))
|
||||
deferred_pending = await bs.get_pending_deferred_count(provider.user_id)
|
||||
|
||||
# Treat unknown deferred count (DB error) as not-healthy so operators
|
||||
# don't get a misleading "all clear" when the bridge can't introspect itself.
|
||||
healthy = (
|
||||
failing_tracker_count == 0
|
||||
and failing_target_count == 0
|
||||
and deferred_pending == 0
|
||||
)
|
||||
if healthy:
|
||||
summary = f"Bridge healthy: {tracker_count} trackers polling, 0 failures"
|
||||
else:
|
||||
parts: list[str] = []
|
||||
if failing_tracker_count:
|
||||
parts.append(f"{failing_tracker_count} trackers failing")
|
||||
if deferred_pending is None:
|
||||
parts.append("deferred backlog unknown (DB unavailable)")
|
||||
elif deferred_pending:
|
||||
parts.append(f"{deferred_pending} deferred")
|
||||
if failing_target_count:
|
||||
parts.append(f"{failing_target_count} targets failing")
|
||||
summary = ", ".join(parts) or "bridge state unknown"
|
||||
|
||||
return {
|
||||
"healthy": healthy,
|
||||
"summary": summary,
|
||||
"tracker_count": tracker_count,
|
||||
"failing_tracker_count": failing_tracker_count,
|
||||
"deferred_pending": deferred_pending,
|
||||
"failing_target_count": failing_target_count,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BridgeSelfCommandHandler(ProviderCommandHandler):
|
||||
"""Read-only commands plus /reset for the bridge_self provider."""
|
||||
|
||||
provider_type = "bridge_self"
|
||||
|
||||
def get_provider_commands(self) -> set[str]:
|
||||
return _BRIDGE_SELF_COMMANDS
|
||||
|
||||
async def handle(
|
||||
self,
|
||||
cmd: str,
|
||||
args: str,
|
||||
count: int,
|
||||
locale: str,
|
||||
response_mode: str,
|
||||
provider: ServiceProvider,
|
||||
cmd_templates: dict[str, dict[str, str]],
|
||||
bot: TelegramBot,
|
||||
tracker: CommandTracker,
|
||||
config: CommandConfig,
|
||||
*,
|
||||
listener: CommandTrackerListener | None = None,
|
||||
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — bridge_self has no album scope
|
||||
page: int = 1,
|
||||
) -> CommandResponse | None:
|
||||
if cmd == "status":
|
||||
ctx = await _build_status_context(provider)
|
||||
return CommandResponse(text=_render_cmd_template(cmd_templates, "status", locale, ctx))
|
||||
if cmd == "thresholds":
|
||||
ctx = await _build_thresholds_context(provider)
|
||||
return CommandResponse(text=_render_cmd_template(cmd_templates, "thresholds", locale, ctx))
|
||||
if cmd == "reset":
|
||||
ctx = await _build_reset_context(args, provider)
|
||||
return CommandResponse(text=_render_cmd_template(cmd_templates, "reset", locale, ctx))
|
||||
if cmd == "health":
|
||||
ctx = await _build_health_context(provider)
|
||||
return CommandResponse(text=_render_cmd_template(cmd_templates, "health", locale, ctx))
|
||||
return None
|
||||
@@ -35,6 +35,7 @@ def _auto_register() -> None:
|
||||
from .nut_handler import NutCommandHandler
|
||||
from .webhook_handler import WebhookCommandHandler
|
||||
from .home_assistant_handler import HomeAssistantCommandHandler
|
||||
from .bridge_self_handler import BridgeSelfCommandHandler
|
||||
|
||||
register_handler(ImmichCommandHandler())
|
||||
register_handler(GiteaCommandHandler())
|
||||
@@ -42,6 +43,7 @@ def _auto_register() -> None:
|
||||
register_handler(NutCommandHandler())
|
||||
register_handler(WebhookCommandHandler())
|
||||
register_handler(HomeAssistantCommandHandler())
|
||||
register_handler(BridgeSelfCommandHandler())
|
||||
|
||||
|
||||
# Auto-register on import
|
||||
|
||||
@@ -195,6 +195,10 @@ async def _seed_default_command_templates() -> None:
|
||||
session, "home_assistant", "Default Home Assistant Commands",
|
||||
"Default Home Assistant command templates",
|
||||
)
|
||||
await _seed_provider_command_template(
|
||||
session, "bridge_self", "Default Bridge Self-Monitoring Commands",
|
||||
"Default Bridge Self-Monitoring command templates",
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -381,6 +385,16 @@ async def _seed_default_command_configs() -> None:
|
||||
"default_count": 5,
|
||||
"rate_limits": {"search": 30, "default": 10},
|
||||
},
|
||||
{
|
||||
"provider_type": "bridge_self",
|
||||
"name": "Default Bridge Self-Monitoring",
|
||||
"enabled_commands": [
|
||||
"help", "status", "thresholds", "reset", "health",
|
||||
],
|
||||
"response_mode": "text",
|
||||
"default_count": 5,
|
||||
"rate_limits": {"default": 10},
|
||||
},
|
||||
]
|
||||
|
||||
for cfg in defaults:
|
||||
|
||||
@@ -263,6 +263,117 @@ def get_target_last_error(target_id: int) -> str:
|
||||
return _target_failure_last_error.get(target_id, "")
|
||||
|
||||
|
||||
def get_all_poll_failures() -> dict[int, int]:
|
||||
"""Return a snapshot of all current poll failure counters (tracker_id -> count).
|
||||
|
||||
Only includes non-zero counters. The returned dict is a copy and can be
|
||||
iterated safely without holding a reference to the live module-level state.
|
||||
"""
|
||||
return {tid: count for tid, count in _poll_failure_counts.items() if count > 0}
|
||||
|
||||
|
||||
def get_all_target_failures() -> dict[int, int]:
|
||||
"""Return a snapshot of all current target failure counters (target_id -> count).
|
||||
|
||||
Only includes non-zero counters.
|
||||
"""
|
||||
return {tid: count for tid, count in _target_failure_counts.items() if count > 0}
|
||||
|
||||
|
||||
def reset_counter(failure_type: str, subject_id: int | None = None) -> int:
|
||||
"""Reset bridge_self failure counters.
|
||||
|
||||
Args:
|
||||
failure_type: One of ``"poll_failures"``, ``"target_failures"``, or
|
||||
``"all"``. Anything else is treated as a no-op.
|
||||
subject_id: When ``failure_type`` is ``"poll_failures"`` or
|
||||
``"target_failures"``, the tracker_id / target_id whose counter
|
||||
to clear. Ignored when ``failure_type == "all"``.
|
||||
|
||||
Returns:
|
||||
The previous count for the reset entry. For ``"all"``, the total
|
||||
number of entries cleared across both counter dicts. Idempotent —
|
||||
clearing an absent entry returns 0.
|
||||
"""
|
||||
if failure_type == "all":
|
||||
cleared = (
|
||||
len(_poll_failure_counts)
|
||||
+ len(_target_failure_counts)
|
||||
)
|
||||
_poll_failure_counts.clear()
|
||||
_poll_failure_last_error.clear()
|
||||
_target_failure_counts.clear()
|
||||
_target_failure_last_error.clear()
|
||||
return cleared
|
||||
if failure_type == "poll_failures" and subject_id is not None:
|
||||
previous = _poll_failure_counts.get(subject_id, 0)
|
||||
reset_poll_counter(subject_id)
|
||||
return previous
|
||||
if failure_type == "target_failures" and subject_id is not None:
|
||||
previous = _target_failure_counts.get(subject_id, 0)
|
||||
reset_target_counter(subject_id)
|
||||
return previous
|
||||
return 0
|
||||
|
||||
|
||||
async def get_pending_deferred_count(user_id: int) -> int | None:
|
||||
"""Return the count of pending DeferredDispatch rows for a user.
|
||||
|
||||
Used by command handlers to render the current backlog in /status and
|
||||
/health responses. Returns ``None`` on any error so command templates
|
||||
can render "unknown" instead of a misleading "0" — operators looking
|
||||
at bridge health when the bridge is unhealthy must not be told
|
||||
everything is fine.
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
from ..database.models import DeferredDispatch
|
||||
|
||||
engine = get_engine()
|
||||
try:
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(func.count(DeferredDispatch.id))
|
||||
.where(DeferredDispatch.status == "pending")
|
||||
.where(DeferredDispatch.user_id == user_id)
|
||||
)
|
||||
count = result.first()
|
||||
return int(count or 0)
|
||||
except Exception: # noqa: BLE001 — never block a command reply
|
||||
_LOGGER.exception("get_pending_deferred_count failed for user_id=%s", user_id)
|
||||
return None
|
||||
|
||||
|
||||
async def get_tracker_name(tracker_id: int) -> str:
|
||||
"""Return the display name of a NotificationTracker, or ``"tracker {id}"``."""
|
||||
from ..database.models import NotificationTracker
|
||||
|
||||
engine = get_engine()
|
||||
try:
|
||||
async with AsyncSession(engine) as session:
|
||||
tracker = await session.get(NotificationTracker, tracker_id)
|
||||
if tracker is not None:
|
||||
return tracker.name or f"tracker {tracker_id}"
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("get_tracker_name failed for tracker_id=%s", tracker_id)
|
||||
return f"tracker {tracker_id}"
|
||||
|
||||
|
||||
async def get_target_name(target_id: int) -> str:
|
||||
"""Return the display name of a NotificationTarget, or ``"target {id}"``."""
|
||||
from ..database.models import NotificationTarget
|
||||
|
||||
engine = get_engine()
|
||||
try:
|
||||
async with AsyncSession(engine) as session:
|
||||
target = await session.get(NotificationTarget, target_id)
|
||||
if target is not None:
|
||||
return target.name or f"target {target_id}"
|
||||
except Exception: # noqa: BLE001
|
||||
_LOGGER.exception("get_target_name failed for target_id=%s", target_id)
|
||||
return f"target {target_id}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User-level helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -300,6 +411,76 @@ async def find_target_owner(target_id: int) -> int | None:
|
||||
return int(target.user_id)
|
||||
|
||||
|
||||
async def get_user_poll_failures(user_id: int) -> list[dict[str, Any]]:
|
||||
"""Return ``[{"id", "name", "count"}]`` for trackers owned by ``user_id``.
|
||||
|
||||
Single batched query — replaces the N+1 pattern of calling
|
||||
``get_tracker_name`` per failing tracker, and enforces ownership so
|
||||
one user cannot see another user's failure list.
|
||||
"""
|
||||
from ..database.models import NotificationTracker
|
||||
|
||||
snapshot = {tid: c for tid, c in _poll_failure_counts.items() if c > 0}
|
||||
if not snapshot:
|
||||
return []
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(NotificationTracker.id, NotificationTracker.name).where(
|
||||
NotificationTracker.id.in_(list(snapshot.keys())),
|
||||
NotificationTracker.user_id == user_id,
|
||||
)
|
||||
)
|
||||
rows = list(result.all())
|
||||
return [
|
||||
{"id": int(tid), "name": name or f"tracker {tid}", "count": snapshot[int(tid)]}
|
||||
for tid, name in rows
|
||||
]
|
||||
|
||||
|
||||
async def get_user_target_failures(user_id: int) -> list[dict[str, Any]]:
|
||||
"""Return ``[{"id", "name", "count"}]`` for targets owned by ``user_id``."""
|
||||
from ..database.models import NotificationTarget
|
||||
|
||||
snapshot = {tid: c for tid, c in _target_failure_counts.items() if c > 0}
|
||||
if not snapshot:
|
||||
return []
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(NotificationTarget.id, NotificationTarget.name).where(
|
||||
NotificationTarget.id.in_(list(snapshot.keys())),
|
||||
NotificationTarget.user_id == user_id,
|
||||
)
|
||||
)
|
||||
rows = list(result.all())
|
||||
return [
|
||||
{"id": int(tid), "name": name or f"target {tid}", "count": snapshot[int(tid)]}
|
||||
for tid, name in rows
|
||||
]
|
||||
|
||||
|
||||
async def reset_user_counters(user_id: int) -> int:
|
||||
"""Clear all poll/target counters for trackers/targets owned by ``user_id``.
|
||||
|
||||
Returns the number of distinct (tracker + target) entries cleared.
|
||||
Cross-user counters are left untouched — addresses the multi-tenant
|
||||
safety hole where ``reset_counter("all")`` wiped every user's state.
|
||||
"""
|
||||
polls = await get_user_poll_failures(user_id)
|
||||
tgts = await get_user_target_failures(user_id)
|
||||
cleared = 0
|
||||
for entry in polls:
|
||||
if _poll_failure_counts.pop(entry["id"], None) is not None:
|
||||
cleared += 1
|
||||
_poll_failure_last_error.pop(entry["id"], None)
|
||||
for entry in tgts:
|
||||
if _target_failure_counts.pop(entry["id"], None) is not None:
|
||||
cleared += 1
|
||||
_target_failure_last_error.pop(entry["id"], None)
|
||||
return cleared
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backlog scan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -263,3 +263,386 @@ def test_default_template_loader_returns_bridge_self_slots() -> None:
|
||||
# Sanity: each template references at least one of the bridge_self vars.
|
||||
for tpl in list(en.values()) + list(ru.values()):
|
||||
assert "{{" in tpl
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bot commands — context builders
|
||||
#
|
||||
# These tests run against the real (temp-dir) DB via the FastAPI lifespan so
|
||||
# that ``services.bridge_self`` helpers using ``get_engine()`` resolve to the
|
||||
# same DB the test seeds rows into. We follow the pattern used by
|
||||
# test_webhook_status_handler.py — bootstrap once, seed under TestClient.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bootstrap_app():
|
||||
"""Bring up the app once so migrations run against the temp DB."""
|
||||
from notify_bridge_server.main import app
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def _seed_user_and_provider(
|
||||
*, username: str, config: dict[str, int],
|
||||
) -> tuple[int, int]:
|
||||
"""Create a fresh ``(user_id, provider_id)`` against the live engine.
|
||||
|
||||
Uses two short-lived sessions to avoid SQLAlchemy auto-expiring the
|
||||
first-committed object once a second commit fires on the same session.
|
||||
"""
|
||||
from notify_bridge_server.database.engine import get_engine
|
||||
from notify_bridge_server.database.models import ServiceProvider, User
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as db:
|
||||
user = User(
|
||||
username=f"{username}_{datetime.now(timezone.utc).timestamp()}",
|
||||
hashed_password="x", role="user",
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
user_id = int(user.id)
|
||||
async with AsyncSession(engine) as db:
|
||||
provider = ServiceProvider(
|
||||
user_id=user_id, type="bridge_self", name="Bridge",
|
||||
config=dict(config),
|
||||
)
|
||||
db.add(provider)
|
||||
await db.commit()
|
||||
await db.refresh(provider)
|
||||
provider_id = int(provider.id)
|
||||
return user_id, provider_id
|
||||
|
||||
|
||||
async def _load_provider(provider_id: int):
|
||||
from notify_bridge_server.database.engine import get_engine
|
||||
from notify_bridge_server.database.models import ServiceProvider
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as db:
|
||||
return await db.get(ServiceProvider, provider_id)
|
||||
|
||||
|
||||
def test_command_status_returns_empty_lists_when_no_failures(tmp_data_dir) -> None: # noqa: ARG001
|
||||
import asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from notify_bridge_server.commands.bridge_self_handler import (
|
||||
_build_status_context,
|
||||
)
|
||||
from notify_bridge_server.services import bridge_self as bs
|
||||
|
||||
app = _bootstrap_app()
|
||||
with TestClient(app):
|
||||
async def run() -> None:
|
||||
_user_id, provider_id = await _seed_user_and_provider(
|
||||
username="status_user",
|
||||
config={
|
||||
"poll_failure_threshold": 3,
|
||||
"deferred_backlog_threshold": 100,
|
||||
"target_failure_threshold": 5,
|
||||
},
|
||||
)
|
||||
provider = await _load_provider(provider_id)
|
||||
|
||||
# Make sure the in-memory dicts contain nothing.
|
||||
bs._poll_failure_counts.clear()
|
||||
bs._target_failure_counts.clear()
|
||||
|
||||
ctx = await _build_status_context(provider)
|
||||
|
||||
assert ctx["poll_failures"] == []
|
||||
assert ctx["target_failures"] == []
|
||||
assert ctx["deferred_pending"] == 0
|
||||
assert ctx["deferred_threshold"] == 100
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_command_thresholds_returns_user_config(tmp_data_dir) -> None: # noqa: ARG001
|
||||
import asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from notify_bridge_server.commands.bridge_self_handler import (
|
||||
_build_thresholds_context,
|
||||
)
|
||||
|
||||
app = _bootstrap_app()
|
||||
with TestClient(app):
|
||||
async def run() -> None:
|
||||
_user_id, provider_id = await _seed_user_and_provider(
|
||||
username="thresholds_user",
|
||||
config={
|
||||
"poll_failure_threshold": 7,
|
||||
"deferred_backlog_threshold": 250,
|
||||
"target_failure_threshold": 11,
|
||||
},
|
||||
)
|
||||
provider = await _load_provider(provider_id)
|
||||
|
||||
ctx = await _build_thresholds_context(provider)
|
||||
|
||||
assert ctx == {
|
||||
"poll_failure_threshold": 7,
|
||||
"deferred_backlog_threshold": 250,
|
||||
"target_failure_threshold": 11,
|
||||
}
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_command_reset_clears_named_counter_and_is_idempotent(tmp_data_dir) -> None: # noqa: ARG001
|
||||
"""``/reset`` clears the in-memory counter and is idempotent.
|
||||
|
||||
Now ownership-aware: we seed a real NotificationTracker owned by the
|
||||
test user so ``find_tracker_owner`` returns a matching user_id and the
|
||||
reset proceeds. The cross-user-rejection case is covered by
|
||||
:func:`test_command_reset_rejects_cross_user_subject` below.
|
||||
"""
|
||||
import asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from notify_bridge_server.commands.bridge_self_handler import (
|
||||
_build_reset_context, _parse_reset_subject,
|
||||
)
|
||||
from notify_bridge_server.database.engine import get_engine
|
||||
from notify_bridge_server.database.models import NotificationTracker
|
||||
from notify_bridge_server.services import bridge_self as bs
|
||||
|
||||
app = _bootstrap_app()
|
||||
with TestClient(app):
|
||||
async def run() -> None:
|
||||
user_id, provider_id = await _seed_user_and_provider(
|
||||
username="reset_user",
|
||||
config={
|
||||
"poll_failure_threshold": 3,
|
||||
"deferred_backlog_threshold": 100,
|
||||
"target_failure_threshold": 5,
|
||||
},
|
||||
)
|
||||
provider = await _load_provider(provider_id)
|
||||
|
||||
# Seed an owned NotificationTracker so the ownership check
|
||||
# in _build_reset_context can match it back to user_id.
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as db:
|
||||
tracker = NotificationTracker(
|
||||
user_id=user_id, provider_id=provider_id,
|
||||
name="reset-test", enabled=True,
|
||||
)
|
||||
db.add(tracker)
|
||||
await db.commit()
|
||||
await db.refresh(tracker)
|
||||
tid = int(tracker.id)
|
||||
|
||||
bs.reset_poll_counter(tid)
|
||||
bs.record_poll_failure(tid, "boom")
|
||||
bs.record_poll_failure(tid, "boom")
|
||||
assert bs.get_poll_failure_count(tid) == 2
|
||||
|
||||
ctx = await _build_reset_context(f"tracker:{tid}", provider)
|
||||
assert ctx["success"] is True
|
||||
assert ctx["subject_type"] == "tracker"
|
||||
assert ctx["subject_id"] == tid
|
||||
assert ctx["previous_count"] == 2
|
||||
assert ctx["error_message"] is None
|
||||
assert bs.get_poll_failure_count(tid) == 0
|
||||
|
||||
# Idempotent — second call still succeeds with previous=0.
|
||||
ctx2 = await _build_reset_context(f"tracker:{tid}", provider)
|
||||
assert ctx2["success"] is True
|
||||
assert ctx2["previous_count"] == 0
|
||||
|
||||
# Parse error → templated error, no exception.
|
||||
bad_ctx = await _build_reset_context("not a subject", provider)
|
||||
assert bad_ctx["success"] is False
|
||||
assert bad_ctx["error_message"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
# Parser direct sanity-checks (pure function, no DB needed).
|
||||
assert _parse_reset_subject("all") == ("all", None, None)
|
||||
assert _parse_reset_subject("tracker:42") == ("tracker", 42, None)
|
||||
assert _parse_reset_subject("target:7") == ("target", 7, None)
|
||||
_, _, err = _parse_reset_subject("rocket:1")
|
||||
assert err is not None
|
||||
|
||||
|
||||
def test_command_reset_rejects_cross_user_subject(tmp_data_dir) -> None: # noqa: ARG001
|
||||
"""User A cannot reset a counter belonging to user B's tracker.
|
||||
|
||||
Regression guard for the multi-tenant data-leak hole the original
|
||||
handler had — ``reset_counter`` was called without verifying the
|
||||
subject's ``user_id`` matched ``provider.user_id``.
|
||||
"""
|
||||
import asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from notify_bridge_server.commands.bridge_self_handler import _build_reset_context
|
||||
from notify_bridge_server.database.engine import get_engine
|
||||
from notify_bridge_server.database.models import NotificationTracker
|
||||
from notify_bridge_server.services import bridge_self as bs
|
||||
|
||||
app = _bootstrap_app()
|
||||
with TestClient(app):
|
||||
async def run() -> None:
|
||||
user_a_id, provider_a_id = await _seed_user_and_provider(
|
||||
username="user_a", config={
|
||||
"poll_failure_threshold": 3,
|
||||
"deferred_backlog_threshold": 100,
|
||||
"target_failure_threshold": 5,
|
||||
},
|
||||
)
|
||||
user_b_id, provider_b_id = await _seed_user_and_provider(
|
||||
username="user_b", config={
|
||||
"poll_failure_threshold": 3,
|
||||
"deferred_backlog_threshold": 100,
|
||||
"target_failure_threshold": 5,
|
||||
},
|
||||
)
|
||||
provider_a = await _load_provider(provider_a_id)
|
||||
|
||||
# Seed a tracker owned by user B and increment its counter.
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as db:
|
||||
tracker_b = NotificationTracker(
|
||||
user_id=user_b_id, provider_id=provider_b_id,
|
||||
name="b-only", enabled=True,
|
||||
)
|
||||
db.add(tracker_b)
|
||||
await db.commit()
|
||||
await db.refresh(tracker_b)
|
||||
tid_b = int(tracker_b.id)
|
||||
|
||||
bs.reset_poll_counter(tid_b)
|
||||
bs.record_poll_failure(tid_b, "boom")
|
||||
assert bs.get_poll_failure_count(tid_b) == 1
|
||||
|
||||
# User A tries to reset user B's tracker — must fail.
|
||||
ctx = await _build_reset_context(f"tracker:{tid_b}", provider_a)
|
||||
assert ctx["success"] is False
|
||||
assert "not found" in (ctx["error_message"] or "").lower()
|
||||
# Counter must remain untouched.
|
||||
assert bs.get_poll_failure_count(tid_b) == 1
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_command_health_is_healthy_when_counters_zero(tmp_data_dir) -> None: # noqa: ARG001
|
||||
import asyncio
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from notify_bridge_server.commands.bridge_self_handler import (
|
||||
_build_health_context,
|
||||
)
|
||||
from notify_bridge_server.services import bridge_self as bs
|
||||
|
||||
app = _bootstrap_app()
|
||||
with TestClient(app):
|
||||
async def run() -> None:
|
||||
_user_id, provider_id = await _seed_user_and_provider(
|
||||
username="health_user",
|
||||
config={
|
||||
"poll_failure_threshold": 3,
|
||||
"deferred_backlog_threshold": 100,
|
||||
"target_failure_threshold": 5,
|
||||
},
|
||||
)
|
||||
provider = await _load_provider(provider_id)
|
||||
|
||||
# Empty counters and no deferred rows for this user.
|
||||
bs._poll_failure_counts.clear()
|
||||
bs._target_failure_counts.clear()
|
||||
|
||||
ctx = await _build_health_context(provider)
|
||||
|
||||
assert ctx["healthy"] is True
|
||||
assert ctx["failing_tracker_count"] == 0
|
||||
assert ctx["failing_target_count"] == 0
|
||||
assert ctx["deferred_pending"] == 0
|
||||
assert "healthy" in ctx["summary"].lower()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reset_counter direct unit test (covers the "all" branch)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reset_counter_all_clears_every_dict() -> None:
|
||||
from notify_bridge_server.services import bridge_self as bs
|
||||
|
||||
# Seed both dicts with a couple of entries.
|
||||
bs._poll_failure_counts.clear()
|
||||
bs._target_failure_counts.clear()
|
||||
bs.record_poll_failure(9_401, "boom")
|
||||
bs.record_poll_failure(9_402, "boom")
|
||||
bs.record_target_failure(9_501, "503")
|
||||
|
||||
cleared = bs.reset_counter("all")
|
||||
|
||||
# 2 poll + 1 target = 3 entries cleared.
|
||||
assert cleared == 3
|
||||
assert bs._poll_failure_counts == {}
|
||||
assert bs._target_failure_counts == {}
|
||||
|
||||
|
||||
def test_reset_counter_unknown_failure_type_is_noop() -> None:
|
||||
from notify_bridge_server.services import bridge_self as bs
|
||||
|
||||
bs._poll_failure_counts.clear()
|
||||
bs.record_poll_failure(9_601, "boom")
|
||||
|
||||
# Unknown type returns 0 and leaves dicts intact.
|
||||
assert bs.reset_counter("rocket_failures", 9_601) == 0
|
||||
assert bs.get_poll_failure_count(9_601) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability / handler registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_capability_registry_lists_bridge_self_commands() -> None:
|
||||
from notify_bridge_core.providers.capabilities import get_capabilities
|
||||
|
||||
caps = get_capabilities("bridge_self")
|
||||
assert caps is not None
|
||||
|
||||
cmd_names = {c["name"] for c in caps.commands}
|
||||
assert {"status", "thresholds", "reset", "health", "help"}.issubset(cmd_names)
|
||||
|
||||
slot_names = {s["name"] for s in caps.command_slots}
|
||||
# Response slots
|
||||
assert {"status", "thresholds", "reset", "health"}.issubset(slot_names)
|
||||
# Description slots — needed so the menu sync registers a description
|
||||
# for every operator-facing command.
|
||||
assert {
|
||||
"desc_status", "desc_thresholds", "desc_reset", "desc_health",
|
||||
}.issubset(slot_names)
|
||||
|
||||
|
||||
def test_command_handler_registered_for_bridge_self() -> None:
|
||||
"""Auto-registration wires the bridge_self handler into dispatch."""
|
||||
from notify_bridge_server.commands.dispatch import get_handler
|
||||
|
||||
handler = get_handler("bridge_self")
|
||||
assert handler is not None
|
||||
assert handler.provider_type == "bridge_self"
|
||||
assert {"status", "thresholds", "reset", "health"} == handler.get_provider_commands()
|
||||
|
||||
|
||||
def test_default_command_template_loader_returns_bridge_self_slots() -> None:
|
||||
"""All shipped command-template slots load for both locales."""
|
||||
from notify_bridge_core.templates.command_defaults.loader import (
|
||||
load_default_command_templates,
|
||||
)
|
||||
|
||||
en = load_default_command_templates("en", "bridge_self")
|
||||
ru = load_default_command_templates("ru", "bridge_self")
|
||||
|
||||
required = {"status", "thresholds", "reset", "health", "help", "start"}
|
||||
assert required.issubset(en.keys())
|
||||
assert required.issubset(ru.keys())
|
||||
|
||||
Reference in New Issue
Block a user