diff --git a/alembic/versions/0017_fc3h_backup_run.py b/alembic/versions/0017_fc3h_backup_run.py new file mode 100644 index 0000000..5b6a839 --- /dev/null +++ b/alembic/versions/0017_fc3h_backup_run.py @@ -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") diff --git a/alembic/versions/0018_fc3h_backup_settings.py b/alembic/versions/0018_fc3h_backup_settings.py new file mode 100644 index 0000000..517c8f1 --- /dev/null +++ b/alembic/versions/0018_fc3h_backup_settings.py @@ -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") diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 441d58d..a2d743b 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -14,6 +14,7 @@ api_bp.add_url_rule("/health", view_func=health.get_health, methods=["GET"]) def all_blueprints() -> list[Blueprint]: + from .admin import admin_bp from .aliases import aliases_bp from .allowlist import allowlist_bp from .artist import artist_bp @@ -34,6 +35,7 @@ def all_blueprints() -> list[Blueprint]: from .sources import sources_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 return [ api_bp, @@ -46,6 +48,8 @@ def all_blueprints() -> list[Blueprint]: showcase_bp, settings_bp, system_activity_bp, + system_backup_bp, + admin_bp, import_admin_bp, migrate_bp, suggestions_bp, diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py new file mode 100644 index 0000000..cefc181 --- /dev/null +++ b/backend/app/api/admin.py @@ -0,0 +1,200 @@ +"""FC-3k: /api/admin — destructive admin actions. + +Five action surfaces: + POST /api/admin/artists//cascade-delete (Tier C) + POST /api/admin/images/bulk-delete (Tier C) + DELETE /api/admin/tags/ (Tier B) + POST /api/admin/tags//merge (Tier B) + POST /api/admin/tags/prune-unused (Tier A) + GET /api/admin/tags//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//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/", 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//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//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) diff --git a/backend/app/api/migrate.py b/backend/app/api/migrate.py index 81e1239..8a67fc6 100644 --- a/backend/app/api/migrate.py +++ b/backend/app/api/migrate.py @@ -1,14 +1,11 @@ """FC-5: /api/migrate — trigger and poll migration runs. Ingest kinds (gs_ingest, ir_ingest) accept multipart/form-data with an -`export_file` field. All other kinds accept JSON. Apply-without-backup -guard rejects non-dry-run ingests unless a pre_migration-tagged backup -exists in the last 24h (override with body.force=true). +`export_file` field. All other kinds accept JSON. Backup + rollback +were retired in FC-3h (2026-05-24); use /api/system/backup/* instead. """ import json -from datetime import UTC, datetime, timedelta -from pathlib import Path from quart import Blueprint, jsonify, request from sqlalchemy import select @@ -19,17 +16,12 @@ from ..tasks.migration import run_migration migrate_bp = Blueprint("migrate", __name__, url_prefix="/api/migrate") +# 'backup' + 'rollback' retired 2026-05-24 (FC-3h); see /api/system/backup/*. _VALID_KINDS = frozenset({ - "backup", "gs_ingest", "ir_ingest", "tag_apply", - "ml_queue", "verify", "rollback", "cleanup", + "gs_ingest", "ir_ingest", "tag_apply", + "ml_queue", "verify", "cleanup", }) _INGEST_KINDS = frozenset({"gs_ingest", "ir_ingest"}) -# Backup gate retired 2026-05-24 — operator-flagged the speculative-safety -# requirement was actively blocking the UI ingest path (backup itself is -# unreliable on large NFS-backed image libraries) and FC-3h will rewrite -# the backup surface as a first-class feature with its own scheduling -# + recovery. Leaving the constant for historical grep. -_APPLY_KINDS: frozenset[str] = frozenset() def _bad(error: str, *, status: int = 400, **extra): @@ -38,19 +30,6 @@ def _bad(error: str, *, status: int = 400, **extra): 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: return { "id": run.id, @@ -83,7 +62,6 @@ async def create_run(kind: str): except (UnicodeDecodeError, json.JSONDecodeError) as exc: return _bad("invalid_export_file", detail=str(exc)) 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} else: body = await request.get_json() @@ -92,20 +70,8 @@ async def create_run(kind: str): if not isinstance(body, dict): return _bad("invalid_body") dry_run = bool(body.get("dry_run", False)) - force = bool(body.get("force", False)) 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: run = MigrationRun(kind=kind, status="pending", dry_run=dry_run) session.add(run) diff --git a/backend/app/api/system_backup.py b/backend/app/api/system_backup.py new file mode 100644 index 0000000..24fa94e --- /dev/null +++ b/backend/app/api/system_backup.py @@ -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/", 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/", 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//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/", 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() diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index b6ce64c..6ba84cb 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -31,6 +31,8 @@ def make_celery() -> Celery: "backend.app.tasks.migration", "backend.app.tasks.ml", "backend.app.tasks.download", + "backend.app.tasks.backup", + "backend.app.tasks.admin", ], ) app.conf.update( @@ -43,6 +45,8 @@ def make_celery() -> Celery: "backend.app.tasks.scan.*": {"queue": "scan"}, "backend.app.tasks.maintenance.*": {"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. task_acks_late=True, @@ -89,6 +93,14 @@ def make_celery() -> Celery: "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", ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index f7a3c94..0cefb2d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ from .app_setting import AppSetting from .artist import Artist +from .backup_run import BackupRun from .base import Base from .credential import Credential from .download_event import DownloadEvent @@ -27,6 +28,7 @@ __all__ = [ "Base", "AppSetting", "Artist", + "BackupRun", "Source", "Credential", "Post", diff --git a/backend/app/models/backup_run.py b/backend/app/models/backup_run.py new file mode 100644 index 0000000..1717aea --- /dev/null +++ b/backend/app/models/backup_run.py @@ -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, + ) diff --git a/backend/app/models/import_settings.py b/backend/app/models/import_settings.py index fca7f88..323fbf6 100644 --- a/backend/app/models/import_settings.py +++ b/backend/app/models/import_settings.py @@ -49,3 +49,17 @@ class ImportSettings(Base): download_failure_warning_threshold: Mapped[int] = mapped_column( 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, + ) diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py new file mode 100644 index 0000000..cc177b9 --- /dev/null +++ b/backend/app/services/backup_service.py @@ -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 /_backups/. Each backup writes: + fc__.{sql|tar.zst} — the artifact + fc__.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 diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py new file mode 100644 index 0000000..3f749ad --- /dev/null +++ b/backend/app/services/cleanup_service.py @@ -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//.(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} diff --git a/backend/app/services/migrators/__init__.py b/backend/app/services/migrators/__init__.py index 0a7154c..b7c0908 100644 --- a/backend/app/services/migrators/__init__.py +++ b/backend/app/services/migrators/__init__.py @@ -1,6 +1,10 @@ """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 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. """ diff --git a/backend/app/services/migrators/backup.py b/backend/app/services/migrators/backup.py deleted file mode 100644 index d0bbf72..0000000 --- a/backend/app/services/migrators/backup.py +++ /dev/null @@ -1,146 +0,0 @@ -"""pg_dump + tar.zst-based backup, restorable via pair of subprocess calls. - -Backups live under /_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 .sql, .tar.zst, .json into - /_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"]} diff --git a/backend/app/services/migrators/rollback.py b/backend/app/services/migrators/rollback.py deleted file mode 100644 index 55ae7ef..0000000 --- a/backend/app/services/migrators/rollback.py +++ /dev/null @@ -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 /_backups/" - ) - return backup_mod.restore_backup( - manifest=manifest, db_url=db_url, images_root=images_root, - ) diff --git a/backend/app/tasks/admin.py b/backend/app/tasks/admin.py new file mode 100644 index 0000000..fec90c8 --- /dev/null +++ b/backend/app/tasks/admin.py @@ -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, + ) diff --git a/backend/app/tasks/backup.py b/backend/app/tasks/backup.py new file mode 100644 index 0000000..9d2fefb --- /dev/null +++ b/backend/app/tasks/backup.py @@ -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': ''} or {'dispatched': ''}.""" + 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} diff --git a/backend/app/tasks/migration.py b/backend/app/tasks/migration.py index d08b68b..a59c8b2 100644 --- a/backend/app/tasks/migration.py +++ b/backend/app/tasks/migration.py @@ -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' 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 @@ -20,10 +21,8 @@ from ..celery_app import celery from ..config import get_config from ..models import MigrationRun 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 gs_ingest, ir_ingest, ml_queue, tag_apply, verify -from ..services.migrators import rollback as rollback_mod 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: await _update_run(db, run_id, status="running") try: - if kind == "backup": - manifest = backup_mod.create_backup( - db_url=get_config().database_url_sync, - images_root=IMAGES_ROOT, - tag=params.get("tag", "manual"), + if kind in ("backup", "rollback"): + raise ValueError( + f"kind {kind!r} retired in FC-3h; " + "use /api/system/backup/* instead" ) - 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": fc_crypto = CredentialCrypto(_KEY_PATH) @@ -166,21 +155,6 @@ async def _run_async(run_id: int, kind: str, params: dict) -> dict: ) 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: raise ValueError(f"unknown kind: {kind}") diff --git a/frontend/src/components/artist/ArtistDangerZone.vue b/frontend/src/components/artist/ArtistDangerZone.vue new file mode 100644 index 0000000..478d785 --- /dev/null +++ b/frontend/src/components/artist/ArtistDangerZone.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/frontend/src/components/discovery/TagCard.vue b/frontend/src/components/discovery/TagCard.vue index acb3905..4475aab 100644 --- a/frontend/src/components/discovery/TagCard.vue +++ b/frontend/src/components/discovery/TagCard.vue @@ -44,7 +44,31 @@
{{ card.kind }} - {{ card.image_count }} +
+ {{ card.image_count }} + + + + + + + +
@@ -54,7 +78,7 @@ import { ref } from 'vue' 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 draft = ref('') @@ -106,4 +130,13 @@ function submit() { } .fc-tagcard:hover .fc-tagcard__edit { opacity: .6; } .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; } diff --git a/frontend/src/components/gallery/BulkEditorPanel.vue b/frontend/src/components/gallery/BulkEditorPanel.vue index d7775eb..eef210f 100644 --- a/frontend/src/components/gallery/BulkEditorPanel.vue +++ b/frontend/src/components/gallery/BulkEditorPanel.vue @@ -55,19 +55,44 @@ +
+

Destructive

+ Delete {{ sel.count }} selected +
+
Clear selection
+ + diff --git a/frontend/src/components/settings/BackupCard.vue b/frontend/src/components/settings/BackupCard.vue new file mode 100644 index 0000000..d83fb51 --- /dev/null +++ b/frontend/src/components/settings/BackupCard.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/frontend/src/components/settings/BackupRunsTable.vue b/frontend/src/components/settings/BackupRunsTable.vue new file mode 100644 index 0000000..dc15d24 --- /dev/null +++ b/frontend/src/components/settings/BackupRunsTable.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue index 1ebdd08..e1391df 100644 --- a/frontend/src/components/settings/MaintenancePanel.vue +++ b/frontend/src/components/settings/MaintenancePanel.vue @@ -12,6 +12,8 @@ + + @@ -23,6 +25,8 @@ import CentroidRecomputeCard from './CentroidRecomputeCard.vue' import MLThresholdSliders from './MLThresholdSliders.vue' import AllowlistTable from './AllowlistTable.vue' import AliasTable from './AliasTable.vue' +import BackupCard from './BackupCard.vue' +import TagMaintenanceCard from './TagMaintenanceCard.vue' import BrowserExtensionCard from './BrowserExtensionCard.vue' import LegacyMigrationCard from './LegacyMigrationCard.vue' diff --git a/frontend/src/components/settings/TagMaintenanceCard.vue b/frontend/src/components/settings/TagMaintenanceCard.vue new file mode 100644 index 0000000..7d82f0b --- /dev/null +++ b/frontend/src/components/settings/TagMaintenanceCard.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js new file mode 100644 index 0000000..37eaf8c --- /dev/null +++ b/frontend/src/stores/admin.js @@ -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, + } +}) diff --git a/frontend/src/stores/backup.js b/frontend/src/stores/backup.js new file mode 100644 index 0000000..1bc5c85 --- /dev/null +++ b/frontend/src/stores/backup.js @@ -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, + } +}) diff --git a/frontend/src/views/ArtistView.vue b/frontend/src/views/ArtistView.vue index f985e10..fcc9fee 100644 --- a/frontend/src/views/ArtistView.vue +++ b/frontend/src/views/ArtistView.vue @@ -96,6 +96,12 @@ @open="openImage" /> + + @@ -106,6 +112,7 @@ import { useRoute, useRouter, RouterLink } from 'vue-router' import { useArtistStore } from '../stores/artist.js' import { useModalStore } from '../stores/modal.js' import MasonryGrid from '../components/discovery/MasonryGrid.vue' +import ArtistDangerZone from '../components/artist/ArtistDangerZone.vue' const route = useRoute() const router = useRouter() diff --git a/frontend/src/views/TagsView.vue b/frontend/src/views/TagsView.vue index 212d43a..552c7ea 100644 --- a/frontend/src/views/TagsView.vue +++ b/frontend/src/views/TagsView.vue @@ -27,6 +27,7 @@ @@ -41,6 +42,49 @@ @confirm="confirmMerge" @cancel="pendingMerge = null" /> + + + + Merge “{{ mergeSource?.name }}” into… + +

+ Pick the target tag. Source tag will be deleted; all its + image associations will move to the target. Must be same + kind ({{ mergeSource?.kind }}). +

+ +
+ + + Cancel + Merge + +
+
+ + @@ -48,8 +92,11 @@ import { ref, watch, onMounted, onUnmounted } from 'vue' import { useRouter } from 'vue-router' 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 MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue' +import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue' // Must stay a subset of the backend TagKind enum (character, fandom, // general, series, archive, post, meta, rating). 'fandom' is this @@ -106,6 +153,80 @@ function onManage(id) { function onRead(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. + } +}