a7a2b4efa4
Backend
- Per-chat album scope for Immich commands (search/latest/memory/...): new
allowed_album_ids on CommandTrackerListener, threaded listener/page kwargs
through ProviderCommandHandler.handle; PATCH listener-scope endpoint.
- /search and /find accept a trailing page number; Immich client search_smart
/ search_metadata take a page param.
- Immich person-asset lookup switched from removed GET /api/people/{id}/assets
to POST /api/search/metadata with personIds (fixes /person command and
auto_organize rules silently returning zero candidates on Immich 1.106+).
- Auto_organize rule now sets the target album's thumbnail to the first added
image when missing (falls back to any asset type); failures do not fail the
rule. add_assets_to_album surfaces the Immich error body on non-2xx.
- EventLog.user_id / action_id / action_name columns with defensive migration
+ backfill. Status query filters by user_id directly; Immich/webhook paths
emit user_id explicitly. action_runner writes an action_success/partial/
failed event on each non-dry-run.
- Dashboard DELETE /api/status/events (scoped to user_id) + rendering live
tracker/provider/action names via FK join with snapshot fallback.
- PATCH /api/users/{id} for username/role change with last-admin guard.
- Deletion protection returns structured {message, entity, blocked_by}
(ApiError carries .blockedBy; frontend opens BlockedByModal).
- Backup prepare-restore → AppSetting markers + atomic write of
pending_restore.json; lifespan hook applies on next startup and archives
under data/applied_restores/. apply-restart sends SIGTERM so the lifespan
shutdown runs; NOTIFY_BRIDGE_SUPERVISED env override gates the button.
Manual POST /api/backup/files (same format as scheduled).
- New periodic-summary test path reuses shared collect_scheduled_assets
(limit=0) so test and future production code go through one primitive.
- Per-receiver locale for Telegram test messages (resolves
TelegramChat.language_override per chat instead of applying the first
receiver's locale to everyone).
- Bounded concurrency (semaphores) in NotificationDispatcher._preload_asset_data
and _refresh_telegram_chat_titles; chat title sweep extended to 24h since
save_chat_from_webhook covers active chats opportunistically.
- Telegram poller detects the \"webhook is active\" 409 and auto-calls
deleteWebhook for bots whose DB update_mode is polling (throttled per bot).
- TelegramClient.get_chat added (CLAUDE.md rule 6); set_album_thumbnail added.
- Seeds: rename \"Default Commands\" → \"Default Immich Commands\";
track_assets_removed default False.
Frontend
- Global provider selector visible when there is only one provider.
- Clear-events button + i18n + ConfirmModal on the dashboard; new icons/
labels/filters/colors for action_success / action_partial / action_failed.
- Auto-select first available tracking/template/command/config + bot on
create forms (trackers, command-trackers, targets, template/command
configs).
- Telegram target disable_url_preview defaults to true.
- BlockedByModal wired into 8 deletion flows; fetchAuth helper for
multipart/binary calls (reuses api()'s refresh + ApiError mapping).
- Immich tracker 'Checking links' parallelised (concurrency cap 6).
- Backup page: pending-restore banner + Apply-now / Apply-later modal,
restarting overlay polling /api/health, manual 'Create backup' button.
- Command-trackers listener row gets an 'Edit album scope' modal with
inherit/explicit multiselect.
- Users page: Edit user modal (username + role).
- parseDate helper for consistent UTC date rendering.
Migrations / schema
- event_log: + user_id, action_id, action_name (+ backfill user_id from
notification_tracker).
- command_tracker_listener: + allowed_album_ids.
314 lines
11 KiB
Python
314 lines
11 KiB
Python
"""Status/dashboard API route."""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy import delete as sa_delete
|
|
from sqlmodel import func, select
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
from ..auth.dependencies import get_current_user
|
|
from ..database.engine import get_session
|
|
from ..database.models import (
|
|
Action,
|
|
CommandConfig,
|
|
CommandTemplateConfig,
|
|
CommandTracker,
|
|
EmailBot,
|
|
EventLog,
|
|
MatrixBot,
|
|
NotificationTarget,
|
|
NotificationTracker,
|
|
ServiceProvider,
|
|
TelegramBot,
|
|
TemplateConfig,
|
|
TrackingConfig,
|
|
User,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/status", tags=["status"])
|
|
|
|
|
|
@router.get("")
|
|
async def get_status(
|
|
user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
# Event filtering
|
|
event_type: str | None = Query(None),
|
|
provider_id: int | None = Query(None),
|
|
search: str | None = Query(None),
|
|
sort: str = Query("newest"),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
):
|
|
"""Get dashboard status data with enriched events."""
|
|
providers_count = (await session.exec(
|
|
select(func.count()).select_from(ServiceProvider).where(ServiceProvider.user_id == user.id)
|
|
)).one()
|
|
|
|
trackers_result = await session.exec(
|
|
select(NotificationTracker).where(NotificationTracker.user_id == user.id)
|
|
)
|
|
trackers = trackers_result.all()
|
|
active_count = sum(1 for t in trackers if t.enabled)
|
|
|
|
targets_count = (await session.exec(
|
|
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
|
)).one()
|
|
|
|
# Build events query with filters. EventLog.user_id is the owner column;
|
|
# action events (event_type starts with "action_") have tracker_id NULL but
|
|
# user_id set, so we filter by user_id directly.
|
|
events_query = select(EventLog).where(EventLog.user_id == user.id)
|
|
|
|
if event_type:
|
|
events_query = events_query.where(EventLog.event_type == event_type)
|
|
if provider_id is not None:
|
|
events_query = events_query.where(EventLog.provider_id == provider_id)
|
|
if search:
|
|
events_query = events_query.where(
|
|
EventLog.collection_name.contains(search)
|
|
| EventLog.tracker_name.contains(search)
|
|
| EventLog.action_name.contains(search)
|
|
| EventLog.provider_name.contains(search)
|
|
)
|
|
|
|
# Count total matching events (for pagination)
|
|
count_query = select(func.count()).select_from(events_query.subquery())
|
|
total_events = (await session.exec(count_query)).one()
|
|
|
|
# Sort
|
|
if sort == "oldest":
|
|
events_query = events_query.order_by(EventLog.created_at.asc())
|
|
else:
|
|
events_query = events_query.order_by(EventLog.created_at.desc())
|
|
|
|
events_query = events_query.offset(offset).limit(limit)
|
|
recent_events = await session.exec(events_query)
|
|
event_rows = recent_events.all()
|
|
|
|
# Resolve live tracker names from FK (fall back to stored snapshot when deleted)
|
|
tracker_ids = {e.tracker_id for e in event_rows if e.tracker_id is not None}
|
|
tracker_name_map: dict[int, str] = {}
|
|
if tracker_ids:
|
|
tracker_rows = (await session.exec(
|
|
select(NotificationTracker.id, NotificationTracker.name).where(
|
|
NotificationTracker.id.in_(tracker_ids)
|
|
)
|
|
)).all()
|
|
tracker_name_map = {tid: tname for tid, tname in tracker_rows}
|
|
|
|
# Resolve live provider names similarly
|
|
provider_ids = {e.provider_id for e in event_rows if e.provider_id is not None}
|
|
provider_name_map: dict[int, str] = {}
|
|
if provider_ids:
|
|
provider_rows = (await session.exec(
|
|
select(ServiceProvider.id, ServiceProvider.name).where(
|
|
ServiceProvider.id.in_(provider_ids)
|
|
)
|
|
)).all()
|
|
provider_name_map = {pid: pname for pid, pname in provider_rows}
|
|
|
|
# Resolve live action names so renames are reflected; fall back to snapshot.
|
|
action_ids = {e.action_id for e in event_rows if e.action_id is not None}
|
|
action_name_map: dict[int, str] = {}
|
|
if action_ids:
|
|
action_rows = (await session.exec(
|
|
select(Action.id, Action.name).where(Action.id.in_(action_ids))
|
|
)).all()
|
|
action_name_map = {aid: aname for aid, aname in action_rows}
|
|
|
|
def _display_tracker_name(e: EventLog) -> str:
|
|
if e.tracker_id is not None and e.tracker_id in tracker_name_map:
|
|
return tracker_name_map[e.tracker_id]
|
|
return f"(deleted) {e.tracker_name}" if e.tracker_name else "(deleted)"
|
|
|
|
def _display_provider_name(e: EventLog) -> str:
|
|
if e.provider_id is not None and e.provider_id in provider_name_map:
|
|
return provider_name_map[e.provider_id]
|
|
return e.provider_name or ""
|
|
|
|
def _display_action_name(e: EventLog) -> str:
|
|
if e.action_id is not None and e.action_id in action_name_map:
|
|
return action_name_map[e.action_id]
|
|
if e.action_name:
|
|
return f"(deleted) {e.action_name}"
|
|
return ""
|
|
|
|
def _display_subject(e: EventLog) -> str:
|
|
"""The primary label shown on the event row.
|
|
|
|
For action events the ``collection_name`` stores the action name;
|
|
use the live-resolved action name when available so renames show.
|
|
"""
|
|
if e.action_id is not None or (e.event_type or "").startswith("action_"):
|
|
return _display_action_name(e) or e.collection_name
|
|
return e.collection_name
|
|
|
|
return {
|
|
"providers": providers_count,
|
|
"trackers": {"total": len(trackers), "active": active_count},
|
|
"targets": targets_count,
|
|
"total_events": total_events,
|
|
"recent_events": [
|
|
{
|
|
"id": e.id,
|
|
"event_type": e.event_type,
|
|
"collection_name": _display_subject(e),
|
|
"tracker_name": _display_tracker_name(e),
|
|
"action_id": e.action_id,
|
|
"action_name": _display_action_name(e),
|
|
"provider_name": _display_provider_name(e),
|
|
"provider_id": e.provider_id,
|
|
"assets_count": e.assets_count or 0,
|
|
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
|
|
"details": e.details or {},
|
|
}
|
|
for e in event_rows
|
|
],
|
|
}
|
|
|
|
|
|
@router.delete("/events")
|
|
async def clear_events(
|
|
user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
older_than_days: int | None = Query(None, ge=0),
|
|
):
|
|
"""Delete all event log entries for the current user.
|
|
|
|
Optionally keep events newer than `older_than_days` days.
|
|
"""
|
|
stmt = sa_delete(EventLog).where(EventLog.user_id == user.id)
|
|
if older_than_days is not None:
|
|
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
|
|
stmt = stmt.where(EventLog.created_at < cutoff)
|
|
|
|
# Use session.execute() for DELETE (consistent with other endpoints and
|
|
# avoids sqlmodel wrapping a CursorResult that may drop rowcount).
|
|
result = await session.execute(stmt)
|
|
await session.commit()
|
|
return {"deleted": result.rowcount or 0}
|
|
|
|
|
|
@router.get("/counts")
|
|
async def get_nav_counts(
|
|
user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Return entity counts for sidebar navigation badges.
|
|
|
|
Note: queries run sequentially because SQLAlchemy AsyncSession is NOT safe
|
|
for concurrent use within a single session (no asyncio.gather). We
|
|
minimise round-trips by combining user + system counts and per-type
|
|
target counts into single aggregate queries where possible.
|
|
"""
|
|
counts: dict[str, int] = {}
|
|
|
|
# --- 1) User-owned entity counts (one query per model) ---
|
|
for model, key in [
|
|
(ServiceProvider, "providers"),
|
|
(NotificationTracker, "notification_trackers"),
|
|
(TrackingConfig, "tracking_configs"),
|
|
(TemplateConfig, "template_configs"),
|
|
(NotificationTarget, "targets"),
|
|
(TelegramBot, "telegram_bots"),
|
|
(EmailBot, "email_bots"),
|
|
(MatrixBot, "matrix_bots"),
|
|
(CommandTracker, "command_trackers"),
|
|
(CommandConfig, "command_configs"),
|
|
(CommandTemplateConfig, "command_template_configs"),
|
|
]:
|
|
count = (await session.exec(
|
|
select(func.count()).select_from(model).where(model.user_id == user.id)
|
|
)).one()
|
|
counts[key] = count
|
|
|
|
# --- 2) Add system-owned counts (user_id=0) for shared entities ---
|
|
for model, key in [
|
|
(TemplateConfig, "template_configs"),
|
|
(CommandTemplateConfig, "command_template_configs"),
|
|
(TrackingConfig, "tracking_configs"),
|
|
(CommandConfig, "command_configs"),
|
|
]:
|
|
system_count = (await session.exec(
|
|
select(func.count()).select_from(model).where(model.user_id == 0)
|
|
)).one()
|
|
counts[key] += system_count
|
|
|
|
# --- 3) Per-type target counts in a single query using conditional aggregation ---
|
|
target_types = ("telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix")
|
|
type_counts_result = (await session.exec(
|
|
select(
|
|
NotificationTarget.type,
|
|
func.count(),
|
|
)
|
|
.where(
|
|
NotificationTarget.user_id == user.id,
|
|
NotificationTarget.type.in_(target_types),
|
|
)
|
|
.group_by(NotificationTarget.type)
|
|
)).all()
|
|
type_counts_map = dict(type_counts_result)
|
|
for target_type in target_types:
|
|
counts[f"targets_{target_type}"] = type_counts_map.get(target_type, 0)
|
|
|
|
return counts
|
|
|
|
|
|
@router.get("/chart")
|
|
async def get_event_chart(
|
|
user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
days: int = Query(14, ge=1, le=90),
|
|
event_type: str | None = Query(None),
|
|
provider_id: int | None = Query(None),
|
|
search: str | None = Query(None),
|
|
):
|
|
"""Return daily event counts by type for the last N days, with optional filters."""
|
|
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
|
|
|
day_col = func.date(EventLog.created_at)
|
|
|
|
query = (
|
|
select(
|
|
day_col.label("day"),
|
|
EventLog.event_type,
|
|
func.count().label("total"),
|
|
)
|
|
.where(EventLog.user_id == user.id, EventLog.created_at >= cutoff)
|
|
)
|
|
|
|
if event_type:
|
|
query = query.where(EventLog.event_type == event_type)
|
|
if provider_id is not None:
|
|
query = query.where(EventLog.provider_id == provider_id)
|
|
if search:
|
|
query = query.where(
|
|
EventLog.collection_name.contains(search)
|
|
| EventLog.tracker_name.contains(search)
|
|
| EventLog.action_name.contains(search)
|
|
| EventLog.provider_name.contains(search)
|
|
)
|
|
|
|
query = query.group_by(day_col, EventLog.event_type).order_by(day_col)
|
|
|
|
rows = (await session.exec(query)).all()
|
|
|
|
# Build a dict: { "2026-03-15": { "assets_added": 18, ... }, ... }
|
|
by_day: dict[str, dict[str, int]] = {}
|
|
for row in rows:
|
|
day_str = str(row.day)
|
|
if day_str not in by_day:
|
|
by_day[day_str] = {}
|
|
by_day[day_str][row.event_type] = row.total
|
|
|
|
# Fill in missing days so the frontend gets a continuous series
|
|
result = []
|
|
for i in range(days):
|
|
d = (datetime.now(timezone.utc) - timedelta(days=days - 1 - i)).strftime("%Y-%m-%d")
|
|
counts = by_day.get(d, {})
|
|
result.append({"date": d, **counts})
|
|
|
|
return {"days": result}
|