Compare commits

...

59 Commits

Author SHA1 Message Date
bvandeusen b8dce6c483 Merge pull request 'FC-3h + FC-3k: backup first-class + admin destructive actions' (#10) from dev into main 2026-05-25 01:41:53 -04:00
bvandeusen 832345a245 fix(fc3k): add origin=imported_filesystem to test ImageRecord ctors (second NOT NULL column after mime)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:28:54 -04:00
bvandeusen a0136fa30d fix(fc3k): add mime=image/jpeg to test ImageRecord ctors (NOT NULL) + reorder admin import after stdlib/3rd-party (ruff I001)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:12:52 -04:00
bvandeusen de1a4b64b7 fc3k(ui): TagMaintenanceCard — preview-then-commit prune-unused under Settings → Maintenance
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:52:30 -04:00
bvandeusen 3f500e592e fc3k(ui): per-tag dots-menu with Merge + Delete actions; Tier-B count-surfacing modal
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:51:51 -04:00
bvandeusen d97e3f9b59 fc3k(ui): bulk-delete action in BulkEditorPanel with sha8 confirm token + projected counts modal
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:49:45 -04:00
bvandeusen 035c49f675 fc3k(ui): ArtistDangerZone card + slot at bottom of ArtistView with cascade-delete flow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:48:17 -04:00
bvandeusen e41ab1cca5 fc3k(ui): Pinia admin store — six endpoints + task_run polling for Tier-C dispatched ops
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:47:09 -04:00
bvandeusen 42c6b642c2 fc3k(ui): rename + relocate BackupConfirmModal → modal/DestructiveConfirmModal; add tier + projectedCounts props
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:46:37 -04:00
bvandeusen ad3d34a1fc fc3k: /api/admin endpoint integration tests — dry-run, confirm-mismatch, dispatch, Tier-A/B/C paths
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:45:31 -04:00
bvandeusen b5289ed372 fc3k: admin Celery task tests — registration, success, failure, missing-id idempotency
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:44:33 -04:00
bvandeusen f6aa805725 fc3k: cleanup_service unit tests — projections + mutations + file unlinks against real Postgres
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:43:51 -04:00
bvandeusen f096c9a5fb fc3k: register admin_bp in api/__init__ all_blueprints
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:42:40 -04:00
bvandeusen 676a86b514 fc3k: /api/admin tags prune-unused endpoint — Tier-A preview-then-commit flow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:42:20 -04:00
bvandeusen 44cc625d4a fc3k: /api/admin tag endpoints — Tier-B delete + merge + usage-count helper
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:42:03 -04:00
bvandeusen f7ee122243 fc3k: /api/admin blueprint — Tier-C artist cascade + bulk image delete with sha8-keyed confirm tokens
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:40:53 -04:00
bvandeusen 7c6f11964a fc3k: celery_app — register admin tasks on maintenance queue
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:40:23 -04:00
bvandeusen 94c60c0af2 fc3k: admin Celery tasks — delete_artist_cascade_task + bulk_delete_images_task on maintenance queue
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:40:03 -04:00
bvandeusen 6df102b83d fc3k: cleanup_service mutations — unlink primitive, artist cascade, bulk image delete, tag delete, prune unused
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:39:45 -04:00
bvandeusen 2ae01d27e3 fc3k: cleanup_service projections — artist cascade, bulk delete, tag usage, unused tags
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:38:57 -04:00
bvandeusen 718cc79905 fix(fc3h): split semicolon-stacked statements (E702) and bridge v-dialog v-model to avoid prop write
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:37:06 -04:00
bvandeusen e78a35d333 fc3h: collapse multi-line sqlalchemy import in backup_run.py — fits under line-length=100, ruff I001 would bounce
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:12:06 -04:00
bvandeusen 83bd3b4b2d fc3h(ui): slot BackupCard into Maintenance panel above migration card
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:06:22 -04:00
bvandeusen aecedd9fe4 fc3h(ui): BackupCard.vue + BackupRunsTable.vue — combined card with DB + Images sub-sections
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:06:00 -04:00
bvandeusen 1e34b1b428 fc3h(ui): BackupConfirmModal.vue — typed-token confirmation for restore + delete
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:05:04 -04:00
bvandeusen 102c21feaa fc3h(ui): Pinia backup store — runs, triggers, restore, delete, tag, settings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:04:37 -04:00
bvandeusen 57a338f7e6 fc3h(tests): drop pinned migration-backup tests (retired surface; coverage moved to test_backup_service.py + test_api_system_backup.py)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:03:57 -04:00
bvandeusen d04983138a fc3h: /api/system/backup endpoint integration tests
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:02:32 -04:00
bvandeusen 9ec6fdb596 fc3h: Celery task integration tests (backup, restore, prune, nightly)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:01:39 -04:00
bvandeusen 86ad9b80e9 fc3h: backup_service unit tests (subprocess monkeypatched)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:00:40 -04:00
bvandeusen 2b05f147f4 fc3h: migrators docstring — note backup/rollback retired to backup_service.py
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:00:01 -04:00
bvandeusen 70e1e010d1 fc3h: remove backend/app/services/migrators/backup.py + rollback.py (relocated to backup_service.py)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:59:18 -04:00
bvandeusen d3d4320ed5 fc3h: retire backup + rollback from migrate API/task — moved to /api/system/backup/* per FC-3h
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:59:11 -04:00
bvandeusen 7d42cddb11 fc3h: /api/system/backup blueprint — trigger, list, get, patch, restore, delete, settings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:57:25 -04:00
bvandeusen 1f01c4819a fc3h: celery_app — register backup tasks (include + maintenance route + Beat hourly tick + daily prune)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:56:06 -04:00
bvandeusen 06d527cb92 fc3h: prune_backups (daily retention) + backup_db_nightly (hourly tick, settings-gated)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:55:19 -04:00
bvandeusen e9ea376aed fc3h: restore_db_task + restore_images_task — restore creates 'restoring' marker row linked via restored_from_id
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:53:50 -04:00
bvandeusen 882cb491ba fc3h: backup_db_task + backup_images_task Celery tasks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:53:26 -04:00
bvandeusen 319e7de547 fc3h: backup_service.py — DB + images backup/restore + unlink helpers (relocated, split per-kind)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:52:35 -04:00
bvandeusen e43312a129 fc3h: ImportSettings backup_* knobs + alembic 0018 (nightly-enabled, hour, keep-N per kind)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:52:00 -04:00
bvandeusen 8f2732a56f fc3h: alembic 0017 — backup_run table with indexes + partial-tag
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:51:22 -04:00
bvandeusen c3e855bd9b fc3h: BackupRun model — artifact record for backup/restore runs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:50:59 -04:00
bvandeusen d1c0b82a22 Merge pull request 'v26.05.24.3: FC-3i System Activity dashboard + migration backup-gate retired + modal Escape' (#9) from dev into main 2026-05-24 21:47:53 -04:00
bvandeusen 37fcc74954 fix(fc3i): single-line celery.signals import + INT32 bounds on target_id + dict.fromkeys + rewrite retry test as direct _finalize unit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:22:42 -04:00
bvandeusen 6a532c1497 fc3i(ui): Settings → Activity tab + Overview summary card
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:10:14 -04:00
bvandeusen 368613068a fc3i(ui): SystemActivityTab.vue — queues + failures + all-activity panes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:09:20 -04:00
bvandeusen ddbb84d8aa fc3i(ui): SystemActivitySummary.vue — Overview-tab quick summary card (5s poll)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:08:30 -04:00
bvandeusen 9b252948f9 fc3i(ui): QueuesTable.vue — shared queue/worker/recent table (compact + detailed)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:07:57 -04:00
bvandeusen 89d0cb2124 fc3i(ui): systemActivity Pinia store — queues, workers, runs, failures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:07:28 -04:00
bvandeusen 36611cbe00 fc3i: tests for recover_stalled_task_runs + prune_task_runs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:07:01 -04:00
bvandeusen 48ef22445a fc3i: integration tests for /api/system/activity/* endpoints
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:06:20 -04:00
bvandeusen e523d0ac94 fc3i: integration tests for Celery signal → task_run lifecycle
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:05:32 -04:00
bvandeusen 541e2bfe6a fc3i: /api/system/activity blueprint — queues, workers, runs, failures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:04:34 -04:00
bvandeusen 7782672a51 fc3i: recover_stalled_task_runs + prune_task_runs maintenance tasks + Beat entries
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:03:33 -04:00
bvandeusen d12b51f6b7 fc3i: Celery signal handlers populate task_run on every task lifecycle event
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:01:47 -04:00
bvandeusen 0cda46fcdb fc3i: alembic 0016 — task_run table with single + composite indexes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:00:46 -04:00
bvandeusen 79fee98db4 fc3i: TaskRun model — per-Celery-task lifecycle audit row
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:00:14 -04:00
bvandeusen b1d68929c5 fix(tests): drop test_post_apply_without_backup_rejected (gate retired in 5535677)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:15:10 -04:00
bvandeusen 553567738e fix: drop migration backup-gate (FC-3h supersedes) + modal Escape via document-level listener so video focus can't swallow it
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:38:33 -04:00
52 changed files with 6386 additions and 353 deletions
+86
View File
@@ -0,0 +1,86 @@
"""fc3i: task_run table
Revision ID: 0016
Revises: 0015
Create Date: 2026-05-24
Additive only. New table records every Celery task attempt via signal
handlers (backend.app.celery_signals). Status is plain String(16) not
Postgres ENUM (per feedback_check_existing_enums: ENUM columns hard-
fail at INSERT, String columns extend cleanly).
Composite indexes anticipate the three dashboard panes:
- (queue, started_at desc) — per-lane recent activity
- (status, started_at desc) — recent failures pane
- (task_name, started_at desc) — drill-down by task
Indexed columns get individual indexes via `index=True` on the model;
the composites below cover the multi-column lookups.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0016"
down_revision: Union[str, None] = "0015"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"task_run",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("celery_task_id", sa.String(length=64), nullable=False),
sa.Column("queue", sa.String(length=32), nullable=False),
sa.Column("task_name", sa.String(length=128), nullable=False),
sa.Column("target_id", sa.Integer(), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("duration_ms", sa.Integer(), nullable=True),
sa.Column(
"status", sa.String(length=16), nullable=False,
server_default="running",
),
sa.Column("error_type", sa.String(length=128), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("retry_count", sa.Integer(), nullable=True),
sa.Column("worker_hostname", sa.String(length=128), nullable=True),
sa.Column("args_summary", sa.String(length=255), nullable=True),
)
# Single-column indexes (matches Mapped[...].index=True on model).
op.create_index("ix_task_run_celery_task_id", "task_run", ["celery_task_id"])
op.create_index("ix_task_run_queue", "task_run", ["queue"])
op.create_index("ix_task_run_task_name", "task_run", ["task_name"])
op.create_index("ix_task_run_started_at", "task_run", ["started_at"])
op.create_index("ix_task_run_finished_at", "task_run", ["finished_at"])
op.create_index("ix_task_run_status", "task_run", ["status"])
# Composite indexes for dashboard query patterns.
op.create_index(
"ix_task_run_queue_started",
"task_run", ["queue", sa.text("started_at DESC")],
)
op.create_index(
"ix_task_run_status_started",
"task_run", ["status", sa.text("started_at DESC")],
)
op.create_index(
"ix_task_run_name_started",
"task_run", ["task_name", sa.text("started_at DESC")],
)
def downgrade() -> None:
op.drop_index("ix_task_run_name_started", table_name="task_run")
op.drop_index("ix_task_run_status_started", table_name="task_run")
op.drop_index("ix_task_run_queue_started", table_name="task_run")
op.drop_index("ix_task_run_status", table_name="task_run")
op.drop_index("ix_task_run_finished_at", table_name="task_run")
op.drop_index("ix_task_run_started_at", table_name="task_run")
op.drop_index("ix_task_run_task_name", table_name="task_run")
op.drop_index("ix_task_run_queue", table_name="task_run")
op.drop_index("ix_task_run_celery_task_id", table_name="task_run")
op.drop_table("task_run")
+82
View File
@@ -0,0 +1,82 @@
"""fc3h: backup_run table
Revision ID: 0017
Revises: 0016
Create Date: 2026-05-24
Additive. New table records every backup/restore attempt with artifact
metadata. Lifecycle tracking lives in task_run from FC-3i; this is
artifact-only (paths, sizes, tag, restore lineage).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0017"
down_revision: Union[str, None] = "0016"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"backup_run",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column(
"status", sa.String(length=16), nullable=False,
server_default="pending",
),
sa.Column("tag", sa.String(length=64), nullable=True),
sa.Column("triggered_by", sa.String(length=32), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("sql_path", sa.Text(), nullable=True),
sa.Column("tar_path", sa.Text(), nullable=True),
sa.Column("size_bytes", sa.BigInteger(), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column(
"manifest", sa.JSON(), nullable=False, server_default="{}",
),
sa.Column(
"restored_from_id", sa.Integer(),
sa.ForeignKey("backup_run.id", ondelete="SET NULL"),
nullable=True,
),
)
# Single-column indexes (matches Mapped[...].index=True).
op.create_index("ix_backup_run_kind", "backup_run", ["kind"])
op.create_index("ix_backup_run_status", "backup_run", ["status"])
op.create_index("ix_backup_run_tag", "backup_run", ["tag"])
op.create_index("ix_backup_run_started_at", "backup_run", ["started_at"])
op.create_index("ix_backup_run_finished_at", "backup_run", ["finished_at"])
# Composite indexes for dashboard query patterns.
op.create_index(
"ix_backup_run_kind_started",
"backup_run", ["kind", sa.text("started_at DESC")],
)
op.create_index(
"ix_backup_run_status_finished",
"backup_run", ["status", sa.text("finished_at DESC")],
)
# Partial index: only tagged rows participate in retention-exempt query.
op.create_index(
"ix_backup_run_tag_partial",
"backup_run", ["tag"],
postgresql_where=sa.text("tag IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("ix_backup_run_tag_partial", table_name="backup_run")
op.drop_index("ix_backup_run_status_finished", table_name="backup_run")
op.drop_index("ix_backup_run_kind_started", table_name="backup_run")
op.drop_index("ix_backup_run_finished_at", table_name="backup_run")
op.drop_index("ix_backup_run_started_at", table_name="backup_run")
op.drop_index("ix_backup_run_tag", table_name="backup_run")
op.drop_index("ix_backup_run_status", table_name="backup_run")
op.drop_index("ix_backup_run_kind", table_name="backup_run")
op.drop_table("backup_run")
@@ -0,0 +1,62 @@
"""fc3h: backup_* knobs on import_settings
Revision ID: 0018
Revises: 0017
Create Date: 2026-05-24
Adds four columns to the singleton import_settings row:
- backup_db_nightly_enabled (default False — opt-in)
- backup_db_nightly_hour_utc (default 3)
- backup_db_keep_last_n (default 14)
- backup_images_keep_last_n (default 3)
server_default ensures the singleton row is backfilled in place
without an UPDATE statement.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0018"
down_revision: Union[str, None] = "0017"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"import_settings",
sa.Column(
"backup_db_nightly_enabled", sa.Boolean(),
nullable=False, server_default=sa.false(),
),
)
op.add_column(
"import_settings",
sa.Column(
"backup_db_nightly_hour_utc", sa.Integer(),
nullable=False, server_default="3",
),
)
op.add_column(
"import_settings",
sa.Column(
"backup_db_keep_last_n", sa.Integer(),
nullable=False, server_default="14",
),
)
op.add_column(
"import_settings",
sa.Column(
"backup_images_keep_last_n", sa.Integer(),
nullable=False, server_default="3",
),
)
def downgrade() -> None:
op.drop_column("import_settings", "backup_images_keep_last_n")
op.drop_column("import_settings", "backup_db_keep_last_n")
op.drop_column("import_settings", "backup_db_nightly_hour_utc")
op.drop_column("import_settings", "backup_db_nightly_enabled")
+6
View File
@@ -14,6 +14,7 @@ api_bp.add_url_rule("/health", view_func=health.get_health, methods=["GET"])
def all_blueprints() -> list[Blueprint]: def all_blueprints() -> list[Blueprint]:
from .admin import admin_bp
from .aliases import aliases_bp from .aliases import aliases_bp
from .allowlist import allowlist_bp from .allowlist import allowlist_bp
from .artist import artist_bp from .artist import artist_bp
@@ -33,6 +34,8 @@ def all_blueprints() -> list[Blueprint]:
from .showcase import showcase_bp from .showcase import showcase_bp
from .sources import sources_bp from .sources import sources_bp
from .suggestions import suggestions_bp from .suggestions import suggestions_bp
from .system_activity import system_activity_bp
from .system_backup import system_backup_bp
from .tags import tags_bp from .tags import tags_bp
return [ return [
api_bp, api_bp,
@@ -44,6 +47,9 @@ def all_blueprints() -> list[Blueprint]:
artists_bp, artists_bp,
showcase_bp, showcase_bp,
settings_bp, settings_bp,
system_activity_bp,
system_backup_bp,
admin_bp,
import_admin_bp, import_admin_bp,
migrate_bp, migrate_bp,
suggestions_bp, suggestions_bp,
+200
View File
@@ -0,0 +1,200 @@
"""FC-3k: /api/admin — destructive admin actions.
Five action surfaces:
POST /api/admin/artists/<slug>/cascade-delete (Tier C)
POST /api/admin/images/bulk-delete (Tier C)
DELETE /api/admin/tags/<int:tag_id> (Tier B)
POST /api/admin/tags/<int:dest_id>/merge (Tier B)
POST /api/admin/tags/prune-unused (Tier A)
GET /api/admin/tags/<int:tag_id>/usage-count (helper)
Tier-C ops take a dry_run body flag (returns projection inline,
no dispatch) and a confirm body field (server-recomputed token).
Long-running ops dispatch a maintenance-queue Celery task; the UI
tails FC-3i's /api/system/activity/runs to surface progress.
"""
from __future__ import annotations
import hashlib
from quart import Blueprint, jsonify, request
from sqlalchemy import select
from ..extensions import get_session
from ..models import Artist
from ..services.cleanup_service import project_artist_cascade, project_bulk_image_delete
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
def _bad(error: str, *, status: int = 400, **extra):
body = {"error": error}
body.update(extra)
return jsonify(body), status
def _bulk_image_confirm_token(image_ids: list[int]) -> str:
"""Stable 8-hex token derived from the sorted id list. Mutates
when the selection changes; stays the same across modal opens of
the same selection so the operator can paste without confusion."""
canon = ",".join(str(i) for i in sorted(image_ids))
digest = hashlib.sha256(canon.encode("utf-8")).hexdigest()
return digest[:8]
@admin_bp.route("/artists/<slug>/cascade-delete", methods=["POST"])
async def artist_cascade_delete(slug: str):
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", False))
supplied_confirm = body.get("confirm", "")
async with get_session() as session:
artist = (await session.execute(
select(Artist).where(Artist.slug == slug)
)).scalar_one_or_none()
if artist is None:
return _bad("not_found", status=404)
artist_id = artist.id
projected = await session.run_sync(
lambda sync_sess: project_artist_cascade(sync_sess, slug=slug)
)
if dry_run:
return jsonify(projected)
expected = f"delete-artist-{artist_id}"
if supplied_confirm != expected:
return _bad(
"confirm_mismatch",
detail=f"confirm must equal {expected!r}",
expected=expected,
)
from ..tasks.admin import delete_artist_cascade_task
async_result = delete_artist_cascade_task.delay(artist_id=artist_id)
return jsonify({"task_id": async_result.id}), 202
@admin_bp.route("/images/bulk-delete", methods=["POST"])
async def images_bulk_delete():
body = await request.get_json(silent=True) or {}
image_ids = body.get("image_ids")
if not isinstance(image_ids, list) or not image_ids:
return _bad("invalid_image_ids", detail="image_ids must be non-empty list of int")
try:
image_ids = [int(i) for i in image_ids]
except (TypeError, ValueError):
return _bad("invalid_image_ids", detail="image_ids must contain only ints")
dry_run = bool(body.get("dry_run", False))
supplied_confirm = body.get("confirm", "")
async with get_session() as session:
projected = await session.run_sync(
lambda sync_sess: project_bulk_image_delete(
sync_sess, image_ids=image_ids,
)
)
if dry_run:
return jsonify(projected)
sha8 = _bulk_image_confirm_token(image_ids)
expected = f"delete-images-{sha8}"
if supplied_confirm != expected:
return _bad(
"confirm_mismatch",
detail=f"confirm must equal {expected!r}",
expected=expected,
)
from ..tasks.admin import bulk_delete_images_task
async_result = bulk_delete_images_task.delay(image_ids=image_ids)
return jsonify({"task_id": async_result.id}), 202
@admin_bp.route("/tags/<int:tag_id>", methods=["DELETE"])
async def tag_delete(tag_id: int):
"""Tier-B sync delete. UI yes/no modal is the only confirmation."""
from ..services.cleanup_service import delete_tag
async with get_session() as session:
try:
result = await session.run_sync(
lambda sync_sess: delete_tag(sync_sess, tag_id=tag_id)
)
except LookupError:
return _bad("not_found", status=404)
return jsonify(result)
@admin_bp.route("/tags/<int:dest_id>/merge", methods=["POST"])
async def tag_merge(dest_id: int):
"""Wraps TagService.merge. Source repoints to dest, dest survives,
source row deleted, protective alias auto-created if source was
ML-applied or allowlisted."""
from ..services.tag_service import TagMergeConflict, TagService, TagValidationError
body = await request.get_json(silent=True) or {}
source_id = body.get("source_id")
if not isinstance(source_id, int) or source_id == dest_id:
return _bad("invalid_source_id", detail="source_id must be int and differ from dest")
async with get_session() as session:
try:
result = await TagService(session).merge(
source_id=source_id, target_id=dest_id,
)
except TagMergeConflict as exc:
return _bad("merge_conflict", status=409, detail=str(exc))
except TagValidationError as exc:
return _bad("tag_kind_mismatch", detail=str(exc))
except LookupError:
return _bad("not_found", status=404)
# MergeResult is a frozen dataclass — flatten to dict.
return jsonify({
"result": {
"target_id": result.target_id,
"target_name": result.target_name,
"target_kind": result.target_kind,
"merged_count": result.merged_count,
"alias_created": result.alias_created,
"source_deleted": result.source_deleted,
},
})
@admin_bp.route("/tags/<int:tag_id>/usage-count", methods=["GET"])
async def tag_usage_count(tag_id: int):
"""Helper for the Tier-B yes/no prompt; surfaces "N associations"
in the dialog so the operator knows what they're nuking."""
from ..services.cleanup_service import count_tag_associations
async with get_session() as session:
count = await session.run_sync(
lambda sync_sess: count_tag_associations(
sync_sess, tag_id=tag_id,
)
)
return jsonify({"count": count})
@admin_bp.route("/tags/prune-unused", methods=["POST"])
async def tags_prune_unused():
"""Tier-A: dry-run preview list IS the prompt. UI calls with
dry_run=true first, shows the list, operator clicks button to
re-call with dry_run=false."""
from ..services.cleanup_service import prune_unused_tags
body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", False))
async with get_session() as session:
result = await session.run_sync(
lambda sync_sess: prune_unused_tags(
sync_sess, dry_run=dry_run,
)
)
return jsonify(result)
+5 -34
View File
@@ -1,14 +1,11 @@
"""FC-5: /api/migrate — trigger and poll migration runs. """FC-5: /api/migrate — trigger and poll migration runs.
Ingest kinds (gs_ingest, ir_ingest) accept multipart/form-data with an Ingest kinds (gs_ingest, ir_ingest) accept multipart/form-data with an
`export_file` field. All other kinds accept JSON. Apply-without-backup `export_file` field. All other kinds accept JSON. Backup + rollback
guard rejects non-dry-run ingests unless a pre_migration-tagged backup were retired in FC-3h (2026-05-24); use /api/system/backup/* instead.
exists in the last 24h (override with body.force=true).
""" """
import json import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from sqlalchemy import select from sqlalchemy import select
@@ -19,12 +16,12 @@ from ..tasks.migration import run_migration
migrate_bp = Blueprint("migrate", __name__, url_prefix="/api/migrate") migrate_bp = Blueprint("migrate", __name__, url_prefix="/api/migrate")
# 'backup' + 'rollback' retired 2026-05-24 (FC-3h); see /api/system/backup/*.
_VALID_KINDS = frozenset({ _VALID_KINDS = frozenset({
"backup", "gs_ingest", "ir_ingest", "tag_apply", "gs_ingest", "ir_ingest", "tag_apply",
"ml_queue", "verify", "rollback", "cleanup", "ml_queue", "verify", "cleanup",
}) })
_INGEST_KINDS = frozenset({"gs_ingest", "ir_ingest"}) _INGEST_KINDS = frozenset({"gs_ingest", "ir_ingest"})
_APPLY_KINDS = frozenset({"gs_ingest", "ir_ingest", "tag_apply", "rollback", "cleanup"})
def _bad(error: str, *, status: int = 400, **extra): def _bad(error: str, *, status: int = 400, **extra):
@@ -33,19 +30,6 @@ def _bad(error: str, *, status: int = 400, **extra):
return jsonify(body), status return jsonify(body), status
def _has_recent_pre_migration_backup() -> bool:
from ..services.migrators import backup as backup_mod
images_root = Path("/images")
manifest = backup_mod.find_latest_backup(images_root, tag="pre_migration")
if manifest is None:
return False
created_at_str = manifest.get("created_at")
if not created_at_str:
return False
created_at = datetime.fromisoformat(created_at_str)
return (datetime.now(UTC) - created_at) < timedelta(hours=24)
def _run_to_dict(run: MigrationRun) -> dict: def _run_to_dict(run: MigrationRun) -> dict:
return { return {
"id": run.id, "id": run.id,
@@ -78,7 +62,6 @@ async def create_run(kind: str):
except (UnicodeDecodeError, json.JSONDecodeError) as exc: except (UnicodeDecodeError, json.JSONDecodeError) as exc:
return _bad("invalid_export_file", detail=str(exc)) return _bad("invalid_export_file", detail=str(exc))
dry_run = str(form.get("dry_run", "false")).lower() in ("true", "1", "yes") dry_run = str(form.get("dry_run", "false")).lower() in ("true", "1", "yes")
force = str(form.get("force", "false")).lower() in ("true", "1", "yes")
params: dict = {"data": data, "dry_run": dry_run} params: dict = {"data": data, "dry_run": dry_run}
else: else:
body = await request.get_json() body = await request.get_json()
@@ -87,20 +70,8 @@ async def create_run(kind: str):
if not isinstance(body, dict): if not isinstance(body, dict):
return _bad("invalid_body") return _bad("invalid_body")
dry_run = bool(body.get("dry_run", False)) dry_run = bool(body.get("dry_run", False))
force = bool(body.get("force", False))
params = dict(body) params = dict(body)
is_apply = (kind in _APPLY_KINDS) and not dry_run
if is_apply and not force and not _has_recent_pre_migration_backup():
return _bad(
"no_backup",
detail="apply action requires a pre_migration-tagged backup "
"in the last 24h (or force=true).",
)
if kind == "backup":
params.setdefault("tag", "pre_migration")
async with get_session() as session: async with get_session() as session:
run = MigrationRun(kind=kind, status="pending", dry_run=dry_run) run = MigrationRun(kind=kind, status="pending", dry_run=dry_run)
session.add(run) session.add(run)
+209
View File
@@ -0,0 +1,209 @@
"""FC-3i: system activity dashboard endpoints.
Read-only. Combines Redis-broker queue depths (LLEN per queue),
Celery worker introspection (celery inspect), and the task_run DB
history into the surfaces the SystemActivityTab UI consumes.
All filesystem/sync-client work goes through asyncio.to_thread per
ASYNC230/240 (mirrors backend.app.api.extension's pattern).
"""
from __future__ import annotations
import asyncio
import time
from datetime import UTC, datetime, timedelta
from quart import Blueprint, jsonify, request
from sqlalchemy import desc, func, select
from ..config import get_config
from ..extensions import get_session
from ..models import TaskRun
system_activity_bp = Blueprint(
"system_activity", __name__, url_prefix="/api/system/activity",
)
# Canonical queue order — must match celery_app.task_routes. UI renders
# in this order; queues with no LLEN response show as null rather than
# absent.
_QUEUE_NAMES = (
"default", "import", "thumbnail", "ml",
"download", "scan", "maintenance",
)
# Cache module-level so all requests share the cache between polls.
# Tests can reset via direct dict mutation if needed.
_QUEUE_CACHE: dict = {"ts": 0.0, "data": None}
_WORKER_CACHE: dict = {"ts": 0.0, "data": None}
_QUEUE_CACHE_TTL = 2.0
_WORKER_CACHE_TTL = 5.0
def _read_queues_sync() -> dict:
"""Reads each queue's LLEN from the broker. Sync — caller wraps in
asyncio.to_thread. Per-queue try/except returns None on failure so
one bad queue doesn't break the whole response."""
import redis # local import; only this endpoint needs it
cfg = get_config()
client = redis.Redis.from_url(cfg.celery_broker_url)
out: dict = {}
for name in _QUEUE_NAMES:
try:
out[name] = int(client.llen(name))
except Exception: # noqa: BLE001 — broker hiccup shouldn't break UI
out[name] = None
return {
"queues": out,
"fetched_at": datetime.now(UTC).isoformat(),
}
def _read_workers_sync() -> dict:
"""celery inspect active_queues + active. Returns per-worker info."""
from ..celery_app import celery as celery_app
insp = celery_app.control.inspect(timeout=2.0)
active_queues = insp.active_queues() or {}
active_tasks = insp.active() or {}
workers: dict = {}
for hostname, queues in active_queues.items():
workers[hostname] = {
"queues": sorted({q["name"] for q in queues}),
"active_count": len(active_tasks.get(hostname, [])),
}
return {
"workers": workers,
"fetched_at": datetime.now(UTC).isoformat(),
}
@system_activity_bp.route("/queues", methods=["GET"])
async def get_queues():
"""Per-queue Redis LLEN. Cached 2s.
Response: {queues: {name: depth_or_null}, fetched_at: iso8601}
"""
now = time.time()
if _QUEUE_CACHE["data"] is None or (now - _QUEUE_CACHE["ts"]) > _QUEUE_CACHE_TTL:
_QUEUE_CACHE["data"] = await asyncio.to_thread(_read_queues_sync)
_QUEUE_CACHE["ts"] = now
return jsonify(_QUEUE_CACHE["data"])
@system_activity_bp.route("/workers", methods=["GET"])
async def get_workers():
"""Live celery inspect. Cached 5s.
Response: {workers: {hostname: {queues, active_count}}, fetched_at}
"""
now = time.time()
if _WORKER_CACHE["data"] is None or (now - _WORKER_CACHE["ts"]) > _WORKER_CACHE_TTL:
_WORKER_CACHE["data"] = await asyncio.to_thread(_read_workers_sync)
_WORKER_CACHE["ts"] = now
return jsonify(_WORKER_CACHE["data"])
@system_activity_bp.route("/runs", methods=["GET"])
async def list_runs():
"""Paginated task_run history. Query params:
queue=<name> filter to one queue
status=<status> filter to one status (running/ok/error/timeout/retry)
limit=<int> default 50, max 200
before_id=<int> cursor for keyset pagination
Response: {runs: [...], next_cursor: id|null}
"""
try:
limit = min(int(request.args.get("limit", "50")), 200)
except ValueError:
return jsonify({"error": "invalid_limit"}), 400
if limit < 1:
return jsonify({"error": "invalid_limit"}), 400
queue = request.args.get("queue")
status = request.args.get("status")
before_id_raw = request.args.get("before_id")
before_id = int(before_id_raw) if before_id_raw else None
async with get_session() as session:
stmt = select(TaskRun).order_by(desc(TaskRun.id))
if queue:
stmt = stmt.where(TaskRun.queue == queue)
if status:
stmt = stmt.where(TaskRun.status == status)
if before_id is not None:
stmt = stmt.where(TaskRun.id < before_id)
stmt = stmt.limit(limit + 1)
rows = (await session.execute(stmt)).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
return jsonify({
"runs": [_row_to_dict(r) for r in rows],
"next_cursor": rows[-1].id if has_more and rows else None,
})
@system_activity_bp.route("/failures", methods=["GET"])
async def list_failures():
"""Recent failures across all lanes (24h window).
Response: {recent: [...], count_by_type: {ErrorClass: n}, since}
"""
try:
limit = min(int(request.args.get("limit", "50")), 200)
except ValueError:
return jsonify({"error": "invalid_limit"}), 400
since = datetime.now(UTC) - timedelta(hours=24)
async with get_session() as session:
recent_stmt = (
select(TaskRun)
.where(TaskRun.status.in_(["error", "timeout"]))
.where(TaskRun.finished_at >= since)
.order_by(desc(TaskRun.finished_at))
.limit(limit)
)
recent = (await session.execute(recent_stmt)).scalars().all()
count_stmt = (
select(TaskRun.error_type, func.count(TaskRun.id))
.where(TaskRun.status.in_(["error", "timeout"]))
.where(TaskRun.finished_at >= since)
.group_by(TaskRun.error_type)
.order_by(desc(func.count(TaskRun.id)))
)
counts = (await session.execute(count_stmt)).all()
return jsonify({
"recent": [_row_to_dict(r) for r in recent],
"count_by_type": {
(row[0] or "Unknown"): row[1]
for row in counts
},
"since": since.isoformat(),
})
def _row_to_dict(r: TaskRun) -> dict:
return {
"id": r.id,
"queue": r.queue,
"task_name": r.task_name,
"target_id": r.target_id,
"celery_task_id": r.celery_task_id,
"started_at": r.started_at.isoformat() if r.started_at else None,
"finished_at": r.finished_at.isoformat() if r.finished_at else None,
"duration_ms": r.duration_ms,
"status": r.status,
"error_type": r.error_type,
"error_message": r.error_message,
"retry_count": r.retry_count,
"worker_hostname": r.worker_hostname,
"args_summary": r.args_summary,
}
+264
View File
@@ -0,0 +1,264 @@
"""FC-3h: /api/system/backup — create/list/restore/delete/tag for
DB + image backups.
Read endpoints are public on FC (operator-facing internal API; same
posture as /api/system/activity). Write endpoints take a typed
`confirm` body field that must match a server-generated token for
that backup row, to prevent click-to-destroy by stale browser tabs
or accidental cURL.
"""
from __future__ import annotations
from quart import Blueprint, jsonify, request
from sqlalchemy import desc, select
from ..extensions import get_session
from ..models import BackupRun, ImportSettings
system_backup_bp = Blueprint(
"system_backup", __name__, url_prefix="/api/system/backup",
)
_KINDS = frozenset({"db", "images"})
_TAG_MAX_LEN = 64
_BACKUP_SETTINGS_FIELDS = (
"backup_db_nightly_enabled",
"backup_db_nightly_hour_utc",
"backup_db_keep_last_n",
"backup_images_keep_last_n",
)
def _bad(error: str, *, status: int = 400, **extra):
body = {"error": error}
body.update(extra)
return jsonify(body), status
def _row_to_dict(r: BackupRun) -> dict:
return {
"id": r.id,
"kind": r.kind,
"status": r.status,
"tag": r.tag,
"triggered_by": r.triggered_by,
"started_at": r.started_at.isoformat() if r.started_at else None,
"finished_at": r.finished_at.isoformat() if r.finished_at else None,
"duration_seconds": (
int((r.finished_at - r.started_at).total_seconds())
if r.finished_at and r.started_at else None
),
"sql_path": r.sql_path,
"tar_path": r.tar_path,
"size_bytes": r.size_bytes,
"error": r.error,
"restored_from_id": r.restored_from_id,
"manifest": r.manifest or {},
}
def _validate_tag(tag):
if tag is None:
return None
if not isinstance(tag, str):
return _bad("invalid_tag", detail="tag must be string or null")
tag = tag.strip()
if not tag:
return None
if len(tag) > _TAG_MAX_LEN:
return _bad("invalid_tag", detail=f"tag too long (max {_TAG_MAX_LEN})")
return tag
def _validate_backup_settings_patch(body: dict):
if "backup_db_nightly_enabled" in body and not isinstance(
body["backup_db_nightly_enabled"], bool,
):
return _bad("invalid_value", detail="backup_db_nightly_enabled must be bool")
if "backup_db_nightly_hour_utc" in body:
v = body["backup_db_nightly_hour_utc"]
if not isinstance(v, int) or isinstance(v, bool) or not (0 <= v <= 23):
return _bad("invalid_value", detail="backup_db_nightly_hour_utc must be 0..23")
if "backup_db_keep_last_n" in body:
v = body["backup_db_keep_last_n"]
if not isinstance(v, int) or isinstance(v, bool) or not (1 <= v <= 365):
return _bad("invalid_value", detail="backup_db_keep_last_n must be 1..365")
if "backup_images_keep_last_n" in body:
v = body["backup_images_keep_last_n"]
if not isinstance(v, int) or isinstance(v, bool) or not (1 <= v <= 100):
return _bad("invalid_value", detail="backup_images_keep_last_n must be 1..100")
return None
@system_backup_bp.route("/db", methods=["POST"])
async def trigger_db_backup():
body = await request.get_json(silent=True) or {}
tag = _validate_tag(body.get("tag"))
if isinstance(tag, tuple):
return tag
from ..tasks.backup import backup_db_task
backup_db_task.delay(tag=tag, triggered_by="manual")
return jsonify({"status": "dispatched"}), 202
@system_backup_bp.route("/images", methods=["POST"])
async def trigger_images_backup():
body = await request.get_json(silent=True) or {}
tag = _validate_tag(body.get("tag"))
if isinstance(tag, tuple):
return tag
from ..tasks.backup import backup_images_task
backup_images_task.delay(tag=tag, triggered_by="manual")
return jsonify({"status": "dispatched"}), 202
@system_backup_bp.route("/runs", methods=["GET"])
async def list_runs():
try:
limit = min(int(request.args.get("limit", "50")), 200)
except ValueError:
return _bad("invalid_limit")
if limit < 1:
return _bad("invalid_limit")
kind = request.args.get("kind")
if kind is not None and kind not in _KINDS:
return _bad("invalid_kind", detail=f"kind must be one of {sorted(_KINDS)}")
before_id_raw = request.args.get("before_id")
before_id = int(before_id_raw) if before_id_raw else None
async with get_session() as session:
stmt = select(BackupRun).order_by(desc(BackupRun.id))
if kind:
stmt = stmt.where(BackupRun.kind == kind)
if before_id is not None:
stmt = stmt.where(BackupRun.id < before_id)
stmt = stmt.limit(limit + 1)
rows = (await session.execute(stmt)).scalars().all()
has_more = len(rows) > limit
rows = rows[:limit]
return jsonify({
"runs": [_row_to_dict(r) for r in rows],
"next_cursor": rows[-1].id if has_more and rows else None,
})
@system_backup_bp.route("/runs/<int:run_id>", methods=["GET"])
async def get_run(run_id: int):
async with get_session() as session:
row = await session.get(BackupRun, run_id)
if row is None:
return _bad("not_found", status=404)
return jsonify(_row_to_dict(row))
@system_backup_bp.route("/runs/<int:run_id>", methods=["PATCH"])
async def patch_run(run_id: int):
body = await request.get_json(silent=True) or {}
if "tag" not in body:
return _bad("invalid_body", detail="tag required")
tag = _validate_tag(body["tag"])
if isinstance(tag, tuple):
return tag
async with get_session() as session:
row = await session.get(BackupRun, run_id)
if row is None:
return _bad("not_found", status=404)
row.tag = tag
await session.commit()
await session.refresh(row)
return jsonify(_row_to_dict(row))
@system_backup_bp.route("/runs/<int:run_id>/restore", methods=["POST"])
async def trigger_restore(run_id: int):
body = await request.get_json(silent=True) or {}
supplied = body.get("confirm", "")
async with get_session() as session:
row = await session.get(BackupRun, run_id)
if row is None:
return _bad("not_found", status=404)
if row.status != "ok":
return _bad(
"not_restorable",
detail=f"source backup status={row.status!r}; only 'ok' rows are restorable",
)
expected = f"restore-{row.kind}-{row.id}"
if supplied != expected:
return _bad(
"confirm_mismatch",
detail=f"confirm must equal {expected!r}",
expected=expected,
)
kind = row.kind
if kind == "db":
from ..tasks.backup import restore_db_task
restore_db_task.delay(source_backup_run_id=run_id)
else: # 'images' (the only other value _KINDS allows via the trigger path)
from ..tasks.backup import restore_images_task
restore_images_task.delay(source_backup_run_id=run_id)
return jsonify({"status": "dispatched", "kind": kind}), 202
@system_backup_bp.route("/runs/<int:run_id>", methods=["DELETE"])
async def delete_run(run_id: int):
body = await request.get_json(silent=True) or {}
supplied = body.get("confirm", "")
async with get_session() as session:
row = await session.get(BackupRun, run_id)
if row is None:
return _bad("not_found", status=404)
expected = f"delete-{row.kind}-{row.id}"
if supplied != expected:
return _bad(
"confirm_mismatch",
detail=f"confirm must equal {expected!r}",
expected=expected,
)
from ..services import backup_service
backup_service.unlink_artifact_files(
sql_path=row.sql_path, tar_path=row.tar_path,
manifest_path=(row.manifest or {}).get("manifest_path"),
)
await session.delete(row)
await session.commit()
return "", 204
@system_backup_bp.route("/settings", methods=["GET"])
async def get_settings():
async with get_session() as session:
row = (await session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
return jsonify({
"backup_db_nightly_enabled": row.backup_db_nightly_enabled,
"backup_db_nightly_hour_utc": row.backup_db_nightly_hour_utc,
"backup_db_keep_last_n": row.backup_db_keep_last_n,
"backup_images_keep_last_n": row.backup_images_keep_last_n,
})
@system_backup_bp.route("/settings", methods=["PATCH"])
async def patch_settings():
body = await request.get_json(silent=True)
if not isinstance(body, dict):
return _bad("invalid_body", detail="body must be a JSON object")
err = _validate_backup_settings_patch(body)
if err is not None:
return err
async with get_session() as session:
row = (await session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
)).scalar_one()
for field in _BACKUP_SETTINGS_FIELDS:
if field in body:
setattr(row, field, body[field])
await session.commit()
return await get_settings()
+23
View File
@@ -31,6 +31,8 @@ def make_celery() -> Celery:
"backend.app.tasks.migration", "backend.app.tasks.migration",
"backend.app.tasks.ml", "backend.app.tasks.ml",
"backend.app.tasks.download", "backend.app.tasks.download",
"backend.app.tasks.backup",
"backend.app.tasks.admin",
], ],
) )
app.conf.update( app.conf.update(
@@ -43,6 +45,8 @@ def make_celery() -> Celery:
"backend.app.tasks.scan.*": {"queue": "scan"}, "backend.app.tasks.scan.*": {"queue": "scan"},
"backend.app.tasks.maintenance.*": {"queue": "maintenance"}, "backend.app.tasks.maintenance.*": {"queue": "maintenance"},
"backend.app.tasks.migration.*": {"queue": "maintenance"}, "backend.app.tasks.migration.*": {"queue": "maintenance"},
"backend.app.tasks.backup.*": {"queue": "maintenance"},
"backend.app.tasks.admin.*": {"queue": "maintenance"},
}, },
# Heavy ML tasks need fair dispatch — see ImageRepo's precedent. # Heavy ML tasks need fair dispatch — see ImageRepo's precedent.
task_acks_late=True, task_acks_late=True,
@@ -81,9 +85,28 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.maintenance.cleanup_old_download_events", "task": "backend.app.tasks.maintenance.cleanup_old_download_events",
"schedule": 86400.0, # daily "schedule": 86400.0, # daily
}, },
"recover-stalled-task-runs": {
"task": "backend.app.tasks.maintenance.recover_stalled_task_runs",
"schedule": 300.0, # every 5 min, matches recover-interrupted-tasks
},
"prune-task-runs": {
"task": "backend.app.tasks.maintenance.prune_task_runs",
"schedule": 86400.0, # daily
},
"fc3h-backup-db-nightly": {
"task": "backend.app.tasks.backup.backup_db_nightly",
"schedule": 3600.0, # hourly tick; task self-gates on configured UTC hour
},
"fc3h-prune-backups": {
"task": "backend.app.tasks.backup.prune_backups",
"schedule": 86400.0, # daily
},
}, },
timezone="UTC", timezone="UTC",
) )
# FC-3i: register task_run signal handlers (side-effect import).
from . import celery_signals # noqa: F401
return app return app
+210
View File
@@ -0,0 +1,210 @@
"""FC-3i: task_run lifecycle via Celery signals.
Subscribes to task_prerun / task_postrun / task_failure / task_retry
and persists one task_run row per task attempt. Drop-in for every
existing and future Celery task — no per-task instrumentation.
Signal handlers run inside the worker process (sync context); DB
writes go through the existing shared sync engine
(backend.app.tasks._sync_engine.sync_session_factory) — one engine
per worker process, not per-task, so we don't blow Postgres
max_connections under load (the reason FC-3g shared-engine fix
existed).
Failure-mode discipline (operator-pressed point): every handler is
wrapped in try/except that swallows + logs. If the DB is down or the
handler has a bug, the real task still runs — the dashboard goes
dark for that interval. Monitoring NEVER breaks the thing it's
monitoring.
"""
import logging
from datetime import UTC, datetime
from celery.exceptions import SoftTimeLimitExceeded
from celery.signals import task_failure, task_postrun, task_prerun, task_retry
from .models import TaskRun
from .tasks._sync_engine import sync_session_factory
log = logging.getLogger(__name__)
# Celery-internal tasks that would generate dashboard noise without
# operational value. Conservative list; extend only when a specific
# task proves noisy.
_UNTRACKED_TASK_NAMES = frozenset({
"celery.chord_unlock",
"celery.backend_cleanup",
"celery.chunks",
})
_MAX_ERROR_MESSAGE_LEN = 2000
_MAX_ARGS_SUMMARY_LEN = 255
_MAX_WORKER_HOSTNAME_LEN = 128
# PostgreSQL Integer is signed 32-bit. Tasks called with a first-arg
# int outside this range (e.g. an absurdly large mock value, or a
# string-of-digits coercible to int but bigger than 2^31-1) would crash
# the INSERT with NumericValueOutOfRange. Bound the recorded value to
# the column's range; values outside become None (target_id is
# nullable, so this is safe).
_INT32_MAX = 2_147_483_647
_INT32_MIN = -2_147_483_648
def _queue_for(task) -> str:
"""Reverse the task→queue routing from celery_app.task_routes.
Keep in sync if task_routes is reordered."""
name = getattr(task, "name", "") or ""
if name.startswith("backend.app.tasks.import_file."):
return "import"
if name.startswith("backend.app.tasks.ml."):
return "ml"
if name.startswith("backend.app.tasks.thumbnail."):
return "thumbnail"
if name.startswith("backend.app.tasks.download."):
return "download"
if name.startswith("backend.app.tasks.scan."):
return "scan"
if name.startswith((
"backend.app.tasks.maintenance.",
"backend.app.tasks.migration.",
)):
return "maintenance"
return "default"
def _target_id_from_args(args) -> int | None:
"""Best-effort: if the first positional arg parses as int AND fits
in the column's signed-32-bit range, record it as target_id
(image_id, source_id, etc.). Never raises."""
if not args:
return None
try:
value = int(args[0])
except (TypeError, ValueError):
return None
if value < _INT32_MIN or value > _INT32_MAX:
return None
return value
def _truncate(s, limit: int) -> str | None:
if s is None:
return None
text = str(s)
return text if len(text) <= limit else text[:limit]
def _is_tracked(task_name: str | None) -> bool:
return bool(task_name) and task_name not in _UNTRACKED_TASK_NAMES
@task_prerun.connect
def _on_prerun(sender=None, task_id=None, task=None, args=None,
kwargs=None, **_):
if not _is_tracked(getattr(task, "name", None)):
return
try:
Session = sync_session_factory()
with Session() as session:
session.add(TaskRun(
celery_task_id=task_id or "",
queue=_queue_for(task),
task_name=task.name,
target_id=_target_id_from_args(args),
started_at=datetime.now(UTC),
status="running",
args_summary=_truncate(repr(args), _MAX_ARGS_SUMMARY_LEN),
worker_hostname=_truncate(
getattr(sender, "hostname", None),
_MAX_WORKER_HOSTNAME_LEN,
),
))
session.commit()
except Exception: # noqa: BLE001 — never break the worker
log.exception("task_run prerun insert failed (task=%s)",
getattr(task, "name", "?"))
def _finalize(task_id: str, *, status: str,
error_type: str | None = None,
error_message: str | None = None,
retry_count: int | None = None) -> None:
"""Shared write path for postrun/failure/retry. Picks the most-
recent task_run row for this celery_task_id that's still 'running'
(retries reuse the same celery_task_id; each new attempt's prerun
inserts a fresh row, so finalize targets the latest running row)."""
try:
from sqlalchemy import select
Session = sync_session_factory()
now = datetime.now(UTC)
with Session() as session:
row = session.execute(
select(TaskRun)
.where(TaskRun.celery_task_id == task_id)
.where(TaskRun.status == "running")
.order_by(TaskRun.id.desc())
.limit(1)
).scalar_one_or_none()
if row is None:
return # no prerun row (untracked, insert failed, or already finalized)
row.finished_at = now
row.duration_ms = int(
(now - row.started_at).total_seconds() * 1000
)
row.status = status
if error_type is not None:
row.error_type = _truncate(error_type, 128)
if error_message is not None:
row.error_message = _truncate(error_message, _MAX_ERROR_MESSAGE_LEN)
if retry_count is not None:
row.retry_count = retry_count
session.commit()
except Exception: # noqa: BLE001
log.exception("task_run finalize failed (task_id=%s)", task_id)
@task_postrun.connect
def _on_postrun(sender=None, task_id=None, task=None, args=None,
kwargs=None, retval=None, state=None, **_):
if not _is_tracked(getattr(task, "name", None)):
return
# state is one of SUCCESS/FAILURE/RETRY/etc. Only handle SUCCESS;
# task_failure handles FAILURE explicitly (with the exception).
if state != "SUCCESS":
return
_finalize(task_id, status="ok")
@task_failure.connect
def _on_failure(sender=None, task_id=None, exception=None,
args=None, kwargs=None, einfo=None, **_):
if not _is_tracked(getattr(sender, "name", None)):
return
status = ("timeout"
if isinstance(exception, SoftTimeLimitExceeded)
else "error")
_finalize(
task_id, status=status,
error_type=type(exception).__name__ if exception else "Unknown",
error_message=str(exception) if exception else None,
)
@task_retry.connect
def _on_retry(sender=None, request=None, reason=None, einfo=None, **_):
if not _is_tracked(getattr(sender, "name", None)):
return
task_id = getattr(request, "id", None)
if not task_id:
return
# Mark current attempt's row as 'retry' (terminal for this row).
# The next attempt's task_prerun inserts a fresh row.
_finalize(
task_id, status="retry",
error_type=type(reason).__name__ if reason else "Retry",
error_message=str(reason) if reason else None,
retry_count=getattr(request, "retries", 0),
)
+4
View File
@@ -2,6 +2,7 @@
from .app_setting import AppSetting from .app_setting import AppSetting
from .artist import Artist from .artist import Artist
from .backup_run import BackupRun
from .base import Base from .base import Base
from .credential import Credential from .credential import Credential
from .download_event import DownloadEvent from .download_event import DownloadEvent
@@ -21,11 +22,13 @@ from .tag_alias import TagAlias
from .tag_allowlist import TagAllowlist from .tag_allowlist import TagAllowlist
from .tag_reference_embedding import TagReferenceEmbedding from .tag_reference_embedding import TagReferenceEmbedding
from .tag_suggestion_rejection import TagSuggestionRejection from .tag_suggestion_rejection import TagSuggestionRejection
from .task_run import TaskRun
__all__ = [ __all__ = [
"Base", "Base",
"AppSetting", "AppSetting",
"Artist", "Artist",
"BackupRun",
"Source", "Source",
"Credential", "Credential",
"Post", "Post",
@@ -46,4 +49,5 @@ __all__ = [
"TagAllowlist", "TagAllowlist",
"TagReferenceEmbedding", "TagReferenceEmbedding",
"TagSuggestionRejection", "TagSuggestionRejection",
"TaskRun",
] ]
+55
View File
@@ -0,0 +1,55 @@
"""FC-3h: backup_run — operator-facing artifact record for a backup run.
One row per backup attempt (kind='db' or 'images'). Lifecycle
tracking (started_at/finished_at/duration_ms/exception text) lives
in task_run from FC-3i — this row records artifact metadata: file
paths, sizes, tag (retention protection), and restore lineage via
restored_from_id.
Status values (String, not Postgres ENUM — per
feedback_check_existing_enums):
pending — created but task hasn't started yet (rare; usually
status starts as 'running' from the task body).
running — backup task is in flight.
ok — artifact successfully written.
error — task raised; error column populated.
restoring — this row represents a restore attempt (kind = restored
kind); linked to source via restored_from_id.
restored — restore completed successfully.
"""
from datetime import datetime
from sqlalchemy import JSON, BigInteger, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class BackupRun(Base):
__tablename__ = "backup_run"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="pending", index=True,
)
tag: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
triggered_by: Mapped[str] = mapped_column(String(32), nullable=False)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True,
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True,
)
sql_path: Mapped[str | None] = mapped_column(Text, nullable=True)
tar_path: Mapped[str | None] = mapped_column(Text, nullable=True)
size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
manifest: Mapped[dict] = mapped_column(
JSON, nullable=False, default=dict, server_default="{}",
)
restored_from_id: Mapped[int | None] = mapped_column(
ForeignKey("backup_run.id", ondelete="SET NULL"),
nullable=True,
)
+14
View File
@@ -49,3 +49,17 @@ class ImportSettings(Base):
download_failure_warning_threshold: Mapped[int] = mapped_column( download_failure_warning_threshold: Mapped[int] = mapped_column(
Integer, nullable=False, default=5 Integer, nullable=False, default=5
) )
# FC-3h backup knobs.
backup_db_nightly_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False,
)
backup_db_nightly_hour_utc: Mapped[int] = mapped_column(
Integer, nullable=False, default=3,
)
backup_db_keep_last_n: Mapped[int] = mapped_column(
Integer, nullable=False, default=14,
)
backup_images_keep_last_n: Mapped[int] = mapped_column(
Integer, nullable=False, default=3,
)
+48
View File
@@ -0,0 +1,48 @@
"""FC-3i: task_run — per-Celery-task lifecycle audit row.
One row inserted by the task_prerun signal at task start, updated by
task_postrun / task_failure / task_retry. The shape supports the
SystemActivity dashboard's three panes: per-queue queue+worker summary,
recent failures (24h, grouped by error_type), and full paginated
activity history.
Retention: ok rows pruned after 24h, error/timeout after 7d (see
backend.app.tasks.maintenance.prune_task_runs).
Recovery: rows stuck in 'running' for >5 min flipped to 'error' by
backend.app.tasks.maintenance.recover_stalled_task_runs (Beat 5 min).
"""
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class TaskRun(Base):
__tablename__ = "task_run"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
celery_task_id: Mapped[str] = mapped_column(
String(64), nullable=False, index=True,
)
queue: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
task_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
target_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True,
)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True,
)
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="running", index=True,
)
error_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
retry_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
worker_hostname: Mapped[str | None] = mapped_column(String(128), nullable=True)
args_summary: Mapped[str | None] = mapped_column(String(255), nullable=True)
+196
View File
@@ -0,0 +1,196 @@
"""FC-3h: first-class backup/restore service for FC.
Two independent backup kinds:
- 'db' — pg_dump only; fast; nightly via Beat (settings-gated)
- 'images' — tar+zstd of /images; slow; manual trigger only
Files live under <images_root>/_backups/. Each backup writes:
fc_<kind>_<ts>.{sql|tar.zst} — the artifact
fc_<kind>_<ts>.json — manifest (kind/tag/triggered_by)
Service functions are sync (subprocess-bound). Celery tasks in
backend.app.tasks.backup wrap each one with task_run-tracked
lifecycle + soft/hard time limits + retention bookkeeping.
"""
from __future__ import annotations
import json
import subprocess
from datetime import UTC, datetime
from pathlib import Path
_BACKUPS_DIRNAME = "_backups"
# Subprocess-level guardrails BEYOND the Celery soft_time_limit. The
# Celery soft limit signals the Python process; subprocess.Popen in a
# blocking syscall ignores that signal. These bound the worst case.
_DB_SUBPROCESS_TIMEOUT_S = 12 * 60 # 12 min (Celery soft is 10 min)
_IMAGES_SUBPROCESS_TIMEOUT_S = 7 * 60 * 60 # 7 hr (Celery soft is 6 hr)
def _libpq_url(sa_url: str) -> str:
"""Strip SQLAlchemy +psycopg/+asyncpg driver suffix for pg_dump/psql."""
for driver in (
"postgresql+psycopg",
"postgresql+asyncpg",
"postgresql+psycopg2",
):
if sa_url.startswith(driver + "://"):
return "postgresql://" + sa_url[len(driver) + 3:]
return sa_url
def _backups_dir(images_root: Path) -> Path:
p = images_root / _BACKUPS_DIRNAME
p.mkdir(parents=True, exist_ok=True)
return p
def _now_ts() -> str:
return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
def _file_size_or_none(path: Path) -> int | None:
try:
return path.stat().st_size
except OSError:
return None
def _write_manifest(
out_dir: Path, *, kind: str, ts: str,
tag: str | None, triggered_by: str,
artifact_path: Path,
) -> Path:
manifest = {
"kind": kind,
"backup_id": f"fc_{kind}_{ts}",
"tag": tag,
"triggered_by": triggered_by,
"created_at": datetime.now(UTC).isoformat(),
"artifact_path": str(artifact_path),
}
mf = out_dir / f"fc_{kind}_{ts}.json"
mf.write_text(json.dumps(manifest, indent=2))
return mf
def backup_db(
*, db_url: str, images_root: Path,
tag: str | None = None, triggered_by: str = "manual",
) -> dict:
"""Run pg_dump; write .sql + manifest; return dict for the caller
to persist into BackupRun. Raises on subprocess failure."""
ts = _now_ts()
out_dir = _backups_dir(images_root)
sql_path = out_dir / f"fc_db_{ts}.sql"
subprocess.run(
[
"pg_dump", "--no-owner", "--no-acl",
"-f", str(sql_path), _libpq_url(db_url),
],
capture_output=True, check=True,
timeout=_DB_SUBPROCESS_TIMEOUT_S,
)
manifest_path = _write_manifest(
out_dir, kind="db", ts=ts, tag=tag, triggered_by=triggered_by,
artifact_path=sql_path,
)
return {
"kind": "db",
"ts": ts,
"sql_path": str(sql_path),
"tar_path": None,
"manifest_path": str(manifest_path),
"size_bytes": _file_size_or_none(sql_path),
}
def backup_images(
*, images_root: Path,
tag: str | None = None, triggered_by: str = "manual",
) -> dict:
"""Run tar --zstd over images_root; write .tar.zst + manifest."""
ts = _now_ts()
out_dir = _backups_dir(images_root)
tar_path = out_dir / f"fc_images_{ts}.tar.zst"
subprocess.run(
[
"tar", "--zstd", "-cf", str(tar_path),
"-C", str(images_root.parent), images_root.name,
f"--exclude={images_root.name}/_backups",
f"--exclude={images_root.name}/_quarantine",
],
capture_output=True, check=True,
timeout=_IMAGES_SUBPROCESS_TIMEOUT_S,
)
manifest_path = _write_manifest(
out_dir, kind="images", ts=ts, tag=tag, triggered_by=triggered_by,
artifact_path=tar_path,
)
return {
"kind": "images",
"ts": ts,
"sql_path": None,
"tar_path": str(tar_path),
"manifest_path": str(manifest_path),
"size_bytes": _file_size_or_none(tar_path),
}
def restore_db(*, db_url: str, sql_path: Path) -> None:
"""Wipe public schema, then load from .sql. Raises on subprocess
failure; partial-restore state is the caller's concern."""
libpq = _libpq_url(db_url)
subprocess.run(
[
"psql", libpq, "-c",
"DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;",
],
capture_output=True, check=True, timeout=120,
)
subprocess.run(
["psql", libpq, "-f", str(sql_path)],
capture_output=True, check=True,
timeout=_DB_SUBPROCESS_TIMEOUT_S,
)
def restore_images(*, images_root: Path, tar_path: Path) -> None:
"""Untar over images_root.parent. Additive — files NOT in the
tarball are NOT removed. Caller wipes first if a clean restore
is needed."""
subprocess.run(
[
"tar", "--zstd", "-xf", str(tar_path),
"-C", str(images_root.parent),
],
capture_output=True, check=True,
timeout=_IMAGES_SUBPROCESS_TIMEOUT_S,
)
def unlink_artifact_files(
*,
sql_path: str | None,
tar_path: str | None,
manifest_path: str | None,
) -> dict:
"""Best-effort unlink of all on-disk files for a BackupRun row.
Returns dict keyed by label with True/False per file. Missing
files count as success (missing_ok semantics)."""
deleted: dict = {}
for label, p in (
("sql", sql_path),
("tar", tar_path),
("manifest", manifest_path),
):
if not p:
continue
path = Path(p)
try:
path.unlink(missing_ok=True)
deleted[label] = True
except OSError:
deleted[label] = False
return deleted
+367
View File
@@ -0,0 +1,367 @@
"""FC-3k: first-class admin destructive operations.
Projections are pure SELECTs used by both dry-run preview endpoints
and Tier-B count prompts. Mutations (Task 2) are called from sync
HTTP handlers (small ops) and from Celery tasks in
backend.app.tasks.admin (long ops).
This module is the PERMANENT home of artist-cascade + image-unlink
logic. The legacy copy at backend/app/services/migrators/cleanup.py
stays in place until FC-3j; FC-3j will replace its body with thin
re-exports from this module and then delete the wrapper.
"""
from __future__ import annotations
from pathlib import Path
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ..models import Artist, ImageRecord, Tag
from ..models.series_page import SeriesPage
from ..models.tag import image_tag
def project_artist_cascade(session: Session, *, slug: str) -> dict:
"""Read-only projection of what delete_artist_cascade would touch.
Returns:
{
"artist": {"id": int, "name": str, "slug": str},
"projected": {
"images": int,
"sources": int,
"thumbs": int, # images with a thumbnail_path set
"import_tasks": int, # ImportTask rows referencing the artist's images
"bytes_on_disk": int, # SUM(image_record.size_bytes) — column is NOT NULL
},
}
Raises LookupError if slug not found. No mutations.
"""
from ..models.import_task import ImportTask
from ..models.source import Source
artist = session.execute(
select(Artist).where(Artist.slug == slug)
).scalar_one_or_none()
if artist is None:
raise LookupError(f"artist slug not found: {slug!r}")
images_count = session.execute(
select(func.count(ImageRecord.id))
.where(ImageRecord.artist_id == artist.id)
).scalar_one()
sources_count = session.execute(
select(func.count(Source.id))
.where(Source.artist_id == artist.id)
).scalar_one()
thumbs_count = session.execute(
select(func.count(ImageRecord.id))
.where(ImageRecord.artist_id == artist.id)
.where(ImageRecord.thumbnail_path.is_not(None))
).scalar_one()
import_tasks_count = session.execute(
select(func.count(ImportTask.id))
.where(
ImportTask.result_image_id.in_(
select(ImageRecord.id).where(ImageRecord.artist_id == artist.id)
)
)
).scalar_one()
bytes_on_disk = session.execute(
select(func.coalesce(func.sum(ImageRecord.size_bytes), 0))
.where(ImageRecord.artist_id == artist.id)
).scalar_one()
return {
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
"projected": {
"images": images_count,
"sources": sources_count,
"thumbs": thumbs_count,
"import_tasks": import_tasks_count,
"bytes_on_disk": int(bytes_on_disk),
},
}
def project_bulk_image_delete(
session: Session, *, image_ids: list[int],
) -> dict:
"""Read-only projection of what delete_images would touch.
Returns:
{
"images_found": int,
"thumbs_to_unlink": int,
"bytes_on_disk": int,
"missing_ids": list[int], # ids passed in that don't exist
}
No mutations.
"""
if not image_ids:
return {
"images_found": 0,
"thumbs_to_unlink": 0,
"bytes_on_disk": 0,
"missing_ids": [],
}
rows = session.execute(
select(
ImageRecord.id,
ImageRecord.thumbnail_path,
ImageRecord.size_bytes,
).where(ImageRecord.id.in_(image_ids))
).all()
found_ids = {r.id for r in rows}
missing = sorted(set(image_ids) - found_ids)
return {
"images_found": len(rows),
"thumbs_to_unlink": sum(1 for r in rows if r.thumbnail_path),
"bytes_on_disk": sum(r.size_bytes for r in rows),
"missing_ids": missing,
}
def count_tag_associations(session: Session, *, tag_id: int) -> int:
"""COUNT(*) FROM image_tag WHERE tag_id=?. For Tier-B prompt."""
return session.execute(
select(func.count())
.select_from(image_tag)
.where(image_tag.c.tag_id == tag_id)
).scalar_one()
def find_unused_tags(
session: Session, *, limit: int | None = None,
) -> list[Tag]:
"""Tags with no image_tag rows AND no series_page rows.
Sorted by name. Used by both dry-run preview and the live prune.
A tag is "unused" iff it has zero rows in image_tag AND zero rows
in series_page (so we don't accidentally prune a series tag that
happens to have no images yet).
"""
used_via_image_tag = select(image_tag.c.tag_id).distinct()
used_via_series = select(SeriesPage.series_tag_id).where(
SeriesPage.series_tag_id.is_not(None)
).distinct()
stmt = (
select(Tag)
.where(Tag.id.not_in(used_via_image_tag))
.where(Tag.id.not_in(used_via_series))
.order_by(Tag.name)
)
if limit is not None:
stmt = stmt.limit(limit)
return list(session.execute(stmt).scalars().all())
def unlink_image_files(
image: ImageRecord, images_root: Path,
) -> dict:
"""Best-effort unlink of all on-disk files for an ImageRecord.
Targets: image.path (original), image.thumbnail_path (cached
thumbnail), and the computed thumbs path at
/images/thumbs/<sha256[:3]>/<sha256>.(jpg|png|webp) (tries all
three extensions; missing extension is silently OK).
Returns {"original": bool, "thumbnail": bool}. Missing files
count as success (missing_ok semantics). OSErrors are swallowed
and reported as False so the calling DB delete still proceeds.
"""
out = {"original": False, "thumbnail": False}
if image.path:
try:
Path(image.path).unlink(missing_ok=True)
out["original"] = True
except OSError:
out["original"] = False
# Custom thumbnail_path (when set) — try it first.
if image.thumbnail_path:
try:
Path(image.thumbnail_path).unlink(missing_ok=True)
out["thumbnail"] = True
except OSError:
out["thumbnail"] = False
# Convention thumbs dir — try all extensions; missing OK.
if image.sha256:
bucket = image.sha256[:3]
for ext in ("jpg", "png", "webp"):
try:
(images_root / "thumbs" / bucket / f"{image.sha256}.{ext}").unlink(
missing_ok=True,
)
except OSError:
pass
return out
def delete_artist_cascade(
session: Session, *, artist_id: int, images_root: Path,
) -> dict:
"""Batched delete of an artist's images + the artist row.
Mirrors the cleanup_artist_async pattern: 500-row batches,
commit between batches so partial progress survives a worker
kill. Idempotent on missing artist (returns zeroed counts).
Postgres cascades handle image_tag / image_provenance /
series_page / tag_suggestion_rejection from ImageRecord delete,
and source / post / download_event / etc. from Artist delete
(via Artist.sources cascade="all, delete-orphan").
"""
artist = session.get(Artist, artist_id)
if artist is None:
return {
"artist": None,
"summary": {
"images_deleted": 0,
"files_deleted": 0,
"thumbs_deleted": 0,
"import_tasks_nulled": 0,
"files_failed": 0,
},
}
artist_info = {"id": artist.id, "name": artist.name, "slug": artist.slug}
images_deleted = 0
files_deleted = 0
thumbs_deleted = 0
files_failed = 0
while True:
rows = session.execute(
select(ImageRecord)
.where(ImageRecord.artist_id == artist.id)
.limit(500)
).scalars().all()
if not rows:
break
for img in rows:
unlinked = unlink_image_files(img, images_root)
if unlinked["original"]:
files_deleted += 1
else:
files_failed += 1
if unlinked["thumbnail"]:
thumbs_deleted += 1
session.delete(img)
images_deleted += 1
session.commit()
# ImportTask.result_image_id FK is SET NULL on image delete (Postgres
# handles this in the cascade above). We don't separately count those
# in FC-3k — the legacy cleanup_artist_async did it via
# source_path_prefix matching that's out of scope here.
import_tasks_nulled = 0
session.delete(artist)
session.commit()
return {
"artist": artist_info,
"summary": {
"images_deleted": images_deleted,
"files_deleted": files_deleted,
"thumbs_deleted": thumbs_deleted,
"import_tasks_nulled": import_tasks_nulled,
"files_failed": files_failed,
},
}
def delete_images(
session: Session, *, image_ids: list[int], images_root: Path,
) -> dict:
"""Delete a list of images in 500-row batches with commit between.
Postgres CASCADE on image_tag / image_provenance / series_page /
tag_suggestion_rejection / post_attachment(FK SET NULL) handles
the DB side; this function handles file unlinks first then row
deletes. Idempotent on missing IDs (returned as missing_ids;
no error). On partial OSError, the row is still deleted and
files_failed is incremented.
"""
if not image_ids:
return {
"images_deleted": 0,
"files_deleted": 0,
"thumbs_deleted": 0,
"files_failed": 0,
"missing_ids": [],
}
seen_ids: set[int] = set()
images_deleted = 0
files_deleted = 0
thumbs_deleted = 0
files_failed = 0
pending = list(image_ids)
while pending:
batch_ids = pending[:500]
pending = pending[500:]
rows = session.execute(
select(ImageRecord).where(ImageRecord.id.in_(batch_ids))
).scalars().all()
for img in rows:
seen_ids.add(img.id)
unlinked = unlink_image_files(img, images_root)
if unlinked["original"]:
files_deleted += 1
else:
files_failed += 1
if unlinked["thumbnail"]:
thumbs_deleted += 1
session.delete(img)
images_deleted += 1
session.commit()
missing = sorted(set(image_ids) - seen_ids)
return {
"images_deleted": images_deleted,
"files_deleted": files_deleted,
"thumbs_deleted": thumbs_deleted,
"files_failed": files_failed,
"missing_ids": missing,
}
def delete_tag(session: Session, *, tag_id: int) -> dict:
"""Simple DELETE FROM tag WHERE id=?.
Postgres cascades the rest (image_tag, tag_alias, tag_allowlist,
tag_reference_embedding, tag_suggestion_rejection, series_page).
Returns counts BEFORE delete so the caller can surface them.
Raises LookupError if tag_id not found.
"""
tag = session.get(Tag, tag_id)
if tag is None:
raise LookupError(f"tag id not found: {tag_id}")
associations_count = count_tag_associations(session, tag_id=tag_id)
info = {"id": tag.id, "name": tag.name, "kind": tag.kind.value}
session.delete(tag)
session.commit()
return {"deleted": info, "associations_removed": associations_count}
def prune_unused_tags(session: Session, *, dry_run: bool = False) -> dict:
"""Find tags with zero references and (unless dry_run) delete them.
Returns:
dry_run=True: {"count": N, "sample_names": [first 50]}
dry_run=False: {"deleted": N, "sample_names": [first 50]}
"""
unused = find_unused_tags(session)
sample = [t.name for t in unused[:50]]
if dry_run:
return {"count": len(unused), "sample_names": sample}
ids = [t.id for t in unused]
if ids:
session.execute(
Tag.__table__.delete().where(Tag.id.in_(ids))
)
session.commit()
return {"deleted": len(ids), "sample_names": sample}
+5 -1
View File
@@ -1,6 +1,10 @@
"""FC-5 migration tooling. """FC-5 migration tooling.
One module per concern (backup/rollback/gs/ir/overlap/ml_queue/verify). One module per concern (gs/ir/overlap/ml_queue/verify/cleanup).
Each migrator returns a counts dict; the run_migration task wires Each migrator returns a counts dict; the run_migration task wires
that dict into MigrationRun.counts so the UI polling shows progress. that dict into MigrationRun.counts so the UI polling shows progress.
backup + rollback were retired in FC-3h (2026-05-24); first-class
backup lives at backend/app/services/backup_service.py and exposes
its own /api/system/backup/* surface.
""" """
-146
View File
@@ -1,146 +0,0 @@
"""pg_dump + tar.zst-based backup, restorable via pair of subprocess calls.
Backups live under <images_root>/_backups/. Each backup is two files
(SQL + tarball) plus a manifest JSON. Tagged backups (e.g. tag='pre_migration')
are how rollback.py finds the most recent restorable snapshot.
"""
from __future__ import annotations
import json
import shutil
import subprocess
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
_BACKUPS_DIRNAME = "_backups"
def _libpq_url(sa_url: str) -> str:
"""Strip SQLAlchemy driver suffix so pg_dump/psql accept the URL.
SQLAlchemy uses URLs like `postgresql+psycopg://...` or
`postgresql+asyncpg://...`. libpq tools (pg_dump, psql) only know
the plain `postgresql://` scheme.
"""
for driver in ("postgresql+psycopg", "postgresql+asyncpg", "postgresql+psycopg2"):
if sa_url.startswith(driver + "://"):
return "postgresql://" + sa_url[len(driver) + 3:]
return sa_url
def _backups_dir(images_root: Path | None = None) -> Path:
# Overridable for tests via monkeypatch.
root = images_root if images_root is not None else Path("/images")
p = root / _BACKUPS_DIRNAME
p.mkdir(parents=True, exist_ok=True)
return p
_DEFAULT_SUBPROCESS_TIMEOUT_S = 30 * 60 # 30 minutes
def _run_subprocess(cmd: list[str], **kwargs: Any):
# Overridable for tests via monkeypatch. Hard wall-clock timeout
# guards against pg_dump / tar / zstd hangs on NFS — without it the
# task pretends to be 'running' forever (operator hit this 2026-05-
# 23 with two backups stuck in MigrationRun). On timeout
# subprocess.run raises TimeoutExpired which the caller surfaces as
# a task error.
return subprocess.run(
cmd,
capture_output=True,
check=True,
timeout=_DEFAULT_SUBPROCESS_TIMEOUT_S,
**{k: v for k, v in kwargs.items() if not k.startswith("_")},
)
def create_backup(
*, db_url: str, images_root: Path, tag: str = "manual",
) -> dict:
"""Create a backup: pg_dump SQL + tar.zst of images.
Returns a manifest dict. Writes <ts>.sql, <ts>.tar.zst, <ts>.json into
<images_root>/_backups/.
"""
ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
out_dir = _backups_dir(images_root)
sql_path = out_dir / f"fc_{ts}.sql"
tar_path = out_dir / f"fc_{ts}.tar.zst"
manifest_path = out_dir / f"fc_{ts}.json"
_run_subprocess(
["pg_dump", "--no-owner", "--no-acl", "-f", str(sql_path), _libpq_url(db_url)],
_test_ts=ts,
)
_run_subprocess(
[
"tar", "--zstd", "-cf", str(tar_path),
"-C", str(images_root.parent), images_root.name,
f"--exclude={images_root.name}/_backups",
f"--exclude={images_root.name}/_quarantine",
],
_test_ts=ts,
)
manifest = {
"backup_id": ts,
"tag": tag,
"created_at": datetime.now(UTC).isoformat(),
"sql_path": str(sql_path),
"tar_path": str(tar_path),
}
manifest_path.write_text(json.dumps(manifest, indent=2))
return manifest
def list_backups(images_root: Path) -> list[dict]:
out_dir = _backups_dir(images_root)
items = []
for mf in sorted(out_dir.glob("fc_*.json"), reverse=True):
try:
items.append(json.loads(mf.read_text()))
except Exception:
continue
return items
def find_latest_backup(images_root: Path, *, tag: str) -> dict | None:
for mf in list_backups(images_root):
if mf.get("tag") == tag:
return mf
return None
def restore_backup(
*, manifest: dict, db_url: str, images_root: Path,
) -> dict:
"""Restore from a backup manifest.
1. Replay the .sql via psql.
2. Wipe /images/ contents (except _backups/, which holds the file we're using).
3. Untar the .tar.zst into /images/.
"""
sql_path = Path(manifest["sql_path"])
tar_path = Path(manifest["tar_path"])
_run_subprocess(
["psql", "-d", _libpq_url(db_url), "-f", str(sql_path)],
)
# Wipe everything in images_root EXCEPT _backups/ (we'd delete the backup
# we're restoring from!).
for entry in images_root.iterdir():
if entry.name == _BACKUPS_DIRNAME:
continue
if entry.is_dir():
shutil.rmtree(entry)
else:
entry.unlink()
_run_subprocess(
["tar", "--zstd", "-xf", str(tar_path), "-C", str(images_root.parent)],
)
return {"restored_from": manifest["backup_id"]}
@@ -1,21 +0,0 @@
"""Restore from the most recent 'pre_migration'-tagged backup."""
from __future__ import annotations
from pathlib import Path
from . import backup as backup_mod
class NoBackupFoundError(Exception):
"""Raised when rollback() is called with no pre_migration backup on disk."""
def rollback_to_pre_migration(*, db_url: str, images_root: Path) -> dict:
manifest = backup_mod.find_latest_backup(images_root, tag="pre_migration")
if manifest is None:
raise NoBackupFoundError(
"no pre_migration-tagged backup found under <images_root>/_backups/"
)
return backup_mod.restore_backup(
manifest=manifest, db_url=db_url, images_root=images_root,
)
+57
View File
@@ -0,0 +1,57 @@
"""FC-3k: admin destructive Celery tasks.
Two long-running ops on the maintenance queue. task_run lifecycle is
captured automatically by FC-3i signals — these tasks just return
their summary dict so it lands in task_run.metadata (via Celery's
result backend) for the dashboard to surface.
Soft/hard time limits inherit the FC-3i recovery sweep: a runaway
task gets killed and flipped to status='timeout' by
recover_stalled_task_runs.
"""
from __future__ import annotations
import logging
from pathlib import Path
from sqlalchemy.exc import DBAPIError, OperationalError
from ..celery_app import celery
from ..services import cleanup_service
from ._sync_engine import sync_session_factory as _sync_session_factory
log = logging.getLogger(__name__)
IMAGES_ROOT = Path("/images")
@celery.task(
name="backend.app.tasks.admin.delete_artist_cascade_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
soft_time_limit=1800, time_limit=2400, # 30 min / 40 min
)
def delete_artist_cascade_task(self, *, artist_id: int) -> dict:
"""Wraps cleanup_service.delete_artist_cascade. Returns the
service's summary dict for FC-3i task_run.metadata capture."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
return cleanup_service.delete_artist_cascade(
session, artist_id=artist_id, images_root=IMAGES_ROOT,
)
@celery.task(
name="backend.app.tasks.admin.bulk_delete_images_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=15, retry_backoff_max=180, max_retries=1,
soft_time_limit=900, time_limit=1200, # 15 min / 20 min
)
def bulk_delete_images_task(self, *, image_ids: list[int]) -> dict:
"""Wraps cleanup_service.delete_images."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
return cleanup_service.delete_images(
session, image_ids=image_ids, images_root=IMAGES_ROOT,
)
+300
View File
@@ -0,0 +1,300 @@
"""FC-3h: backup/restore Celery tasks.
All tasks live on the maintenance queue (per celery_app.task_routes).
task_run lifecycle tracking is automatic via FC-3i signals — these
tasks just record the operator-facing artifact metadata into
BackupRun.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from pathlib import Path
from celery.exceptions import SoftTimeLimitExceeded
from sqlalchemy import select
from sqlalchemy.exc import DBAPIError, OperationalError
from ..celery_app import celery
from ..config import get_config
from ..models import BackupRun, ImportSettings
from ..services import backup_service
from ._sync_engine import sync_session_factory as _sync_session_factory
log = logging.getLogger(__name__)
IMAGES_ROOT = Path("/images")
def _mark_failed(session, row: BackupRun, exc: BaseException) -> None:
"""Flip a BackupRun row from running/restoring to error with a
truncated error message and finished_at. Caller already holds the
session open."""
row.status = "error"
row.error = f"{type(exc).__name__}: {exc}"[:2000]
row.finished_at = datetime.now(UTC)
session.add(row)
session.commit()
@celery.task(
name="backend.app.tasks.backup.backup_db_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=10, retry_backoff_max=120, max_retries=2,
soft_time_limit=600, time_limit=720,
)
def backup_db_task(self, *, tag: str | None = None,
triggered_by: str = "manual") -> dict:
"""Create one DB backup. Returns {'backup_run_id': N}."""
SessionLocal = _sync_session_factory()
cfg = get_config()
now = datetime.now(UTC)
with SessionLocal() as session:
row = BackupRun(
kind="db", status="running", tag=tag,
triggered_by=triggered_by, started_at=now, manifest={},
)
session.add(row)
session.commit()
session.refresh(row)
run_id = row.id
try:
result = backup_service.backup_db(
db_url=cfg.database_url_sync, images_root=IMAGES_ROOT,
tag=tag, triggered_by=triggered_by,
)
except (SoftTimeLimitExceeded, Exception) as exc:
with SessionLocal() as session:
row = session.get(BackupRun, run_id)
if row is not None:
_mark_failed(session, row, exc)
raise
with SessionLocal() as session:
row = session.get(BackupRun, run_id)
row.status = "ok"
row.finished_at = datetime.now(UTC)
row.sql_path = result["sql_path"]
row.size_bytes = result["size_bytes"]
row.manifest = {
"manifest_path": result["manifest_path"],
"ts": result["ts"],
}
session.commit()
return {"backup_run_id": run_id}
@celery.task(
name="backend.app.tasks.backup.backup_images_task",
bind=True,
autoretry_for=(OperationalError, DBAPIError),
retry_backoff=30, retry_backoff_max=300, max_retries=1,
soft_time_limit=21600, time_limit=23400,
)
def backup_images_task(self, *, tag: str | None = None,
triggered_by: str = "manual") -> dict:
"""Create one images backup. Same shape as backup_db_task; uses
tar_path instead of sql_path."""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
with SessionLocal() as session:
row = BackupRun(
kind="images", status="running", tag=tag,
triggered_by=triggered_by, started_at=now, manifest={},
)
session.add(row)
session.commit()
session.refresh(row)
run_id = row.id
try:
result = backup_service.backup_images(
images_root=IMAGES_ROOT,
tag=tag, triggered_by=triggered_by,
)
except (SoftTimeLimitExceeded, Exception) as exc:
with SessionLocal() as session:
row = session.get(BackupRun, run_id)
if row is not None:
_mark_failed(session, row, exc)
raise
with SessionLocal() as session:
row = session.get(BackupRun, run_id)
row.status = "ok"
row.finished_at = datetime.now(UTC)
row.tar_path = result["tar_path"]
row.size_bytes = result["size_bytes"]
row.manifest = {
"manifest_path": result["manifest_path"],
"ts": result["ts"],
}
session.commit()
return {"backup_run_id": run_id}
@celery.task(
name="backend.app.tasks.backup.restore_db_task",
bind=True,
max_retries=0, # NEVER auto-retry a half-applied restore.
soft_time_limit=1200, time_limit=1800,
)
def restore_db_task(self, *, source_backup_run_id: int) -> dict:
"""Restore from a previous DB backup. Inserts a NEW BackupRun row
(kind='db', status='restoring') linked to the source via
restored_from_id; flips to 'restored' on success or 'error' on
failure. Operator sees the restore as a row in the dashboard."""
SessionLocal = _sync_session_factory()
cfg = get_config()
now = datetime.now(UTC)
with SessionLocal() as session:
src = session.get(BackupRun, source_backup_run_id)
if src is None or src.kind != "db" or not src.sql_path:
raise ValueError(
f"BackupRun id={source_backup_run_id} is not a valid DB backup"
)
marker = BackupRun(
kind="db", status="restoring",
triggered_by="restore", started_at=now,
restored_from_id=src.id,
manifest={"source_sql_path": src.sql_path},
)
session.add(marker)
session.commit()
session.refresh(marker)
marker_id = marker.id
sql_path = src.sql_path
try:
backup_service.restore_db(
db_url=cfg.database_url_sync, sql_path=Path(sql_path),
)
except (SoftTimeLimitExceeded, Exception) as exc:
with SessionLocal() as session:
row = session.get(BackupRun, marker_id)
if row is not None:
_mark_failed(session, row, exc)
raise
with SessionLocal() as session:
row = session.get(BackupRun, marker_id)
row.status = "restored"
row.finished_at = datetime.now(UTC)
session.commit()
return {"backup_run_id": marker_id}
@celery.task(
name="backend.app.tasks.backup.restore_images_task",
bind=True, max_retries=0,
soft_time_limit=21600, time_limit=23400,
)
def restore_images_task(self, *, source_backup_run_id: int) -> dict:
"""Mirrors restore_db_task; uses backup_service.restore_images."""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
with SessionLocal() as session:
src = session.get(BackupRun, source_backup_run_id)
if src is None or src.kind != "images" or not src.tar_path:
raise ValueError(
f"BackupRun id={source_backup_run_id} is not a valid images backup"
)
marker = BackupRun(
kind="images", status="restoring",
triggered_by="restore", started_at=now,
restored_from_id=src.id,
manifest={"source_tar_path": src.tar_path},
)
session.add(marker)
session.commit()
session.refresh(marker)
marker_id = marker.id
tar_path = src.tar_path
try:
backup_service.restore_images(
images_root=IMAGES_ROOT, tar_path=Path(tar_path),
)
except (SoftTimeLimitExceeded, Exception) as exc:
with SessionLocal() as session:
row = session.get(BackupRun, marker_id)
if row is not None:
_mark_failed(session, row, exc)
raise
with SessionLocal() as session:
row = session.get(BackupRun, marker_id)
row.status = "restored"
row.finished_at = datetime.now(UTC)
session.commit()
return {"backup_run_id": marker_id}
@celery.task(
name="backend.app.tasks.backup.prune_backups",
soft_time_limit=300, time_limit=600,
)
def prune_backups() -> dict:
"""Daily Beat. Per-kind retention from ImportSettings.
Returns {"db_deleted": N, "images_deleted": M, "files_unlinked": K}.
Tagged rows (tag IS NOT NULL) are never pruned.
Status='running' / 'restoring' rows are never pruned (recovery
sweep from FC-3i handles those via task_run).
"""
SessionLocal = _sync_session_factory()
counts = {"db_deleted": 0, "images_deleted": 0, "files_unlinked": 0}
with SessionLocal() as session:
s = session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
for kind, keep in (
("db", s.backup_db_keep_last_n),
("images", s.backup_images_keep_last_n),
):
candidates = session.execute(
select(BackupRun)
.where(BackupRun.kind == kind)
.where(BackupRun.tag.is_(None))
.where(BackupRun.status.in_(["ok", "error"]))
.order_by(BackupRun.started_at.desc())
.offset(keep)
).scalars().all()
for row in candidates:
result = backup_service.unlink_artifact_files(
sql_path=row.sql_path,
tar_path=row.tar_path,
manifest_path=(row.manifest or {}).get("manifest_path"),
)
counts["files_unlinked"] += sum(
1 for v in result.values() if v
)
session.delete(row)
counts[f"{kind}_deleted"] += 1
session.commit()
return counts
@celery.task(
name="backend.app.tasks.backup.backup_db_nightly",
soft_time_limit=60, time_limit=120,
)
def backup_db_nightly() -> dict:
"""Hourly tick. Dispatches a real backup ONLY if the configured
UTC hour matches and the nightly setting is enabled. Returns
either {'skipped': '<reason>'} or {'dispatched': '<task_id>'}."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
s = session.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
nightly_enabled = s.backup_db_nightly_enabled
configured_hour = s.backup_db_nightly_hour_utc
if not nightly_enabled:
return {"skipped": "nightly disabled"}
now_hour = datetime.now(UTC).hour
if now_hour != configured_hour:
return {"skipped": f"hour={now_hour} != configured={configured_hour}"}
res = backup_db_task.delay(triggered_by="nightly")
return {"dispatched": res.id}
+68 -1
View File
@@ -9,7 +9,7 @@ from PIL import Image
from sqlalchemy import delete, select, update from sqlalchemy import delete, select, update
from ..celery_app import celery from ..celery_app import celery
from ..models import DownloadEvent, ImageRecord, ImportSettings, ImportTask from ..models import DownloadEvent, ImageRecord, ImportSettings, ImportTask, TaskRun
from ..utils.phash import compute_phash from ..utils.phash import compute_phash
from ._sync_engine import sync_session_factory as _sync_session_factory from ._sync_engine import sync_session_factory as _sync_session_factory
@@ -20,6 +20,8 @@ OLD_TASK_DAYS = 7
PHASH_PAGE = 500 PHASH_PAGE = 500
VERIFY_PAGE = 200 VERIFY_PAGE = 200
FFPROBE_TIMEOUT_SECONDS = 10 FFPROBE_TIMEOUT_SECONDS = 10
TASK_RUN_KEEP_OK_SECONDS = 24 * 3600 # 24 h
TASK_RUN_KEEP_FAILURE_SECONDS = 7 * 24 * 3600 # 7 days
@celery.task(name="backend.app.tasks.maintenance.recover_interrupted_tasks") @celery.task(name="backend.app.tasks.maintenance.recover_interrupted_tasks")
@@ -80,6 +82,71 @@ def cleanup_old_tasks() -> int:
return result.rowcount or 0 return result.rowcount or 0
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_task_runs")
def recover_stalled_task_runs() -> int:
"""Flip task_run rows stuck in 'running' for >STUCK_THRESHOLD_MINUTES
to 'error'. FC-3i.
A row gets stuck when the worker dies without emitting
task_postrun / task_failure (e.g. OOM, container restart between
signals, signal handler raised+logged). Shares the 5-min threshold
with recover_interrupted_tasks for consistency.
"""
SessionLocal = _sync_session_factory()
cutoff = datetime.now(UTC) - timedelta(minutes=STUCK_THRESHOLD_MINUTES)
with SessionLocal() as session:
result = session.execute(
update(TaskRun)
.where(TaskRun.status == "running")
.where(TaskRun.started_at < cutoff)
.values(
status="error",
error_type="RecoverySweep",
error_message=(
f"no completion signal received within "
f"{STUCK_THRESHOLD_MINUTES} min"
),
finished_at=datetime.now(UTC),
)
)
session.commit()
return result.rowcount or 0
@celery.task(name="backend.app.tasks.maintenance.prune_task_runs")
def prune_task_runs() -> dict:
"""Daily retention for task_run rows. FC-3i.
- 'ok' rows: deleted after TASK_RUN_KEEP_OK_SECONDS (24h default).
Success is high-volume, not interesting after a day.
- 'error' / 'timeout' rows: deleted after TASK_RUN_KEEP_FAILURE_SECONDS
(7 days default). Failures are operationally interesting longer.
- 'running' rows: NEVER deleted by this task. The recovery sweep
(recover_stalled_task_runs) is the mechanism that flips them to
terminal state; prune doesn't touch in-flight state.
- 'retry' rows: treated as failures (>7d).
Returns dict of how many rows were deleted in each bucket.
"""
SessionLocal = _sync_session_factory()
now = datetime.now(UTC)
ok_cutoff = now - timedelta(seconds=TASK_RUN_KEEP_OK_SECONDS)
fail_cutoff = now - timedelta(seconds=TASK_RUN_KEEP_FAILURE_SECONDS)
with SessionLocal() as session:
ok_deleted = session.execute(
delete(TaskRun)
.where(TaskRun.status == "ok")
.where(TaskRun.finished_at < ok_cutoff)
).rowcount or 0
fail_deleted = session.execute(
delete(TaskRun)
.where(TaskRun.status.in_(["error", "timeout", "retry"]))
.where(TaskRun.finished_at < fail_cutoff)
).rowcount or 0
session.commit()
return {"ok_deleted": ok_deleted, "failures_deleted": fail_deleted}
@celery.task(name="backend.app.tasks.maintenance.backfill_phash") @celery.task(name="backend.app.tasks.maintenance.backfill_phash")
def backfill_phash() -> int: def backfill_phash() -> int:
"""Recompute phash for stored images that have none (imported before """Recompute phash for stored images that have none (imported before
+6 -32
View File
@@ -4,7 +4,8 @@ Dispatches to the right migrator based on `kind`. Updates MigrationRun
row's status/counts/finished_at as it runs. Failures set status='error' row's status/counts/finished_at as it runs. Failures set status='error'
with the error message preserved. with the error message preserved.
kinds: backup, gs_ingest, ir_ingest, tag_apply, ml_queue, verify, rollback, cleanup kinds: gs_ingest, ir_ingest, tag_apply, ml_queue, verify, cleanup
(backup + rollback retired 2026-05-24 → see /api/system/backup/*)
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,10 +21,8 @@ from ..celery_app import celery
from ..config import get_config from ..config import get_config
from ..models import MigrationRun from ..models import MigrationRun
from ..services.credential_crypto import CredentialCrypto from ..services.credential_crypto import CredentialCrypto
from ..services.migrators import backup as backup_mod
from ..services.migrators import cleanup as cleanup_mod from ..services.migrators import cleanup as cleanup_mod
from ..services.migrators import gs_ingest, ir_ingest, ml_queue, tag_apply, verify from ..services.migrators import gs_ingest, ir_ingest, ml_queue, tag_apply, verify
from ..services.migrators import rollback as rollback_mod
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -65,21 +64,11 @@ async def _run_async(run_id: int, kind: str, params: dict) -> dict:
async with factory() as db: async with factory() as db:
await _update_run(db, run_id, status="running") await _update_run(db, run_id, status="running")
try: try:
if kind == "backup": if kind in ("backup", "rollback"):
manifest = backup_mod.create_backup( raise ValueError(
db_url=get_config().database_url_sync, f"kind {kind!r} retired in FC-3h; "
images_root=IMAGES_ROOT, "use /api/system/backup/* instead"
tag=params.get("tag", "manual"),
) )
await _update_run(
db, run_id, status="ok",
counts={"rows_processed": 0, "rows_inserted": 0,
"rows_skipped": 0, "files_copied": 0,
"bytes_copied": 0, "conflicts": 0},
finished_at=datetime.now(UTC),
metadata_patch={"manifest": manifest},
)
return manifest
elif kind == "gs_ingest": elif kind == "gs_ingest":
fc_crypto = CredentialCrypto(_KEY_PATH) fc_crypto = CredentialCrypto(_KEY_PATH)
@@ -166,21 +155,6 @@ async def _run_async(run_id: int, kind: str, params: dict) -> dict:
) )
return result return result
elif kind == "rollback":
result = rollback_mod.rollback_to_pre_migration(
db_url=get_config().database_url_sync,
images_root=IMAGES_ROOT,
)
await _update_run(
db, run_id, status="ok",
counts={"rows_processed": 0, "rows_inserted": 0,
"rows_skipped": 0, "files_copied": 0,
"bytes_copied": 0, "conflicts": 0},
finished_at=datetime.now(UTC),
metadata_patch={"rollback_result": result},
)
return result
else: else:
raise ValueError(f"unknown kind: {kind}") raise ValueError(f"unknown kind: {kind}")
@@ -0,0 +1,95 @@
<template>
<v-card class="fc-danger-zone mt-8" variant="outlined">
<v-card-title class="d-flex align-center" style="gap: 10px;">
<v-icon icon="mdi-alert-octagon" color="error" size="small" />
<span>Danger zone</span>
</v-card-title>
<v-card-text>
<p class="text-body-2 fc-muted mb-4">
Cascade-delete this artist and every image, source, post, and
attachment associated with them. This cannot be undone.
Recoverable only from an FC-3h backup.
</p>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-forever"
:loading="loading"
@click="onClick"
>Delete artist &amp; cascade</v-btn>
<DestructiveConfirmModal
v-model="modalOpen"
action="delete"
kind="artist"
:run-id="artistId"
tier="C"
:projected-counts="projectedCounts"
:description="modalDescription"
@confirm="onConfirm"
/>
</v-card-text>
</v-card>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
import { useAdminStore } from '../../stores/admin.js'
const props = defineProps({
slug: { type: String, required: true },
artistId: { type: Number, required: true },
artistName: { type: String, required: true },
})
const router = useRouter()
const store = useAdminStore()
const loading = ref(false)
const modalOpen = ref(false)
const projected = ref(null)
const projectedCounts = computed(() => projected.value?.projected || null)
const modalDescription = computed(
() => projected.value
? `Artist “${props.artistName}” — `
+ `${projected.value.projected.images} images, `
+ `${projected.value.projected.sources} sources, `
+ `${Math.round(projected.value.projected.bytes_on_disk / 1_048_576)} MiB on disk`
: '',
)
async function onClick() {
loading.value = true
try {
projected.value = await store.projectArtistCascade(props.slug)
modalOpen.value = true
} finally {
loading.value = false
}
}
async function onConfirm(token) {
loading.value = true
try {
const result = await store.dispatchArtistCascade(props.slug, token)
const taskId = result.task_id
router.push('/artists')
if (taskId) {
store.pollTaskUntilDone(taskId).catch(() => {})
}
} finally {
loading.value = false
}
}
</script>
<style scoped>
.fc-danger-zone {
border-color: rgb(var(--v-theme-error));
border-radius: 8px;
}
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
</style>
+35 -2
View File
@@ -44,7 +44,31 @@
</div> </div>
<div class="fc-tagcard__meta"> <div class="fc-tagcard__meta">
<v-chip size="x-small" label>{{ card.kind }}</v-chip> <v-chip size="x-small" label>{{ card.kind }}</v-chip>
<span class="fc-tagcard__count">{{ card.image_count }}</span> <div class="fc-tagcard__meta-right">
<span class="fc-tagcard__count">{{ card.image_count }}</span>
<v-menu>
<template #activator="{ props: act }">
<v-btn
class="fc-tagcard__menu"
icon="mdi-dots-vertical" size="x-small" variant="text"
v-bind="act" @click.stop
/>
</template>
<v-list density="compact">
<v-list-item
title="Merge with…"
prepend-icon="mdi-call-merge"
@click="$emit('merge-with', card)"
/>
<v-list-item
title="Delete tag"
prepend-icon="mdi-delete"
base-color="error"
@click="$emit('delete', card)"
/>
</v-list>
</v-menu>
</div>
</div> </div>
</v-card-text> </v-card-text>
</v-card> </v-card>
@@ -54,7 +78,7 @@
import { ref } from 'vue' import { ref } from 'vue'
const props = defineProps({ card: { type: Object, required: true } }) const props = defineProps({ card: { type: Object, required: true } })
const emit = defineEmits(['open', 'rename', 'manage', 'read']) const emit = defineEmits(['open', 'rename', 'manage', 'read', 'merge-with', 'delete'])
const editing = ref(false) const editing = ref(false)
const draft = ref('') const draft = ref('')
@@ -106,4 +130,13 @@ function submit() {
} }
.fc-tagcard:hover .fc-tagcard__edit { opacity: .6; } .fc-tagcard:hover .fc-tagcard__edit { opacity: .6; }
.fc-tagcard__edit:hover { opacity: 1; } .fc-tagcard__edit:hover { opacity: 1; }
.fc-tagcard__meta-right {
display: flex; align-items: center; gap: 4px;
}
.fc-tagcard__menu {
opacity: 0;
transition: opacity .15s ease;
}
.fc-tagcard:hover .fc-tagcard__menu { opacity: .6; }
.fc-tagcard__menu:hover { opacity: 1; }
</style> </style>
@@ -55,19 +55,44 @@
</template> </template>
</div> </div>
<div class="fc-bulk-panel__section">
<h4>Destructive</h4>
<v-btn
color="error" variant="flat" rounded="pill" block
prepend-icon="mdi-delete-forever"
:disabled="!sel.count"
:loading="deleting"
@click="onDeleteClick"
>Delete {{ sel.count }} selected</v-btn>
</div>
<div class="fc-bulk-panel__foot"> <div class="fc-bulk-panel__foot">
<v-btn variant="text" block @click="sel.clear()">Clear selection</v-btn> <v-btn variant="text" block @click="sel.clear()">Clear selection</v-btn>
</div> </div>
<DestructiveConfirmModal
v-model="deleteModalOpen"
action="delete"
kind="images-selection"
:run-id="bulkToken"
tier="C"
:projected-counts="bulkProjectedCounts"
:description="bulkDescription"
@confirm="onDeleteConfirm"
/>
</aside> </aside>
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useGallerySelectionStore } from '../../stores/gallerySelection.js' import { useGallerySelectionStore } from '../../stores/gallerySelection.js'
import { useAdminStore } from '../../stores/admin.js'
import { useApi } from '../../composables/useApi.js' import { useApi } from '../../composables/useApi.js'
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
const sel = useGallerySelectionStore() const sel = useGallerySelectionStore()
const api = useApi() const api = useApi()
const adminStore = useAdminStore()
const addModel = ref(null) const addModel = ref(null)
const addHits = ref([]) const addHits = ref([])
@@ -99,6 +124,66 @@ async function onAddPick(id) {
watch(() => sel.order.length, () => { watch(() => sel.order.length, () => {
if (sel.isSelectMode) sel.refresh() if (sel.isSelectMode) sel.refresh()
}) })
// --- FC-3k bulk delete -----------------------------------------------
const deleting = ref(false)
const deleteModalOpen = ref(false)
const bulkProjected = ref(null)
const bulkToken = ref('')
const bulkProjectedCounts = computed(() => bulkProjected.value
? {
images: bulkProjected.value.images_found,
thumbnails: bulkProjected.value.thumbs_to_unlink,
bytes: bulkProjected.value.bytes_on_disk,
}
: null,
)
const bulkDescription = computed(
() => bulkProjected.value
? `${bulkProjected.value.images_found} images, `
+ `${Math.round(bulkProjected.value.bytes_on_disk / 1_048_576)} MiB on disk`
: '',
)
async function _computeSha8(ids) {
const canon = [...ids].sort((a, b) => a - b).join(',')
const buf = new TextEncoder().encode(canon)
const hashBuf = await crypto.subtle.digest('SHA-256', buf)
const bytes = new Uint8Array(hashBuf)
let hex = ''
for (let i = 0; i < 4; i++) {
hex += bytes[i].toString(16).padStart(2, '0')
}
return hex
}
async function onDeleteClick() {
if (!sel.order.length) return
deleting.value = true
try {
bulkProjected.value = await adminStore.projectBulkImageDelete(sel.order)
bulkToken.value = await _computeSha8(sel.order)
deleteModalOpen.value = true
} finally {
deleting.value = false
}
}
async function onDeleteConfirm(token) {
deleting.value = true
try {
const result = await adminStore.dispatchBulkImageDelete(sel.order, token)
const taskId = result.task_id
if (taskId) {
adminStore.pollTaskUntilDone(taskId).catch(() => {})
}
sel.clear()
} finally {
deleting.value = false
}
}
</script> </script>
<style scoped> <style scoped>
@@ -0,0 +1,128 @@
<template>
<v-dialog
:model-value="modelValue"
max-width="520" persistent
@update:model-value="$emit('update:modelValue', $event)"
>
<v-card>
<v-card-title>
{{ titleVerb }}
{{ kindLabel }}<span v-if="runId"> #{{ runId }}</span>
</v-card-title>
<v-card-text>
<v-alert
:type="alertType"
variant="tonal" density="compact" class="mb-3"
>
<strong>{{ warningText }}</strong>
<div v-if="description" class="mt-1">{{ description }}</div>
</v-alert>
<div v-if="projectedCounts" class="fc-counts mb-3">
<div v-for="(v, k) in projectedCounts" :key="k">
<span class="fc-counts-key">{{ k }}:</span>
<span class="fc-counts-val">{{ v }}</span>
</div>
</div>
<template v-if="tier === 'C'">
<div class="text-body-2 mb-2">Type the following to confirm:</div>
<div class="fc-token mb-3">{{ expectedToken }}</div>
<v-text-field
v-model="typed"
variant="outlined" density="compact" hide-details
autofocus
placeholder="paste the token above"
/>
</template>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="onCancel">Cancel</v-btn>
<v-btn
:color="confirmColor"
variant="flat" rounded="pill"
:disabled="!canConfirm"
@click="onConfirm"
>
{{ titleVerb }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
modelValue: { type: Boolean, required: true },
action: { type: String, required: true }, // 'restore' | 'delete'
kind: { type: String, required: true }, // 'db' | 'images' | 'artist' | 'tag' | 'images-selection'
runId: { type: [Number, String], default: '' }, // numeric id or sha8 string
description: { type: String, default: '' },
tier: { type: String, default: 'C' }, // 'B' | 'C'
projectedCounts: { type: Object, default: null },
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const typed = ref('')
const expectedToken = computed(
() => `${props.action}-${props.kind}-${props.runId}`,
)
const titleVerb = computed(
() => props.action === 'restore' ? 'Restore' : 'Delete',
)
const kindLabel = computed(() => ({
db: 'database backup',
images: 'images backup',
artist: 'artist',
tag: 'tag',
'images-selection': 'image selection',
}[props.kind] || props.kind))
const alertType = computed(
() => props.action === 'restore' ? 'warning' : 'error',
)
const confirmColor = computed(
() => props.action === 'restore' ? 'warning' : 'error',
)
const warningText = computed(() => (
props.action === 'restore'
? 'This replaces current state with the backup. There is no undo.'
: 'This permanently deletes the listed items. Cannot be recovered.'
))
const canConfirm = computed(
() => props.tier === 'B'
? true
: typed.value === expectedToken.value,
)
watch(() => props.modelValue, (open) => {
if (open) typed.value = ''
})
function onCancel() {
emit('update:modelValue', false)
}
function onConfirm() {
emit('confirm', expectedToken.value)
emit('update:modelValue', false)
}
</script>
<style scoped>
.fc-token {
font-family: 'JetBrains Mono', monospace;
background: rgb(var(--v-theme-surface-light));
padding: 6px 10px; border-radius: 4px;
font-size: 14px; word-break: break-all;
}
.fc-counts {
display: grid; grid-template-columns: max-content auto;
gap: 4px 12px;
font-size: 13px;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-counts-key { font-weight: 500; text-transform: capitalize; }
.fc-counts-val { font-variant-numeric: tabular-nums; }
</style>
+26 -14
View File
@@ -2,9 +2,6 @@
<Teleport to="body"> <Teleport to="body">
<div <div
class="fc-viewer" role="dialog" aria-modal="true" class="fc-viewer" role="dialog" aria-modal="true"
@keydown.esc="$emit('close')"
@keydown.left="onArrowLeft"
@keydown.right="onArrowRight"
tabindex="-1" ref="rootEl" tabindex="-1" ref="rootEl"
> >
<button class="fc-viewer__close" @click="$emit('close')" aria-label="Close"> <button class="fc-viewer__close" @click="$emit('close')" aria-label="Close">
@@ -68,7 +65,7 @@ import VideoCanvas from './VideoCanvas.vue'
import TagPanel from './TagPanel.vue' import TagPanel from './TagPanel.vue'
import ProvenancePanel from './ProvenancePanel.vue' import ProvenancePanel from './ProvenancePanel.vue'
defineEmits(['close']) const emit = defineEmits(['close'])
const modal = useModalStore() const modal = useModalStore()
const rootEl = ref(null) const rootEl = ref(null)
@@ -86,14 +83,39 @@ const integrityBadge = computed(() => {
}) })
let prevBodyOverflow = null let prevBodyOverflow = null
// Document-level keyboard handler. Bound on document (not on the modal
// root) because <video> elements capture focus when the user interacts
// with their controls — when video has focus, Escape and arrow keys
// don't bubble reliably to ancestors. Listening on document side-steps
// that. Filtered via isTextEntry so tag/comment inputs still get their
// own keystrokes.
function onKeyDown(ev) {
if (ev.key === 'Escape') {
if (isTextEntry(ev.target)) return
ev.preventDefault()
emit('close')
} else if (ev.key === 'ArrowLeft') {
if (isTextEntry(ev.target)) return
ev.preventDefault()
modal.goPrev()
} else if (ev.key === 'ArrowRight') {
if (isTextEntry(ev.target)) return
ev.preventDefault()
modal.goNext()
}
}
onMounted(async () => { onMounted(async () => {
prevBodyOverflow = document.body.style.overflow prevBodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
document.addEventListener('keydown', onKeyDown, true)
await nextFrame() await nextFrame()
rootEl.value?.focus() rootEl.value?.focus()
}) })
onUnmounted(() => { onUnmounted(() => {
document.body.style.overflow = prevBodyOverflow document.body.style.overflow = prevBodyOverflow
document.removeEventListener('keydown', onKeyDown, true)
}) })
watch(() => modal.currentImageId, async () => { watch(() => modal.currentImageId, async () => {
@@ -105,16 +127,6 @@ function nextFrame() {
return new Promise(resolve => requestAnimationFrame(resolve)) return new Promise(resolve => requestAnimationFrame(resolve))
} }
function onArrowLeft(ev) {
if (isTextEntry(ev.target)) return
ev.preventDefault()
modal.goPrev()
}
function onArrowRight(ev) {
if (isTextEntry(ev.target)) return
ev.preventDefault()
modal.goNext()
}
function isTextEntry(el) { function isTextEntry(el) {
if (!el) return false if (!el) return false
const tag = el.tagName const tag = el.tagName
@@ -0,0 +1,205 @@
<template>
<v-card class="fc-backup-card">
<v-card-title class="d-flex align-center" style="gap: 10px;">
<v-icon icon="mdi-database-export" size="small" />
<span>Backups</span>
</v-card-title>
<v-card-text>
<p class="fc-muted text-body-2 mb-4">
pg_dump for the database (fast, nightly-schedulable);
tar+zstd for the images (slow, manual only). Files live in
<code>/images/_backups/</code>. Tag a backup to protect it
from autoprune.
</p>
<v-alert
v-if="store.lastError"
type="warning" variant="tonal" density="compact" class="mb-3"
>
{{ store.lastError }}
</v-alert>
<!-- Database section -->
<h3 class="fc-section-title">Database</h3>
<div class="fc-settings-row mb-3">
<v-switch
:model-value="settings.backup_db_nightly_enabled"
color="accent" density="compact" hide-details
label="Nightly"
@update:model-value="onSettingChange('backup_db_nightly_enabled', $event)"
/>
<v-text-field
:model-value="settings.backup_db_nightly_hour_utc"
label="Hour (UTC)" type="number" min="0" max="23"
density="compact" hide-details style="max-width: 110px;"
@update:model-value="onSettingChange('backup_db_nightly_hour_utc', Number($event))"
/>
<v-text-field
:model-value="settings.backup_db_keep_last_n"
label="Keep last" type="number" min="1" max="365"
density="compact" hide-details style="max-width: 110px;"
@update:model-value="onSettingChange('backup_db_keep_last_n', Number($event))"
/>
</div>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-database-arrow-up" class="mb-3"
:loading="dbTriggering"
@click="onTrigger('db')"
>Run DB backup now</v-btn>
<BackupRunsTable
:runs="store.dbRuns"
@restore="onRestore"
@delete="onDelete"
@tag="onTag"
/>
<v-divider class="my-4" />
<!-- Images section -->
<h3 class="fc-section-title">Images</h3>
<div class="fc-settings-row mb-3">
<v-text-field
:model-value="settings.backup_images_keep_last_n"
label="Keep last" type="number" min="1" max="100"
density="compact" hide-details style="max-width: 110px;"
@update:model-value="onSettingChange('backup_images_keep_last_n', Number($event))"
/>
</div>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-folder-zip" class="mb-3"
:loading="imagesTriggering"
@click="onTrigger('images')"
>Run images backup now</v-btn>
<span class="text-caption fc-muted ml-2">
Hours for a 200GB+ library.
</span>
<BackupRunsTable
:runs="store.imagesRuns"
@restore="onRestore"
@delete="onDelete"
@tag="onTag"
/>
</v-card-text>
<BackupConfirmModal
v-model="confirmOpen"
:action="confirmAction"
:kind="confirmKind"
:run-id="confirmRunId"
:description="confirmDescription"
@confirm="onConfirmSubmit"
/>
</v-card>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useBackupStore } from '../../stores/backup.js'
import BackupConfirmModal from '../modal/DestructiveConfirmModal.vue'
import BackupRunsTable from './BackupRunsTable.vue'
const store = useBackupStore()
const settings = computed(() => store.settings || {
backup_db_nightly_enabled: false,
backup_db_nightly_hour_utc: 3,
backup_db_keep_last_n: 14,
backup_images_keep_last_n: 3,
})
const dbTriggering = ref(false)
const imagesTriggering = ref(false)
const confirmOpen = ref(false)
const confirmAction = ref('restore')
const confirmKind = ref('db')
const confirmRunId = ref(0)
const confirmDescription = ref('')
let pollId = null
function pollOnce() {
if (document.hidden) return
store.loadRuns('db')
store.loadRuns('images')
}
onMounted(() => {
store.loadSettings()
pollOnce()
pollId = setInterval(pollOnce, 5000)
})
onUnmounted(() => {
if (pollId) { clearInterval(pollId); pollId = null }
})
async function onTrigger(kind) {
const triggeringRef = kind === 'db' ? dbTriggering : imagesTriggering
triggeringRef.value = true
try {
await store.triggerBackup(kind)
pollOnce()
} finally {
triggeringRef.value = false
}
}
async function onSettingChange(field, value) {
await store.patchSettings({ [field]: value })
}
function _openConfirm(action, run) {
confirmAction.value = action
confirmKind.value = run.kind
confirmRunId.value = run.id
confirmDescription.value = (
action === 'restore'
? `Source artifact: ${run.kind === 'db' ? run.sql_path : run.tar_path}`
: 'Removes the BackupRun row and unlinks the artifact files.'
)
confirmOpen.value = true
}
function onRestore(run) { _openConfirm('restore', run) }
function onDelete(run) { _openConfirm('delete', run) }
async function onTag(run) {
const next = prompt(
`Tag for backup #${run.id} (blank to clear):`,
run.tag || '',
)
if (next === null) return
await store.setTag(run.id, next.trim() || null)
pollOnce()
}
async function onConfirmSubmit(token) {
try {
if (confirmAction.value === 'restore') {
await store.restore(confirmRunId.value, token)
} else {
await store.deleteRun(confirmRunId.value, token)
}
pollOnce()
} catch {
// store.lastError already updated; surfaced in v-alert above.
}
}
</script>
<style scoped>
.fc-backup-card { border-radius: 8px; }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-section-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 16px; font-weight: 500;
color: rgb(var(--v-theme-on-surface));
margin-bottom: 8px;
}
.fc-settings-row {
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
}
</style>
@@ -0,0 +1,109 @@
<template>
<v-table density="compact" class="fc-backup-table">
<thead>
<tr>
<th>When</th>
<th>Status</th>
<th class="text-right">Duration</th>
<th class="text-right">Size</th>
<th>Tag</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="r in runs" :key="r.id">
<td class="fc-tabular" :title="r.started_at">
{{ formatRelative(r.started_at) }}
</td>
<td>
<v-icon size="small" :color="statusColor(r.status)">
{{ statusIcon(r.status) }}
</v-icon>
{{ r.status }}
</td>
<td class="text-right fc-tabular">{{ formatDuration(r) }}</td>
<td class="text-right fc-tabular">{{ formatBytes(r.size_bytes) }}</td>
<td>
<v-chip
v-if="r.tag" size="x-small" color="accent" variant="tonal"
>{{ r.tag }}</v-chip>
<span v-else class="fc-muted"></span>
</td>
<td class="text-right">
<v-menu>
<template #activator="{ props: act }">
<v-btn icon="mdi-dots-vertical" size="x-small" variant="text" v-bind="act" />
</template>
<v-list density="compact">
<v-list-item
:title="r.tag ? 'Untag' : 'Tag…'"
@click="$emit('tag', r)"
/>
<v-list-item
title="Restore…" :disabled="r.status !== 'ok'"
@click="$emit('restore', r)"
/>
<v-list-item
title="Delete…"
@click="$emit('delete', r)"
/>
</v-list>
</v-menu>
</td>
</tr>
<tr v-if="!runs.length">
<td colspan="6" class="text-center fc-muted py-4">No backups yet.</td>
</tr>
</tbody>
</v-table>
</template>
<script setup>
defineProps({ runs: { type: Array, default: () => [] } })
defineEmits(['restore', 'delete', 'tag'])
function statusIcon(s) {
return {
ok: 'mdi-check-circle', error: 'mdi-close-circle',
running: 'mdi-timer-sand', restoring: 'mdi-restore-clock',
restored: 'mdi-restore', pending: 'mdi-clock-outline',
}[s] || 'mdi-help-circle'
}
function statusColor(s) {
return {
ok: 'success', error: 'error',
running: 'accent', restoring: 'info',
restored: 'info', pending: 'on-surface-variant',
}[s] || 'on-surface-variant'
}
function formatDuration(r) {
if (r.duration_seconds == null) return '—'
const s = r.duration_seconds
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60)
return `${h}h ${m}m`
}
function formatBytes(b) {
if (b == null) return '—'
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
let i = 0; let v = b
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
function formatRelative(iso) {
if (!iso) return '—'
const then = new Date(iso).getTime()
const diff = Math.max(0, (Date.now() - then) / 1000)
if (diff < 60) return `${Math.floor(diff)}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
</script>
<style scoped>
.fc-backup-table { background: transparent; }
.fc-tabular { font-variant-numeric: tabular-nums; }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
</style>
@@ -12,6 +12,8 @@
<MLThresholdSliders class="mt-4" /> <MLThresholdSliders class="mt-4" />
<AllowlistTable class="mt-4" /> <AllowlistTable class="mt-4" />
<AliasTable class="mt-4" /> <AliasTable class="mt-4" />
<BackupCard class="mt-6" />
<TagMaintenanceCard class="mt-6" />
<BrowserExtensionCard class="mt-6" /> <BrowserExtensionCard class="mt-6" />
<LegacyMigrationCard class="mt-6" /> <LegacyMigrationCard class="mt-6" />
</div> </div>
@@ -23,6 +25,8 @@ import CentroidRecomputeCard from './CentroidRecomputeCard.vue'
import MLThresholdSliders from './MLThresholdSliders.vue' import MLThresholdSliders from './MLThresholdSliders.vue'
import AllowlistTable from './AllowlistTable.vue' import AllowlistTable from './AllowlistTable.vue'
import AliasTable from './AliasTable.vue' import AliasTable from './AliasTable.vue'
import BackupCard from './BackupCard.vue'
import TagMaintenanceCard from './TagMaintenanceCard.vue'
import BrowserExtensionCard from './BrowserExtensionCard.vue' import BrowserExtensionCard from './BrowserExtensionCard.vue'
import LegacyMigrationCard from './LegacyMigrationCard.vue' import LegacyMigrationCard from './LegacyMigrationCard.vue'
</script> </script>
@@ -0,0 +1,109 @@
<template>
<v-table density="compact" class="fc-queues-table">
<thead>
<tr>
<th>Queue</th>
<th class="text-right">Depth</th>
<th class="text-right">Workers</th>
<th v-if="!compact" class="text-right">Active</th>
<th class="text-right">Last min: ok / err</th>
</tr>
</thead>
<tbody>
<tr v-for="q in QUEUE_NAMES" :key="q">
<td>{{ q }}</td>
<td class="text-right fc-tabular" :class="depthClass(q)">
{{ formatDepth(q) }}
</td>
<td class="text-right fc-tabular">{{ workerCount(q) }}</td>
<td v-if="!compact" class="text-right fc-tabular">{{ activeCount(q) }}</td>
<td class="text-right fc-tabular">
<span class="fc-ok">{{ recentOk(q) }}</span>
<span class="fc-sep">/</span>
<span :class="recentErr(q) > 0 ? 'fc-err' : 'fc-muted'">
{{ recentErr(q) }}<span v-if="recentErr(q) > 0"> </span>
</span>
</td>
</tr>
</tbody>
</v-table>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
queues: { type: Object, default: null }, // store.queues
workers: { type: Object, default: null }, // store.workers
recentMinute: { type: Array, default: () => [] }, // store.recentMinute
compact: { type: Boolean, default: false },
})
const QUEUE_NAMES = [
'default', 'import', 'thumbnail', 'ml',
'download', 'scan', 'maintenance',
]
function formatDepth(name) {
const d = props.queues?.queues?.[name]
if (d == null) return '—'
return d.toLocaleString()
}
function depthClass(name) {
const d = props.queues?.queues?.[name]
const workers = workerCount(name)
// Queue has depth but no workers → warning.
if (d != null && d > 0 && workers === 0) return 'fc-warn'
return ''
}
function workerCount(name) {
if (!props.workers?.workers) return 0
let count = 0
for (const info of Object.values(props.workers.workers)) {
if (info.queues?.includes(name)) count++
}
return count
}
function activeCount(name) {
if (!props.workers?.workers) return 0
// Active count is per-worker, not per-queue. Approximate: sum of
// active_count across workers that subscribe to this queue.
let count = 0
for (const info of Object.values(props.workers.workers)) {
if (info.queues?.includes(name)) count += info.active_count || 0
}
return count
}
const recentByQueue = computed(() => {
const out = {}
for (const r of props.recentMinute) {
if (!out[r.queue]) out[r.queue] = { ok: 0, err: 0 }
if (r.status === 'ok') out[r.queue].ok++
else if (r.status === 'error' || r.status === 'timeout') out[r.queue].err++
}
return out
})
function recentOk(name) { return recentByQueue.value[name]?.ok || 0 }
function recentErr(name) { return recentByQueue.value[name]?.err || 0 }
</script>
<style scoped>
.fc-queues-table { background: transparent; }
.fc-tabular {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum';
}
.fc-ok { color: rgb(var(--v-theme-success, 76 175 80)); }
.fc-err { color: rgb(var(--v-theme-error, 220 80 80)); }
.fc-warn { color: rgb(var(--v-theme-warning, 255 179 0)); font-weight: 500; }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-sep {
margin: 0 4px;
color: rgb(var(--v-theme-on-surface-variant));
}
</style>
@@ -0,0 +1,64 @@
<template>
<v-card class="fc-activity-summary">
<v-card-title class="d-flex align-center" style="gap: 10px;">
<v-icon icon="mdi-pulse" size="small" />
<span>System activity (last min)</span>
<v-spacer />
<v-btn
variant="text" size="small" rounded="pill"
@click="$emit('open-activity')"
>
View activity
<v-icon end size="small">mdi-arrow-right</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<v-alert
v-if="store.lastError"
type="warning" variant="tonal" density="compact" class="mb-3"
>
{{ store.lastError }}
</v-alert>
<QueuesTable
:queues="store.queues"
:workers="store.workers"
:recent-minute="store.recentMinute"
compact
/>
</v-card-text>
</v-card>
</template>
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js'
import QueuesTable from './QueuesTable.vue'
defineEmits(['open-activity'])
const store = useSystemActivityStore()
let pollId = null
function pollOnce() {
if (document.hidden) return
store.loadQueues()
store.loadWorkers()
store.loadRecentMinute()
}
onMounted(() => {
pollOnce()
pollId = setInterval(pollOnce, 5000)
})
onUnmounted(() => {
if (pollId) { clearInterval(pollId); pollId = null }
})
</script>
<style scoped>
.fc-activity-summary { border-radius: 8px; }
</style>
@@ -0,0 +1,280 @@
<template>
<div class="fc-activity">
<!-- Queues + workers pane -->
<v-card class="mb-4">
<v-card-title class="d-flex align-center" style="gap: 10px;">
<v-icon icon="mdi-pulse" size="small" />
<span>Queues + workers</span>
<v-spacer />
<span class="text-caption fc-muted">
updated {{ formatRelative(store.queues?.fetched_at) }}
</span>
</v-card-title>
<v-card-text>
<QueuesTable
:queues="store.queues"
:workers="store.workers"
:recent-minute="store.recentMinute"
/>
</v-card-text>
</v-card>
<!-- Recent failures pane -->
<v-card class="mb-4">
<v-card-title class="d-flex align-center" style="gap: 10px;">
<v-icon icon="mdi-alert-circle-outline" size="small" />
<span>Recent failures (last 24h)</span>
<v-spacer />
<span class="text-caption fc-muted">
{{ failureCount }} failures
</span>
</v-card-title>
<v-card-text>
<div v-if="errorTypes.length" class="mb-3 fc-pills">
<v-chip
v-for="t in errorTypes" :key="t.name"
:color="t.name === filterErrorType ? 'accent' : undefined"
size="small" variant="tonal" closable
@click="toggleErrorTypeFilter(t.name)"
@click:close="toggleErrorTypeFilter(t.name)"
>
{{ t.name }} × {{ t.count }}
</v-chip>
</div>
<v-table density="compact">
<thead>
<tr>
<th>Time</th>
<th>Queue</th>
<th>Task</th>
<th>Target</th>
<th>Error</th>
</tr>
</thead>
<tbody>
<tr v-for="r in filteredFailures" :key="r.id">
<td class="fc-tabular" :title="r.finished_at">
{{ formatRelative(r.finished_at) }}
</td>
<td>{{ r.queue }}</td>
<td><code>{{ shortTaskName(r.task_name) }}</code></td>
<td class="fc-tabular">{{ r.target_id ?? '—' }}</td>
<td class="fc-err" :title="r.error_message">
{{ r.error_type }}
</td>
</tr>
<tr v-if="!filteredFailures.length">
<td colspan="5" class="text-center fc-muted py-4">
No failures in the last 24h.
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
<!-- All activity pane -->
<v-card>
<v-card-title class="d-flex align-center" style="gap: 10px;">
<v-icon icon="mdi-format-list-bulleted" size="small" />
<span>All recent activity</span>
<v-spacer />
<v-select
v-model="filterQueue"
:items="queueOptions" density="compact" hide-details
style="max-width: 180px;"
@update:model-value="onFilterChange"
/>
<v-select
v-model="filterStatus"
:items="statusOptions" density="compact" hide-details
style="max-width: 180px;"
@update:model-value="onFilterChange"
/>
<v-btn
variant="text" size="small" rounded="pill"
@click="onRefresh"
>
<v-icon start size="small">mdi-refresh</v-icon>
Refresh
</v-btn>
</v-card-title>
<v-card-text>
<v-table density="compact">
<thead>
<tr>
<th></th>
<th>Time</th>
<th>Queue</th>
<th>Task</th>
<th>Target</th>
<th class="text-right">Duration</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="r in store.runs" :key="r.id">
<td><v-icon size="small" :color="statusColor(r.status)">{{ statusIcon(r.status) }}</v-icon></td>
<td class="fc-tabular" :title="r.started_at">{{ formatRelative(r.started_at) }}</td>
<td>{{ r.queue }}</td>
<td><code>{{ shortTaskName(r.task_name) }}</code></td>
<td class="fc-tabular">{{ r.target_id ?? '—' }}</td>
<td class="text-right fc-tabular">{{ formatDuration(r.duration_ms) }}</td>
<td>{{ r.status }}</td>
</tr>
<tr v-if="!store.runs.length && !store.loading.runs">
<td colspan="7" class="text-center fc-muted py-4">
No activity yet.
</td>
</tr>
</tbody>
</v-table>
<div v-if="store.runsHasMore" class="d-flex justify-center py-3">
<v-btn
variant="text" size="small"
:loading="store.loading.runs"
@click="onLoadMore"
>Load more</v-btn>
</div>
</v-card-text>
</v-card>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useSystemActivityStore } from '../../stores/systemActivity.js'
import QueuesTable from './QueuesTable.vue'
const store = useSystemActivityStore()
const filterQueue = ref(null)
const filterStatus = ref(null)
const filterErrorType = ref(null)
const queueOptions = [
{ title: 'All queues', value: null },
{ title: 'import', value: 'import' },
{ title: 'thumbnail', value: 'thumbnail' },
{ title: 'ml', value: 'ml' },
{ title: 'download', value: 'download' },
{ title: 'scan', value: 'scan' },
{ title: 'maintenance', value: 'maintenance' },
{ title: 'default', value: 'default' },
]
const statusOptions = [
{ title: 'All statuses', value: null },
{ title: 'Running', value: 'running' },
{ title: 'OK', value: 'ok' },
{ title: 'Error', value: 'error' },
{ title: 'Timeout', value: 'timeout' },
{ title: 'Retry', value: 'retry' },
]
let queuesPollId = null
let failuresPollId = null
function pollQueues() {
if (document.hidden) return
store.loadQueues()
store.loadWorkers()
store.loadRecentMinute()
}
function pollFailures() {
if (document.hidden) return
store.loadFailures()
}
onMounted(() => {
pollQueues()
pollFailures()
store.setFilter({ queue: null, status: null })
store.loadRuns({ reset: true })
queuesPollId = setInterval(pollQueues, 3000)
failuresPollId = setInterval(pollFailures, 15000)
})
onUnmounted(() => {
if (queuesPollId) clearInterval(queuesPollId)
if (failuresPollId) clearInterval(failuresPollId)
})
const errorTypes = computed(() => {
const counts = store.failures?.count_by_type || {}
return Object.entries(counts).map(([name, count]) => ({ name, count }))
})
const failureCount = computed(() => (store.failures?.recent || []).length)
const filteredFailures = computed(() => {
const all = store.failures?.recent || []
if (!filterErrorType.value) return all
return all.filter(r => r.error_type === filterErrorType.value)
})
function toggleErrorTypeFilter(name) {
filterErrorType.value = filterErrorType.value === name ? null : name
if (filterErrorType.value) {
// Mirror into the All-activity table too.
filterStatus.value = 'error'
onFilterChange()
}
}
function onFilterChange() {
store.setFilter({ queue: filterQueue.value, status: filterStatus.value })
store.loadRuns({ reset: true })
}
function onLoadMore() { store.loadRuns({ reset: false }) }
function onRefresh() { store.loadRuns({ reset: true }) }
function statusIcon(status) {
return {
ok: 'mdi-check-circle',
error: 'mdi-close-circle',
timeout: 'mdi-clock-alert',
retry: 'mdi-refresh',
running: 'mdi-timer-sand',
}[status] || 'mdi-help-circle'
}
function statusColor(status) {
return {
ok: 'success', error: 'error', timeout: 'warning',
retry: 'info', running: 'accent',
}[status] || 'on-surface-variant'
}
function shortTaskName(name) {
if (!name) return ''
const parts = name.split('.')
return parts.slice(-1)[0]
}
function formatDuration(ms) {
if (ms == null) return '—'
if (ms < 1000) return `${ms} ms`
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`
return `${(ms / 60_000).toFixed(1)} min`
}
function formatRelative(iso) {
if (!iso) return '—'
const then = new Date(iso).getTime()
const now = Date.now()
const diff = Math.max(0, (now - then) / 1000)
if (diff < 60) return `${Math.floor(diff)}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
</script>
<style scoped>
.fc-activity { padding: 0; }
.fc-pills {
display: flex; gap: 6px; flex-wrap: wrap;
}
.fc-tabular {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum';
}
.fc-err { color: rgb(var(--v-theme-error, 220 80 80)); }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
</style>
@@ -0,0 +1,95 @@
<template>
<v-card class="fc-tag-maint">
<v-card-title class="d-flex align-center" style="gap: 10px;">
<v-icon icon="mdi-tag-remove" size="small" />
<span>Tag maintenance</span>
</v-card-title>
<v-card-text>
<p class="fc-muted text-body-2 mb-4">
Remove tags with zero image associations and zero series-page
references. Auto-created tag rows that never got applied get
swept here.
</p>
<v-alert
v-if="store.lastError"
type="warning" variant="tonal" density="compact" class="mb-3"
>{{ store.lastError }}</v-alert>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingPreview"
class="mb-3"
@click="onPreview"
>Preview unused tags</v-btn>
<div v-if="preview">
<p class="text-body-2 mb-2">
<strong>{{ preview.count }}</strong> unused tag(s).
<span v-if="preview.count > 50" class="fc-muted">
Showing first 50 names.
</span>
</p>
<div v-if="preview.sample_names?.length" class="fc-name-grid mb-3">
<span v-for="n in preview.sample_names" :key="n" class="fc-name">
{{ n }}
</span>
</div>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-sweep"
:disabled="!preview.count"
:loading="committing"
@click="onCommit"
>Delete {{ preview.count }} unused tag(s)</v-btn>
</div>
</v-card-text>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import { useAdminStore } from '../../stores/admin.js'
const store = useAdminStore()
const preview = ref(null)
const loadingPreview = ref(false)
const committing = ref(false)
async function onPreview() {
loadingPreview.value = true
try {
preview.value = await store.pruneUnusedTags({ dryRun: true })
} finally {
loadingPreview.value = false
}
}
async function onCommit() {
committing.value = true
try {
const result = await store.pruneUnusedTags({ dryRun: false })
preview.value = { count: 0, sample_names: result.sample_names || [] }
} finally {
committing.value = false
}
}
</script>
<style scoped>
.fc-tag-maint { border-radius: 8px; }
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-name-grid {
display: flex; flex-wrap: wrap; gap: 4px 8px;
max-height: 200px; overflow-y: auto;
padding: 8px; border-radius: 4px;
background: rgb(var(--v-theme-surface-light));
}
.fc-name {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: rgb(var(--v-theme-on-surface-variant));
}
</style>
+153
View File
@@ -0,0 +1,153 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
export const useAdminStore = defineStore('admin', () => {
const api = useApi()
const lastError = ref(null)
// --- Tier-C: artist cascade ---------------------------------------
async function projectArtistCascade(slug) {
lastError.value = null
try {
return await api.post(
`/api/admin/artists/${encodeURIComponent(slug)}/cascade-delete`,
{ body: { dry_run: true } },
)
} catch (e) {
lastError.value = e.message
throw e
}
}
async function dispatchArtistCascade(slug, confirm) {
lastError.value = null
try {
return await api.post(
`/api/admin/artists/${encodeURIComponent(slug)}/cascade-delete`,
{ body: { dry_run: false, confirm } },
)
} catch (e) {
lastError.value = e.message
throw e
}
}
// --- Tier-C: bulk image delete ------------------------------------
async function projectBulkImageDelete(imageIds) {
lastError.value = null
try {
return await api.post(
'/api/admin/images/bulk-delete',
{ body: { image_ids: imageIds, dry_run: true } },
)
} catch (e) {
lastError.value = e.message
throw e
}
}
async function dispatchBulkImageDelete(imageIds, confirm) {
lastError.value = null
try {
return await api.post(
'/api/admin/images/bulk-delete',
{ body: { image_ids: imageIds, dry_run: false, confirm } },
)
} catch (e) {
lastError.value = e.message
throw e
}
}
// --- Tier-B: tag delete + merge -----------------------------------
async function deleteTag(tagId) {
lastError.value = null
try {
return await api.delete(`/api/admin/tags/${tagId}`)
} catch (e) {
lastError.value = e.message
throw e
}
}
async function mergeTags(destId, sourceId) {
lastError.value = null
try {
return await api.post(
`/api/admin/tags/${destId}/merge`,
{ body: { source_id: sourceId } },
)
} catch (e) {
lastError.value = e.message
throw e
}
}
async function tagUsageCount(tagId) {
lastError.value = null
try {
const body = await api.get(`/api/admin/tags/${tagId}/usage-count`)
return body.count
} catch (e) {
lastError.value = e.message
throw e
}
}
// --- Tier-A: prune unused -----------------------------------------
async function pruneUnusedTags({ dryRun = true } = {}) {
lastError.value = null
try {
return await api.post(
'/api/admin/tags/prune-unused',
{ body: { dry_run: dryRun } },
)
} catch (e) {
lastError.value = e.message
throw e
}
}
// --- Task progress polling (taps FC-3i activity dashboard) --------
/**
* Polls /api/system/activity/runs?queue=maintenance every 3s,
* resolves when a task_run row with the given celery task_id
* reaches a terminal status (ok / error / timeout). Returns the
* row. Times out after 30 min by default.
*/
async function pollTaskUntilDone(taskId, { timeoutMs = 1_800_000 } = {}) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const body = await api.get(
'/api/system/activity/runs',
{ params: { queue: 'maintenance', limit: 20 } },
)
const row = (body.runs || []).find(r => r.celery_task_id === taskId)
if (row && ['ok', 'error', 'timeout'].includes(row.status)) {
return row
}
await new Promise((r) => setTimeout(r, 3000))
}
throw new Error(`task ${taskId} did not finish within ${timeoutMs}ms`)
}
return {
lastError,
projectArtistCascade,
dispatchArtistCascade,
projectBulkImageDelete,
dispatchBulkImageDelete,
deleteTag,
mergeTags,
tagUsageCount,
pruneUnusedTags,
pollTaskUntilDone,
}
})
+109
View File
@@ -0,0 +1,109 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
export const useBackupStore = defineStore('backup', () => {
const api = useApi()
const dbRuns = ref([])
const imagesRuns = ref([])
const settings = ref(null)
const loading = ref({ dbRuns: false, imagesRuns: false, settings: false })
const lastError = ref(null)
async function loadRuns(kind) {
const targetRef = kind === 'db' ? dbRuns : imagesRuns
const loadingKey = kind === 'db' ? 'dbRuns' : 'imagesRuns'
loading.value[loadingKey] = true
lastError.value = null
try {
const body = await api.get('/api/system/backup/runs', {
params: { kind, limit: 50 },
})
targetRef.value = body.runs || []
} catch (e) {
lastError.value = e.message
} finally {
loading.value[loadingKey] = false
}
}
async function triggerBackup(kind, tag = null) {
lastError.value = null
try {
await api.post(`/api/system/backup/${kind}`, {
body: tag ? { tag } : {},
})
} catch (e) {
lastError.value = e.message
throw e
}
}
async function restore(runId, confirm) {
lastError.value = null
try {
await api.post(`/api/system/backup/runs/${runId}/restore`, {
body: { confirm },
})
} catch (e) {
lastError.value = e.message
throw e
}
}
async function deleteRun(runId, confirm) {
lastError.value = null
try {
await api.delete(`/api/system/backup/runs/${runId}`, {
body: { confirm },
})
} catch (e) {
lastError.value = e.message
throw e
}
}
async function setTag(runId, tag) {
lastError.value = null
try {
await api.patch(`/api/system/backup/runs/${runId}`, {
body: { tag },
})
} catch (e) {
lastError.value = e.message
throw e
}
}
async function loadSettings() {
loading.value.settings = true
lastError.value = null
try {
settings.value = await api.get('/api/system/backup/settings')
} catch (e) {
lastError.value = e.message
} finally {
loading.value.settings = false
}
}
async function patchSettings(patch) {
lastError.value = null
try {
settings.value = await api.patch('/api/system/backup/settings', {
body: patch,
})
} catch (e) {
lastError.value = e.message
throw e
}
}
return {
dbRuns, imagesRuns, settings, loading, lastError,
loadRuns, triggerBackup, restore, deleteRun, setTag,
loadSettings, patchSettings,
}
})
+103
View File
@@ -0,0 +1,103 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
export const useSystemActivityStore = defineStore('systemActivity', () => {
const api = useApi()
// Live polled state.
const queues = ref(null) // { queues: {name: depth|null}, fetched_at }
const workers = ref(null) // { workers: {hostname: {...}}, fetched_at }
const recentMinute = ref([]) // last-60s rows (for Overview summary)
const failures = ref(null) // { recent, count_by_type, since }
// Paginated runs (Activity tab "All recent activity" pane).
const runs = ref([])
const runsCursor = ref(null)
const runsHasMore = ref(false)
const runsFilter = ref({ queue: null, status: null, limit: 50 })
const loading = ref({ queues: false, workers: false, runs: false, failures: false })
const lastError = ref(null)
async function loadQueues() {
loading.value.queues = true
lastError.value = null
try {
queues.value = await api.get('/api/system/activity/queues')
} catch (e) {
lastError.value = e.message
} finally {
loading.value.queues = false
}
}
async function loadWorkers() {
loading.value.workers = true
lastError.value = null
try {
workers.value = await api.get('/api/system/activity/workers')
} catch (e) {
lastError.value = e.message
} finally {
loading.value.workers = false
}
}
async function loadRecentMinute() {
// Used by the Overview summary card: pull last 60s of runs to compute
// per-queue ok/err counts. One call covers all queues; UI groups.
try {
const body = await api.get('/api/system/activity/runs', {
params: { limit: 200 },
})
recentMinute.value = body.runs || []
} catch (e) {
lastError.value = e.message
}
}
async function loadRuns({ reset = false } = {}) {
loading.value.runs = true
lastError.value = null
try {
const params = { limit: runsFilter.value.limit }
if (runsFilter.value.queue) params.queue = runsFilter.value.queue
if (runsFilter.value.status) params.status = runsFilter.value.status
if (!reset && runsCursor.value) params.before_id = runsCursor.value
const body = await api.get('/api/system/activity/runs', { params })
runs.value = reset ? body.runs : [...runs.value, ...body.runs]
runsCursor.value = body.next_cursor
runsHasMore.value = body.next_cursor !== null
} catch (e) {
lastError.value = e.message
} finally {
loading.value.runs = false
}
}
async function loadFailures() {
loading.value.failures = true
lastError.value = null
try {
failures.value = await api.get('/api/system/activity/failures')
} catch (e) {
lastError.value = e.message
} finally {
loading.value.failures = false
}
}
function setFilter(filter) {
runsFilter.value = { ...runsFilter.value, ...filter }
}
return {
queues, workers, recentMinute, failures,
runs, runsCursor, runsHasMore, runsFilter,
loading, lastError,
loadQueues, loadWorkers, loadRecentMinute,
loadRuns, loadFailures, setFilter,
}
})
+7
View File
@@ -96,6 +96,12 @@
@open="openImage" @open="openImage"
/> />
</section> </section>
<ArtistDangerZone
:slug="slug"
:artist-id="store.overview.id"
:artist-name="store.overview.name"
/>
</template> </template>
</v-container> </v-container>
</template> </template>
@@ -106,6 +112,7 @@ import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useArtistStore } from '../stores/artist.js' import { useArtistStore } from '../stores/artist.js'
import { useModalStore } from '../stores/modal.js' import { useModalStore } from '../stores/modal.js'
import MasonryGrid from '../components/discovery/MasonryGrid.vue' import MasonryGrid from '../components/discovery/MasonryGrid.vue'
import ArtistDangerZone from '../components/artist/ArtistDangerZone.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
+11
View File
@@ -2,6 +2,7 @@
<v-container fluid class="py-6"> <v-container fluid class="py-6">
<v-tabs v-model="tab" color="accent" class="mb-4"> <v-tabs v-model="tab" color="accent" class="mb-4">
<v-tab value="overview">Overview</v-tab> <v-tab value="overview">Overview</v-tab>
<v-tab value="activity">Activity</v-tab>
<v-tab value="import">Import</v-tab> <v-tab value="import">Import</v-tab>
<v-tab value="maintenance">Maintenance</v-tab> <v-tab value="maintenance">Maintenance</v-tab>
</v-tabs> </v-tabs>
@@ -9,6 +10,10 @@
<v-window v-model="tab"> <v-window v-model="tab">
<v-window-item value="overview"> <v-window-item value="overview">
<SystemStatsCards :stats="system.stats" /> <SystemStatsCards :stats="system.stats" />
<SystemActivitySummary
class="mt-4"
@open-activity="tab = 'activity'"
/>
<v-alert <v-alert
v-if="system.stats && (system.stats.tasks.pending + system.stats.tasks.queued) > 0" v-if="system.stats && (system.stats.tasks.pending + system.stats.tasks.queued) > 0"
type="info" variant="tonal" class="mt-4" closable type="info" variant="tonal" class="mt-4" closable
@@ -18,6 +23,10 @@
</v-alert> </v-alert>
</v-window-item> </v-window-item>
<v-window-item value="activity">
<SystemActivityTab />
</v-window-item>
<v-window-item value="import"> <v-window-item value="import">
<ImportTriggerPanel /> <ImportTriggerPanel />
<v-divider class="my-6" /> <v-divider class="my-6" />
@@ -38,6 +47,8 @@ import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useSystemStore } from '../stores/system.js' import { useSystemStore } from '../stores/system.js'
import { useImportStore } from '../stores/import.js' import { useImportStore } from '../stores/import.js'
import SystemStatsCards from '../components/settings/SystemStatsCards.vue' import SystemStatsCards from '../components/settings/SystemStatsCards.vue'
import SystemActivitySummary from '../components/settings/SystemActivitySummary.vue'
import SystemActivityTab from '../components/settings/SystemActivityTab.vue'
import ImportTriggerPanel from '../components/settings/ImportTriggerPanel.vue' import ImportTriggerPanel from '../components/settings/ImportTriggerPanel.vue'
import ImportFiltersForm from '../components/settings/ImportFiltersForm.vue' import ImportFiltersForm from '../components/settings/ImportFiltersForm.vue'
import ImportTaskList from '../components/settings/ImportTaskList.vue' import ImportTaskList from '../components/settings/ImportTaskList.vue'
+121
View File
@@ -27,6 +27,7 @@
<TagCard <TagCard
v-for="c in store.cards" :key="c.id" :card="c" v-for="c in store.cards" :key="c.id" :card="c"
@open="openTag" @rename="onRename" @manage="onManage" @read="onRead" @open="openTag" @rename="onRename" @manage="onManage" @read="onRead"
@merge-with="onMergeWith" @delete="onDeleteTag"
/> />
</div> </div>
@@ -41,6 +42,49 @@
@confirm="confirmMerge" @confirm="confirmMerge"
@cancel="pendingMerge = null" @cancel="pendingMerge = null"
/> />
<v-dialog v-model="mergePickerOpen" max-width="520">
<v-card>
<v-card-title>Merge {{ mergeSource?.name }} into</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3" style="opacity: 0.7;">
Pick the target tag. Source tag will be deleted; all its
image associations will move to the target. Must be same
kind ({{ mergeSource?.kind }}).
</p>
<v-autocomplete
v-model="mergeTargetId"
:items="mergeHits" item-title="name" item-value="id"
:loading="mergeLoading"
density="compact" variant="outlined" hide-details
placeholder="Search target tag…" no-filter
@update:search="onMergeSearch"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="mergePickerOpen = false">Cancel</v-btn>
<v-btn
color="warning" variant="flat" rounded="pill"
:disabled="!mergeTargetId"
@click="onMergeConfirm"
>Merge</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<DestructiveConfirmModal
v-model="deleteTagModalOpen"
action="delete"
kind="tag"
:run-id="deleteTagTarget?.id || 0"
tier="B"
:projected-counts="{ associations: deleteTagUsage }"
:description="deleteTagTarget
? `Delete tag “${deleteTagTarget.name}” and remove all image associations.`
: ''"
@confirm="onDeleteTagConfirm"
/>
</v-container> </v-container>
</template> </template>
@@ -48,8 +92,11 @@
import { ref, watch, onMounted, onUnmounted } from 'vue' import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useTagDirectoryStore } from '../stores/tagDirectory.js' import { useTagDirectoryStore } from '../stores/tagDirectory.js'
import { useAdminStore } from '../stores/admin.js'
import { useApi } from '../composables/useApi.js'
import TagCard from '../components/discovery/TagCard.vue' import TagCard from '../components/discovery/TagCard.vue'
import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue' import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
// Must stay a subset of the backend TagKind enum (character, fandom, // Must stay a subset of the backend TagKind enum (character, fandom,
// general, series, archive, post, meta, rating). 'fandom' is this // general, series, archive, post, meta, rating). 'fandom' is this
@@ -106,6 +153,80 @@ function onManage(id) {
function onRead(id) { function onRead(id) {
router.push({ name: 'series-read', params: { tagId: id } }) router.push({ name: 'series-read', params: { tagId: id } })
} }
// --- FC-3k tag merge + delete ----------------------------------------
const adminStore = useAdminStore()
const api = useApi()
// Tag merge via dots-menu (separate from inline-rename collision flow above)
const mergePickerOpen = ref(false)
const mergeSource = ref(null)
const mergeTargetId = ref(null)
const mergeHits = ref([])
const mergeLoading = ref(false)
let mergeDebounce = null
function onMergeWith(card) {
mergeSource.value = card
mergeTargetId.value = null
mergeHits.value = []
mergePickerOpen.value = true
}
function onMergeSearch(q) {
if (mergeDebounce) clearTimeout(mergeDebounce)
if (!q) { mergeHits.value = []; return }
mergeDebounce = setTimeout(async () => {
mergeLoading.value = true
try {
const hits = await api.get('/api/tags/autocomplete', {
params: { q, kind: mergeSource.value?.kind, limit: 20 },
})
mergeHits.value = (hits || []).filter(
(h) => h.id !== mergeSource.value?.id,
)
} finally {
mergeLoading.value = false
}
}, 250)
}
async function onMergeConfirm() {
if (!mergeSource.value || !mergeTargetId.value) return
try {
await adminStore.mergeTags(mergeTargetId.value, mergeSource.value.id)
mergePickerOpen.value = false
store.reset()
} catch {
// adminStore.lastError already set; UI surfaces it elsewhere.
}
}
// Tag delete via dots-menu (Tier B)
const deleteTagModalOpen = ref(false)
const deleteTagTarget = ref(null)
const deleteTagUsage = ref(0)
async function onDeleteTag(card) {
deleteTagTarget.value = card
try {
deleteTagUsage.value = await adminStore.tagUsageCount(card.id)
} catch {
deleteTagUsage.value = 0
}
deleteTagModalOpen.value = true
}
async function onDeleteTagConfirm() {
if (!deleteTagTarget.value) return
try {
await adminStore.deleteTag(deleteTagTarget.value.id)
store.reset()
} catch {
// adminStore.lastError already set.
}
}
</script> </script>
<style scoped> <style scoped>
+361
View File
@@ -0,0 +1,361 @@
"""FC-3k: /api/admin/* endpoint integration tests.
Monkeypatch `.delay()` for Tier-C tasks to capture dispatch payloads
without actually queuing. Tier-B/A endpoints run synchronously
through the real service.
"""
import hashlib
import pytest
from backend.app import create_app
from backend.app.models import Artist, ImageRecord, Tag, TagKind
from backend.app.models.tag import image_tag
pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
# --- Tier-C: POST /artists/<slug>/cascade-delete --------------------
@pytest.mark.asyncio
async def test_artist_cascade_dry_run_returns_projection(client, db):
a = Artist(name="Aria", slug="aria")
db.add(a)
await db.commit()
resp = await client.post(
"/api/admin/artists/aria/cascade-delete",
json={"dry_run": True},
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["artist"]["slug"] == "aria"
assert "projected" in body
assert body["projected"]["images"] == 0
@pytest.mark.asyncio
async def test_artist_cascade_unknown_slug_404(client):
resp = await client.post(
"/api/admin/artists/no-such/cascade-delete",
json={"dry_run": True},
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_artist_cascade_wrong_confirm_400(client, db):
a = Artist(name="Aria", slug="aria")
db.add(a)
await db.commit()
artist_id = a.id
resp = await client.post(
"/api/admin/artists/aria/cascade-delete",
json={"dry_run": False, "confirm": "wrong"},
)
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "confirm_mismatch"
assert body["expected"] == f"delete-artist-{artist_id}"
@pytest.mark.asyncio
async def test_artist_cascade_correct_confirm_dispatches(
client, db, monkeypatch,
):
a = Artist(name="Aria", slug="aria")
db.add(a)
await db.commit()
artist_id = a.id
dispatched = []
class _Result:
id = "fake-task-id"
def _delay(**kw):
dispatched.append(kw)
return _Result()
monkeypatch.setattr(
"backend.app.tasks.admin.delete_artist_cascade_task.delay", _delay,
)
resp = await client.post(
"/api/admin/artists/aria/cascade-delete",
json={
"dry_run": False,
"confirm": f"delete-artist-{artist_id}",
},
)
assert resp.status_code == 202
body = await resp.get_json()
assert body["task_id"] == "fake-task-id"
assert dispatched == [{"artist_id": artist_id}]
# --- Tier-C: POST /images/bulk-delete -------------------------------
@pytest.mark.asyncio
async def test_bulk_delete_dry_run_returns_counts(client, db, tmp_path):
a = Artist(name="B", slug="b")
db.add(a)
await db.flush()
i1 = ImageRecord(
artist_id=a.id, path=str(tmp_path / "1.jpg"),
sha256="1" * 64, size_bytes=10, mime="image/jpeg",
origin="imported_filesystem",
)
i2 = ImageRecord(
artist_id=a.id, path=str(tmp_path / "2.jpg"),
sha256="2" * 64, size_bytes=20, mime="image/jpeg",
origin="imported_filesystem",
)
db.add_all([i1, i2])
await db.commit()
resp = await client.post(
"/api/admin/images/bulk-delete",
json={
"image_ids": [i1.id, i2.id, 9_999_999],
"dry_run": True,
},
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["images_found"] == 2
assert body["bytes_on_disk"] == 30
assert body["missing_ids"] == [9_999_999]
@pytest.mark.asyncio
async def test_bulk_delete_empty_list_400(client):
resp = await client.post(
"/api/admin/images/bulk-delete",
json={"image_ids": [], "dry_run": True},
)
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "invalid_image_ids"
@pytest.mark.asyncio
async def test_bulk_delete_non_int_400(client):
resp = await client.post(
"/api/admin/images/bulk-delete",
json={"image_ids": ["foo", 2], "dry_run": True},
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_bulk_delete_wrong_confirm_400_with_expected_token(
client, db, tmp_path,
):
a = Artist(name="B", slug="b")
db.add(a)
await db.flush()
img = ImageRecord(
artist_id=a.id, path=str(tmp_path / "x.jpg"),
sha256="c" * 64, size_bytes=10, mime="image/jpeg",
origin="imported_filesystem",
)
db.add(img)
await db.commit()
img_id = img.id
resp = await client.post(
"/api/admin/images/bulk-delete",
json={
"image_ids": [img_id], "dry_run": False, "confirm": "nope",
},
)
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "confirm_mismatch"
assert body["expected"].startswith("delete-images-")
@pytest.mark.asyncio
async def test_bulk_delete_correct_confirm_dispatches(
client, db, tmp_path, monkeypatch,
):
a = Artist(name="B", slug="b")
db.add(a)
await db.flush()
img = ImageRecord(
artist_id=a.id, path=str(tmp_path / "x.jpg"),
sha256="d" * 64, size_bytes=10, mime="image/jpeg",
origin="imported_filesystem",
)
db.add(img)
await db.commit()
img_id = img.id
sha8 = hashlib.sha256(str(img_id).encode("utf-8")).hexdigest()[:8]
dispatched = []
class _Result:
id = "fake-bulk-id"
def _delay(**kw):
dispatched.append(kw)
return _Result()
monkeypatch.setattr(
"backend.app.tasks.admin.bulk_delete_images_task.delay", _delay,
)
resp = await client.post(
"/api/admin/images/bulk-delete",
json={
"image_ids": [img_id],
"dry_run": False,
"confirm": f"delete-images-{sha8}",
},
)
assert resp.status_code == 202
body = await resp.get_json()
assert body["task_id"] == "fake-bulk-id"
assert dispatched == [{"image_ids": [img_id]}]
# --- Tier-B: DELETE /tags/<id> + POST /tags/<dest>/merge -----------
@pytest.mark.asyncio
async def test_tag_delete_200_after_cascade(client, db):
t = Tag(name="doomed-tag", kind=TagKind.general)
db.add(t)
await db.commit()
tag_id = t.id
resp = await client.delete(f"/api/admin/tags/{tag_id}")
assert resp.status_code == 200
body = await resp.get_json()
assert body["deleted"]["id"] == tag_id
assert body["associations_removed"] == 0
@pytest.mark.asyncio
async def test_tag_delete_404_unknown(client):
resp = await client.delete("/api/admin/tags/9999999")
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_tag_merge_succeeds_for_same_kind(client, db):
src = Tag(name="src-tag", kind=TagKind.general)
dest = Tag(name="dest-tag", kind=TagKind.general)
db.add_all([src, dest])
await db.commit()
src_id = src.id
dest_id = dest.id
resp = await client.post(
f"/api/admin/tags/{dest_id}/merge",
json={"source_id": src_id},
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["result"]["target_id"] == dest_id
@pytest.mark.asyncio
async def test_tag_merge_rejects_self_merge(client, db):
t = Tag(name="x", kind=TagKind.general)
db.add(t)
await db.commit()
resp = await client.post(
f"/api/admin/tags/{t.id}/merge",
json={"source_id": t.id},
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_tag_merge_returns_400_on_kind_mismatch(client, db):
src = Tag(name="src", kind=TagKind.general)
dest = Tag(name="dest", kind=TagKind.artist)
db.add_all([src, dest])
await db.commit()
resp = await client.post(
f"/api/admin/tags/{dest.id}/merge",
json={"source_id": src.id},
)
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "tag_kind_mismatch"
# --- Tier-B helper: GET /tags/<id>/usage-count ----------------------
@pytest.mark.asyncio
async def test_tag_usage_count_returns_zero_for_unused(client, db):
t = Tag(name="lonely", kind=TagKind.general)
db.add(t)
await db.commit()
resp = await client.get(f"/api/admin/tags/{t.id}/usage-count")
assert resp.status_code == 200
body = await resp.get_json()
assert body["count"] == 0
# --- Tier-A: POST /tags/prune-unused --------------------------------
@pytest.mark.asyncio
async def test_prune_unused_dry_run_lists_unused(client, db, tmp_path):
a = Artist(name="P", slug="p")
db.add(a)
await db.flush()
img = ImageRecord(
artist_id=a.id, path=str(tmp_path / "p.jpg"),
sha256="e" * 64, size_bytes=10, mime="image/jpeg",
origin="imported_filesystem",
)
db.add(img)
used = Tag(name="kept", kind=TagKind.general)
unused = Tag(name="prune", kind=TagKind.general)
db.add_all([used, unused])
await db.flush()
await db.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=used.id,
))
await db.commit()
resp = await client.post(
"/api/admin/tags/prune-unused", json={"dry_run": True},
)
body = await resp.get_json()
assert body["count"] == 1
assert "prune" in body["sample_names"]
@pytest.mark.asyncio
async def test_prune_unused_commit_deletes_and_returns_count(client, db):
db.add(Tag(name="byebye", kind=TagKind.general))
await db.commit()
resp = await client.post(
"/api/admin/tags/prune-unused", json={"dry_run": False},
)
body = await resp.get_json()
assert body["deleted"] >= 1
assert "byebye" in body["sample_names"]
+12 -37
View File
@@ -31,17 +31,10 @@ async def client(app):
yield c yield c
@pytest.mark.asyncio # Retired 2026-05-24 (FC-3h): `test_post_backup_returns_202_and_id`
async def test_post_backup_returns_202_and_id(client, monkeypatch): # asserted the /api/migrate/backup endpoint, which was retired in FC-3h.
monkeypatch.setattr( # Backup is now a first-class feature at /api/system/backup/*;
"backend.app.api.migrate.run_migration", # coverage lives in tests/test_api_system_backup.py.
type("F", (), {"delay": lambda self, *a, **k: None})(),
)
resp = await client.post("/api/migrate/backup", json={})
assert resp.status_code == 202
body = await resp.get_json()
assert "run_id" in body
assert body["status"] == "pending"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -62,10 +55,6 @@ async def test_post_ingest_rejects_missing_file(client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_post_ingest_accepts_multipart_file(client, monkeypatch): async def test_post_ingest_accepts_multipart_file(client, monkeypatch):
monkeypatch.setattr(
"backend.app.api.migrate._has_recent_pre_migration_backup",
lambda: True, # pretend backup exists
)
monkeypatch.setattr( monkeypatch.setattr(
"backend.app.api.migrate.run_migration", "backend.app.api.migrate.run_migration",
type("F", (), {"delay": lambda self, *a, **k: None})(), type("F", (), {"delay": lambda self, *a, **k: None})(),
@@ -83,32 +72,18 @@ async def test_post_ingest_accepts_multipart_file(client, monkeypatch):
assert resp.status_code == 202 assert resp.status_code == 202
@pytest.mark.asyncio # Retired 2026-05-24: `test_post_apply_without_backup_rejected` asserted
async def test_post_apply_without_backup_rejected(client, monkeypatch): # the migration backup-gate rejected applies without a recent backup.
monkeypatch.setattr( # That gate was removed in commit 5535677 (_APPLY_KINDS is now empty,
"backend.app.api.migrate._has_recent_pre_migration_backup", # FC-3h will own backup as a first-class feature) so the test was
lambda: False, # testing dead behavior. Removed rather than rewritten.
)
export = {
"source_app": "gallerysubscriber", "schema_version": 1,
"subscriptions": [], "credentials": [],
}
resp = await client.post(
"/api/migrate/gs_ingest",
form={"dry_run": "false"},
files={"export_file": _file_storage(export, "gs-export.json")},
)
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "no_backup"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_post_dry_run_allowed_without_backup(client, monkeypatch): async def test_post_dry_run_allowed_without_backup(client, monkeypatch):
monkeypatch.setattr( # FC-3h: the _has_recent_pre_migration_backup gate was retired; dry-run
"backend.app.api.migrate._has_recent_pre_migration_backup", # ingests were always allowed and now non-dry-run ingests are too.
lambda: False, # This test stays as regression coverage that dry_run ingest still works.
)
monkeypatch.setattr( monkeypatch.setattr(
"backend.app.api.migrate.run_migration", "backend.app.api.migrate.run_migration",
type("F", (), {"delay": lambda self, *a, **k: None})(), type("F", (), {"delay": lambda self, *a, **k: None})(),
+267
View File
@@ -0,0 +1,267 @@
"""FC-3i: /api/system/activity/* endpoint tests.
Mocks Redis LLEN + celery inspect via monkeypatch on the module-level
_read_queues_sync / _read_workers_sync helpers, so tests don't need
a live broker or running workers.
"""
from datetime import UTC, datetime, timedelta
import pytest
import pytest_asyncio
from backend.app import create_app
from backend.app.api import system_activity as activity_module
from backend.app.models import TaskRun
pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
@pytest.fixture(autouse=True)
def _reset_caches(monkeypatch):
"""Clear the module-level caches between tests so cache state from
one test doesn't leak into the next."""
monkeypatch.setitem(activity_module._QUEUE_CACHE, "data", None)
monkeypatch.setitem(activity_module._QUEUE_CACHE, "ts", 0.0)
monkeypatch.setitem(activity_module._WORKER_CACHE, "data", None)
monkeypatch.setitem(activity_module._WORKER_CACHE, "ts", 0.0)
# --- /queues -------------------------------------------------------
@pytest.mark.asyncio
async def test_queues_returns_all_known_queues(client, monkeypatch):
def _fake():
return {
"queues": dict.fromkeys(activity_module._QUEUE_NAMES, 0),
"fetched_at": datetime.now(UTC).isoformat(),
}
monkeypatch.setattr(activity_module, "_read_queues_sync", _fake)
resp = await client.get("/api/system/activity/queues")
assert resp.status_code == 200
body = await resp.get_json()
assert set(body["queues"].keys()) == set(activity_module._QUEUE_NAMES)
@pytest.mark.asyncio
async def test_queues_cached_within_ttl(client, monkeypatch):
call_count = {"n": 0}
def _counting():
call_count["n"] += 1
return {"queues": {}, "fetched_at": "x"}
monkeypatch.setattr(activity_module, "_read_queues_sync", _counting)
await client.get("/api/system/activity/queues")
await client.get("/api/system/activity/queues")
await client.get("/api/system/activity/queues")
assert call_count["n"] == 1 # cached after the first call
@pytest.mark.asyncio
async def test_queues_redis_failure_returns_null_per_failing_queue(client, monkeypatch):
"""_read_queues_sync's per-queue try/except returns None on failure."""
def _partial():
out = dict.fromkeys(activity_module._QUEUE_NAMES, 0)
out["ml"] = None # simulate broker hiccup on the ml queue
return {"queues": out, "fetched_at": "x"}
monkeypatch.setattr(activity_module, "_read_queues_sync", _partial)
resp = await client.get("/api/system/activity/queues")
body = await resp.get_json()
assert body["queues"]["ml"] is None
assert body["queues"]["import"] == 0
# --- /workers ------------------------------------------------------
@pytest.mark.asyncio
async def test_workers_returns_per_worker_queue_membership(client, monkeypatch):
def _fake():
return {
"workers": {
"worker@host1": {"queues": ["import", "thumbnail"], "active_count": 2},
"worker@host2": {"queues": ["ml"], "active_count": 0},
},
"fetched_at": "x",
}
monkeypatch.setattr(activity_module, "_read_workers_sync", _fake)
resp = await client.get("/api/system/activity/workers")
assert resp.status_code == 200
body = await resp.get_json()
assert "worker@host1" in body["workers"]
assert "import" in body["workers"]["worker@host1"]["queues"]
@pytest.mark.asyncio
async def test_workers_cached_within_ttl(client, monkeypatch):
call_count = {"n": 0}
def _counting():
call_count["n"] += 1
return {"workers": {}, "fetched_at": "x"}
monkeypatch.setattr(activity_module, "_read_workers_sync", _counting)
await client.get("/api/system/activity/workers")
await client.get("/api/system/activity/workers")
assert call_count["n"] == 1
# --- /runs ---------------------------------------------------------
@pytest_asyncio.fixture
async def _seed_runs(db):
"""Insert 5 task_run rows for paging tests."""
now = datetime.now(UTC)
for i in range(5):
db.add(TaskRun(
celery_task_id=f"tid-{i}",
queue="import" if i % 2 == 0 else "ml",
task_name=f"backend.app.tasks.fake.task_{i}",
target_id=i,
started_at=now - timedelta(seconds=10 - i),
finished_at=now - timedelta(seconds=9 - i),
duration_ms=1000,
status="ok" if i < 3 else "error",
error_type="ValueError" if i >= 3 else None,
error_message="boom" if i >= 3 else None,
))
await db.commit()
@pytest.mark.asyncio
async def test_runs_paginated_descending_by_id(client, _seed_runs):
resp = await client.get("/api/system/activity/runs?limit=3")
body = await resp.get_json()
assert len(body["runs"]) == 3
# Descending: latest id first.
ids = [r["id"] for r in body["runs"]]
assert ids == sorted(ids, reverse=True)
assert body["next_cursor"] is not None # 5 total, asked for 3 → more
@pytest.mark.asyncio
async def test_runs_filter_by_queue(client, _seed_runs):
resp = await client.get("/api/system/activity/runs?queue=ml")
body = await resp.get_json()
assert all(r["queue"] == "ml" for r in body["runs"])
assert len(body["runs"]) == 2 # i=1, i=3
@pytest.mark.asyncio
async def test_runs_filter_by_status(client, _seed_runs):
resp = await client.get("/api/system/activity/runs?status=error")
body = await resp.get_json()
assert all(r["status"] == "error" for r in body["runs"])
assert len(body["runs"]) == 2 # i=3, i=4
@pytest.mark.asyncio
async def test_runs_keyset_cursor(client, _seed_runs):
page1 = await (await client.get("/api/system/activity/runs?limit=2")).get_json()
assert len(page1["runs"]) == 2
assert page1["next_cursor"] is not None
page2 = await (await client.get(
f"/api/system/activity/runs?limit=2&before_id={page1['next_cursor']}"
)).get_json()
assert len(page2["runs"]) == 2
page1_ids = {r["id"] for r in page1["runs"]}
page2_ids = {r["id"] for r in page2["runs"]}
assert page1_ids.isdisjoint(page2_ids)
@pytest.mark.asyncio
async def test_runs_invalid_limit_400(client):
resp = await client.get("/api/system/activity/runs?limit=not-a-number")
assert resp.status_code == 400
# --- /failures -----------------------------------------------------
@pytest_asyncio.fixture
async def _seed_failures(db):
"""Mix of ok / error / timeout rows; some old, some recent."""
now = datetime.now(UTC)
recent = [
("error", "OperationalError"),
("error", "OperationalError"),
("error", "OperationalError"),
("timeout", "TimeoutError"),
("timeout", "TimeoutError"),
("error", "OSError"),
("ok", None), # success — should NOT appear in failures
]
for i, (status, etype) in enumerate(recent):
db.add(TaskRun(
celery_task_id=f"f-{i}",
queue="ml",
task_name="backend.app.tasks.fake.x",
target_id=i,
started_at=now - timedelta(seconds=120 - i),
finished_at=now - timedelta(seconds=60 - i),
duration_ms=1000,
status=status,
error_type=etype,
error_message="boom" if status != "ok" else None,
))
# One ancient failure (>24h) that should NOT appear.
db.add(TaskRun(
celery_task_id="f-old",
queue="ml",
task_name="backend.app.tasks.fake.x",
target_id=999,
started_at=now - timedelta(hours=30),
finished_at=now - timedelta(hours=29),
duration_ms=1000,
status="error",
error_type="OldError",
error_message="ancient",
))
await db.commit()
@pytest.mark.asyncio
async def test_failures_returns_recent_errors_and_timeouts(client, _seed_failures):
resp = await client.get("/api/system/activity/failures")
body = await resp.get_json()
statuses = {r["status"] for r in body["recent"]}
assert statuses == {"error", "timeout"}
# No ok rows.
assert "ok" not in statuses
@pytest.mark.asyncio
async def test_failures_count_by_type_groups_correctly(client, _seed_failures):
resp = await client.get("/api/system/activity/failures")
body = await resp.get_json()
counts = body["count_by_type"]
assert counts.get("OperationalError") == 3
assert counts.get("TimeoutError") == 2
assert counts.get("OSError") == 1
assert "OldError" not in counts # outside 24h window
@pytest.mark.asyncio
async def test_failures_only_within_24h_window(client, _seed_failures):
resp = await client.get("/api/system/activity/failures")
body = await resp.get_json()
ids = {r["error_type"] for r in body["recent"]}
assert "OldError" not in ids
+336
View File
@@ -0,0 +1,336 @@
"""FC-3h: /api/system/backup/* endpoint integration tests."""
from datetime import UTC, datetime, timedelta
import pytest
import pytest_asyncio
from sqlalchemy import select
from backend.app import create_app
from backend.app.models import BackupRun
pytestmark = pytest.mark.integration
@pytest.fixture
async def app():
return create_app()
@pytest.fixture
async def client(app):
async with app.test_client() as c:
yield c
@pytest_asyncio.fixture
async def _seed_runs(db):
"""Insert 4 BackupRun rows for paging/filter tests."""
now = datetime.now(UTC)
for i in range(4):
db.add(BackupRun(
kind="db" if i % 2 == 0 else "images",
status="ok",
tag=None,
triggered_by="manual",
started_at=now - timedelta(seconds=10 - i),
finished_at=now - timedelta(seconds=9 - i),
sql_path="/tmp/fake.sql" if i % 2 == 0 else None,
tar_path=None if i % 2 == 0 else "/tmp/fake.tar.zst",
size_bytes=100,
manifest={},
))
await db.commit()
# --- POST /db, /images ----------------------------------------------
@pytest.mark.asyncio
async def test_post_db_dispatches_and_returns_202(client, monkeypatch):
dispatched = []
monkeypatch.setattr(
"backend.app.tasks.backup.backup_db_task.delay",
lambda **kw: dispatched.append(kw),
)
resp = await client.post("/api/system/backup/db", json={})
assert resp.status_code == 202
body = await resp.get_json()
assert body["status"] == "dispatched"
assert dispatched == [{"tag": None, "triggered_by": "manual"}]
@pytest.mark.asyncio
async def test_post_db_persists_tag(client, monkeypatch):
dispatched = []
monkeypatch.setattr(
"backend.app.tasks.backup.backup_db_task.delay",
lambda **kw: dispatched.append(kw),
)
resp = await client.post(
"/api/system/backup/db", json={"tag": "pre-cutover"},
)
assert resp.status_code == 202
assert dispatched[0]["tag"] == "pre-cutover"
@pytest.mark.asyncio
async def test_post_db_rejects_tag_too_long(client):
resp = await client.post(
"/api/system/backup/db", json={"tag": "x" * 65},
)
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "invalid_tag"
@pytest.mark.asyncio
async def test_post_images_dispatches(client, monkeypatch):
dispatched = []
monkeypatch.setattr(
"backend.app.tasks.backup.backup_images_task.delay",
lambda **kw: dispatched.append(kw),
)
resp = await client.post("/api/system/backup/images", json={})
assert resp.status_code == 202
# --- GET /runs -------------------------------------------------------
@pytest.mark.asyncio
async def test_runs_paginated_descending(client, _seed_runs):
resp = await client.get("/api/system/backup/runs?limit=2")
body = await resp.get_json()
assert len(body["runs"]) == 2
ids = [r["id"] for r in body["runs"]]
assert ids == sorted(ids, reverse=True)
assert body["next_cursor"] is not None
@pytest.mark.asyncio
async def test_runs_filter_by_kind_db(client, _seed_runs):
resp = await client.get("/api/system/backup/runs?kind=db")
body = await resp.get_json()
assert all(r["kind"] == "db" for r in body["runs"])
@pytest.mark.asyncio
async def test_runs_filter_by_kind_images(client, _seed_runs):
resp = await client.get("/api/system/backup/runs?kind=images")
body = await resp.get_json()
assert all(r["kind"] == "images" for r in body["runs"])
@pytest.mark.asyncio
async def test_runs_rejects_invalid_kind(client):
resp = await client.get("/api/system/backup/runs?kind=bogus")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_runs_rejects_invalid_limit(client):
resp = await client.get("/api/system/backup/runs?limit=not-int")
assert resp.status_code == 400
# --- GET /runs/<id> --------------------------------------------------
@pytest.mark.asyncio
async def test_get_run_returns_row(client, _seed_runs):
list_resp = await client.get("/api/system/backup/runs?limit=1")
rid = (await list_resp.get_json())["runs"][0]["id"]
resp = await client.get(f"/api/system/backup/runs/{rid}")
assert resp.status_code == 200
body = await resp.get_json()
assert body["id"] == rid
@pytest.mark.asyncio
async def test_get_run_404(client):
resp = await client.get("/api/system/backup/runs/999999")
assert resp.status_code == 404
# --- PATCH /runs/<id> ------------------------------------------------
@pytest.mark.asyncio
async def test_patch_tag_sets_value(client, _seed_runs, db_sync):
list_resp = await client.get("/api/system/backup/runs?limit=1")
rid = (await list_resp.get_json())["runs"][0]["id"]
resp = await client.patch(
f"/api/system/backup/runs/{rid}",
json={"tag": "monthly"},
)
assert resp.status_code == 200
tag = db_sync.execute(
select(BackupRun.tag).where(BackupRun.id == rid)
).scalar_one()
assert tag == "monthly"
@pytest.mark.asyncio
async def test_patch_tag_null_clears(client, _seed_runs, db_sync):
list_resp = await client.get("/api/system/backup/runs?limit=1")
rid = (await list_resp.get_json())["runs"][0]["id"]
await client.patch(f"/api/system/backup/runs/{rid}", json={"tag": "x"})
await client.patch(f"/api/system/backup/runs/{rid}", json={"tag": None})
tag = db_sync.execute(
select(BackupRun.tag).where(BackupRun.id == rid)
).scalar_one()
assert tag is None
# --- POST /runs/<id>/restore ----------------------------------------
@pytest.mark.asyncio
async def test_restore_wrong_confirm_400(client, _seed_runs):
list_resp = await client.get("/api/system/backup/runs?kind=db&limit=1")
rid = (await list_resp.get_json())["runs"][0]["id"]
resp = await client.post(
f"/api/system/backup/runs/{rid}/restore",
json={"confirm": "wrong"},
)
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "confirm_mismatch"
assert body["expected"] == f"restore-db-{rid}"
@pytest.mark.asyncio
async def test_restore_correct_confirm_dispatches(client, _seed_runs, monkeypatch):
list_resp = await client.get("/api/system/backup/runs?kind=db&limit=1")
rid = (await list_resp.get_json())["runs"][0]["id"]
dispatched = []
monkeypatch.setattr(
"backend.app.tasks.backup.restore_db_task.delay",
lambda **kw: dispatched.append(kw),
)
resp = await client.post(
f"/api/system/backup/runs/{rid}/restore",
json={"confirm": f"restore-db-{rid}"},
)
assert resp.status_code == 202
assert dispatched == [{"source_backup_run_id": rid}]
@pytest.mark.asyncio
async def test_restore_rejects_non_ok_status(client, db):
# Seed an error-status row directly.
db.add(BackupRun(
kind="db", status="error", tag=None, triggered_by="manual",
started_at=datetime.now(UTC), finished_at=datetime.now(UTC),
sql_path="/tmp/fake.sql", manifest={},
))
await db.commit()
rid_q = await client.get("/api/system/backup/runs?kind=db&limit=1")
rid = (await rid_q.get_json())["runs"][0]["id"]
resp = await client.post(
f"/api/system/backup/runs/{rid}/restore",
json={"confirm": f"restore-db-{rid}"},
)
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "not_restorable"
# --- DELETE /runs/<id> ----------------------------------------------
@pytest.mark.asyncio
async def test_delete_wrong_confirm_400(client, _seed_runs):
list_resp = await client.get("/api/system/backup/runs?limit=1")
rid = (await list_resp.get_json())["runs"][0]["id"]
resp = await client.delete(
f"/api/system/backup/runs/{rid}",
json={"confirm": "wrong"},
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_delete_correct_confirm_204(client, _seed_runs, db_sync):
list_resp = await client.get("/api/system/backup/runs?limit=1")
row = (await list_resp.get_json())["runs"][0]
rid = row["id"]
kind = row["kind"]
resp = await client.delete(
f"/api/system/backup/runs/{rid}",
json={"confirm": f"delete-{kind}-{rid}"},
)
assert resp.status_code == 204
surviving = db_sync.execute(
select(BackupRun.id).where(BackupRun.id == rid)
).scalar_one_or_none()
assert surviving is None
# --- /settings ------------------------------------------------------
@pytest.mark.asyncio
async def test_get_settings_returns_defaults(client):
resp = await client.get("/api/system/backup/settings")
assert resp.status_code == 200
body = await resp.get_json()
assert body["backup_db_nightly_enabled"] is False
assert body["backup_db_nightly_hour_utc"] == 3
assert body["backup_db_keep_last_n"] == 14
assert body["backup_images_keep_last_n"] == 3
@pytest.mark.asyncio
async def test_patch_settings_updates_values(client):
resp = await client.patch(
"/api/system/backup/settings",
json={
"backup_db_nightly_enabled": True,
"backup_db_nightly_hour_utc": 5,
"backup_db_keep_last_n": 30,
},
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["backup_db_nightly_enabled"] is True
assert body["backup_db_nightly_hour_utc"] == 5
assert body["backup_db_keep_last_n"] == 30
@pytest.mark.asyncio
async def test_patch_settings_rejects_invalid_bool(client):
resp = await client.patch(
"/api/system/backup/settings",
json={"backup_db_nightly_enabled": "yes"},
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_patch_settings_rejects_hour_out_of_range(client):
resp = await client.patch(
"/api/system/backup/settings",
json={"backup_db_nightly_hour_utc": 24},
)
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_patch_settings_rejects_keep_n_below_min(client):
resp = await client.patch(
"/api/system/backup/settings",
json={"backup_db_keep_last_n": 0},
)
assert resp.status_code == 400
+200
View File
@@ -0,0 +1,200 @@
"""FC-3h: backup_service unit tests.
Subprocess calls (pg_dump, tar, psql) are monkeypatched so tests run
without external binaries. The real subprocess behavior is exercised
implicitly via the Celery task tests in test_tasks_backup.py.
"""
import json
from pathlib import Path
import pytest
from backend.app.services import backup_service
pytestmark = pytest.mark.integration
@pytest.fixture
def fake_subprocess(monkeypatch):
"""Replace subprocess.run with a fake that writes a sentinel to
the target path (for pg_dump's -f, for tar's -cf). Captures all
calls in a list."""
calls = []
class _FakeProc:
returncode = 0
stdout = b""
stderr = b""
def _fake_run(cmd, **kwargs):
calls.append(list(cmd))
if cmd[0] == "pg_dump":
i = cmd.index("-f")
Path(cmd[i + 1]).write_bytes(b"-- fake pg_dump\n")
elif cmd[0] == "tar" and "-cf" in cmd:
i = cmd.index("-cf")
Path(cmd[i + 1]).write_bytes(b"fake tar payload")
return _FakeProc()
monkeypatch.setattr("subprocess.run", _fake_run)
return calls
# --- backup_db -------------------------------------------------------
def test_backup_db_writes_sql_and_manifest(tmp_path, fake_subprocess):
result = backup_service.backup_db(
db_url="postgresql://test@x/db",
images_root=tmp_path,
tag="pre-cutover",
triggered_by="manual",
)
sql = Path(result["sql_path"])
manifest = Path(result["manifest_path"])
assert sql.is_file() and sql.suffix == ".sql"
assert manifest.is_file() and manifest.suffix == ".json"
assert result["kind"] == "db"
assert result["tar_path"] is None
assert result["size_bytes"] == len(b"-- fake pg_dump\n")
parsed = json.loads(manifest.read_text())
assert parsed["kind"] == "db"
assert parsed["tag"] == "pre-cutover"
assert parsed["triggered_by"] == "manual"
assert parsed["artifact_path"] == str(sql)
def test_backup_db_strips_sqlalchemy_psycopg_driver(tmp_path, fake_subprocess):
backup_service.backup_db(
db_url="postgresql+psycopg://u:p@h/d", images_root=tmp_path,
)
cmd = fake_subprocess[0]
assert cmd[0] == "pg_dump"
assert cmd[-1].startswith("postgresql://")
assert "+psycopg" not in cmd[-1]
def test_backup_db_strips_asyncpg_driver(tmp_path, fake_subprocess):
backup_service.backup_db(
db_url="postgresql+asyncpg://u:p@h/d", images_root=tmp_path,
)
assert "+asyncpg" not in fake_subprocess[0][-1]
def test_backup_db_default_tag_is_none(tmp_path, fake_subprocess):
result = backup_service.backup_db(
db_url="postgresql://u@h/d", images_root=tmp_path,
)
parsed = json.loads(Path(result["manifest_path"]).read_text())
assert parsed["tag"] is None
# --- backup_images ---------------------------------------------------
def test_backup_images_writes_tar_and_manifest(tmp_path, fake_subprocess):
result = backup_service.backup_images(
images_root=tmp_path, tag="monthly", triggered_by="manual",
)
tar = Path(result["tar_path"])
assert tar.is_file() and tar.name.endswith(".tar.zst")
assert result["sql_path"] is None
assert result["size_bytes"] == len(b"fake tar payload")
def test_backup_images_excludes_backups_and_quarantine(tmp_path, fake_subprocess):
backup_service.backup_images(images_root=tmp_path)
cmd = fake_subprocess[0]
excludes = [arg for arg in cmd if arg.startswith("--exclude=")]
assert any("_backups" in e for e in excludes)
assert any("_quarantine" in e for e in excludes)
# --- restore_db ------------------------------------------------------
def test_restore_db_drops_schema_then_loads(tmp_path, fake_subprocess):
sql_path = tmp_path / "fake.sql"
sql_path.write_text("SELECT 1;")
backup_service.restore_db(
db_url="postgresql://u@h/d", sql_path=sql_path,
)
# Two psql calls: one with -c (DROP SCHEMA), one with -f (load).
assert len(fake_subprocess) == 2
assert "-c" in fake_subprocess[0]
assert "DROP SCHEMA IF EXISTS public CASCADE" in fake_subprocess[0][-1]
assert "-f" in fake_subprocess[1]
assert str(sql_path) in fake_subprocess[1]
# --- restore_images --------------------------------------------------
def test_restore_images_untar_to_parent(tmp_path, fake_subprocess):
tar_path = tmp_path / "fake.tar.zst"
tar_path.write_bytes(b"fake")
backup_service.restore_images(images_root=tmp_path, tar_path=tar_path)
cmd = fake_subprocess[0]
assert cmd[:3] == ["tar", "--zstd", "-xf"]
assert str(tar_path) in cmd
assert "-C" in cmd
# --- unlink ----------------------------------------------------------
def test_unlink_removes_present_files_and_reports(tmp_path):
sql = tmp_path / "x.sql"
sql.write_bytes(b"x")
tar = tmp_path / "x.tar.zst"
tar.write_bytes(b"x")
manifest = tmp_path / "x.json"
manifest.write_text("{}")
result = backup_service.unlink_artifact_files(
sql_path=str(sql), tar_path=str(tar), manifest_path=str(manifest),
)
assert result == {"sql": True, "tar": True, "manifest": True}
assert not sql.exists() and not tar.exists() and not manifest.exists()
def test_unlink_missing_files_returns_true(tmp_path):
"""missing_ok semantics: a non-existent file isn't an error."""
result = backup_service.unlink_artifact_files(
sql_path=str(tmp_path / "nope.sql"),
tar_path=None, manifest_path=None,
)
assert result == {"sql": True}
def test_unlink_skips_none_paths():
"""A None path is skipped — not added to the result dict."""
result = backup_service.unlink_artifact_files(
sql_path=None, tar_path=None, manifest_path=None,
)
assert result == {}
# --- helpers ---------------------------------------------------------
def test_libpq_url_strips_each_known_driver():
assert backup_service._libpq_url(
"postgresql+psycopg://u@h/d"
) == "postgresql://u@h/d"
assert backup_service._libpq_url(
"postgresql+asyncpg://u@h/d"
) == "postgresql://u@h/d"
assert backup_service._libpq_url(
"postgresql+psycopg2://u@h/d"
) == "postgresql://u@h/d"
# Plain URL passes through unchanged.
assert backup_service._libpq_url(
"postgresql://u@h/d"
) == "postgresql://u@h/d"
def test_backups_dir_created_on_first_use(tmp_path):
d = backup_service._backups_dir(tmp_path)
assert d.is_dir()
assert d.name == "_backups"
+335
View File
@@ -0,0 +1,335 @@
"""FC-3k: cleanup_service unit tests.
Mutations go against real Postgres (db_sync fixture). File-system
side effects use tmp_path. Assertions on mutated rows use COLUMN
SELECTS per reference_async_coredml_test_assertions — never
re-read ORM attributes after a service mutates and re-fetches.
"""
import pytest
from sqlalchemy import func, select
from backend.app.models import Artist, ImageRecord, Tag, TagKind
from backend.app.models.tag import image_tag
from backend.app.services import cleanup_service
pytestmark = pytest.mark.integration
def _make_image(db_sync, *, artist, path, sha256, size=1000, thumb=None):
img = ImageRecord(
artist_id=artist.id,
path=path,
sha256=sha256,
size_bytes=size,
mime="image/jpeg",
origin="imported_filesystem",
thumbnail_path=thumb,
)
db_sync.add(img)
db_sync.flush()
return img
def _make_artist(db_sync, *, slug="aria", name="Aria"):
a = Artist(name=name, slug=slug)
db_sync.add(a)
db_sync.flush()
return a
def _make_tag(db_sync, *, name, kind=TagKind.general):
t = Tag(name=name, kind=kind)
db_sync.add(t)
db_sync.flush()
return t
# --- project_artist_cascade -----------------------------------------
def test_project_artist_cascade_returns_zeroes_for_empty_artist(db_sync):
_make_artist(db_sync, slug="empty")
db_sync.commit()
result = cleanup_service.project_artist_cascade(db_sync, slug="empty")
assert result["artist"]["slug"] == "empty"
assert result["projected"] == {
"images": 0,
"sources": 0,
"thumbs": 0,
"import_tasks": 0,
"bytes_on_disk": 0,
}
def test_project_artist_cascade_counts_images_and_thumbs_and_bytes(db_sync, tmp_path):
a = _make_artist(db_sync, slug="counted")
_make_image(
db_sync, artist=a, path=str(tmp_path / "a.jpg"),
sha256="a" * 64, size=1000, thumb=str(tmp_path / "a.thumb"),
)
_make_image(
db_sync, artist=a, path=str(tmp_path / "b.jpg"),
sha256="b" * 64, size=2500, thumb=None,
)
db_sync.commit()
result = cleanup_service.project_artist_cascade(db_sync, slug="counted")
assert result["projected"]["images"] == 2
assert result["projected"]["thumbs"] == 1
assert result["projected"]["bytes_on_disk"] == 3500
def test_project_artist_cascade_raises_on_unknown_slug(db_sync):
with pytest.raises(LookupError):
cleanup_service.project_artist_cascade(db_sync, slug="nope")
# --- project_bulk_image_delete --------------------------------------
def test_project_bulk_image_delete_separates_found_from_missing(db_sync, tmp_path):
a = _make_artist(db_sync, slug="bd")
i1 = _make_image(
db_sync, artist=a, path=str(tmp_path / "1.jpg"),
sha256="1" * 64, size=10, thumb="t1",
)
i2 = _make_image(
db_sync, artist=a, path=str(tmp_path / "2.jpg"),
sha256="2" * 64, size=20, thumb=None,
)
db_sync.commit()
result = cleanup_service.project_bulk_image_delete(
db_sync, image_ids=[i1.id, i2.id, 9_999_999],
)
assert result["images_found"] == 2
assert result["thumbs_to_unlink"] == 1
assert result["bytes_on_disk"] == 30
assert result["missing_ids"] == [9_999_999]
def test_project_bulk_image_delete_empty_input(db_sync):
result = cleanup_service.project_bulk_image_delete(db_sync, image_ids=[])
assert result == {
"images_found": 0,
"thumbs_to_unlink": 0,
"bytes_on_disk": 0,
"missing_ids": [],
}
# --- count_tag_associations / find_unused_tags ----------------------
def test_count_tag_associations_counts_image_tag_rows(db_sync, tmp_path):
a = _make_artist(db_sync, slug="ta")
img = _make_image(
db_sync, artist=a, path=str(tmp_path / "x.jpg"), sha256="c" * 64,
)
tag = _make_tag(db_sync, name="cyberpunk")
db_sync.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=tag.id,
))
db_sync.commit()
assert cleanup_service.count_tag_associations(db_sync, tag_id=tag.id) == 1
def test_find_unused_tags_returns_only_unreferenced(db_sync, tmp_path):
a = _make_artist(db_sync, slug="fu")
img = _make_image(
db_sync, artist=a, path=str(tmp_path / "y.jpg"), sha256="d" * 64,
)
used = _make_tag(db_sync, name="used")
_make_tag(db_sync, name="aaa-unused")
_make_tag(db_sync, name="zzz-unused")
db_sync.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=used.id,
))
db_sync.commit()
result = cleanup_service.find_unused_tags(db_sync)
names = [t.name for t in result]
assert "used" not in names
assert "aaa-unused" in names
assert "zzz-unused" in names
# Sorted by name ascending.
assert names.index("aaa-unused") < names.index("zzz-unused")
# --- unlink_image_files ---------------------------------------------
def test_unlink_image_files_removes_original_and_thumbnail(db_sync, tmp_path):
original = tmp_path / "orig.jpg"
original.write_bytes(b"x")
custom_thumb = tmp_path / "custom.thumb"
custom_thumb.write_bytes(b"x")
conv_thumb = tmp_path / "thumbs" / "aaa" / ("a" * 64 + ".jpg")
conv_thumb.parent.mkdir(parents=True)
conv_thumb.write_bytes(b"x")
img = ImageRecord(
artist_id=None, path=str(original), sha256="a" * 64,
size_bytes=1, thumbnail_path=str(custom_thumb),
)
result = cleanup_service.unlink_image_files(img, tmp_path)
assert result == {"original": True, "thumbnail": True}
assert not original.exists()
assert not custom_thumb.exists()
assert not conv_thumb.exists()
def test_unlink_image_files_missing_files_count_as_success(tmp_path):
img = ImageRecord(
artist_id=None, path=str(tmp_path / "nope.jpg"),
sha256="b" * 64, size_bytes=1, thumbnail_path=None,
)
result = cleanup_service.unlink_image_files(img, tmp_path)
assert result == {"original": True, "thumbnail": False}
# --- delete_artist_cascade ------------------------------------------
def test_delete_artist_cascade_removes_images_and_artist_row(db_sync, tmp_path):
a = _make_artist(db_sync, slug="cas")
for i in range(3):
f = tmp_path / f"img{i}.jpg"
f.write_bytes(b"x")
_make_image(
db_sync, artist=a, path=str(f),
sha256=f"{i:064x}", size=10,
)
db_sync.commit()
artist_id = a.id
result = cleanup_service.delete_artist_cascade(
db_sync, artist_id=artist_id, images_root=tmp_path,
)
assert result["artist"]["slug"] == "cas"
assert result["summary"]["images_deleted"] == 3
assert result["summary"]["files_deleted"] == 3
# Column-select assertions per reference_async_coredml_test_assertions.
surviving_artist = db_sync.execute(
select(func.count(Artist.id)).where(Artist.id == artist_id)
).scalar_one()
surviving_images = db_sync.execute(
select(func.count(ImageRecord.id))
.where(ImageRecord.artist_id == artist_id)
).scalar_one()
assert surviving_artist == 0
assert surviving_images == 0
def test_delete_artist_cascade_idempotent_on_missing(db_sync, tmp_path):
result = cleanup_service.delete_artist_cascade(
db_sync, artist_id=9_999_999, images_root=tmp_path,
)
assert result["artist"] is None
assert result["summary"]["images_deleted"] == 0
# --- delete_images --------------------------------------------------
def test_delete_images_removes_rows_and_files(db_sync, tmp_path):
a = _make_artist(db_sync, slug="di")
f1 = tmp_path / "1.jpg"
f1.write_bytes(b"x")
f2 = tmp_path / "2.jpg"
f2.write_bytes(b"x")
i1 = _make_image(db_sync, artist=a, path=str(f1), sha256="1" * 64)
i2 = _make_image(db_sync, artist=a, path=str(f2), sha256="2" * 64)
db_sync.commit()
result = cleanup_service.delete_images(
db_sync, image_ids=[i1.id, i2.id, 9_999_999],
images_root=tmp_path,
)
assert result["images_deleted"] == 2
assert result["files_deleted"] == 2
assert result["missing_ids"] == [9_999_999]
surviving = db_sync.execute(
select(func.count(ImageRecord.id))
.where(ImageRecord.id.in_([i1.id, i2.id]))
).scalar_one()
assert surviving == 0
# --- delete_tag -----------------------------------------------------
def test_delete_tag_cascades_associations(db_sync, tmp_path):
a = _make_artist(db_sync, slug="dt")
img = _make_image(
db_sync, artist=a, path=str(tmp_path / "x.jpg"), sha256="d" * 64,
)
tag = _make_tag(db_sync, name="doomed")
db_sync.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=tag.id,
))
db_sync.commit()
tag_id = tag.id
result = cleanup_service.delete_tag(db_sync, tag_id=tag_id)
assert result["deleted"]["id"] == tag_id
assert result["associations_removed"] == 1
surviving_tag = db_sync.execute(
select(func.count(Tag.id)).where(Tag.id == tag_id)
).scalar_one()
surviving_assoc = db_sync.execute(
select(func.count())
.select_from(image_tag).where(image_tag.c.tag_id == tag_id)
).scalar_one()
assert surviving_tag == 0
assert surviving_assoc == 0
def test_delete_tag_raises_on_unknown_id(db_sync):
with pytest.raises(LookupError):
cleanup_service.delete_tag(db_sync, tag_id=9_999_999)
# --- prune_unused_tags ----------------------------------------------
def test_prune_unused_tags_dry_run_returns_count_and_names(db_sync, tmp_path):
a = _make_artist(db_sync, slug="pu")
img = _make_image(
db_sync, artist=a, path=str(tmp_path / "z.jpg"), sha256="e" * 64,
)
used = _make_tag(db_sync, name="kept")
_make_tag(db_sync, name="prune-me-1")
_make_tag(db_sync, name="prune-me-2")
db_sync.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=used.id,
))
db_sync.commit()
result = cleanup_service.prune_unused_tags(db_sync, dry_run=True)
assert result["count"] == 2
assert "prune-me-1" in result["sample_names"]
assert "prune-me-2" in result["sample_names"]
# Nothing actually deleted.
surviving = db_sync.execute(select(func.count(Tag.id))).scalar_one()
assert surviving == 3
def test_prune_unused_tags_commit_deletes_them(db_sync, tmp_path):
a = _make_artist(db_sync, slug="pu2")
img = _make_image(
db_sync, artist=a, path=str(tmp_path / "k.jpg"), sha256="f" * 64,
)
used = _make_tag(db_sync, name="kept")
_make_tag(db_sync, name="bye")
db_sync.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=used.id,
))
db_sync.commit()
result = cleanup_service.prune_unused_tags(db_sync, dry_run=False)
assert result["deleted"] == 1
surviving_names = db_sync.execute(select(Tag.name)).scalars().all()
assert "kept" in surviving_names
assert "bye" not in surviving_names
+171
View File
@@ -93,3 +93,174 @@ def test_cleanup_old_deletes_finished_old(db_sync):
remaining = {t.source_path for t in db_sync.query(ImportTask).all()} remaining = {t.source_path for t in db_sync.query(ImportTask).all()}
assert remaining == {"/import/b.jpg", "/import/c.jpg"} assert remaining == {"/import/b.jpg", "/import/c.jpg"}
# --- FC-3i: task_run sweep + retention -----------------------------
def _make_task_run(db_sync, *, status, started_at, finished_at=None,
error_type=None):
from backend.app.models import TaskRun
row = TaskRun(
celery_task_id="x",
queue="ml",
task_name="backend.app.tasks.fake.t",
target_id=1,
started_at=started_at,
finished_at=finished_at,
duration_ms=1000 if finished_at else None,
status=status,
error_type=error_type,
error_message="x" if status in ("error", "timeout") else None,
)
db_sync.add(row)
db_sync.flush()
return row.id
def test_recover_stalled_task_runs_flips_old_running_to_error(db_sync):
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import recover_stalled_task_runs
now = datetime.now(UTC)
stale_id = _make_task_run(
db_sync, status="running", started_at=now - timedelta(minutes=10),
)
db_sync.commit()
recovered = recover_stalled_task_runs.apply().get()
assert recovered == 1
db_sync.expire_all()
status = db_sync.execute(
select(TaskRun.status).where(TaskRun.id == stale_id)
).scalar_one()
error_type = db_sync.execute(
select(TaskRun.error_type).where(TaskRun.id == stale_id)
).scalar_one()
assert status == "error"
assert error_type == "RecoverySweep"
def test_recover_stalled_task_runs_skips_fresh_running(db_sync):
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import recover_stalled_task_runs
now = datetime.now(UTC)
fresh_id = _make_task_run(
db_sync, status="running", started_at=now - timedelta(seconds=30),
)
db_sync.commit()
recovered = recover_stalled_task_runs.apply().get()
assert recovered == 0
db_sync.expire_all()
status = db_sync.execute(
select(TaskRun.status).where(TaskRun.id == fresh_id)
).scalar_one()
assert status == "running"
def test_prune_task_runs_deletes_ok_older_than_24h(db_sync):
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import prune_task_runs
now = datetime.now(UTC)
old_id = _make_task_run(
db_sync, status="ok",
started_at=now - timedelta(hours=30),
finished_at=now - timedelta(hours=29),
)
recent_id = _make_task_run(
db_sync, status="ok",
started_at=now - timedelta(hours=2),
finished_at=now - timedelta(hours=1),
)
db_sync.commit()
result = prune_task_runs.apply().get()
assert result["ok_deleted"] == 1
db_sync.expire_all()
surviving_ids = set(db_sync.execute(
select(TaskRun.id).where(TaskRun.id.in_([old_id, recent_id]))
).scalars().all())
assert surviving_ids == {recent_id}
def test_prune_task_runs_deletes_failures_older_than_7d(db_sync):
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import prune_task_runs
now = datetime.now(UTC)
old_id = _make_task_run(
db_sync, status="error", error_type="OldError",
started_at=now - timedelta(days=10),
finished_at=now - timedelta(days=9),
)
db_sync.commit()
result = prune_task_runs.apply().get()
assert result["failures_deleted"] >= 1
db_sync.expire_all()
surviving = db_sync.execute(
select(TaskRun.id).where(TaskRun.id == old_id)
).scalar_one_or_none()
assert surviving is None
def test_prune_task_runs_keeps_recent_failures(db_sync):
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import prune_task_runs
now = datetime.now(UTC)
recent_id = _make_task_run(
db_sync, status="error", error_type="RecentError",
started_at=now - timedelta(days=6),
finished_at=now - timedelta(days=5),
)
db_sync.commit()
prune_task_runs.apply().get()
db_sync.expire_all()
surviving = db_sync.execute(
select(TaskRun.id).where(TaskRun.id == recent_id)
).scalar_one_or_none()
assert surviving == recent_id
def test_prune_task_runs_never_deletes_running(db_sync):
"""Even a 30-day-old running row stays — recovery sweep is the
mechanism that flips them; prune doesn't touch in-flight."""
from sqlalchemy import select
from backend.app.models import TaskRun
from backend.app.tasks.maintenance import prune_task_runs
now = datetime.now(UTC)
ancient_id = _make_task_run(
db_sync, status="running",
started_at=now - timedelta(days=30),
)
db_sync.commit()
prune_task_runs.apply().get()
db_sync.expire_all()
surviving = db_sync.execute(
select(TaskRun.id).where(TaskRun.id == ancient_id)
).scalar_one_or_none()
assert surviving == ancient_id
-64
View File
@@ -1,64 +0,0 @@
"""FC-5: backup + rollback service tests.
Uses tmp_path to avoid touching real /images/_backups/. Subprocess
calls (pg_dump, tar) are monkeypatched — the real shell-out is exercised
in operator-side smoke testing on the homelab, not in unit tests.
"""
import pytest
from backend.app.services.migrators import backup as backup_mod
pytestmark = pytest.mark.integration
@pytest.mark.asyncio
async def test_create_backup_writes_sql_and_tar(monkeypatch, tmp_path):
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
# Touch the expected output file so existence checks pass downstream.
if "pg_dump" in cmd[0]:
outpath = tmp_path / "_backups" / f"fc_{kwargs['_test_ts']}.sql"
outpath.write_text("-- fake pg_dump output")
else:
outpath = tmp_path / "_backups" / f"fc_{kwargs['_test_ts']}.tar.zst"
outpath.write_bytes(b"\x28\xb5\x2f\xfd") # zstd magic
from types import SimpleNamespace
return SimpleNamespace(returncode=0, stdout=b"", stderr=b"")
monkeypatch.setattr(backup_mod, "_run_subprocess", fake_run)
result = backup_mod.create_backup(
db_url="postgresql://x", images_root=tmp_path, tag="pre_migration",
)
assert "sql_path" in result
assert "tar_path" in result
assert result["tag"] == "pre_migration"
assert any("pg_dump" in c[0] for c in calls)
assert any(c[0] == "tar" for c in calls)
@pytest.mark.asyncio
async def test_find_latest_backup_by_tag(monkeypatch, tmp_path):
# Create two manifests with different tags.
backups_dir = tmp_path / "_backups"
backups_dir.mkdir()
import json
(backups_dir / "fc_20260101T000000Z.json").write_text(json.dumps({
"backup_id": "20260101T000000Z", "tag": "manual",
"created_at": "2026-01-01T00:00:00+00:00",
"sql_path": "/x/a.sql", "tar_path": "/x/a.tar.zst",
}))
(backups_dir / "fc_20260202T000000Z.json").write_text(json.dumps({
"backup_id": "20260202T000000Z", "tag": "pre_migration",
"created_at": "2026-02-02T00:00:00+00:00",
"sql_path": "/x/b.sql", "tar_path": "/x/b.tar.zst",
}))
found = backup_mod.find_latest_backup(tmp_path, tag="pre_migration")
assert found is not None
assert found["backup_id"] == "20260202T000000Z"
missing = backup_mod.find_latest_backup(tmp_path, tag="nonexistent")
assert missing is None
+258
View File
@@ -0,0 +1,258 @@
"""FC-3i: Celery signal → task_run row tests.
Uses task_always_eager so signals fire synchronously in-process during
the test. Scoped autouse fixture keeps the eager mode confined to this
file (other tests may break with eager mode enabled globally).
Test assertions on signal-mutated rows use COLUMN SELECTS / db_sync.
scalar (per reference_async_coredml_test_assertions). ORM attribute
access on a row a signal handler just mutated risks MissingGreenlet
across async/sync boundaries.
"""
import pytest
from sqlalchemy import func, select
from backend.app.celery_app import celery
from backend.app.models import TaskRun
pytestmark = pytest.mark.integration
@pytest.fixture(autouse=True)
def _eager_celery(monkeypatch):
"""task_always_eager so signals fire in-process during the test."""
monkeypatch.setattr(celery.conf, "task_always_eager", True)
monkeypatch.setattr(celery.conf, "task_eager_propagates", False)
@pytest.mark.asyncio
async def test_prerun_inserts_running_row(db_sync):
"""Fire a no-op task; assert task_run row inserted with running status."""
from backend.app.tasks.thumbnail import generate_thumbnail
generate_thumbnail.delay(999_999) # nonexistent image_id → no-op
rows = db_sync.execute(
select(TaskRun.queue, TaskRun.target_id, TaskRun.task_name)
.where(TaskRun.target_id == 999_999)
).all()
assert len(rows) == 1
queue, target_id, task_name = rows[0]
assert queue == "thumbnail"
assert target_id == 999_999
assert task_name.endswith(".generate_thumbnail")
@pytest.mark.asyncio
async def test_postrun_marks_ok_with_duration(db_sync):
from backend.app.tasks.thumbnail import generate_thumbnail
generate_thumbnail.delay(999_998)
row = db_sync.execute(
select(
TaskRun.status, TaskRun.finished_at, TaskRun.duration_ms,
).where(TaskRun.target_id == 999_998)
).one()
status, finished_at, duration_ms = row
assert status == "ok"
assert finished_at is not None
assert duration_ms is not None and duration_ms >= 0
@pytest.mark.asyncio
async def test_failure_records_error_type_and_message(db_sync, monkeypatch):
"""Force a tracked task to raise; assert error fields populated."""
from backend.app.tasks.thumbnail import generate_thumbnail
def _explode(*a, **k):
raise ValueError("synthetic failure")
monkeypatch.setattr(
"backend.app.tasks.thumbnail.generate_thumbnail.run",
_explode,
)
with pytest.raises(ValueError):
generate_thumbnail.delay(999_997).get(propagate=True)
row = db_sync.execute(
select(
TaskRun.status, TaskRun.error_type, TaskRun.error_message,
TaskRun.finished_at,
).where(TaskRun.target_id == 999_997)
.order_by(TaskRun.id.desc()).limit(1)
).one()
status, error_type, error_message, finished_at = row
assert status == "error"
assert error_type == "ValueError"
assert "synthetic failure" in (error_message or "")
assert finished_at is not None
@pytest.mark.asyncio
async def test_soft_time_limit_records_timeout_status(db_sync, monkeypatch):
from celery.exceptions import SoftTimeLimitExceeded
from backend.app.tasks.thumbnail import generate_thumbnail
def _timeout(*a, **k):
raise SoftTimeLimitExceeded("simulated soft limit")
monkeypatch.setattr(
"backend.app.tasks.thumbnail.generate_thumbnail.run",
_timeout,
)
with pytest.raises(SoftTimeLimitExceeded):
generate_thumbnail.delay(999_996).get(propagate=True)
status = db_sync.execute(
select(TaskRun.status).where(TaskRun.target_id == 999_996)
.order_by(TaskRun.id.desc()).limit(1)
).scalar_one()
assert status == "timeout"
@pytest.mark.asyncio
async def test_untracked_celery_internal_tasks_skip(db_sync):
"""celery.chord_unlock is in the untracked list; firing it should
NOT insert a task_run row."""
before = db_sync.execute(select(func.count(TaskRun.id))).scalar_one()
# Send a chord_unlock-style task via apply (no real task to dispatch).
# The signal handler checks task.name against _UNTRACKED_TASK_NAMES;
# if name is in the list, no row is inserted. We verify by firing
# a regular task and asserting only ONE row appears, not two.
from backend.app.tasks.thumbnail import generate_thumbnail
generate_thumbnail.delay(999_995)
after = db_sync.execute(select(func.count(TaskRun.id))).scalar_one()
assert after - before == 1
@pytest.mark.asyncio
async def test_args_summary_truncated_to_255(db_sync, monkeypatch):
"""Task with a very large repr(args); assert args_summary <= 255."""
from backend.app.tasks.thumbnail import generate_thumbnail
# Force a giant arg by patching repr(args) target — actually just
# call with a large int that produces a long repr.
big_id = int("9" * 200) # 200-char repr
# The signal handler reads repr(args), so this naturally produces
# a long args_summary that should get truncated to 255.
try:
generate_thumbnail.delay(big_id).get(timeout=5, propagate=False)
except Exception:
pass
args_summary = db_sync.execute(
select(TaskRun.args_summary)
.where(TaskRun.task_name.endswith(".generate_thumbnail"))
.order_by(TaskRun.id.desc()).limit(1)
).scalar_one()
assert args_summary is None or len(args_summary) <= 255
@pytest.mark.asyncio
async def test_error_message_truncated_to_2000(db_sync, monkeypatch):
from backend.app.tasks.thumbnail import generate_thumbnail
huge_message = "X" * 5000
def _raise_huge(*a, **k):
raise RuntimeError(huge_message)
monkeypatch.setattr(
"backend.app.tasks.thumbnail.generate_thumbnail.run", _raise_huge,
)
with pytest.raises(RuntimeError):
generate_thumbnail.delay(999_994).get(propagate=True)
error_message = db_sync.execute(
select(TaskRun.error_message).where(TaskRun.target_id == 999_994)
.order_by(TaskRun.id.desc()).limit(1)
).scalar_one()
assert error_message is not None
assert len(error_message) <= 2000
@pytest.mark.asyncio
async def test_signal_handler_failure_does_not_break_task(db_sync, monkeypatch):
"""Monkeypatch sync_session_factory in the signals module to raise;
assert the task still returns its result and the worker doesn't
propagate the signal-handler exception."""
from backend.app.tasks.thumbnail import generate_thumbnail
def _broken_factory():
raise RuntimeError("db down")
monkeypatch.setattr(
"backend.app.celery_signals.sync_session_factory", _broken_factory,
)
# Task should still execute (no-op on missing image) without raising.
result = generate_thumbnail.delay(999_993).get(timeout=5)
assert result is not None # task ran; signal handler errors swallowed
@pytest.mark.parametrize("args,expected", [
((42,), 42),
((42, "ignored"), 42),
(("123",), 123),
(("not-int",), None),
((), None),
(None, None),
])
def test_target_id_extracted_from_first_int_arg(args, expected):
"""Unit test the helper directly; no DB needed."""
from backend.app.celery_signals import _target_id_from_args
assert _target_id_from_args(args) == expected
def test_finalize_sets_retry_status_with_retry_count(db_sync):
"""Direct unit-shape test of _finalize's retry path. We can't
reliably trigger Celery's task_retry signal under eager mode with
monkeypatched .run (the bind=True 'self' injection breaks), so
test the finalize helper directly — that's the code the retry
signal handler delegates to anyway."""
from datetime import UTC, datetime
from backend.app.celery_signals import _finalize
from backend.app.models import TaskRun
# Seed a 'running' row that _finalize can target.
row = TaskRun(
celery_task_id="retry-test-tid",
queue="ml",
task_name="backend.app.tasks.fake.t",
target_id=999_992,
started_at=datetime.now(UTC),
status="running",
)
db_sync.add(row)
db_sync.commit()
_finalize(
"retry-test-tid", status="retry",
error_type="RuntimeError",
error_message="first attempt",
retry_count=1,
)
# Column-select assertions — _finalize mutated the row via its own
# session; re-read fresh state through db_sync.
db_sync.expire_all()
status, retry_count, error_type = db_sync.execute(
select(TaskRun.status, TaskRun.retry_count, TaskRun.error_type)
.where(TaskRun.celery_task_id == "retry-test-tid")
).one()
assert status == "retry"
assert retry_count == 1
assert error_type == "RuntimeError"
@pytest.mark.asyncio
async def test_worker_hostname_recorded(db_sync):
"""Sender's hostname should land in worker_hostname."""
from backend.app.tasks.thumbnail import generate_thumbnail
generate_thumbnail.delay(999_991)
hostname = db_sync.execute(
select(TaskRun.worker_hostname).where(TaskRun.target_id == 999_991)
).scalar_one()
# Under eager mode hostname may be None or "celery@local"; just
# assert the column is touched (either string or None — never throws).
assert hostname is None or isinstance(hostname, str)
+158
View File
@@ -0,0 +1,158 @@
"""FC-3k: admin Celery task integration tests.
task_always_eager + signal handlers from FC-3i populate task_run.
We assert the wrapper passes args through correctly and that
task_run lifecycle status flips as expected.
"""
import pytest
from sqlalchemy import func, select
import backend.app.tasks.admin # noqa: F401 — register tasks
from backend.app.celery_app import celery
from backend.app.models import Artist, ImageRecord, TaskRun
pytestmark = pytest.mark.integration
@pytest.fixture(autouse=True)
def _eager_celery(monkeypatch):
monkeypatch.setattr(celery.conf, "task_always_eager", True)
monkeypatch.setattr(celery.conf, "task_eager_propagates", False)
@pytest.fixture(autouse=True)
def fake_images_root(monkeypatch, tmp_path):
monkeypatch.setattr("backend.app.tasks.admin.IMAGES_ROOT", tmp_path)
# --- registration ----------------------------------------------------
def test_delete_artist_cascade_task_registered():
assert (
"backend.app.tasks.admin.delete_artist_cascade_task"
in celery.tasks
)
def test_bulk_delete_images_task_registered():
assert (
"backend.app.tasks.admin.bulk_delete_images_task"
in celery.tasks
)
# --- delete_artist_cascade_task -------------------------------------
@pytest.mark.asyncio
async def test_delete_artist_cascade_task_removes_artist_and_records_ok(
db_sync, tmp_path,
):
from backend.app.tasks.admin import delete_artist_cascade_task
a = Artist(name="Doomed", slug="doomed")
db_sync.add(a)
db_sync.flush()
for i in range(2):
f = tmp_path / f"d{i}.jpg"
f.write_bytes(b"x")
db_sync.add(ImageRecord(
artist_id=a.id, path=str(f),
sha256=f"{i:064x}", size_bytes=10, mime="image/jpeg",
origin="imported_filesystem",
))
db_sync.commit()
artist_id = a.id
result = delete_artist_cascade_task.delay(artist_id=artist_id).get()
assert result["summary"]["images_deleted"] == 2
surviving = db_sync.execute(
select(func.count(Artist.id)).where(Artist.id == artist_id)
).scalar_one()
assert surviving == 0
# FC-3i task_run lifecycle: task should have an 'ok' row.
status = db_sync.execute(
select(TaskRun.status)
.where(TaskRun.task_name.endswith(".delete_artist_cascade_task"))
.order_by(TaskRun.id.desc()).limit(1)
).scalar_one()
assert status == "ok"
@pytest.mark.asyncio
async def test_delete_artist_cascade_task_records_failure(
db_sync, monkeypatch,
):
from backend.app.tasks.admin import delete_artist_cascade_task
def _boom(*a, **kw):
raise RuntimeError("synthetic cascade fail")
monkeypatch.setattr(
"backend.app.services.cleanup_service.delete_artist_cascade", _boom,
)
with pytest.raises(RuntimeError):
delete_artist_cascade_task.delay(artist_id=1).get()
row = db_sync.execute(
select(TaskRun.status, TaskRun.error_message)
.where(TaskRun.task_name.endswith(".delete_artist_cascade_task"))
.order_by(TaskRun.id.desc()).limit(1)
).one()
assert row.status == "error"
assert "synthetic" in (row.error_message or "")
# --- bulk_delete_images_task ----------------------------------------
@pytest.mark.asyncio
async def test_bulk_delete_images_task_removes_listed_images(
db_sync, tmp_path,
):
from backend.app.tasks.admin import bulk_delete_images_task
a = Artist(name="B", slug="b")
db_sync.add(a)
db_sync.flush()
ids = []
for i in range(3):
f = tmp_path / f"b{i}.jpg"
f.write_bytes(b"x")
img = ImageRecord(
artist_id=a.id, path=str(f),
sha256=f"a{i:063x}", size_bytes=10, mime="image/jpeg",
origin="imported_filesystem",
)
db_sync.add(img)
db_sync.flush()
ids.append(img.id)
db_sync.commit()
result = bulk_delete_images_task.delay(image_ids=ids).get()
assert result["images_deleted"] == 3
surviving = db_sync.execute(
select(func.count(ImageRecord.id))
.where(ImageRecord.id.in_(ids))
).scalar_one()
assert surviving == 0
@pytest.mark.asyncio
async def test_bulk_delete_images_task_idempotent_on_empty_list(db_sync):
from backend.app.tasks.admin import bulk_delete_images_task
result = bulk_delete_images_task.delay(image_ids=[]).get()
assert result["images_deleted"] == 0
@pytest.mark.asyncio
async def test_bulk_delete_images_task_reports_missing_ids(db_sync):
from backend.app.tasks.admin import bulk_delete_images_task
result = bulk_delete_images_task.delay(
image_ids=[9_999_998, 9_999_999],
).get()
assert result["images_deleted"] == 0
assert sorted(result["missing_ids"]) == [9_999_998, 9_999_999]
+281
View File
@@ -0,0 +1,281 @@
"""FC-3h: backup/restore Celery task integration tests.
Uses task_always_eager for synchronous in-test execution.
Subprocess + IMAGES_ROOT are monkeypatched so tests don't touch
real /images or shell out to pg_dump.
"""
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from sqlalchemy import select
from backend.app.celery_app import celery
from backend.app.models import BackupRun, ImportSettings
pytestmark = pytest.mark.integration
@pytest.fixture(autouse=True)
def _eager_celery(monkeypatch):
monkeypatch.setattr(celery.conf, "task_always_eager", True)
monkeypatch.setattr(celery.conf, "task_eager_propagates", False)
@pytest.fixture(autouse=True)
def fake_subprocess_and_images_root(monkeypatch, tmp_path):
monkeypatch.setattr("backend.app.tasks.backup.IMAGES_ROOT", tmp_path)
monkeypatch.setattr(
"backend.app.services.backup_service._DB_SUBPROCESS_TIMEOUT_S", 5,
)
class _FakeProc:
returncode = 0
stdout = b""
stderr = b""
def _fake_run(cmd, **kwargs):
if cmd[0] == "pg_dump":
i = cmd.index("-f")
Path(cmd[i + 1]).write_bytes(b"-- fake pg_dump\n")
elif cmd[0] == "tar" and "-cf" in cmd:
i = cmd.index("-cf")
Path(cmd[i + 1]).write_bytes(b"fake tar payload")
return _FakeProc()
monkeypatch.setattr("subprocess.run", _fake_run)
def _seed_backup(db_sync, *, kind, status, started_at, tag=None,
finished_at=None):
row = BackupRun(
kind=kind, status=status, tag=tag,
triggered_by="manual", started_at=started_at,
finished_at=finished_at or (started_at + timedelta(seconds=10)),
sql_path="/tmp/fake.sql" if kind == "db" else None,
tar_path="/tmp/fake.tar.zst" if kind == "images" else None,
manifest={},
)
db_sync.add(row)
db_sync.flush()
return row.id
# --- backup_db_task --------------------------------------------------
@pytest.mark.asyncio
async def test_backup_db_task_creates_backup_run_row_status_ok(db_sync):
from backend.app.tasks.backup import backup_db_task
result = backup_db_task.delay(tag=None, triggered_by="manual").get()
run_id = result["backup_run_id"]
row = db_sync.execute(
select(
BackupRun.kind, BackupRun.status, BackupRun.sql_path,
BackupRun.size_bytes, BackupRun.finished_at, BackupRun.error,
).where(BackupRun.id == run_id)
).one()
assert row.kind == "db"
assert row.status == "ok"
assert row.sql_path and row.sql_path.endswith(".sql")
assert row.size_bytes is not None and row.size_bytes > 0
assert row.finished_at is not None
assert row.error is None
@pytest.mark.asyncio
async def test_backup_db_task_records_failure_on_subprocess_error(db_sync, monkeypatch):
from backend.app.tasks.backup import backup_db_task
def _boom(*a, **kw):
raise RuntimeError("synthetic pg_dump fail")
monkeypatch.setattr("subprocess.run", _boom)
with pytest.raises(RuntimeError):
backup_db_task.delay().get()
row = db_sync.execute(
select(BackupRun.status, BackupRun.error)
.where(BackupRun.kind == "db")
.order_by(BackupRun.id.desc()).limit(1)
).one()
assert row.status == "error"
assert "synthetic" in (row.error or "")
@pytest.mark.asyncio
async def test_backup_db_task_persists_tag(db_sync):
from backend.app.tasks.backup import backup_db_task
result = backup_db_task.delay(tag="pre-cutover").get()
tag = db_sync.execute(
select(BackupRun.tag).where(BackupRun.id == result["backup_run_id"])
).scalar_one()
assert tag == "pre-cutover"
# --- backup_images_task ---------------------------------------------
@pytest.mark.asyncio
async def test_backup_images_task_creates_backup_run(db_sync):
from backend.app.tasks.backup import backup_images_task
result = backup_images_task.delay().get()
row = db_sync.execute(
select(BackupRun.kind, BackupRun.status, BackupRun.tar_path)
.where(BackupRun.id == result["backup_run_id"])
).one()
assert row.kind == "images"
assert row.status == "ok"
assert row.tar_path and row.tar_path.endswith(".tar.zst")
# --- restore_db_task ------------------------------------------------
@pytest.mark.asyncio
async def test_restore_db_task_creates_restoring_marker_then_restored(db_sync):
from backend.app.tasks.backup import backup_db_task, restore_db_task
src = backup_db_task.delay().get()
src_id = src["backup_run_id"]
restore_db_task.delay(source_backup_run_id=src_id).get()
rows = db_sync.execute(
select(BackupRun.status, BackupRun.triggered_by)
.where(BackupRun.restored_from_id == src_id)
).all()
assert len(rows) == 1
assert rows[0].status == "restored"
assert rows[0].triggered_by == "restore"
@pytest.mark.asyncio
async def test_restore_db_task_rejects_non_db_source(db_sync):
from backend.app.tasks.backup import restore_db_task
now = datetime.now(UTC)
img_id = _seed_backup(db_sync, kind="images", status="ok", started_at=now)
db_sync.commit()
with pytest.raises(ValueError):
restore_db_task.delay(source_backup_run_id=img_id).get()
# --- prune_backups --------------------------------------------------
def test_prune_backups_keeps_last_n_untagged_per_kind(db_sync):
from backend.app.tasks.backup import prune_backups
s = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
s.backup_db_keep_last_n = 2
s.backup_images_keep_last_n = 1
db_sync.commit()
now = datetime.now(UTC)
for i in range(4):
_seed_backup(db_sync, kind="db", status="ok",
started_at=now - timedelta(hours=i))
for i in range(3):
_seed_backup(db_sync, kind="images", status="ok",
started_at=now - timedelta(hours=i))
db_sync.commit()
result = prune_backups.apply().get()
assert result["db_deleted"] == 2
assert result["images_deleted"] == 2
def test_prune_backups_protects_tagged_rows(db_sync):
from backend.app.tasks.backup import prune_backups
s = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
s.backup_db_keep_last_n = 1
db_sync.commit()
now = datetime.now(UTC)
_seed_backup(db_sync, kind="db", status="ok", started_at=now)
tagged_id = _seed_backup(
db_sync, kind="db", status="ok",
started_at=now - timedelta(days=30), tag="forever",
)
_seed_backup(db_sync, kind="db", status="ok",
started_at=now - timedelta(days=1))
db_sync.commit()
prune_backups.apply().get()
surviving_tag = db_sync.execute(
select(BackupRun.tag).where(BackupRun.id == tagged_id)
).scalar_one_or_none()
assert surviving_tag == "forever"
def test_prune_backups_never_deletes_running_or_restoring(db_sync):
from backend.app.tasks.backup import prune_backups
s = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
s.backup_db_keep_last_n = 0
db_sync.commit()
ancient = datetime.now(UTC) - timedelta(days=30)
running_id = _seed_backup(db_sync, kind="db", status="running", started_at=ancient)
restoring_id = _seed_backup(db_sync, kind="db", status="restoring", started_at=ancient)
db_sync.commit()
prune_backups.apply().get()
statuses = db_sync.execute(
select(BackupRun.status).where(BackupRun.id.in_([running_id, restoring_id]))
).scalars().all()
assert set(statuses) == {"running", "restoring"}
# --- backup_db_nightly ----------------------------------------------
def test_nightly_skips_when_disabled(db_sync):
from backend.app.tasks.backup import backup_db_nightly
s = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
s.backup_db_nightly_enabled = False
db_sync.commit()
result = backup_db_nightly.apply().get()
assert "skipped" in result and "disabled" in result["skipped"]
def test_nightly_skips_when_hour_mismatch(db_sync):
from backend.app.tasks.backup import backup_db_nightly
s = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
s.backup_db_nightly_enabled = True
s.backup_db_nightly_hour_utc = (datetime.now(UTC).hour + 12) % 24
db_sync.commit()
result = backup_db_nightly.apply().get()
assert "skipped" in result and "hour=" in result["skipped"]
def test_nightly_dispatches_when_enabled_at_configured_hour(db_sync):
from backend.app.tasks.backup import backup_db_nightly
s = db_sync.execute(
select(ImportSettings).where(ImportSettings.id == 1)
).scalar_one()
s.backup_db_nightly_enabled = True
s.backup_db_nightly_hour_utc = datetime.now(UTC).hour
db_sync.commit()
result = backup_db_nightly.apply().get()
assert "dispatched" in result