Files
notify-bridge/.claude/reviews/frontend-review.md
T
alexei.dolgolyov 6a8f374678 feat: observability, per-receiver Telegram options, oversized-video fallback
Operability:
- Correlation IDs end-to-end: shared dispatch_id between log lines and
  EventLog rows (event/watcher/scheduled/deferred/action/HA/command paths)
  and a new X-Request-Id middleware that normalizes inbound ids and binds
  request_id into log context.
- dispatch_summary block merged into EventLog.details: per-target
  success/failure counts plus Telegram media delivered/skipped/failed and
  truncated error lists, so partial outcomes surface in the UI.
- Diagnostic mode: admin can flip one module to DEBUG for a bounded
  window with auto-revert (in-memory only; setup_logging() resets on
  boot, lifespan reverts on shutdown). New /diagnostic-mode endpoints
  plus DiagnosticsCassette UI on the settings page.

Telegram:
- Per-receiver options: disable_notification (silent send) and
  message_thread_id (forum-topic routing), wired through the dispatcher
  via a ContextVar so all four send sites (sendMessage / sendPhoto-Video-
  Document / sendMediaGroup / cache-hit POST) pick them up.
- send_large_videos_as_documents target setting: bypass the 50 MB
  sendVideo cap by falling back to sendDocument for oversized videos.
- sendMediaGroup byte-budget enforcement (TELEGRAM_MAX_GROUP_TOTAL_BYTES,
  45 MB) with per-item fallback on chunk failure so a stale file_id no
  longer silently drops a cached asset.

Tests:
- New: diagnostic_mode, dispatch_summary, request_correlation,
  telegram_media_group_partial, telegram_per_send_options.

Docs:
- .claude/reviews/: six-axis production-readiness review of v0.8.1.
- .claude/docs/functional-review-2026-05-28.md: focused review of
  Telegram/Immich/logging subsystems.
2026-05-28 15:19:31 +03:00

683 lines
29 KiB
Markdown

# Frontend Production-Readiness Review
Scope: `frontend/src/**` (~26k lines, Svelte 5 runes + SvelteKit). `npm run check`
passes with exit code 0. The codebase is in good shape overall - i18n EN/RU keys
are 1:1 in sync (1466 each), Modal/Snackbar overlays follow the `position:fixed`
+ `z-index:9999` convention, no `eval`, no `innerHTML`, no string-interpolated
`setTimeout`, and the sanitizer (`lib/sanitize.ts`) is a sound DOMParser-based
allowlist. The issues below are real production risks layered on top of an
otherwise clean architecture.
## Executive Summary
- **Auth tokens live in `localStorage`** (`lib/api.ts`). Any XSS that bypasses
the (good) `sanitizePreview` allowlist - or sneaks past it via a future code
path - exfiltrates both access and refresh tokens. There is no httpOnly-cookie
alternative, no token rotation on refresh failure, and `redirectToLogin` only
fires once per session (a leaked refresh token can outlive that flag).
- **One real provider-hardcoding violation** (`routes/actions/RuleEditor.svelte`)
breaks the "descriptors only" rule in CLAUDE.md item 8 and silently disables
the people/album picker for any non-Immich provider - every other page is
clean.
- **Caches duplicated into local `$state`** on `notification-trackers`,
`command-trackers`, and `command-template-configs` pages - the cache is
populated but the page never re-reads it, so cross-page mutations (search
palette pre-warming) won't update the list and cache `invalidate()` becomes
useless. Convention #4 says "always use cache".
- **Three CRUD pages refetch all entities after every mutation** (full
`await load()` after upsert/delete) instead of using `cache.upsert()`/
`remove()` - defeats the optimistic-cache design and produces visible flicker
on slow connections.
- **Floating async work + N+1 patterns**: `providers/+page.svelte` fires N
parallel health checks without an AbortController (state writes continue
after navigation); `bots/TelegramBotTab.svelte` does a sequential
`for (const trk of trackers) { await api('/listeners') }` loop.
- **`backup/+page.svelte` post-restart health poll** keeps recursing for up to
120s with no unmount guard - if the user navigates away mid-restart, the
recursive `setTimeout` chain keeps calling `fetch('/api/health')` until it
reloads the page out from under whatever route they're on.
- **`api()` 30s timeout is per-request, hard-coded, with no observability** -
long-running provider operations (Immich bulk fetch, full backup export) hit
it silently and surface as `AbortError` with no telemetry.
---
## CRITICAL
### C1. JWT tokens stored in `localStorage` - XSS-exfiltratable
[lib/api.ts:78-91](frontend/src/lib/api.ts#L78-L91)
```ts
function getToken(): string | null {
return localStorage.getItem('access_token');
}
export function setTokens(access: string, refresh: string) {
localStorage.setItem('access_token', access);
localStorage.setItem('refresh_token', refresh);
}
```
Both the short-lived access token and the long-lived refresh token sit in
`localStorage`. Any successful XSS - including a future template-preview path
that escapes `sanitizePreview`, a vulnerable third-party CodeMirror extension,
or a Telegram bot username that ends up unescaped somewhere - reads both with a
single `localStorage.getItem` call.
**Fix:** Move to httpOnly + Secure + SameSite=Strict cookies set by the backend.
If a cookie-based session is infeasible for the deployment model, at minimum
move the refresh token to an httpOnly cookie and keep only the short-lived
access token in memory (a module-level `let accessToken` is XSS-readable but
not persistent across reloads, which limits the exfiltration window).
### C2. Provider type hardcoded in `RuleEditor.svelte` (convention violation)
[routes/actions/RuleEditor.svelte:55-67](frontend/src/routes/actions/RuleEditor.svelte#L55-L67)
```ts
async function loadProviderData() {
if (actionType !== 'auto_organize') return;
const provider = providersCache.items.find((p: any) => p.id === providerId);
if (!provider || provider.type !== 'immich') return;
...
```
CLAUDE.md item 8 explicitly forbids `if (type === 'immich')` in components -
this is the canonical example. As written, adding a second provider with
auto-organize support (Google Photos, future SmugMug, etc.) is a silent no-op:
the form renders with empty people/album lists and gives no error.
**Fix:** Add an `actionTypes` / `peopleFilter` capability flag to
`ProviderDescriptor`, or add a `supportsAutoOrganize: boolean` discriminator,
then check `getDescriptor(provider.type)?.supportsAutoOrganize` instead of the
literal string.
---
## HIGH
### H1. Caches imported but copied into local `$state` - invalidation no-op
[routes/notification-trackers/+page.svelte:33](frontend/src/routes/notification-trackers/+page.svelte#L33)
[routes/command-trackers/+page.svelte:27](frontend/src/routes/command-trackers/+page.svelte#L27)
[routes/command-template-configs/+page.svelte:51](frontend/src/routes/command-template-configs/+page.svelte#L51)
```ts
// notification-trackers - line 33
let allNotificationTrackers = $state<Tracker[]>([]);
// ...
[allNotificationTrackers] = await Promise.all([
api<Tracker[]>('/notification-trackers'),
...
]);
```
The cache modules expose `notificationTrackersCache`, `commandTrackersCache`,
and `commandTemplateConfigsCache` - populated by `+layout.svelte` on mount and
by the search palette - but these three pages don't read from them. They each
issue their own `api(...)` call and store the result locally. Side effects:
1. The cache shows stale data on every other page that reads it (dashboard nav
counts, search palette).
2. `commandTemplateConfigsCache.fetch(true)` is called on `command-template-configs`
`load()` but the result is then re-assigned from the function return value
into `allCmdTplConfigs` - the cache itself is updated, but the page has no
reactive link to it.
3. `cache.upsert()` / `cache.remove()` after mutations would short-circuit a
full refetch - but with the local-state copy, every save triggers a full
`await load()` (see H2).
**Fix:** Replace `let allX = $state([])` with `let allX = $derived(cache.items)`
(see how `targets/+page.svelte:147` does it correctly) and remove the parallel
`api()` call.
### H2. Full refetch after every mutation - cache.upsert/remove not used
[routes/providers/+page.svelte:238-250](frontend/src/routes/providers/+page.svelte#L238-L250)
[routes/actions/+page.svelte:139](frontend/src/routes/actions/+page.svelte#L139)
[routes/notification-trackers/+page.svelte:291](frontend/src/routes/notification-trackers/+page.svelte#L291)
[routes/targets/+page.svelte:476](frontend/src/routes/targets/+page.svelte#L476)
Every save/delete/toggle on these pages calls `cache.invalidate(); await load()`,
which re-fetches the entire list from the server. The cache exposes
`upsert(entity)` and `remove(id)` for exactly this case - the server already
returned the new entity (or 204), so the round-trip is wasted bandwidth and
produces a visible "list redraws" flash on slow links.
**Fix:** On POST/PUT response, `cache.upsert(savedEntity)`. On DELETE,
`cache.remove(id)`. Reserve `invalidate()` + `fetch()` for cases where the
mutation may have changed *other* entities (e.g. broadcast target updates
affect children).
### H3. Provider health checks fire-and-forget - leak past navigation
[routes/providers/+page.svelte:175-181](frontend/src/routes/providers/+page.svelte#L175-L181)
```ts
for (const p of allProviders) {
health = { ...health, [p.id]: null };
api(`/providers/${p.id}/test`, { method: 'POST' })
.then((r: any) => { health = { ...health, [p.id]: r.ok }; })
.catch(() => { health = { ...health, [p.id]: false }; });
}
```
No `AbortController`, no unmount guard. If the user navigates away while N
slow Immich/Gitea probes are inflight, every probe still resolves and tries to
write to the (now-detached) `health` `$state`. With Svelte 5 runes this won't
crash, but it does waste backend connections (Immich health checks call the
real API) and may trigger duplicate probes on quick back/forward navigation.
**Fix:** Pass `{ signal: controller.signal }` to `api()` (already supported -
see `lib/api.ts:150`), abort in `onDestroy`. Or use `cache.probeAll()` driven
from a single store so revisiting the page reuses the previous result.
### H4. Sequential awaits for independent fetches - N+1 in TelegramBotTab
[routes/bots/TelegramBotTab.svelte:215-223](frontend/src/routes/bots/TelegramBotTab.svelte#L215-L223)
```ts
const trackers = await api<CommandTrackerSummary[]>('/command-trackers');
const matched: CommandTrackerSummary[] = [];
for (const trk of trackers) {
try {
const listeners = await api<ListenerEntry[]>(`/command-trackers/${trk.id}/listeners`);
const hasBot = listeners.some(...);
if (hasBot) matched.push(trk);
} catch (e) { console.warn(...); }
}
```
For a deployment with 20 command trackers, opening the listener section on a
bot triggers 20 serial `GET /command-trackers/{id}/listeners` requests -
visibly slow over a high-latency link.
**Fix:** Either expose a single backend endpoint
(`GET /command-trackers/listeners?bot_id=X`) or run the loop through
`Promise.all(trackers.map(trk => api(...).catch(() => null)))` and filter
afterwards.
### H5. Post-restart health poll keeps running after unmount
[routes/settings/backup/+page.svelte:117-139](frontend/src/routes/settings/backup/+page.svelte#L117-L139)
```ts
async function applyAndRestart(): Promise<void> {
await api('/backup/apply-restart', { method: 'POST' });
restartingOverlay = true;
const startedAt = Date.now();
let attempts = 0;
const poll = async (): Promise<void> => {
attempts += 1;
try {
const res = await fetch('/api/health');
if (res.ok && Date.now() - startedAt > 2000) {
window.location.reload();
return;
}
} catch { /* still down */ }
if (attempts < 120) setTimeout(poll, 1000);
};
setTimeout(poll, 1500);
}
```
The recursive `setTimeout(poll, 1000)` chain has no cancellation. If the user
navigates to another route between `apply-restart` and the next health probe,
the chain keeps firing for up to 120s and eventually calls
`window.location.reload()` from a route the user has since moved away from.
Side effects:
1. Unauthenticated `fetch('/api/health')` calls keep going while the user is
on `/login`.
2. A user who hit "restart later" on a different tab will still get reloaded
from the original tab's poll.
**Fix:** Capture `controller = new AbortController()` and pass to `fetch`,
`onDestroy(() => controller.abort())`. Also store the timeout handle and
`clearTimeout` it on destroy.
### H6. Token refresh races with logout in a sneaky edge
[lib/api.ts:97-127](frontend/src/lib/api.ts#L97-L127)
The dedupe via `refreshPromise` is correct *for the refresh itself*, but the
outer `api()` reads `getToken()` before awaiting `refreshAccessToken()`. Three
concurrent requests that all 401 will all queue on the same refresh promise,
then *all* retry - fine. But if the refresh succeeds and an unrelated
`clearTokens()` (from `logout()`) fires between the refresh resolving and the
retry running, the retry uses an empty `Authorization: Bearer ` header. The
result is "ApiError: HTTP 401" surfaced via snackbar even though the redirect
to `/login` already happened.
**Fix:** Either re-check `isAuthenticated()` immediately before the retry, or
make `clearTokens()` cancel an inflight `refreshPromise`.
### H7. `AuthRedirectError` is thrown but not consistently caught
[lib/api.ts:165-170](frontend/src/lib/api.ts#L165-L170)
Most pages use the pattern `catch (err: unknown) { snackError(errMsg(err)); }` -
which catches `AuthRedirectError` too and shows "Unauthorized - redirecting
to login" in a snackbar that the user sees *as* the route changes. The error
class exists specifically to be distinguished, but only one or two call sites
actually check `instanceof AuthRedirectError` before showing a snackbar.
**Fix:** Make `errMsg()` (or a new helper) return `null` for `AuthRedirectError`
and have snackbar helpers ignore null messages. Or filter in the snackbar
store.
### H8. `api()` JSON-decode failure path swallowed silently
[lib/api.ts:189](frontend/src/lib/api.ts#L189)
```ts
return res.json();
```
When the backend returns a `200 OK` with a non-JSON body (proxy error page,
HTML 502 from a misconfigured reverse proxy in front), `res.json()` rejects
with a `SyntaxError: Unexpected token < in JSON at position 0`. The page
shows the raw parser message in a snackbar, which is confusing UX.
**Fix:** Wrap `res.json()` in try/catch and throw a typed `ApiError("Backend
returned non-JSON response", 502)` so the UI can show a clean message.
### H9. Email/Matrix bot tabs strip secrets via `as any`
[routes/bots/EmailBotTab.svelte:84](frontend/src/routes/bots/EmailBotTab.svelte#L84)
[routes/bots/MatrixBotTab.svelte:79](frontend/src/routes/bots/MatrixBotTab.svelte#L79)
```ts
if (!body.smtp_password) delete (body as any).smtp_password;
if (editingMatrix && !body.access_token) delete (body as any).access_token;
```
The `as any` bypass exists because the body type doesn't allow `delete` on a
required field. The intent - "don't send a blank secret which would overwrite
the stored one" - is correct, but the cast hides a real risk: if the field
name ever changes (`smtp_password` -> `smtpPassword`), the `delete` is a no-op
and the blank field is sent.
**Fix:** Build `body` as `Partial<...>` from the start and only conditionally
include the secret field.
### H10. `template-configs` hardcodes a slot name
[routes/template-configs/+page.svelte:228](frontend/src/routes/template-configs/+page.svelte#L228)
```ts
.map(s => ({ key: s.name, label: ..., rows: s.name === 'message_assets_added' ? 10 : 3, isDateFormat: false }))
```
Special-casing one Immich slot name inside a provider-agnostic component is
the same pattern CLAUDE.md item 8 forbids for components, scoped to template
configs. Other providers' "large" slots (Gitea PR descriptions, Planka card
content) would render in 3-row editors that the author probably didn't intend.
**Fix:** Add a `rows?: number` field to the backend slot definition and read
it via `notification_slots[].rows`.
---
## MEDIUM
### M1. Three placeholder strings hardcoded English in shared components
[lib/components/EntitySelect.svelte:18](frontend/src/lib/components/EntitySelect.svelte#L18)
[lib/components/IconGridSelect.svelte:16](frontend/src/lib/components/IconGridSelect.svelte#L16)
[lib/components/MultiEntitySelect.svelte:16](frontend/src/lib/components/MultiEntitySelect.svelte#L16)
```ts
placeholder = 'Select...',
```
These defaults render `Select...` in RU locale when a caller doesn't pass an
explicit placeholder. The convention (CLAUDE.md item 5) prescribes plain text
selectors but says nothing about translation - these still need to flow through
`t()`.
**Fix:** Move the default into the template: `placeholder = $props().placeholder
?? t('common.selectPlaceholder')`, with `common.selectPlaceholder` added to
both locales.
### M2. `EntitySelect.noneLabel` defaults to a decorative em-dash literal
[lib/components/EntitySelect.svelte:20](frontend/src/lib/components/EntitySelect.svelte#L20)
```
noneLabel = (em-dash literal),
```
CLAUDE.md item 5 calls out decorative dashes specifically. `LinkedTargetsSection`
already overrides this with `t('common.noneDefault')` (good), but other
consumers that do not override get the bare em-dash. It also fails the
localizable smell test.
**Fix:** Default to `t('common.none')`.
### M3. `lib/auth.svelte.ts` logout does a full page reload, losing UX continuity
[lib/auth.svelte.ts:54-61](frontend/src/lib/auth.svelte.ts#L54-L61)
```ts
export function logout() {
clearTokens();
clearAllCaches();
user = null;
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}
```
`window.location.href` triggers a hard reload - the SvelteKit router exists
specifically to avoid this. Side effects: any inflight requests get cancelled
without proper cleanup, the splash-loader flashes between the two pages, and
the search-palette / overlays do not get a chance to close gracefully.
**Fix:** `goto('/login', { invalidateAll: true, replaceState: true })`.
### M4. `+layout.svelte` auto-expand `$effect` writes during read
[routes/+layout.svelte:336-342](frontend/src/routes/+layout.svelte#L336-L342)
The effect reads `expandedGroups` (via `expandedGroups[entry.key]`) and writes
to `expandedGroups`. Svelte 5 dedupes the write back to the same set of keys,
but the pattern is fragile - adding any side effect that re-derives from
`expandedGroups` here would loop. It also persists to localStorage in
`toggleGroup` but not from this effect - so auto-expansion stays in memory only.
**Fix:** Compute the next state in a single pass and write once; either
include the localStorage save, or move the auto-expand into the initial
hydration block.
### M5. `commandTemplateConfigsCache.fetch(true)` result discarded; cache populated but unused
[routes/command-template-configs/+page.svelte:208](frontend/src/routes/command-template-configs/+page.svelte#L208)
The `Promise.all` destructures `cfgs` from `commandTemplateConfigsCache.fetch(true)`
but then writes `allCmdTplConfigs = cfgs` instead of $derived-reading the cache.
The cache is updated (good) but this page never reads it (bad - see H1).
**Fix:** Same fix as H1 - use `$derived(commandTemplateConfigsCache.items)`.
### M6. Dashboard search debounce timeout not cleared on filter change
[routes/+page.svelte:268-272](frontend/src/routes/+page.svelte#L268-L272)
If the user changes the type/provider filter (`applyFilters` runs synchronously
from the `$effect` at line 249) while a search debounce is pending, the pending
timeout still fires 300ms later and triggers an identical request. Not a leak,
just a wasted call.
**Fix:** Clear `searchTimeout` from `applyFilters()` as well.
### M7. Dashboard `Promise.all` destructure uses empty middle slot
[routes/+page.svelte:283-287](frontend/src/routes/+page.svelte#L283-L287)
```ts
const [statusRes, , chartRes] = await Promise.all([
api<DashboardStatus>(`/status?limit=${eventsLimit}`),
providersCache.fetch(),
api<{ days: ... }>('/status/chart'),
]);
```
The empty middle slot is brittle - anyone reordering for readability silently
swaps `statusRes` and `chartRes`. Trivially avoided.
**Fix:** Either await `providersCache.fetch()` separately (it caches anyway),
or `const [statusRes, _providers, chartRes] = ...` with an explicit `_providers`
local.
### M8. `actions/+page.svelte` derives `actionTypes` from a function-in-derived
[routes/actions/+page.svelte:78-81](frontend/src/routes/actions/+page.svelte#L78-L81)
```ts
let actionTypes = $derived((() => {
const caps = capabilitiesCache.items[selectedProviderType];
return caps?.action_types || [];
})());
```
The IIFE is unnecessary; `$derived` already runs the expression on every
dependency change. Reads as a refactor leftover.
**Fix:** `let actionTypes = $derived(capabilitiesCache.items[selectedProviderType]?.action_types ?? []);`
### M9. `RuleEditor.svelte` mutates rule object in `toggleRule` then sends to API
[routes/actions/RuleEditor.svelte:105-108](frontend/src/routes/actions/RuleEditor.svelte#L105-L108)
```ts
async function toggleRule(rule: ActionRule) {
rule.enabled = !rule.enabled;
await updateRule(rule);
}
```
Direct mutation of the prop violates the immutability rule (coding-style.md).
If the API call fails, the local state is already flipped - the UI shows the
new value even though the server still has the old one.
**Fix:** `await updateRule({ ...rule, enabled: !rule.enabled })`. After
successful response, `await loadRules()` (already happens) re-syncs.
### M10. `+layout.svelte` filter functions use `as any[]` four times
[routes/+layout.svelte:145-151](frontend/src/routes/+layout.svelte#L145-L151)
```ts
notification_trackers: filterById(notificationTrackersCache.items as any[]).length,
```
The cast exists because `filterById<T extends { provider_id?: number }>` is
narrower than the cache item types. The proper fix is a single base interface
`{ provider_id?: number }` on the relevant types so the cast goes away.
### M11. `setLocale` does not update `<html lang>` attr
[lib/i18n/index.svelte.ts:31-36](frontend/src/lib/i18n/index.svelte.ts#L31-L36)
Screen readers and browser translation extensions rely on `<html lang="en">`.
The app never sets it, so switching to RU leaves accessibility tooling thinking
the page is still English.
**Fix:** `document.documentElement.lang = locale` in `setLocale`.
### M12. `Modal.svelte` focus restore does not verify element still in DOM
[lib/components/Modal.svelte:43-45](frontend/src/lib/components/Modal.svelte#L43-L45)
If the previously focused element has been removed from the DOM between modal
open and close (common with optimistic UI updates that rerender the source
button), `.focus()` is a silent no-op on a detached node. Focus ends up on
`<body>` and the next Tab restarts from the top of the page.
**Fix:** `if (... && document.contains(previouslyFocused)) previouslyFocused.focus()`,
else focus a sensible fallback (the trigger that opened the page).
### M13. TimezoneSelector ticks at 1s - wakes the event loop forever
[lib/components/TimezoneSelector.svelte:33-37](frontend/src/lib/components/TimezoneSelector.svelte#L33-L37)
```ts
let tickHandle: ReturnType<typeof setInterval> | null = null;
onMount(() => {
tickHandle = setInterval(() => { now = new Date(); }, 1000);
});
```
A 1Hz tick is fine for visible UI; the issue is it keeps running even when
the selector dropdown is closed (the time display is only visible when the
dropdown is open). Battery impact is non-trivial on mobile for what is
essentially a hidden component.
**Fix:** Start/stop the interval based on `open` state, or use
`requestAnimationFrame` driven by `IntersectionObserver`.
### M14. Backup file download builds blob from JSON without size guard
[routes/settings/backup/+page.svelte:269-281](frontend/src/routes/settings/backup/+page.svelte#L269-L281)
```ts
const data = await api(`/backup/files/${filename}`);
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
```
For a deployment with hundreds of providers/trackers, the JSON serialization
of the entire backup happens in-memory in a single string before the Blob
constructor - wasted memory peak and a frozen tab on slow machines. Worse,
`api()` parses the JSON and then `JSON.stringify` re-serializes it.
**Fix:** Use `fetchAuth()` for the download path and pipe the response stream
straight into a Blob (`new Blob([await res.arrayBuffer()])`).
### M15. Modal focus-trap query selector includes disabled inputs
[lib/components/Modal.svelte:62-67](frontend/src/lib/components/Modal.svelte#L62-L67)
Re-querying the DOM on every Tab keystroke is OK but means disabled inputs
(common in long forms with submit-in-progress) are included in the trap and
focus can land on them. The selector should add `:not([disabled])`.
### M16. i18n resolve uses any for the recursion accumulator
[lib/i18n/index.svelte.ts:55-62](frontend/src/lib/i18n/index.svelte.ts#L55-L62)
```ts
function resolve(obj: any, path: string): string | undefined {
```
`obj: unknown` plus a runtime check would let TS narrow `current` properly and
catch the case where someone accidentally passes a `string` (returns undefined
silently today).
### M17. Tracker name auto-set string concat - English-only
[routes/notification-trackers/+page.svelte:82-84](frontend/src/routes/notification-trackers/+page.svelte#L82-L84)
[routes/command-trackers/+page.svelte:69-71](frontend/src/routes/command-trackers/+page.svelte#L69-L71)
```ts
form.name = provider ? `${provider.name} Tracker` : 'Tracker';
form.name = provider ? `${provider.name} Commands` : 'Commands';
```
Defaults the tracker name to "Provider Name Tracker" / "Provider Name Commands"
- only English. Russian users get an English suffix on the auto-generated
name. Inconsistent with the rest of the i18n discipline.
**Fix:** Use `t('notificationTracker.defaultName').replace('{name}', provider.name)`.
### M18. topbar-action store not cleared on auth state change
[routes/providers/+page.svelte:160-167](frontend/src/routes/providers/+page.svelte#L160)
Each page sets a topbar CTA in `onMount` and clears it in `onDestroy`. If
`logout()` is called from inside the page (via the search palette, etc.), the
page never destroys cleanly and the topbar action sticks into the login screen.
Defensive `topbarAction.clear()` in `logout()` would plug this.
### M19. Many `: any` and `as any` types in critical paths
[routes/users/+page.svelte:62](frontend/src/routes/users/+page.svelte#L62)
[routes/command-trackers/+page.svelte:27](frontend/src/routes/command-trackers/+page.svelte#L27)
[routes/providers/+page.svelte:179](frontend/src/routes/providers/+page.svelte#L179)
[lib/providers/types.ts:120](frontend/src/lib/providers/types.ts#L120)
64 occurrences of `: any` / `as any` across 20 files. None are in
security-sensitive paths, but they remove type safety in exactly the call
sites that shape API requests (`body: any = { ... }`). Recommended cleanup
task, not a blocker.
---
## LOW
### L1. +page.svelte event types hardcoded in three parallel maps
[routes/+page.svelte:475-512](frontend/src/routes/+page.svelte#L475-L512)
`eventLabels`, `eventIcons`, and `eventGradients` are three parallel dicts
keyed by the same set of strings. Adding a new event type requires editing
three places (plus i18n). A single `EVENT_META` object would be more
maintainable.
### L2. TestMenu.svelte uses z-index 9998 instead of 9999
[routes/notification-trackers/TestMenu.svelte:25](frontend/src/routes/notification-trackers/TestMenu.svelte#L25)
```svelte
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
```
The convention says 9999 for overlays. Using 9998 was probably intentional
(so the menu sits above the backdrop), but the cleaner pattern is to give the
backdrop a slightly lower stacking context inside the same parent.
### L3. console.warn left in production-bound code
14 `console.warn`/`console.error` occurrences. Most are guarded by a
"failed to load" + UI fallback - legitimate debug noise. Recommend wiring to
a structured logger before public release; current state is acceptable for an
internal tool but spam-prone in DevTools.
### L4. Dashboard setTimeout(animateCount, 200) is uncancelled
[routes/+page.svelte:290-299](frontend/src/routes/+page.svelte#L290-L299)
The 200ms delay before triggering count animations is uncancelled. Navigating
away during the first 200ms means the count animation `requestAnimationFrame`
chain still runs against a stale `status` reference. Cosmetic only.
### L5. app.html inline theme bootstrap reads localStorage without try/catch
[src/app.html:12](frontend/src/app.html#L12)
Theme is hydrated synchronously in `<head>` to avoid FOUC - fine - but if
localStorage is blocked (Safari private mode, some enterprise policies) the
inline script throws and the rest of the head bootstrap may be skipped.
### L6. EventChart computes activeTypes and hasData from same loop twice
[lib/components/EventChart.svelte:46-49](frontend/src/lib/components/EventChart.svelte#L46-L49)
`hasData` and `activeTypes` traverse the same data twice. Single-pass
derivation would be cheaper for the rare "many days of events" case.
### L7. Single-letter t shadowing in +layout.svelte
`+layout.svelte:140` uses `for (const t of targets)` inside `navCounts`, which
shadows the imported i18n function `t`. Svelte 5 does not flag it (inner scope
wins), but it confuses search/grep and breaks IDE go-to-definition. Several
other pages use single-letter `t` as iteration var (`actions/+page.svelte`,
`command-trackers/+page.svelte`, `targets/+page.svelte`). Recommend `target` /
`tracker` for legibility.
---
## Notes & non-findings
- **Modal overlay convention** (CLAUDE.md #2): Modal.svelte, Snackbar,
IconPicker, IconGridSelect, MultiEntitySelect, EntitySelect, TimezoneSelector,
EventChart, Hint, SearchPalette, and TestMenu all use `position:fixed` with
`z-index: 9999` (or 9998 for the TestMenu backdrop - see L2). Convention
upheld.
- **@html usage** - only three call sites, all pipe through `sanitizePreview`,
which is a DOMParser-based allowlist limited to `B`, `I`, `CODE`, `PRE`, `A`,
`BR` with `https?://` href validation. Safe.
- **i18n parity**: EN and RU JSON have the exact same 1466 keys - no orphans.
- **Selector placeholders**: `LinkedTargetsSection` correctly uses
`t('common.noneDefault')`, no em-dash leaks in user-facing flows (only
defaults inside shared components - see M1/M2).
- **svelte-check passes** (exit 0) - no type errors at the strict level the
project compiles with.
- **No eval, new Function, or string-setTimeout**: dynamic code execution
surface is clean.
- **No var declarations**, no `==` (loose equality) outside generated CSS.
- **AbortController usage**: present in `lib/api.ts` for the canonical fetch
wrapper - the rest of the codebase could lean on it more (see H3, H5).