Admin IA overhaul, cap-aware autoscaler, optional ml-worker, hardened tag reset #187

Merged
bvandeusen merged 6 commits from dev into main 2026-07-02 18:01:57 -04:00
50 changed files with 1291 additions and 953 deletions
+3 -2
View File
@@ -21,7 +21,7 @@ log = logging.getLogger("fc_agent.app")
# Bump on every agent change. The page embeds this and /status reports it; the UI # Bump on every agent change. The page embeds this and /status reports it; the UI
# warns to reload when they differ — so a stale browser-cached page can't be # warns to reload when they differ — so a stale browser-cached page can't be
# mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.) # mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.)
VERSION = "2026-07-02.4 · bandwidth governor: aggregate download cap (MB/s dial) so the agent can't saturate the desktop's network" VERSION = "2026-07-02.5 · cap-aware autoscaler: downloaders stop growing (and shed) when the bandwidth cap — not concurrency — is the bottleneck"
logbuf.install() logbuf.install()
cfg = Config.from_env() cfg = Config.from_env()
@@ -344,7 +344,8 @@ _PAGE = """<!doctype html><html><head><meta charset=utf-8>
// Auto on → dial reflects the auto-chosen count (read-only); off → manual. // Auto on → dial reflects the auto-chosen count (read-only); off → manual.
if(document.activeElement!==autochk) autochk.checked=!!s.auto if(document.activeElement!==autochk) autochk.checked=!!s.auto
conc.disabled=!!s.auto; conc.style.opacity=s.auto?0.55:1 conc.disabled=!!s.auto; conc.style.opacity=s.auto?0.55:1
conchint.textContent=s.auto?('auto-tuning downloaders to keep the GPU fed · max '+CAP):('manual downloaders · max '+CAP) conchint.textContent=(s.auto?('auto-tuning downloaders to keep the GPU fed · max '+CAP):('manual downloaders · max '+CAP))
+(s.bw_capped?' · holding at the bandwidth cap (more downloaders would not go faster)':'')
if(document.activeElement!==conc) conc.value=s.concurrency if(document.activeElement!==conc) conc.value=s.concurrency
conc.max=CAP conc.max=CAP
// Connection pill + queue come only from the /status poll (the Start/Stop POST // Connection pill + queue come only from the /status poll (the Start/Stop POST
+35 -4
View File
@@ -124,6 +124,17 @@ OCC_LOW = 0.25 # below this = buffer starving → add a downloade
OCC_HIGH = 0.80 # above this = downloaders outpace the GPU OCC_HIGH = 0.80 # above this = downloaders outpace the GPU
UTIL_ALPHA = 0.25 # GPU-util EWMA weight UTIL_ALPHA = 0.25 # GPU-util EWMA weight
UTIL_START = 85 # GPU has headroom below this (gate a 2nd consumer) UTIL_START = 85 # GPU has headroom below this (gate a 2nd consumer)
# Bandwidth-cap awareness (operator 2026-07-02): with the aggregate governor in
# place, the occupancy signal alone would peg downloaders at DL_MAX while the
# CAP — not concurrency — is the real constraint: 8 streams sharing 8 MB/s move
# no more data than 4, they just hold more leases + RAM and stretch every job's
# latency. So growth is gated on the pipe having headroom, and a pipe pinned at
# the cap sheds streams down to BW_MIN_DL (enough overlap to keep the cap
# filled through TTFB + decode gaps). The dead band between the two thresholds
# prevents add/trim flapping.
BW_ADD_HEADROOM = 0.85 # add a downloader only while net < 85% of the cap
BW_TRIM_AT = 0.95 # net ≥ 95% of the cap → shed toward BW_MIN_DL
BW_MIN_DL = 3
VRAM_HI = 0.90 # memory pressure → shed a consumer VRAM_HI = 0.90 # memory pressure → shed a consumer
VRAM_GROW_MAX = 0.82 # don't add a consumer past this VRAM VRAM_GROW_MAX = 0.82 # don't add a consumer past this VRAM
TPUT_ALPHA = 0.5 # throughput EWMA weight TPUT_ALPHA = 0.5 # throughput EWMA weight
@@ -225,6 +236,7 @@ class Worker:
self._jpm = 0.0 self._jpm = 0.0
self._dpm = 0.0 self._dpm = 0.0
self._net_mb_s = 0.0 # smoothed aggregate download rate (UI readout) self._net_mb_s = 0.0 # smoothed aggregate download rate (UI readout)
self._bw_capped = False # autoscaler is holding/shedding at the cap (UI)
self._util_smooth: float | None = None # EWMA GPU util (set by control loop) self._util_smooth: float | None = None # EWMA GPU util (set by control loop)
# Curator queue snapshot, refreshed by a background poller so the UI # Curator queue snapshot, refreshed by a background poller so the UI
# /status read is instant — never an inline curator HTTP call (which # /status read is instant — never an inline curator HTTP call (which
@@ -584,6 +596,7 @@ class Worker:
"transient": self.transient, "transient": self.transient,
"bandwidth_limit_mb_s": round(self.throttle.rate / 1_048_576, 1), "bandwidth_limit_mb_s": round(self.throttle.rate / 1_048_576, 1),
"net_mb_s": round(self._net_mb_s, 1), # observed aggregate rate "net_mb_s": round(self._net_mb_s, 1), # observed aggregate rate
"bw_capped": self._bw_capped, # autoscaler holding at the cap (UI hint)
} }
def _bump(self, *, processed=0, downloaded=0, errors=0, active=0, transient=0): def _bump(self, *, processed=0, downloaded=0, errors=0, active=0, transient=0):
@@ -982,10 +995,19 @@ class Worker:
tick = 0 tick = 0
con_grew = False con_grew = False
self._util_smooth = None self._util_smooth = None
self._bw_capped = False
continue continue
occ = self._buffer.qsize() / BUFFER_MAX occ = self._buffer.qsize() / BUFFER_MAX
occ_ewma = _ewma(occ_ewma, occ, OCC_ALPHA) occ_ewma = _ewma(occ_ewma, occ, OCC_ALPHA)
# Bandwidth-cap position: compare the observed aggregate (the same
# EWMA the UI shows) against the governor's cap. `soft` gates
# growth; `hard` sheds streams (see BW_* rationale above).
bw_rate = self.throttle.rate
net_bytes = self._net_mb_s * 1_048_576
bw_soft = bw_rate > 0 and net_bytes >= BW_ADD_HEADROOM * bw_rate
bw_hard = bw_rate > 0 and net_bytes >= BW_TRIM_AT * bw_rate
self._bw_capped = bw_soft
g = gpumod.read_gpu() or {} g = gpumod.read_gpu() or {}
mt = g.get("mem_total_mb") or 0 mt = g.get("mem_total_mb") or 0
vram = (g.get("mem_used_mb", 0) / mt) if mt else 0.0 vram = (g.get("mem_used_mb", 0) / mt) if mt else 0.0
@@ -1022,8 +1044,15 @@ class Worker:
self._apply_downloaders(-1) self._apply_downloaders(-1)
con_grew = False con_grew = False
elif occ_ewma < OCC_LOW: elif occ_ewma < OCC_LOW:
# Buffer starving → GPU idle waiting on downloads → add a feeder. # Buffer starving → downloads are the bottleneck. WHICH kind
self._apply_downloaders(+1) # decides the move: a pipe pinned at the bandwidth cap gains
# nothing from more streams (they'd split the same budget and
# stretch per-job latency) — shed toward BW_MIN_DL; with cap
# headroom, concurrency is genuinely short — add a feeder.
if bw_hard and self._dl_target > BW_MIN_DL:
self._apply_downloaders(-1)
elif not bw_soft:
self._apply_downloaders(+1)
con_grew = False con_grew = False
elif occ_ewma > OCC_HIGH: elif occ_ewma > OCC_HIGH:
# Downloaders outpace the GPU. Prefer helping the GPU (add a 2nd # Downloaders outpace the GPU. Prefer helping the GPU (add a 2nd
@@ -1049,7 +1078,9 @@ class Worker:
if self._dl_target != d0 or self._consumer_target != c0: if self._dl_target != d0 or self._consumer_target != c0:
log.info( log.info(
"autoscale: dl %d%d · consumers %d%d " "autoscale: dl %d%d · consumers %d%d "
"(buf %d%% · util~%d%% · %.2f j/s · vram %d%%)", "(buf %d%% · util~%d%% · %.2f j/s · vram %d%% · "
"net %.1f MB/s%s)",
d0, self._dl_target, c0, self._consumer_target, d0, self._dl_target, c0, self._consumer_target,
round(occ_ewma * 100), round(util_ewma), tput_ewma, round(occ_ewma * 100), round(util_ewma), tput_ewma,
round(vram * 100)) round(vram * 100), self._net_mb_s,
" — at cap" if bw_soft else "")
@@ -0,0 +1,35 @@
"""ml_settings.cpu_embed_enabled — the CPU embed fallback becomes a switch
B3 (operator 2026-07-02): the ml-worker's only processing role is the CPU
whole-image embed for stacks without a GPU agent. ON by default (a fresh
install works agent-less); agent-equipped stacks that drop the ml-worker
container turn it off so import hooks stop queueing embed work into a queue
nothing consumes — the daily GPU 'embed' backfill covers those images.
Revision ID: 0074
Revises: 0073
Create Date: 2026-07-02
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0074"
down_revision: Union[str, None] = "0073"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"ml_settings",
sa.Column(
"cpu_embed_enabled", sa.Boolean(), nullable=False,
server_default=sa.true(),
),
)
def downgrade() -> None:
op.drop_column("ml_settings", "cpu_embed_enabled")
+38 -8
View File
@@ -276,18 +276,48 @@ async def posts_reconcile_duplicates():
return await _run_dry_run_op(reconcile_duplicate_posts, source_id=source_id) return await _run_dry_run_op(reconcile_duplicate_posts, source_id=source_id)
def _reset_content_confirm_token(projection: dict) -> str:
"""Stable 8-hex token derived from the live counts (mirrors the Tier-C
bulk-delete token): it changes whenever the data changes, so the apply can
only ever run against numbers the operator just previewed."""
canon = f"reset-content:{projection.get('count')}:{projection.get('applications')}"
return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:8]
@admin_bp.route("/tags/reset-content", methods=["POST"]) @admin_bp.route("/tags/reset-content", methods=["POST"])
async def tags_reset_content(): async def tags_reset_content():
"""Tier-A: delete ALL general + character tags (the Camie-suggestable """Full-instance reset of the CONTENT vocabulary: deletes ALL general +
content vocabulary) so the operator can re-tag from scratch via character tags and their image applications — INCLUDING the examples the
auto-suggest. fandom + series tags + series_page ordering are preserved, tagging heads learned from. Suggestions do NOT repopulate on their own
and image_prediction rows are untouched so suggestions repopulate. (the Camie predictions that once did are long retired): the operator
dry-run preview returns per-kind counts + applications + a sample so the re-tags from scratch and the heads retrain from the new signal. fandom +
UI shows exactly what'll go before the operator confirms (dry_run=false). series tags + series_page ordering are preserved.
Irreversible except via DB backup restore."""
Deliberately Tier-C-gated despite the Tier-A shape (operator 2026-07-02:
the full reset stays, but behind extra steps): dry_run returns the
projection + a `confirm` token derived from the live counts; the apply
must echo that token back or it is rejected."""
from ..services.cleanup_service import reset_content_tagging from ..services.cleanup_service import reset_content_tagging
return await _run_dry_run_op(reset_content_tagging) body = await request.get_json(silent=True) or {}
dry_run = bool(body.get("dry_run", False))
async with get_session() as session:
projection = await session.run_sync(
lambda s: reset_content_tagging(s, dry_run=True)
)
token = _reset_content_confirm_token(projection)
if dry_run:
projection["confirm"] = token
return jsonify(projection)
if str(body.get("confirm", "")) != token:
return _bad(
"confirm_mismatch",
detail="run a fresh preview and echo its confirm token",
)
result = await session.run_sync(
lambda s: reset_content_tagging(s, dry_run=False)
)
return jsonify(result)
@admin_bp.route("/tags/normalize", methods=["POST"]) @admin_bp.route("/tags/normalize", methods=["POST"])
+2 -2
View File
@@ -96,7 +96,7 @@ async def backfill():
"""Enqueue a job for every image that doesn't already have one for `task`.""" """Enqueue a job for every image that doesn't already have one for `task`."""
body = await request.get_json(silent=True) or {} body = await request.get_json(silent=True) or {}
task = str(body.get("task") or "ccip") task = str(body.get("task") or "ccip")
from ..tasks.ml import enqueue_gpu_backfill from ..tasks.gpu_queue import enqueue_gpu_backfill
r = enqueue_gpu_backfill.delay(task) r = enqueue_gpu_backfill.delay(task)
return jsonify({"celery_task_id": r.id, "task": task}), 202 return jsonify({"celery_task_id": r.id, "task": task}), 202
@@ -109,7 +109,7 @@ async def reprocess():
detectors). Heavy — the back-catalogue is otherwise skipped by the backfills.""" detectors). Heavy — the back-catalogue is otherwise skipped by the backfills."""
body = await request.get_json(silent=True) or {} body = await request.get_json(silent=True) or {}
task = str(body.get("task") or "ccip") task = str(body.get("task") or "ccip")
from ..tasks.ml import reprocess_gpu_jobs from ..tasks.gpu_queue import reprocess_gpu_jobs
r = reprocess_gpu_jobs.delay(task) r = reprocess_gpu_jobs.delay(task)
return jsonify({"celery_task_id": r.id, "task": task}), 202 return jsonify({"celery_task_id": r.id, "task": task}), 202
+2
View File
@@ -9,6 +9,7 @@ ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
_EDITABLE = ( _EDITABLE = (
"cpu_embed_enabled",
"video_frame_interval_seconds", "video_frame_interval_seconds",
"video_max_frames", "video_max_frames",
"head_min_positives", "head_min_positives",
@@ -63,6 +64,7 @@ async def get_settings():
).scalar_one() ).scalar_one()
return jsonify( return jsonify(
{ {
"cpu_embed_enabled": s.cpu_embed_enabled,
"video_frame_interval_seconds": s.video_frame_interval_seconds, "video_frame_interval_seconds": s.video_frame_interval_seconds,
"video_max_frames": s.video_max_frames, "video_max_frames": s.video_max_frames,
"embedder_model_version": s.embedder_model_version, "embedder_model_version": s.embedder_model_version,
+11 -5
View File
@@ -7,7 +7,7 @@ Queues:
download — gallery-dl tasks (FC-3) download — gallery-dl tasks (FC-3)
scan — periodic source checks (FC-3) — kept separate so long imports scan — periodic source checks (FC-3) — kept separate so long imports
don't starve the scheduler don't starve the scheduler
maintenance — pHash recomputation, centroid rebuild, etc. (FC-2/FC-3) maintenance — recovery sweeps, pHash backfill, GPU-queue coordination, etc.
default — anything not explicitly routed default — anything not explicitly routed
""" """
@@ -29,6 +29,7 @@ def make_celery() -> Celery:
"backend.app.tasks.thumbnail", "backend.app.tasks.thumbnail",
"backend.app.tasks.maintenance", "backend.app.tasks.maintenance",
"backend.app.tasks.ml", "backend.app.tasks.ml",
"backend.app.tasks.gpu_queue",
"backend.app.tasks.download", "backend.app.tasks.download",
"backend.app.tasks.external", "backend.app.tasks.external",
"backend.app.tasks.backup", "backend.app.tasks.backup",
@@ -41,6 +42,11 @@ def make_celery() -> Celery:
task_routes={ task_routes={
"backend.app.tasks.import_file.*": {"queue": "import"}, "backend.app.tasks.import_file.*": {"queue": "import"},
"backend.app.tasks.ml.*": {"queue": "ml"}, "backend.app.tasks.ml.*": {"queue": "ml"},
# GPU-queue coordination (backfill enqueues, orphan recovery,
# reprocess) is pure DB work — it rides the maintenance quick lane
# so the GPU agent pipeline works even on stacks that drop the
# (now-optional, B3) ml-worker container entirely.
"backend.app.tasks.gpu_queue.*": {"queue": "maintenance"},
"backend.app.tasks.thumbnail.*": {"queue": "thumbnail"}, "backend.app.tasks.thumbnail.*": {"queue": "thumbnail"},
"backend.app.tasks.download.*": {"queue": "download"}, "backend.app.tasks.download.*": {"queue": "download"},
# External file-host fetches are downloads — same lane (they can run # External file-host fetches are downloads — same lane (they can run
@@ -106,7 +112,7 @@ def make_celery() -> Celery:
"schedule": 86400.0, # no-op unless head_auto_apply_enabled "schedule": 86400.0, # no-op unless head_auto_apply_enabled
}, },
"recover-orphaned-gpu-jobs": { "recover-orphaned-gpu-jobs": {
"task": "backend.app.tasks.ml.recover_orphaned_gpu_jobs", "task": "backend.app.tasks.gpu_queue.recover_orphaned_gpu_jobs",
"schedule": 60.0, # quick pickup of work a dead agent orphaned "schedule": 60.0, # quick pickup of work a dead agent orphaned
}, },
"triage-gpu-errors": { "triage-gpu-errors": {
@@ -114,17 +120,17 @@ def make_celery() -> Celery:
"schedule": 900.0, # probe errored jobs' files → defect/file_ok "schedule": 900.0, # probe errored jobs' files → defect/file_ok
}, },
"enqueue-ccip-backfill-hourly": { "enqueue-ccip-backfill-hourly": {
"task": "backend.app.tasks.ml.enqueue_gpu_backfill", "task": "backend.app.tasks.gpu_queue.enqueue_gpu_backfill",
"schedule": 3600.0, # auto-feed NEW images; errored are "schedule": 3600.0, # auto-feed NEW images; errored are
"args": ("ccip",), # tombstoned — retry is the button only "args": ("ccip",), # tombstoned — retry is the button only
}, },
"enqueue-siglip-backfill-daily": { "enqueue-siglip-backfill-daily": {
"task": "backend.app.tasks.ml.enqueue_gpu_backfill", "task": "backend.app.tasks.gpu_queue.enqueue_gpu_backfill",
"schedule": 86400.0, # drain the concept-crop back-catalogue "schedule": 86400.0, # drain the concept-crop back-catalogue
"args": ("siglip",), # (errored are tombstoned, not retried) "args": ("siglip",), # (errored are tombstoned, not retried)
}, },
"enqueue-embed-backfill-daily": { "enqueue-embed-backfill-daily": {
"task": "backend.app.tasks.ml.enqueue_gpu_backfill", "task": "backend.app.tasks.gpu_queue.enqueue_gpu_backfill",
"schedule": 86400.0, # whole-image re-embed under the current "schedule": 86400.0, # whole-image re-embed under the current
"args": ("embed",), # model (an operator swap) drains via agent "args": ("embed",), # model (an operator swap) drains via agent
}, },
+9
View File
@@ -23,6 +23,15 @@ class MLSettings(Base):
__table_args__ = (CheckConstraint("id = 1", name="singleton"),) __table_args__ = (CheckConstraint("id = 1", name="singleton"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
# CPU whole-image embedding (B3, operator 2026-07-02). The ml-worker's ONLY
# processing role is the embed fallback for stacks WITHOUT a GPU agent — ON
# by default so a fresh install works with no agent. Stacks that run the
# agent and drop the ml-worker container turn this OFF so import hooks stop
# queueing embed work nothing will consume (the daily GPU 'embed' backfill
# covers those images instead).
cpu_embed_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
)
# Video embedding (#747). Sample one frame every N seconds (fixed CADENCE, not # Video embedding (#747). Sample one frame every N seconds (fixed CADENCE, not
# a fixed count) so coverage reflects real screen time regardless of length; # a fixed count) so coverage reflects real screen time regardless of length;
# cap the total so a long video can't explode into hundreds of embeds. The # cap the total so a long video can't explode into hundreds of embeds. The
+5 -4
View File
@@ -1,8 +1,9 @@
"""Single-color audit: matches images where one color dominates beyond """Single-color audit: matches images where one color dominates beyond
the threshold (within the given Euclidean RGB tolerance). The first the threshold (within the given Euclidean RGB tolerance). The canonical
canonical implementation — the import-side filter (SkipReason.single_color) predicate for BOTH surfaces: FC-Cleanup's retroactive audit and — since
was never wired; FC-Cleanup's audit module is the source of truth and a 2026-07-02 — the import-side filter (Importer._single_color_hit /
future spec can adopt it on the import path too. SkipReason.single_color), so what the audit flags and what the import
skips can never disagree.
""" """
from PIL import Image from PIL import Image
+8 -3
View File
@@ -726,7 +726,10 @@ RESETTABLE_TAG_KINDS = ("general", "character")
def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict: def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
"""Count (dry_run) or DELETE every general + character tag so the operator """Count (dry_run) or DELETE every general + character tag so the operator
can re-tag from scratch (heads/CCIP repopulate suggestions). can re-tag from scratch. NB: the deleted applications include the tagging
heads' training positives — suggestions do NOT repopulate on their own; the
heads retrain from whatever the operator re-tags. (The API route gates the
live run behind a preview-derived confirm token for exactly this reason.)
PRESERVED: fandom + series tags and their series_page ordering. CASCADE on PRESERVED: fandom + series tags and their series_page ordering. CASCADE on
image_tag / tag_alias / tag_suggestion_rejection clears each deleted tag's image_tag / tag_alias / tag_suggestion_rejection clears each deleted tag's
@@ -1005,7 +1008,7 @@ def reextract_archive_attachments(
still an archive on disk, so the cursor is what guarantees forward progress. still an archive on disk, so the cursor is what guarantees forward progress.
""" """
from ..models import ImportSettings, Post, PostAttachment, Source from ..models import ImportSettings, Post, PostAttachment, Source
from ..tasks.ml import tag_and_embed from ..tasks.ml import cpu_embed_enabled, embed_image
from ..tasks.thumbnail import generate_thumbnail from ..tasks.thumbnail import generate_thumbnail
from .archive_extractor import is_archive from .archive_extractor import is_archive
from .importer import Importer from .importer import Importer
@@ -1086,10 +1089,12 @@ def reextract_archive_attachments(
# Thumbnails + ML for the newly-imported members (best-effort; off the # Thumbnails + ML for the newly-imported members (best-effort; off the
# critical path — a Redis hiccup must not fail the whole re-extract). # critical path — a Redis hiccup must not fail the whole re-extract).
do_embed = cpu_embed_enabled()
for img_id in enqueue_ids: for img_id in enqueue_ids:
try: try:
generate_thumbnail.delay(img_id) generate_thumbnail.delay(img_id)
tag_and_embed.delay(img_id) if do_embed:
embed_image.delay(img_id)
except Exception as exc: except Exception as exc:
log.warning("re-extract enqueue failed for image %s: %s", img_id, exc) log.warning("re-extract enqueue failed for image %s: %s", img_id, exc)
return summary return summary
+4 -2
View File
@@ -326,14 +326,16 @@ class DownloadService:
# for hours after a download landed. Lazy import to avoid # for hours after a download landed. Lazy import to avoid
# circular-import risk between this service and the # circular-import risk between this service and the
# tasks/* modules that import it. # tasks/* modules that import it.
from ..tasks.ml import tag_and_embed from ..tasks.ml import cpu_embed_enabled, embed_image
from ..tasks.thumbnail import generate_thumbnail from ..tasks.thumbnail import generate_thumbnail
do_embed = cpu_embed_enabled()
ids = list(result.member_image_ids) ids = list(result.member_image_ids)
if result.image_id is not None and result.image_id not in ids: if result.image_id is not None and result.image_id not in ids:
ids.append(result.image_id) ids.append(result.image_id)
for img_id in ids: for img_id in ids:
generate_thumbnail.delay(img_id) generate_thumbnail.delay(img_id)
tag_and_embed.delay(img_id) if do_embed:
embed_image.delay(img_id)
elif result.status == "attached": elif result.status == "attached":
# Non-media or extracted archive captured as PostAttachment # Non-media or extracted archive captured as PostAttachment
# (FC-2d-iii). The canonical copy lives in the attachments # (FC-2d-iii). The canonical copy lives in the attachments
+42
View File
@@ -44,6 +44,7 @@ from ..utils.sidecar import find_sidecar, parse_sidecar
from ..utils.slug import slugify from ..utils.slug import slugify
from .archive_extractor import extract_archive, is_archive from .archive_extractor import extract_archive, is_archive
from .attachment_store import AttachmentStore from .attachment_store import AttachmentStore
from .audits import single_color
from .link_extract import extract_external_links from .link_extract import extract_external_links
from .thumbnailer import Thumbnailer from .thumbnailer import Thumbnailer
@@ -790,6 +791,13 @@ class Importer:
error=f"{pct:.2%} transparent", error=f"{pct:.2%} transparent",
) )
if self.settings.skip_single_color and self._single_color_hit(source):
return ImportResult(
status="skipped", skip_reason=SkipReason.single_color,
error=(f"one color dominates >"
f"{self.settings.single_color_threshold:.0%}"),
)
# Artist anchored to the attribution path (folder→artist), resolved # Artist anchored to the attribution path (folder→artist), resolved
# UP-FRONT so the enrich-on-duplicate branches link provenance with the # UP-FRONT so the enrich-on-duplicate branches link provenance with the
# right artist even when the sidecar carries none — which is now the norm # right artist even when the sidecar carries none — which is now the norm
@@ -1123,6 +1131,13 @@ class Importer:
status="skipped", skip_reason=SkipReason.too_transparent, status="skipped", skip_reason=SkipReason.too_transparent,
error=f"{pct:.2%} transparent", error=f"{pct:.2%} transparent",
) )
if self.settings.skip_single_color and self._single_color_hit(path):
return ImportResult(
status="skipped", skip_reason=SkipReason.single_color,
error=(f"one color dominates >"
f"{self.settings.single_color_threshold:.0%}"),
)
else: else:
# Best-effort probe for dims + duration so downloaded videos can dedup # Best-effort probe for dims + duration so downloaded videos can dedup
# (#871). LENIENT: unlike _import_media this path does not reject on a # (#871). LENIENT: unlike _import_media this path does not reject on a
@@ -1538,6 +1553,33 @@ class Importer:
# Benign orphan; the DB swap already committed. Don't undo it. # Benign orphan; the DB swap already committed. Don't undo it.
pass pass
# Matches the Cleanup audit card's default tolerance: the import-side
# filter and the retroactive audit must agree on what "single color" MEANS
# (Euclidean RGB distance to the dominant color); only the match threshold
# is operator-tunable per surface.
_SINGLE_COLOR_TOLERANCE = 30
def _single_color_hit(self, source: Path) -> bool:
"""True when one color dominates beyond the configured threshold — the
same canonical predicate the Cleanup audit runs (audits.single_color,
whose docstring anticipated this adoption; the skip_single_color
setting existed but was never wired until 2026-07-02). Never raises:
unreadable files were already rejected by verify() upstream, and a
residual decode error just declines to match (the import proceeds)."""
try:
with Image.open(source) as im:
if getattr(im, "is_animated", False):
# Frame 0 only would misjudge animations; skip like the
# transparency check does.
return False
return single_color.evaluate(
im,
threshold=self.settings.single_color_threshold,
tolerance=self._SINGLE_COLOR_TOLERANCE,
)
except Exception:
return False
def _transparency_pct(self, source: Path) -> float: def _transparency_pct(self, source: Path) -> float:
"""Fraction of fully-transparent pixels in the image. 0.0 if no alpha. """Fraction of fully-transparent pixels in the image. 0.0 if no alpha.
+3 -1
View File
@@ -1 +1,3 @@
"""ML pipeline services: tagger, embedder, suggestions, centroids, allowlist, aliases.""" """ML pipeline services: embedders, heads (the learning suggester), suggestions,
GPU-job queue + failure triage, CCIP characters, crops/regions, allowlist and
aliases."""
+4 -2
View File
@@ -216,11 +216,13 @@ def fetch_external_link(self, link_id: int, _serialize_waits: int = 0) -> dict:
# Thumbnails + ML for any newly-attached images (mirrors the download # Thumbnails + ML for any newly-attached images (mirrors the download
# path). Lazy import to dodge a task-module import cycle. # path). Lazy import to dodge a task-module import cycle.
if image_ids: if image_ids:
from .ml import tag_and_embed from .ml import cpu_embed_enabled, embed_image
from .thumbnail import generate_thumbnail from .thumbnail import generate_thumbnail
do_embed = cpu_embed_enabled()
for img_id in image_ids: for img_id in image_ids:
generate_thumbnail.delay(img_id) generate_thumbnail.delay(img_id)
tag_and_embed.delay(img_id) if do_embed:
embed_image.delay(img_id)
return {"link_id": link_id, "files": len(result.files), "images": len(image_ids)} return {"link_id": link_id, "files": len(result.files), "images": len(image_ids)}
except Exception as exc: # never leave a link stuck in 'downloading' except Exception as exc: # never leave a link stuck in 'downloading'
log.exception("external fetch task failed for link %s", link_id) log.exception("external fetch task failed for link %s", link_id)
+171
View File
@@ -0,0 +1,171 @@
"""GPU-job queue coordination: backfill enqueues, orphan recovery, reprocess.
These are pure-DB sweeps (INSERT…SELECT / UPDATE) — no torch, no sklearn —
that keep the desktop GPU agent's work queue fed and self-healing. They lived
in tasks/ml.py (routed to the 'ml' queue) purely by colocation, which made the
ml-worker container a hard dependency of the GPU pipeline; under B3 the
ml-worker is OPTIONAL (its only processing role is the CPU embed fallback), so
these moved here and route to the 'maintenance' quick lane with the other
recovery sweeps. A stack with no ml-worker keeps a fully-working GPU pipeline.
"""
import logging
from sqlalchemy import select
from ..celery_app import celery
from ._sync_engine import sync_session_factory as _sync_session_factory
log = logging.getLogger(__name__)
@celery.task(name="backend.app.tasks.gpu_queue.enqueue_gpu_backfill")
def enqueue_gpu_backfill(task_name: str) -> int:
"""Enqueue a gpu_job for every image that still needs `task_name` (one
INSERT…SELECT, so it scales to a full library). The desktop agent drains the
queue over HTTP. Returns the number enqueued.
Completion is judged PER PIPELINE, never across them (B3, operator
2026-07-02): 'ccip' by prior gpu_job rows, 'siglip' by concept regions at
the current model version, and only 'embed' by image_record's whole-image
embedding — the one artifact the CPU fallback also produces. A CPU embed
therefore never closes crop/detect work for the agent.
An ERRORED job is a tombstone for its (image, task): no variant re-enqueues
it. Retry is deliberate-only (/retry_errors), which also means an errored
back-catalogue needs one "Retry errored jobs" press after a model swap.
Before the tombstone rule, this loop re-minted a fresh doomed job for every
permanently-bad file each run — ~24 duplicate error rows/day per file (the
2026-07-02 "unprocessable" flood)."""
from sqlalchemy import exists, insert, literal, or_
from sqlalchemy import select as sa_select
from ..models import GpuJob, ImageRecord, ImageRegion, MLSettings
from ..services.ml.gpu_jobs import error_dedupe_statements
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
# Prune stale tombstones first (loop-era duplicates + rows made moot by
# a later success), so 'error' reads as one row per distinct failing
# file and the skip-guards below see a clean picture.
pruned = sum(
session.execute(s).rowcount or 0 for s in error_dedupe_statements()
)
if pruned:
log.info("gpu backfill: pruned %d stale/duplicate error rows", pruned)
cur_version = session.execute(
select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
).scalar_one()
if task_name == "embed":
# Whole-image GPU re-embed (#1190): images with no embedding, or one
# stamped under a DIFFERENT model version (an operator model swap).
stale = or_(
ImageRecord.siglip_embedding.is_(None),
ImageRecord.siglip_model_version.is_(None),
ImageRecord.siglip_model_version != cur_version,
)
# 'error' blocks too — tombstone rule, see docstring.
blocked = exists().where(
GpuJob.image_record_id == ImageRecord.id,
GpuJob.task == "embed",
GpuJob.status.in_(["pending", "leased", "error"]),
)
sel = sa_select(
ImageRecord.id, literal("embed"), literal("pending")
).where(stale).where(~blocked)
elif task_name == "siglip":
# Concept-crop re-embed: enqueue when there's no concept region AT THE
# CURRENT model version — so a model swap re-triggers crops too, not
# only the never-embedded back-catalogue.
has_current_concept = exists().where(
ImageRegion.image_record_id == ImageRecord.id,
ImageRegion.kind == "concept",
ImageRegion.embedding_version == cur_version,
)
# 'error' blocks too — tombstone rule, see docstring.
blocked = exists().where(
GpuJob.image_record_id == ImageRecord.id,
GpuJob.task == "siglip",
GpuJob.status.in_(["pending", "leased", "error"]),
)
sel = sa_select(
ImageRecord.id, literal("siglip"), literal("pending")
).where(~has_current_concept).where(~blocked)
else:
# ANY prior row blocks — including 'error' (tombstone rule, see
# docstring): pre-fix this branch ran HOURLY and was the loop.
already = exists().where(
GpuJob.image_record_id == ImageRecord.id,
GpuJob.task == task_name,
GpuJob.status.in_(["pending", "leased", "done", "error"]),
)
sel = sa_select(
ImageRecord.id, literal(task_name), literal("pending")
).where(~already)
# RETURNING + count: result.rowcount is unreliable for INSERT…SELECT.
rows = session.execute(
insert(GpuJob)
.from_select(["image_record_id", "task", "status"], sel)
.returning(GpuJob.id)
).fetchall()
session.commit()
return len(rows)
@celery.task(name="backend.app.tasks.gpu_queue.recover_orphaned_gpu_jobs")
def recover_orphaned_gpu_jobs() -> int:
"""Reset expired GPU-job leases back to pending — recovers work orphaned by an
agent that died mid-job (no graceful release) — and convert poison-loopers
(release/expiry cycles that never reach fail()'s attempt cap) to 'error'.
Statements are shared with GpuJobService.recover_orphaned so the sweep and
the service can't drift. Short beat cadence so orphans get picked back up
quickly + the queue counts read honestly. Returns the number recovered."""
from datetime import UTC, datetime
from ..services.ml.gpu_jobs import recover_statements
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
counts = {
name: session.execute(stmt).rowcount or 0
for name, stmt in recover_statements(datetime.now(UTC)).items()
}
session.commit()
if counts["poison_expired"] or counts["poison_pending"]:
log.warning(
"gpu jobs poisoned -> error: %d crash-loop (expired lease), "
"%d never-complete (pending)",
counts["poison_expired"], counts["poison_pending"],
)
return counts["recovered"]
@celery.task(name="backend.app.tasks.gpu_queue.reprocess_gpu_jobs")
def reprocess_gpu_jobs(task_name: str = "ccip") -> int:
"""Reset every done/error job of `task_name` back to pending so the agent
re-runs the WHOLE library under the CURRENT pipeline — e.g. after adding crop
detectors (#1202), re-cropping existing images. Heavy + operator-triggered;
the back-catalogue won't otherwise re-process (the backfills skip images that
already have current-version regions). Returns the number reset."""
from datetime import UTC, datetime
from sqlalchemy import update
from ..models import GpuJob
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
now = datetime.now(UTC)
res = session.execute(
update(GpuJob)
.where(
GpuJob.task == task_name,
GpuJob.status.in_(["done", "error"]),
)
.values(
status="pending", attempts=0, lease_token=None, leased_at=None,
lease_expires_at=None, updated_at=now,
)
)
session.commit()
return res.rowcount or 0
+4 -2
View File
@@ -228,15 +228,17 @@ def _do_import(session, task, import_task_id: int) -> dict:
# Enqueue thumbnail + ML for newly imported AND superseded images # Enqueue thumbnail + ML for newly imported AND superseded images
# (a superseded row has cleared ML + no thumbnail). # (a superseded row has cleared ML + no thumbnail).
if result.status in ("imported", "superseded"): if result.status in ("imported", "superseded"):
from .ml import tag_and_embed from .ml import cpu_embed_enabled, embed_image
from .thumbnail import generate_thumbnail from .thumbnail import generate_thumbnail
do_embed = cpu_embed_enabled()
ids = list(result.member_image_ids) ids = list(result.member_image_ids)
if result.image_id is not None and result.image_id not in ids: if result.image_id is not None and result.image_id not in ids:
ids.append(result.image_id) ids.append(result.image_id)
for img_id in ids: for img_id in ids:
generate_thumbnail.delay(img_id) generate_thumbnail.delay(img_id)
tag_and_embed.delay(img_id) if do_embed:
embed_image.delay(img_id)
# If this was the last task in the batch, mark the batch complete. # If this was the last task in the batch, mark the batch complete.
remaining = session.execute( remaining = session.execute(
+4 -5
View File
@@ -121,7 +121,7 @@ IMPORT_BATCH_KEEP_DAYS = 30
# task.time_limit + a small buffer. task_name overrides take precedence # task.time_limit + a small buffer. task_name overrides take precedence
# over queue overrides. # over queue overrides.
# #
# ml queue: tag_and_embed video branch (≈20 GPU ops); time_limit=1200. # ml queue: embed_image video branch (≈20 GPU ops); time_limit=1200.
# import_archive_file: shares the 'import' queue with the fast # import_archive_file: shares the 'import' queue with the fast
# single-file import_media_file, so it needs a task-name override # single-file import_media_file, so it needs a task-name override
# (the import queue itself stays at the 5-min default for single # (the import queue itself stays at the 5-min default for single
@@ -139,10 +139,9 @@ QUEUE_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
"download": 30, "download": 30,
# Audit 2026-06-02 — maintenance/scan queues run tasks that # Audit 2026-06-02 — maintenance/scan queues run tasks that
# legitimately exceed the 5-min default (verify_integrity at 70m # legitimately exceed the 5-min default (verify_integrity at 70m
# hard, scan_directory at 70m hard, apply_allowlist_tags / # hard, scan_directory at 70m hard, backfill_phash at 35m hard).
# recompute_centroids / backfill_phash at 35m hard). 75 min lives # 75 min lives above the longest of those and the per-task
# above the longest of those and the per-task overrides below # overrides below cover the outliers (backups, library audit).
# cover the outliers (backups, library audit).
"maintenance": 75, "maintenance": 75,
"scan": 75, "scan": 75,
} }
+51 -166
View File
@@ -1,8 +1,15 @@
"""ML Celery tasks: per-image embedding, backfill discovery, head training, """ML Celery tasks: per-image embedding, backfill discovery, head training,
model self-heal. model self-heal.
All run on the ml-worker (queue 'ml'). Sync sessions (Celery workers are sync All run on the ml-worker (queue 'ml'), which under B3 (2026-07-02) is an
processes), same pattern as FC-2a tasks. OPTIONAL container: its only processing role is the CPU whole-image embed
fallback (gated by ml_settings.cpu_embed_enabled) for stacks without a GPU
agent — plus head training / auto-apply, which need sklearn/numpy and so
live on this image. GPU-queue coordination (backfill enqueues, orphan
recovery, reprocess) deliberately does NOT live here — see tasks/gpu_queue.py
(maintenance lane), so the agent pipeline works with no ml-worker at all.
Sync sessions (Celery workers are sync processes), same pattern as FC-2a
tasks.
""" """
import logging import logging
@@ -26,8 +33,24 @@ def _is_video(path: Path) -> bool:
return path.suffix.lower() in VIDEO_EXTS return path.suffix.lower() in VIDEO_EXTS
def cpu_embed_enabled() -> bool:
"""Dispatch gate for the CPU embed fallback (B3, operator 2026-07-02):
stacks that run a GPU agent and DROP the (optional) ml-worker container
turn ml_settings.cpu_embed_enabled off, so the import hooks stop queueing
embed work into a queue nothing consumes — the daily GPU 'embed' backfill
covers those images instead. Opens its own short session because the four
dispatch sites sit in different session scopes; defaults ON when the
settings row is missing (a fresh install must work agent-less)."""
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
val = session.execute(
select(MLSettings.cpu_embed_enabled).where(MLSettings.id == 1)
).scalar_one_or_none()
return True if val is None else bool(val)
@celery.task( @celery.task(
name="backend.app.tasks.ml.tag_and_embed", name="backend.app.tasks.ml.embed_image",
bind=True, bind=True,
autoretry_for=(OperationalError, DBAPIError, OSError), autoretry_for=(OperationalError, DBAPIError, OSError),
retry_backoff=5, retry_backoff=5,
@@ -44,13 +67,21 @@ def _is_video(path: Path) -> bool:
soft_time_limit=900, # 15 min soft_time_limit=900, # 15 min
time_limit=1200, # 20 min hard time_limit=1200, # 20 min hard
) )
def tag_and_embed(self, image_id: int) -> dict: def embed_image(self, image_id: int) -> dict:
"""Compute + store one image's SigLIP embedding. """Compute + store one image's whole-image SigLIP embedding — the CPU
fallback path (B3, operator 2026-07-02): this is the ml-worker's ONLY
processing role, keeping search/similarity/head-suggestions alive on
deployments without a GPU agent. Detection, cropping and CCIP are
deliberately agent-only, and their backfill predicates read image_region /
gpu_job state — never image_record.siglip_embedding — so a CPU whole-image
embed can NEVER mark crop work as done. (Renamed from tag_and_embed —
Camie tagging was retired #1189; the old name kept implying a tagging step
that no longer exists.)
Video (#747): sample frames at a fixed cadence (ml_settings Video (#747): sample frames at a fixed cadence (ml_settings
video_frame_interval_seconds, capped at video_max_frames) and mean-pool the video_frame_interval_seconds, capped at video_max_frames) and mean-pool the
per-frame SigLIP embeddings. On no-frames returns status='no_frames' (not an per-frame SigLIP embeddings — the same shape as the GPU agent's video
error). (Camie tagging was retired #1189 — heads + CCIP are the tag source.) handling. On no-frames returns status='no_frames' (not an error).
""" """
import time import time
@@ -84,9 +115,9 @@ def tag_and_embed(self, image_id: int) -> dict:
f"image_id={image_id} path={record.path} mime={record.mime} " f"image_id={image_id} path={record.path} mime={record.mime} "
f"bytes={record.size_bytes} video={is_vid}" f"bytes={record.size_bytes} video={is_vid}"
) )
log.info("tag_and_embed start: %s", ctx) log.info("embed_image start: %s", ctx)
if not src.is_file(): if not src.is_file():
log.warning("tag_and_embed file missing on disk: %s", ctx) log.warning("embed_image file missing on disk: %s", ctx)
return {"status": "file_missing", "image_id": image_id} return {"status": "file_missing", "image_id": image_id}
phase = "load_models" phase = "load_models"
@@ -102,7 +133,7 @@ def tag_and_embed(self, image_id: int) -> dict:
vprobe = safe_probe.probe_video(src) vprobe = safe_probe.probe_video(src)
if not vprobe.ok: if not vprobe.ok:
log.warning( log.warning(
"tag_and_embed bad video (%s): %s", vprobe.reason, ctx "embed_image bad video (%s): %s", vprobe.reason, ctx
) )
return { return {
"status": "bad_video", "image_id": image_id, "status": "bad_video", "image_id": image_id,
@@ -130,7 +161,7 @@ def tag_and_embed(self, image_id: int) -> dict:
t0 = time.monotonic() t0 = time.monotonic()
embedding = embedder.infer(src) embedding = embedder.infer(src)
log.info( log.info(
"tag_and_embed embedded in %.1fs: %s", "embed_image embedded in %.1fs: %s",
time.monotonic() - t0, ctx, time.monotonic() - t0, ctx,
) )
@@ -141,7 +172,7 @@ def tag_and_embed(self, image_id: int) -> dict:
session.commit() session.commit()
except SoftTimeLimitExceeded: except SoftTimeLimitExceeded:
log.error( log.error(
"tag_and_embed TIMED OUT after %.0fs in phase=%s: %s", "embed_image TIMED OUT after %.0fs in phase=%s: %s",
_elapsed(), phase, ctx, _elapsed(), phase, ctx,
) )
# Re-raise as SoftTimeLimitExceeded (preserves the 'timeout' status in # Re-raise as SoftTimeLimitExceeded (preserves the 'timeout' status in
@@ -155,12 +186,12 @@ def tag_and_embed(self, image_id: int) -> dict:
# ORIGINAL so the type is preserved; just make sure it's logged with # ORIGINAL so the type is preserved; just make sure it's logged with
# context first. # context first.
log.exception( log.exception(
"tag_and_embed FAILED in phase=%s after %.0fs: %s", "embed_image FAILED in phase=%s after %.0fs: %s",
phase, _elapsed(), ctx, phase, _elapsed(), ctx,
) )
raise raise
log.info("tag_and_embed ok in %.1fs: %s", _elapsed(), ctx) log.info("embed_image ok in %.1fs: %s", _elapsed(), ctx)
return {"status": "ok", "image_id": image_id} return {"status": "ok", "image_id": image_id}
@@ -222,13 +253,17 @@ def _sample_video_frames(
@celery.task(name="backend.app.tasks.ml.backfill", bind=True) @celery.task(name="backend.app.tasks.ml.backfill", bind=True)
def backfill(self) -> int: def backfill(self) -> int:
"""Enqueue tag_and_embed (embed-only) for images with no SigLIP embedding. """Enqueue embed_image (embed-only) for images with no SigLIP embedding.
Keyset pagination by id ASC (restart-safe). Keyset pagination by id ASC (restart-safe).
NB: a siglip MODEL-VERSION mismatch (an operator model swap, #1190) is NOT NB: a siglip MODEL-VERSION mismatch (an operator model swap, #1190) is NOT
re-embedded here — the CPU ml-worker can't churn the library at 384/512px; re-embedded here — the CPU ml-worker can't churn the library at 384/512px;
the GPU agent owns version re-embeds via the 'embed' job. the GPU agent owns version re-embeds via the 'embed' job.
""" """
if not cpu_embed_enabled():
log.info("cpu backfill skipped: cpu_embed_enabled is off (B3 — the "
"GPU 'embed' backfill owns whole-image embeds on this stack)")
return 0
SessionLocal = _sync_session_factory() SessionLocal = _sync_session_factory()
enqueued = 0 enqueued = 0
last_id = 0 last_id = 0
@@ -244,7 +279,7 @@ def backfill(self) -> int:
if not rows: if not rows:
break break
for image_id in rows: for image_id in rows:
tag_and_embed.delay(image_id) embed_image.delay(image_id)
enqueued += 1 enqueued += 1
last_id = rows[-1] last_id = rows[-1]
return enqueued return enqueued
@@ -405,156 +440,6 @@ def scheduled_apply_head_tags() -> str:
return "dispatched" return "dispatched"
@celery.task(name="backend.app.tasks.ml.enqueue_gpu_backfill")
def enqueue_gpu_backfill(task_name: str) -> int:
"""Enqueue a gpu_job for every image that still needs `task_name` (one
INSERT…SELECT, so it scales to a full library). The desktop agent drains the
queue over HTTP. Returns the number enqueued.
'siglip' gates on the RESULT (no concept region yet) rather than on a prior
job, so it picks up the back-catalogue of images that were CCIP-embedded
before concept crops existed — without re-touching their figure/CCIP regions.
An ERRORED job is a tombstone for its (image, task): no variant re-enqueues
it. Retry is deliberate-only (/retry_errors), which also means an errored
back-catalogue needs one "Retry errored jobs" press after a model swap.
Before the tombstone rule, this loop re-minted a fresh doomed job for every
permanently-bad file each run — ~24 duplicate error rows/day per file (the
2026-07-02 "unprocessable" flood)."""
from sqlalchemy import exists, insert, literal, or_
from sqlalchemy import select as sa_select
from ..models import GpuJob, ImageRecord, ImageRegion, MLSettings
from ..services.ml.gpu_jobs import error_dedupe_statements
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
# Prune stale tombstones first (loop-era duplicates + rows made moot by
# a later success), so 'error' reads as one row per distinct failing
# file and the skip-guards below see a clean picture.
pruned = sum(
session.execute(s).rowcount or 0 for s in error_dedupe_statements()
)
if pruned:
log.info("gpu backfill: pruned %d stale/duplicate error rows", pruned)
cur_version = session.execute(
select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
).scalar_one()
if task_name == "embed":
# Whole-image GPU re-embed (#1190): images with no embedding, or one
# stamped under a DIFFERENT model version (an operator model swap).
stale = or_(
ImageRecord.siglip_embedding.is_(None),
ImageRecord.siglip_model_version.is_(None),
ImageRecord.siglip_model_version != cur_version,
)
# 'error' blocks too — tombstone rule, see docstring.
blocked = exists().where(
GpuJob.image_record_id == ImageRecord.id,
GpuJob.task == "embed",
GpuJob.status.in_(["pending", "leased", "error"]),
)
sel = sa_select(
ImageRecord.id, literal("embed"), literal("pending")
).where(stale).where(~blocked)
elif task_name == "siglip":
# Concept-crop re-embed: enqueue when there's no concept region AT THE
# CURRENT model version — so a model swap re-triggers crops too, not
# only the never-embedded back-catalogue.
has_current_concept = exists().where(
ImageRegion.image_record_id == ImageRecord.id,
ImageRegion.kind == "concept",
ImageRegion.embedding_version == cur_version,
)
# 'error' blocks too — tombstone rule, see docstring.
blocked = exists().where(
GpuJob.image_record_id == ImageRecord.id,
GpuJob.task == "siglip",
GpuJob.status.in_(["pending", "leased", "error"]),
)
sel = sa_select(
ImageRecord.id, literal("siglip"), literal("pending")
).where(~has_current_concept).where(~blocked)
else:
# ANY prior row blocks — including 'error' (tombstone rule, see
# docstring): pre-fix this branch ran HOURLY and was the loop.
already = exists().where(
GpuJob.image_record_id == ImageRecord.id,
GpuJob.task == task_name,
GpuJob.status.in_(["pending", "leased", "done", "error"]),
)
sel = sa_select(
ImageRecord.id, literal(task_name), literal("pending")
).where(~already)
# RETURNING + count: result.rowcount is unreliable for INSERT…SELECT.
rows = session.execute(
insert(GpuJob)
.from_select(["image_record_id", "task", "status"], sel)
.returning(GpuJob.id)
).fetchall()
session.commit()
return len(rows)
@celery.task(name="backend.app.tasks.ml.recover_orphaned_gpu_jobs")
def recover_orphaned_gpu_jobs() -> int:
"""Reset expired GPU-job leases back to pending — recovers work orphaned by an
agent that died mid-job (no graceful release) — and convert poison-loopers
(release/expiry cycles that never reach fail()'s attempt cap) to 'error'.
Statements are shared with GpuJobService.recover_orphaned so the sweep and
the service can't drift. Short beat cadence so orphans get picked back up
quickly + the queue counts read honestly. Returns the number recovered."""
from datetime import UTC, datetime
from ..services.ml.gpu_jobs import recover_statements
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
counts = {
name: session.execute(stmt).rowcount or 0
for name, stmt in recover_statements(datetime.now(UTC)).items()
}
session.commit()
if counts["poison_expired"] or counts["poison_pending"]:
log.warning(
"gpu jobs poisoned -> error: %d crash-loop (expired lease), "
"%d never-complete (pending)",
counts["poison_expired"], counts["poison_pending"],
)
return counts["recovered"]
@celery.task(name="backend.app.tasks.ml.reprocess_gpu_jobs")
def reprocess_gpu_jobs(task_name: str = "ccip") -> int:
"""Reset every done/error job of `task_name` back to pending so the agent
re-runs the WHOLE library under the CURRENT pipeline — e.g. after adding crop
detectors (#1202), re-cropping existing images. Heavy + operator-triggered;
the back-catalogue won't otherwise re-process (the backfills skip images that
already have current-version regions). Returns the number reset."""
from datetime import UTC, datetime
from sqlalchemy import update
from ..models import GpuJob
SessionLocal = _sync_session_factory()
with SessionLocal() as session:
now = datetime.now(UTC)
res = session.execute(
update(GpuJob)
.where(
GpuJob.task == task_name,
GpuJob.status.in_(["done", "error"]),
)
.values(
status="pending", attempts=0, lease_token=None, leased_at=None,
lease_expires_at=None, updated_at=now,
)
)
session.commit()
return res.rowcount or 0
@celery.task( @celery.task(
name="backend.app.tasks.ml.scheduled_ccip_auto_apply", name="backend.app.tasks.ml.scheduled_ccip_auto_apply",
soft_time_limit=1800, time_limit=2100, soft_time_limit=1800, time_limit=2100,
@@ -0,0 +1,122 @@
<template>
<MaintenanceTile
icon="mdi-nuke"
title="Reset content tagging (whole instance)"
blurb="Delete ALL general/character tags and their applications — a start-over. Requires a confirmation code."
destructive
>
<p class="text-body-2 mb-2">
Deletes every <code>general</code> and <code>character</code> tag and
removes them from every image <strong>including the examples the
tagging heads learned from</strong>. Suggestions will <strong>not</strong>
repopulate on their own: you re-tag from scratch, and the heads retrain
from your new tags as they accumulate. Fandoms and series (with their
page order) are kept.
</p>
<v-alert type="error" variant="tonal" density="compact" class="mb-3">
Irreversible no undo except restoring a DB backup
(Settings Maintenance Backup). Back one up first.
</v-alert>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingPreview"
class="mb-3"
@click="onPreview"
>Preview content-tag reset</v-btn>
<div v-if="preview">
<p class="text-body-2 mb-2">
<strong>{{ preview.count }}</strong> content tag(s)
<span v-for="(n, k) in preview.by_kind" :key="k" class="fc-muted">
({{ k }}: {{ n }})&nbsp;
</span>
across <strong>{{ preview.applications }}</strong> image
application(s).
</p>
<SampleNameGrid
v-if="preview.sample_names?.length"
:names="preview.sample_names" class="mb-3"
/>
<template v-if="preview.count">
<p class="text-body-2 mb-2">
To arm the reset, type the confirmation code
<code class="fc-code">{{ preview.confirm }}</code> below.
</p>
<div class="d-flex align-center mb-1" style="gap: 12px">
<v-text-field
v-model="typed" density="compact" hide-details variant="outlined"
label="Confirmation code" style="max-width: 200px"
autocomplete="off" spellcheck="false"
/>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-alert"
:disabled="typed !== preview.confirm"
:loading="committing"
@click="onCommit"
>Delete {{ preview.count }} tag(s) +
{{ preview.applications }} application(s)</v-btn>
</div>
<p class="fc-muted text-caption mb-0">
The code is derived from the counts above if tagging changes
between preview and apply, the server rejects the stale code.
</p>
</template>
</div>
</MaintenanceTile>
</template>
<script setup>
import { ref } from 'vue'
import { toast } from '../../utils/toast.js'
import MaintenanceTile from '../common/MaintenanceTile.vue'
import SampleNameGrid from '../common/SampleNameGrid.vue'
import { useAdminStore } from '../../stores/admin.js'
const store = useAdminStore()
const preview = ref(null)
const loadingPreview = ref(false)
const committing = ref(false)
const typed = ref('')
async function onPreview() {
loadingPreview.value = true
typed.value = ''
try {
preview.value = await store.resetContentTagging({ dryRun: true })
} catch (e) {
toast({ text: `Preview failed: ${e.message}`, type: 'error' })
} finally {
loadingPreview.value = false
}
}
async function onCommit() {
committing.value = true
try {
const res = await store.resetContentTagging({
dryRun: false, confirm: typed.value,
})
toast({ text: `Deleted ${res.deleted} content tag(s) — re-tagging starts fresh`, type: 'success' })
preview.value = null
typed.value = ''
} catch (e) {
toast({ text: `Reset rejected: ${e.message}`, type: 'error' })
} finally {
committing.value = false
}
}
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-code {
background: rgb(var(--v-theme-surface-light));
border-radius: 4px; padding: 2px 8px;
font-family: 'JetBrains Mono', monospace; font-weight: 700;
letter-spacing: 0.06em;
}
</style>
@@ -0,0 +1,77 @@
<template>
<v-card class="mb-4">
<CardHeading icon="mdi-download" title="Downloads (last 24h)">
<v-spacer />
<v-btn
variant="text" size="small" rounded="pill"
to="/subscriptions?tab=downloads"
>
Open subscriptions
<v-icon end size="small">mdi-arrow-right</v-icon>
</v-btn>
</CardHeading>
<v-card-text>
<div class="d-flex align-center flex-wrap mb-2" style="gap: 8px">
<v-chip size="small" variant="tonal" color="success">
{{ stats.ok }} ok
</v-chip>
<v-chip
size="small" variant="tonal"
:color="stats.error ? 'error' : undefined"
>
{{ stats.error }} failed
</v-chip>
<v-chip v-if="stats.running" size="small" variant="tonal" color="accent">
{{ stats.running }} running
</v-chip>
<v-chip v-if="stats.pending" size="small" variant="tonal">
{{ stats.pending }} pending
</v-chip>
<v-chip v-if="stats.skipped" size="small" variant="tonal">
{{ stats.skipped }} skipped
</v-chip>
</div>
<p v-if="!failing.length" class="fc-muted text-body-2 mb-0">
All subscription sources healthy.
</p>
<p v-else class="text-body-2 mb-0">
<b class="fc-bad">{{ failing.length }}</b> failing source(s):
<span class="fc-muted">{{ failingNames }}</span>
</p>
</v-card-text>
</v-card>
</template>
<script setup>
import { computed, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import CardHeading from '../common/CardHeading.vue'
import { useDownloadsStore } from '../../stores/downloads.js'
const store = useDownloadsStore()
const { stats, failing } = storeToRefs(store)
let pollId = null
const failingNames = computed(() => {
const names = failing.value.map((s) => s.artist_name || s.url).slice(0, 3)
const extra = failing.value.length - names.length
return names.join(', ') + (extra > 0 ? ` +${extra} more` : '')
})
function poll() {
store.loadStats(24)
store.loadFailing()
}
onMounted(() => {
poll()
pollId = setInterval(() => { if (!document.hidden) poll() }, 30000)
})
onUnmounted(() => { if (pollId) clearInterval(pollId) })
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-bad { color: rgb(var(--v-theme-error)); }
</style>
@@ -0,0 +1,110 @@
<template>
<v-card class="mb-4">
<CardHeading icon="mdi-expansion-card" title="GPU agent pipeline">
<v-spacer />
<v-btn
variant="text" size="small" rounded="pill"
@click="$emit('open-maintenance')"
>
Open maintenance
<v-icon end size="small">mdi-arrow-right</v-icon>
</v-btn>
</CardHeading>
<v-card-text>
<div class="fc-cells mb-2">
<div class="fc-cell">
<div class="fc-cell__n">{{ q.pending }}</div>
<div class="fc-cell__l">pending</div>
</div>
<div class="fc-cell">
<div class="fc-cell__n">{{ q.leased }}</div>
<div class="fc-cell__l">in flight</div>
</div>
<div class="fc-cell">
<div class="fc-cell__n fc-good">{{ q.done }}</div>
<div class="fc-cell__l">done</div>
</div>
<div class="fc-cell">
<div class="fc-cell__n" :class="q.error ? 'fc-bad' : ''">{{ q.error }}</div>
<div class="fc-cell__l">errored</div>
</div>
</div>
<p v-if="!q.error" class="fc-muted text-body-2 mb-0">
No failed jobs the pipeline is clean. Work drains whenever the
desktop agent is running.
</p>
<p v-else class="text-body-2 mb-0">
Triage: <b>{{ triage.defect }}</b> defective file(s) ·
{{ triage.file_ok }} file-ok · {{ triage.unclassified }} unprobed
<span v-if="reasonSummary" class="fc-muted"> {{ reasonSummary }}</span>
<br>
<span class="fc-muted text-caption">
Recover defective files from Maintenance Failed processing.
</span>
</p>
</v-card-text>
</v-card>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import CardHeading from '../common/CardHeading.vue'
import { useGpuStore } from '../../stores/gpu.js'
defineEmits(['open-maintenance'])
const store = useGpuStore()
const q = ref({ pending: 0, leased: 0, done: 0, error: 0 })
const triage = ref({ defect: 0, file_ok: 0, unclassified: 0 })
const byClass = ref({})
let pollId = null
let lastErrorCount = -1
const reasonSummary = computed(() =>
Object.entries(byClass.value)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([k, n]) => `${k.replaceAll('_', ' ')} ${n}`)
.join(' · '))
async function poll() {
try {
q.value = await store.status()
// The triage detail is only worth a second call when the error count
// actually moved (it's a 500-row join server-side).
if (q.value.error !== lastErrorCount) {
lastErrorCount = q.value.error
if (q.value.error > 0) {
const body = await store.errors()
triage.value = body.triage
byClass.value = body.by_class
} else {
triage.value = { defect: 0, file_ok: 0, unclassified: 0 }
byClass.value = {}
}
}
} catch { /* non-fatal — panel just shows the last snapshot */ }
}
onMounted(() => {
poll()
pollId = setInterval(() => { if (!document.hidden) poll() }, 5000)
})
onUnmounted(() => { if (pollId) clearInterval(pollId) })
</script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-cells { display: flex; gap: 28px; }
.fc-cell__n {
font-size: 20px; font-weight: 700; line-height: 1.1;
font-family: 'JetBrains Mono', monospace;
}
.fc-cell__l {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-good { color: rgb(var(--v-theme-success)); }
.fc-bad { color: rgb(var(--v-theme-error)); }
</style>
@@ -2,7 +2,7 @@
<MaintenanceTile <MaintenanceTile
icon="mdi-brain" icon="mdi-brain"
title="Concept heads (the learning suggester)" title="Concept heads (the learning suggester)"
blurb="Train the per-concept heads that turn your tags into suggestions — they replace Camie and sharpen every time you accept or reject." blurb="Train the per-concept heads that turn your tags into suggestions — they learn from your library and sharpen every time you accept or reject."
:open="running" :open="running"
> >
<p class="fc-muted text-body-2 mb-3"> <p class="fc-muted text-body-2 mb-3">
@@ -90,10 +90,14 @@
</template> </template>
<script setup> <script setup>
import { reactive, watch } from 'vue' import { onMounted, reactive, watch } from 'vue'
import { useImportStore } from '../../stores/import.js' import { useImportStore } from '../../stores/import.js'
const store = useImportStore() const store = useImportStore()
// Self-sufficient since the Import tab dissolved (2026-07-02): this form now
// lives in Maintenance → Ingestion & filters and loads its own settings
// instead of relying on the old tab's mount hook.
onMounted(() => { if (!store.settings) store.loadSettings() })
// Labelled stops so the less-initiated get the gist without knowing what a // Labelled stops so the less-initiated get the gist without knowing what a
// Hamming distance is. 0 = byte-for-byte only; 10 = the shipped default. // Hamming distance is. 0 = byte-for-byte only; 10 = the shipped default.
const PHASH_TICKS = { 0: 'Exact', 4: 'Strict', 10: 'Default', 16: 'Loose' } const PHASH_TICKS = { 0: 'Exact', 4: 'Strict', 10: 'Default', 16: 'Loose' }
@@ -1,254 +0,0 @@
<template>
<v-card>
<CardHeading title="Recent import tasks">
<v-spacer />
<v-select
v-model="statusFilter" :items="statusOptions" density="compact"
hide-details style="max-width: 180px;" @update:model-value="onFilterChange"
/>
<v-btn variant="text" rounded="pill" size="small" @click="onRefresh">
<v-icon start>mdi-refresh</v-icon>
Refresh
</v-btn>
<v-btn
variant="text" rounded="pill" size="small" color="warning"
:disabled="!hasFailed" @click="onRetryFailed"
>
Retry failed
</v-btn>
<v-btn
variant="text" rounded="pill" size="small" color="warning"
:disabled="!hasStuck" @click="onClearStuckOpen"
>
Clear stuck
</v-btn>
<v-btn
variant="text" rounded="pill" size="small" color="error"
@click="onClearOpen"
>
Clear completed
</v-btn>
</CardHeading>
<v-data-table-virtual
:headers="headers" :items="store.tasks" :loading="store.tasksLoading"
height="480" density="compact" fixed-header no-data-text="No tasks yet trigger a scan above."
>
<template #item.status="{ item }">
<v-chip :color="statusColor(item.status)" size="small" variant="tonal">
{{ item.status }}
</v-chip>
</template>
<template #item.source_path="{ item }">
<span :title="item.source_path">{{ shorten(item.source_path) }}</span>
</template>
<template #item.size_bytes="{ item }">{{ formatBytes(item.size_bytes) }}</template>
<template #item.created_at="{ item }">{{ formatDate(item.created_at) }}</template>
<template #item.error="{ item }">
<button
v-if="item.error" type="button" class="fc-err-link text-caption"
@click="openError(`Task ${item.id} failed`, item.error)"
title="Click for full error"
>{{ shorten(item.error, 60) }}</button>
</template>
<template #item.actions="{ item }">
<v-btn
v-if="item.status === 'failed'"
icon size="x-small" variant="text"
:loading="refetching === item.id"
@click="onRefetch(item)"
>
<v-icon size="small">mdi-cloud-refresh</v-icon>
<v-tooltip activator="parent" location="top">
Re-fetch original (re-download from source)
</v-tooltip>
</v-btn>
</template>
</v-data-table-virtual>
<div v-if="store.hasMore" class="d-flex justify-center py-3">
<v-btn variant="text" size="small" @click="onLoadMore">Load more</v-btn>
</div>
<v-dialog v-model="clearDialog" max-width="400">
<v-card>
<v-card-title>Clear completed tasks</v-card-title>
<v-card-text>
<v-select
v-model="clearAgeDays" label="Older than"
:items="[
{ title: 'All finished', value: 0 },
{ title: '1 day', value: 1 },
{ title: '7 days', value: 7 },
{ title: '30 days', value: 30 }
]"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="clearDialog = false">Cancel</v-btn>
<v-btn color="error" rounded="pill" @click="onClearConfirm">Clear</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="clearStuckDialog" max-width="480">
<v-card>
<v-card-title>Clear stuck tasks</v-card-title>
<v-card-text>
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
Force every <strong>pending / queued / processing</strong> task to
<strong>failed</strong> and finalize any active batch that
has no remaining work. Use this when the automatic recovery
sweep keeps re-queueing the same row (e.g., corrupt file in
an autoretry loop, or worker model missing).
</v-alert>
<p class="text-body-2">
Tasks remain in the database with status=<code>failed</code>;
click <em>Retry failed</em> once the underlying cause is
resolved to re-queue them.
</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="clearStuckDialog = false">Cancel</v-btn>
<v-btn color="warning" rounded="pill" @click="onClearStuckConfirm">Clear stuck</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<ErrorDetailModal
v-model="showErrorModal"
:title="errorModalTitle"
:message="errorModalMessage"
/>
</v-card>
</template>
<script setup>
import { toast } from '../../utils/toast.js'
import { computed, ref } from 'vue'
import { useImportStore } from '../../stores/import.js'
import ErrorDetailModal from '../common/ErrorDetailModal.vue'
import CardHeading from '../common/CardHeading.vue'
const store = useImportStore()
const statusFilter = ref(null)
const clearDialog = ref(false)
// Click-to-open modal for full error text (operator-flagged 2026-05-26
// — the prior :title="..." tooltip cramped multi-line SQLAlchemy
// tracebacks into an unusable popup with no copy-paste affordance).
const showErrorModal = ref(false)
const errorModalTitle = ref('')
const errorModalMessage = ref('')
function openError(title, message) {
errorModalTitle.value = title
errorModalMessage.value = message || ''
showErrorModal.value = true
}
const clearAgeDays = ref(7)
const clearStuckDialog = ref(false)
const statusOptions = [
{ title: 'All', value: null },
{ title: 'Pending', value: 'pending' },
{ title: 'Queued', value: 'queued' },
{ title: 'Processing', value: 'processing' },
{ title: 'Complete', value: 'complete' },
{ title: 'Skipped', value: 'skipped' },
{ title: 'Failed', value: 'failed' }
]
const headers = [
{ title: 'Status', key: 'status', sortable: false, width: 120 },
{ title: 'Source', key: 'source_path', sortable: false },
{ title: 'Size', key: 'size_bytes', sortable: false, width: 90 },
{ title: 'Created', key: 'created_at', sortable: false, width: 150 },
{ title: 'Note', key: 'error', sortable: false },
{ title: '', key: 'actions', sortable: false, width: 56 }
]
const refetching = ref(null)
const _REFETCH_MSG = {
refetch_queued: { text: 'Re-fetch queued — re-downloading from source', type: 'success' },
no_source: { text: 'No re-fetchable source (filesystem import — replace the file manually)', type: 'info' },
already_refetched: { text: 'Already re-fetched once', type: 'info' },
}
async function onRefetch(item) {
refetching.value = item.id
try {
const res = await store.refetchTask(item.id)
const msg = _REFETCH_MSG[res.status] || { text: `Re-fetch: ${res.status}`, type: 'info' }
toast(msg)
} catch (e) {
toast({ text: `Re-fetch failed: ${e.message}`, type: 'error' })
} finally {
refetching.value = null
}
}
const hasFailed = computed(() => store.tasks.some(t => t.status === 'failed'))
const hasStuck = computed(() => store.tasks.some(
t => t.status === 'pending' || t.status === 'queued' || t.status === 'processing'
))
function statusColor(s) {
return {
complete: 'success',
skipped: 'warning',
failed: 'error',
processing: 'accent',
queued: 'info',
pending: 'info'
}[s] || 'default'
}
function shorten(s, max = 90) {
if (!s) return ''
if (s.length <= max) return s
const head = Math.floor((max - 3) * 0.6)
const tail = max - 3 - head
return s.slice(0, head) + '...' + s.slice(-tail)
}
function formatBytes(b) {
if (!b) return ''
const units = ['B', 'KiB', 'MiB', 'GiB']
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 formatDate(s) {
try { return new Date(s).toLocaleString() } catch { return s }
}
async function onRefresh() { await store.loadTasks(true) }
function onFilterChange() { store.setStatusFilter(statusFilter.value); store.loadTasks(true) }
async function onLoadMore() { await store.loadTasks(false) }
async function onRetryFailed() { await store.retryFailed() }
function onClearOpen() { clearDialog.value = true }
async function onClearConfirm() {
await store.clearCompleted(clearAgeDays.value)
clearDialog.value = false
}
function onClearStuckOpen() { clearStuckDialog.value = true }
async function onClearStuckConfirm() {
await store.clearStuck()
clearStuckDialog.value = false
}
</script>
<style scoped>
.fc-err-link {
/* Truncated error preview as a clickable button — opens
ErrorDetailModal with the full text. Inherits the row's font
sizing so it doesn't visually drift from the prior tooltip-bearing
span. */
color: rgb(var(--v-theme-error, 220 80 80));
background: transparent;
border: 0;
padding: 0;
font: inherit;
text-align: left;
text-decoration: underline dotted;
cursor: pointer;
}
.fc-err-link:hover { text-decoration: underline; }
</style>
@@ -1,97 +0,0 @@
<template>
<v-card>
<v-card-title>Trigger scan</v-card-title>
<v-card-text>
<div v-if="store.activeBatch" class="d-flex align-center mb-3" style="gap: 12px;">
<v-progress-circular
indeterminate color="accent" size="20"
/>
<span>
{{ store.activeBatch.scan_mode === 'deep' ? 'Deep scanning' : 'Scanning' }}
{{ store.activeBatch.source_path || '/import' }}
imported {{ store.activeBatch.imported }},
<template v-if="store.activeBatch.scan_mode === 'deep'">
refreshed {{ store.activeBatch.refreshed || 0 }},
</template>
skipped {{ store.activeBatch.skipped }},
failed {{ store.activeBatch.failed }} /
{{ store.activeBatch.total_files }} files
</span>
<v-spacer />
<v-btn
variant="text" rounded="pill" size="small" color="warning"
:loading="clearing" @click="onClearStuck"
>
Clear stuck
</v-btn>
</div>
<p class="text-body-2 mb-3">
<span v-if="!store.activeBatch">
<strong>Quick scan</strong> walks <code>/import</code> and enqueues
new files only.
<strong>Deep scan</strong> additionally re-walks already-imported
files so updated sidecar metadata (post title/date/attribution) and
previously-NULL phashes / artist links get refreshed. Use after
bulk-downloading fresh sidecars for existing content. Both modes
route non-media + sidecar pairs through PostAttachment capture.
</span>
<span v-else>
An active batch is in progress. Wait for it to finish, or click
<em>Clear stuck</em> above if it has been wedged with no
measurable progress.
</span>
</p>
<div class="d-flex flex-wrap" style="gap: 12px;">
<v-btn
color="primary" rounded="pill"
:disabled="!!store.activeBatch"
:loading="busy === 'quick'"
@click="trigger('quick')"
>
<v-icon start>mdi-magnify-scan</v-icon>
Quick scan
</v-btn>
<v-btn
color="secondary" rounded="pill" variant="tonal"
:disabled="!!store.activeBatch"
:loading="busy === 'deep'"
@click="trigger('deep')"
>
<v-icon start>mdi-magnify-plus-outline</v-icon>
Deep scan
</v-btn>
</div>
<v-alert v-if="store.triggerError" type="error" variant="tonal" class="mt-3" closable>
{{ store.triggerError }}
</v-alert>
</v-card-text>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import { useImportStore } from '../../stores/import.js'
const store = useImportStore()
const busy = ref(null)
const clearing = ref(false)
async function trigger(mode) {
busy.value = mode
try { await store.triggerScan(mode) } catch {} finally { busy.value = null }
}
async function onClearStuck() {
clearing.value = true
try {
await store.clearStuck()
} catch {
// store surfaces error via triggerError if needed
} finally {
clearing.value = false
}
}
</script>
@@ -1,16 +1,33 @@
<template> <template>
<MaintenanceTile <MaintenanceTile
icon="mdi-refresh" icon="mdi-refresh"
title="ML backfill" title="CPU embedding backfill"
blurb="Compute SigLIP embeddings on images missing them." blurb="Whole-image embeddings without a GPU agent — the built-in fallback."
:open="busy" :open="busy"
> >
<p class="text-body-2 mb-3"> <p class="text-body-2 mb-3">
Compute the SigLIP embedding for any image that doesn't have one yet Computes the whole-image SigLIP embedding for anything missing one
(CPU). Safe to re-run. To re-embed under a NEW model, use the GPU images directly, videos by sampling frames (the same approach as the
agent's "Re-embed library" instead. GPU agent). Runs on the ml-worker's CPU, so search, similarity and
head suggestions work <strong>without</strong> a GPU agent; new imports
are embedded this way automatically. Detection, cropping and character
(CCIP) embeddings are GPU-agent-only. Safe to re-run. To re-embed under
a NEW model, use the GPU agent's "Re-embed library" instead.
</p> </p>
<v-btn color="primary" rounded="pill" :loading="busy" @click="run"> <v-switch
v-model="enabled" color="accent" hide-details density="compact"
:loading="saving" label="CPU embedding enabled"
class="mb-1" @update:model-value="onToggle"
/>
<p class="fc-muted text-caption mb-3">
Turn OFF if you run the GPU agent and removed the ml-worker container
imports then stop queueing CPU embed work nothing will consume (the
daily GPU embed backfill covers those images instead).
</p>
<v-btn
color="primary" rounded="pill" :loading="busy" :disabled="!enabled"
@click="run"
>
<v-icon start>mdi-refresh</v-icon> Run backfill now <v-icon start>mdi-refresh</v-icon> Run backfill now
</v-btn> </v-btn>
<span v-if="done" class="ml-3 text-caption">Enqueued.</span> <span v-if="done" class="ml-3 text-caption">Enqueued.</span>
@@ -20,13 +37,40 @@
<script setup> <script setup>
import { toast } from '../../utils/toast.js' import { toast } from '../../utils/toast.js'
import { ref } from 'vue' import { onMounted, ref } from 'vue'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
import MaintenanceTile from '../common/MaintenanceTile.vue' import MaintenanceTile from '../common/MaintenanceTile.vue'
import QueueStatusBar from './QueueStatusBar.vue' import QueueStatusBar from './QueueStatusBar.vue'
const store = useMLStore() const store = useMLStore()
const busy = ref(false) const busy = ref(false)
const done = ref(false) const done = ref(false)
const enabled = ref(true)
const saving = ref(false)
onMounted(async () => {
try {
await store.loadSettings()
if (store.settings?.cpu_embed_enabled != null) {
enabled.value = store.settings.cpu_embed_enabled
}
} catch { /* non-fatal */ }
})
async function onToggle() {
saving.value = true
try {
await store.patchSettings({ cpu_embed_enabled: enabled.value })
toast({
text: enabled.value
? 'CPU embedding on — imports queue embeds for the ml-worker'
: 'CPU embedding off — the GPU embed backfill owns whole-image embeds',
type: 'success',
})
} catch (e) {
toast({ text: `Could not save: ${e.message}`, type: 'error' })
enabled.value = !enabled.value
} finally {
saving.value = false
}
}
async function run() { async function run() {
busy.value = true busy.value = true
try { await store.triggerBackfill(); done.value = true } try { await store.triggerBackfill(); done.value = true }
@@ -34,3 +78,7 @@ async function run() {
finally { busy.value = false } finally { busy.value = false }
} }
</script> </script>
<style scoped>
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
</style>
@@ -1,36 +1,59 @@
<template> <template>
<div class="fc-maint"> <div class="fc-maint">
<p class="fc-muted text-body-2 mb-5"> <p class="fc-muted text-body-2 mb-5">
One-off backfills, tagging config and storage tools. Heads train nightly Processing, tagging and storage tools, grouped by system. Heads train
and auto-apply earned tags. Click a tile to open it. nightly and auto-apply earned tags. Click a tile to open it.
</p> </p>
<section class="fc-section"> <section class="fc-section">
<h3 class="fc-section__title">Backfills &amp; reprocessing</h3> <h3 class="fc-section__title">Ingestion &amp; filters</h3>
<p class="fc-section__hint">Re-run tagging, thumbnails, extraction and DB upkeep.</p> <p class="fc-section__hint">
<div class="fc-tile-grid"> What gets imported dedup sensitivity, size/transparency/solid-color
<MLBackfillCard /> filters. Applies to downloads and folder imports alike.
<ThumbnailBackfillCard /> </p>
<ArchiveReextractCard /> <div class="fc-tile-stack">
<MissingFileRepairCard /> <ImportFiltersForm />
</div>
</section>
<section class="fc-section">
<h3 class="fc-section__title">GPU agent &amp; embeddings</h3>
<p class="fc-section__hint">
The desktop agent that does the heavy lifting, its failure triage, and
the CPU fallback.
</p>
<div class="fc-tile-stack">
<GpuAgentCard />
<GpuTriageCard /> <GpuTriageCard />
<DbMaintenanceCard /> <MLBackfillCard />
</div> </div>
</section> </section>
<section class="fc-section"> <section class="fc-section">
<h3 class="fc-section__title">Tagging</h3> <h3 class="fc-section__title">Tagging</h3>
<p class="fc-section__hint"> <p class="fc-section__hint">
Suggestion thresholds, the auto-apply allowlist and tag aliases. Suggestion thresholds, trained heads and tag aliases.
</p> </p>
<div class="fc-tile-stack"> <div class="fc-tile-stack">
<MLThresholdSliders /> <MLThresholdSliders />
<HeadsCard /> <HeadsCard />
<GpuAgentCard />
<AliasTable /> <AliasTable />
</div> </div>
</section> </section>
<section class="fc-section">
<h3 class="fc-section__title">Library health</h3>
<p class="fc-section__hint">
Self-healing and repair: missing files, thumbnails, database upkeep.
</p>
<div class="fc-tile-grid">
<MissingFileRepairCard />
<ThumbnailBackfillCard />
<DbMaintenanceCard />
<ArchiveReextractCard />
</div>
</section>
<section class="fc-section"> <section class="fc-section">
<h3 class="fc-section__title">Storage</h3> <h3 class="fc-section__title">Storage</h3>
<p class="fc-section__hint">Database + image backups and restore.</p> <p class="fc-section__hint">Database + image backups and restore.</p>
@@ -44,6 +67,7 @@
<script setup> <script setup>
import { onMounted, onUnmounted } from 'vue' import { onMounted, onUnmounted } from 'vue'
import ImportFiltersForm from './ImportFiltersForm.vue'
import MLBackfillCard from './MLBackfillCard.vue' import MLBackfillCard from './MLBackfillCard.vue'
import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue' import ThumbnailBackfillCard from './ThumbnailBackfillCard.vue'
import ArchiveReextractCard from './ArchiveReextractCard.vue' import ArchiveReextractCard from './ArchiveReextractCard.vue'
@@ -17,6 +17,12 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
<!-- The non-Celery halves of the app (2026-07-02): the GPU agent does the
majority of processing and downloads feed the library Activity is
the whole-app pulse, not just the worker queues. -->
<GpuActivityPanel @open-maintenance="$emit('open-maintenance')" />
<DownloadsActivityPanel />
<!-- Recent failures pane --> <!-- Recent failures pane -->
<v-card class="mb-4"> <v-card class="mb-4">
<CardHeading icon="mdi-alert-circle-outline" title="Recent failures (last 24h)"> <CardHeading icon="mdi-alert-circle-outline" title="Recent failures (last 24h)">
@@ -167,6 +173,10 @@ import { formatRelative as fmtRelative } from '../../utils/date.js'
import ErrorDetailModal from '../common/ErrorDetailModal.vue' import ErrorDetailModal from '../common/ErrorDetailModal.vue'
import QueuesTable from './QueuesTable.vue' import QueuesTable from './QueuesTable.vue'
import CardHeading from '../common/CardHeading.vue' import CardHeading from '../common/CardHeading.vue'
import GpuActivityPanel from './GpuActivityPanel.vue'
import DownloadsActivityPanel from './DownloadsActivityPanel.vue'
defineEmits(['open-maintenance'])
// Click-to-open modal for full error text. Replaces the unusable // Click-to-open modal for full error text. Replaces the unusable
// :title="..." tooltip (operator-flagged 2026-05-26: SQLAlchemy // :title="..." tooltip (operator-flagged 2026-05-26: SQLAlchemy
@@ -42,56 +42,6 @@
</div> </div>
</MaintenanceTile> </MaintenanceTile>
<MaintenanceTile
icon="mdi-tag-multiple"
title="Reset content tagging"
blurb="Delete all general/character tags to re-tag from scratch."
destructive
>
<p class="text-body-2 mb-2">
Deletes every <code>general</code> and <code>character</code> tag and
removes them from every image, so you can re-tag from scratch with the
auto-suggest. <strong>Fandoms and series (with their page order) are
kept</strong>, and each image's saved predictions are untouched — open
an image and its suggestions reappear.
</p>
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
Irreversible — there's no undo except restoring a DB backup.
Back one up first (Settings Maintenance Backup).
</v-alert>
<v-btn
color="accent" variant="flat" rounded="pill"
prepend-icon="mdi-magnify"
:loading="loadingResetPreview"
class="mb-3"
@click="onResetPreview"
>Preview content-tag reset</v-btn>
<div v-if="resetPreview">
<p class="text-body-2 mb-2">
<strong>{{ resetPreview.count }}</strong> content tag(s)
<span v-for="(n, k) in resetPreview.by_kind" :key="k" class="fc-muted">
({{ k }}: {{ n }})&nbsp;
</span>
across <strong>{{ resetPreview.applications }}</strong> image
application(s).
</p>
<SampleNameGrid
v-if="resetPreview.sample_names?.length"
:names="resetPreview.sample_names" class="mb-3"
/>
<v-btn
color="error" variant="flat" rounded="pill"
prepend-icon="mdi-delete-alert"
:disabled="!resetPreview.count"
:loading="resetCommitting"
@click="onResetCommit"
>Delete {{ resetPreview.count }} content tag(s) +
{{ resetPreview.applications }} application(s)</v-btn>
</div>
</MaintenanceTile>
<MaintenanceTile <MaintenanceTile
icon="mdi-format-letter-case" icon="mdi-format-letter-case"
title="Standardize tag casing" title="Standardize tag casing"
@@ -169,16 +119,6 @@ const {
emptyPreview: (r) => ({ count: 0, sample_names: r.sample_names || [] }), emptyPreview: (r) => ({ count: 0, sample_names: r.sample_names || [] }),
}) })
// Reset content tagging (general + character).
const {
previewData: resetPreview, previewing: loadingResetPreview,
committing: resetCommitting, runPreview: onResetPreview, runCommit: onResetCommit,
} = usePreviewCommit({
preview: () => store.resetContentTagging({ dryRun: true }),
commit: () => store.resetContentTagging({ dryRun: false }),
emptyPreview: { count: 0, by_kind: {}, applications: 0, sample_names: [] },
})
// Standardize casing. The apply DISPATCHES a self-resuming background task (no // Standardize casing. The apply DISPATCHES a self-resuming background task (no
// poll-until-done — that would falsely report complete after the first chunk), // poll-until-done — that would falsely report complete after the first chunk),
// so there's no emptyPreview: leave the projection up; a truthy normResult means // so there's no emptyPreview: leave the projection up; a truthy normResult means
@@ -52,14 +52,9 @@
{{ String(store.error) }} {{ String(store.error) }}
</v-alert> </v-alert>
<FailingSourcesCard <!-- The failing-sources rollup moved to the Subscriptions landing tab
:sources="store.failing" (needs-attention strip, 2026-07-02); the maintenance menu above keeps
:retrying-ids="sourcesStore.checkingIds" its bulk-retry via the same shared store action. -->
:retrying-all="retryingAll"
@retry="onRetrySource"
@retry-all="onRetryAll"
@view-logs="onViewFailingLogs"
/>
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading"> <div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
<v-progress-circular indeterminate color="accent" size="36" /> <v-progress-circular indeterminate color="accent" size="36" />
@@ -124,21 +119,17 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useDownloadsStore } from '../../stores/downloads.js' import { useDownloadsStore } from '../../stores/downloads.js'
import { useSourcesStore } from '../../stores/sources.js'
import DownloadEventRow from '../downloads/DownloadEventRow.vue' import DownloadEventRow from '../downloads/DownloadEventRow.vue'
import DownloadDetailModal from '../downloads/DownloadDetailModal.vue' import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
import DownloadStatChips from './DownloadStatChips.vue' import DownloadStatChips from './DownloadStatChips.vue'
import DownloadActivitySparkline from './DownloadActivitySparkline.vue' import DownloadActivitySparkline from './DownloadActivitySparkline.vue'
import FailingSourcesCard from './FailingSourcesCard.vue'
import ActiveDownloadsPanel from './ActiveDownloadsPanel.vue' import ActiveDownloadsPanel from './ActiveDownloadsPanel.vue'
import MaintenanceMenu from './MaintenanceMenu.vue' import MaintenanceMenu from './MaintenanceMenu.vue'
import DownloadsFilterPopover from './DownloadsFilterPopover.vue' import DownloadsFilterPopover from './DownloadsFilterPopover.vue'
const route = useRoute() const route = useRoute()
const store = useDownloadsStore() const store = useDownloadsStore()
const sourcesStore = useSourcesStore()
const filterModel = ref({ ...store.filter }) const filterModel = ref({ ...store.filter })
const retryingAll = ref(false)
// Free-text search (client-side over the loaded events) + a toggle to // Free-text search (client-side over the loaded events) + a toggle to
// hide "no-change" scheduled scans (status=ok/skipped with 0 files) so // hide "no-change" scheduled scans (status=ok/skipped with 0 files) so
@@ -175,51 +166,18 @@ async function refresh() {
// rollup + stats so the operator sees it move. Passes force=true so the // rollup + stats so the operator sees it move. Passes force=true so the
// platform cooldown is bypassed — single-source click is an explicit // platform cooldown is bypassed — single-source click is an explicit
// operator override, useful for rapid auth-fix or fixture testing. // operator override, useful for rapid auth-fix or fixture testing.
async function onRetrySource(source) { // Bulk retry for the maintenance menu — the shared store action keeps the
try { // cooldown semantics + tally shape identical to the needs-attention card on
await sourcesStore.checkNow(source.id, { force: true }) // the Subscriptions tab. Toast tallies the three outcomes so the operator
toast({ text: `Retry queued for ${source.artist_name || source.platform}`, type: 'success' }) // can read whether cooldown is the dominant failure mode ("12 deferred
} catch (e) { // (cooldown)" → yes, rate limit is the issue).
if (e?.body?.download_event_id) {
toast({ text: 'Already running — see below', type: 'info' })
} else {
toast({ text: `Retry failed: ${e?.detail || e?.message || e}`, type: 'error' })
}
} finally {
await Promise.all([store.loadFailing(), store.loadStats(24), store.loadFirst()])
}
}
// Bulk retry — leaves cooldown enforcement ON so N failing sources on
// the same platform don't all retry into the rate limit the cooldown is
// preventing. Sources deferred by cooldown will be picked up by the
// next scan tick after the AppSetting expires. Toast tallies the three
// outcomes so the operator can quickly read whether cooldown is the
// dominant failure mode ("12 deferred (cooldown)" → yes, rate limit is
// the issue).
async function onRetryAll(sources) { async function onRetryAll(sources) {
retryingAll.value = true const t = await store.retryAllFailing(sources)
let ok = 0 await store.loadFirst()
let conflict = 0
let deferred = 0
try {
for (const s of sources) {
try {
const body = await sourcesStore.checkNow(s.id)
if (body?.status === 'deferred') deferred += 1
else ok += 1
} catch (e) {
if (e?.body?.download_event_id) conflict += 1
}
}
} finally {
retryingAll.value = false
await Promise.all([store.loadFailing(), store.loadStats(24), store.loadFirst()])
}
const parts = [] const parts = []
if (ok) parts.push(`${ok} queued`) if (t.ok) parts.push(`${t.ok} queued`)
if (deferred) parts.push(`${deferred} deferred (cooldown)`) if (t.deferred) parts.push(`${t.deferred} deferred (cooldown)`)
if (conflict) parts.push(`${conflict} already running`) if (t.conflict) parts.push(`${t.conflict} already running`)
toast({ text: parts.join(', ') || 'Nothing to retry', type: 'info' }) toast({ text: parts.join(', ') || 'Nothing to retry', type: 'info' })
} }
@@ -276,6 +234,16 @@ onMounted(() => {
}) })
onUnmounted(stopPolling) onUnmounted(stopPolling)
// The needs-attention card (Subscriptions tab) deep-links here with
// ?source_id= while this tab may ALREADY be mounted (v-window keeps tabs
// alive) — react to the query, not just the mount.
watch(() => route.query.source_id, (v) => {
if (v) {
filterModel.value = { ...filterModel.value, source_id: Number(v) }
refresh()
}
})
// Client-side date filter on the loaded page (avoids a backend round-trip // Client-side date filter on the loaded page (avoids a backend round-trip
// for the date pickers; the existing /api/downloads endpoint can grow // for the date pickers; the existing /api/downloads endpoint can grow
// these as proper query params later if a UX need shows up). // these as proper query params later if a UX need shows up).
@@ -366,22 +334,6 @@ async function openDetail(id) {
await store.loadOne(id) await store.loadOne(id)
} }
async function onViewFailingLogs(source) {
// Find and open the most recent DownloadEvent for this source.
// Reuses the existing DownloadDetailModal — same stdout/stderr/error
// surface the row-click in the events feed shows.
try {
const ev = await store.loadLastForSource(source.id)
if (!ev) {
toast({
text: `No download events recorded for ${source.artist_name || source.platform} yet.`,
type: 'warning',
})
}
} catch (e) {
toast({ text: `Failed to load logs: ${e.message}`, type: 'error' })
}
}
</script> </script>
<style scoped> <style scoped>
@@ -18,14 +18,6 @@
subtitle="Mark stranded pending/running events as error (also runs every 5 min)" subtitle="Mark stranded pending/running events as error (also runs every 5 min)"
@click="emit('recover-stalled')" @click="emit('recover-stalled')"
/> />
<v-list-item
:disabled="true"
prepend-icon="mdi-download-box"
title="Export failed logs"
subtitle="CSV dump — v2"
>
<v-tooltip activator="parent" location="start">Deferred to a future release</v-tooltip>
</v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
</template> </template>
@@ -33,7 +25,8 @@
<script setup> <script setup>
// The Downloads tab parent (DownloadsTab.vue) owns the actual retry/sweep // The Downloads tab parent (DownloadsTab.vue) owns the actual retry/sweep
// handlers — same toast + refresh logic already used by the failing-sources // handlers — same toast + refresh logic already used by the failing-sources
// RETRY ALL button. We just emit. Import-pipeline maintenance lives in // RETRY ALL button. We just emit. (A permanently-disabled "Export failed
// Settings → Imports (ImportTaskList.vue), not here. // logs" stub sat here until 2026-07-02 — retired; the event list + detail
// modal cover forensics.)
const emit = defineEmits(['retry-failed', 'recover-stalled']) const emit = defineEmits(['retry-failed', 'recover-stalled'])
</script> </script>
@@ -0,0 +1,73 @@
<template>
<!-- Renders nothing when everything is healthy the daily answer to
"does anything need me?" should be silence, not an empty card. -->
<FailingSourcesCard
v-if="failing.length"
class="mb-4"
:sources="failing"
:retrying-ids="sourcesStore.checkingIds"
:retrying-all="retryingAll"
@retry="onRetry"
@retry-all="onRetryAll"
@view-logs="onViewLogs"
/>
</template>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { toast } from '../../utils/toast.js'
import FailingSourcesCard from './FailingSourcesCard.vue'
import { useDownloadsStore } from '../../stores/downloads.js'
import { useSourcesStore } from '../../stores/sources.js'
// The needs-attention strip on the Subscriptions LANDING tab (2026-07-02):
// failing sources used to live below the fold of the Downloads tab, so a
// broken subscription was invisible unless you went looking. Retry logic is
// shared with the Downloads maintenance menu via the downloads store.
const store = useDownloadsStore()
const sourcesStore = useSourcesStore()
const router = useRouter()
const { failing } = storeToRefs(store)
const retryingAll = ref(false)
let pollId = null
async function onRetry(source) {
try {
const res = await store.retrySource(source)
toast(res === 'queued'
? { text: `Retry queued for ${source.artist_name || source.platform}`, type: 'success' }
: { text: 'Already running — check Downloads', type: 'info' })
} catch (e) {
toast({ text: `Retry failed: ${e?.detail || e?.message || e}`, type: 'error' })
}
}
async function onRetryAll(sources) {
retryingAll.value = true
try {
const t = await store.retryAllFailing(sources)
const parts = []
if (t.ok) parts.push(`${t.ok} queued`)
if (t.deferred) parts.push(`${t.deferred} deferred (cooldown)`)
if (t.conflict) parts.push(`${t.conflict} already running`)
toast({ text: parts.join(', ') || 'Nothing to retry', type: 'info' })
} finally {
retryingAll.value = false
}
}
function onViewLogs(source) {
// The Downloads tab owns the log/detail surface — deep-link into it
// pre-filtered to this source (it watches ?source_id).
router.push({ path: '/subscriptions', query: { tab: 'downloads', source_id: source.id } })
}
onMounted(() => {
store.loadFailing()
pollId = setInterval(() => { if (!document.hidden) store.loadFailing() }, 60000)
})
onUnmounted(() => { if (pollId) clearInterval(pollId) })
</script>
@@ -0,0 +1,80 @@
<template>
<v-card v-if="arrivals.length" class="mb-4">
<CardHeading icon="mdi-new-box" title="Recent arrivals">
<v-spacer />
<v-btn
variant="text" size="small" rounded="pill"
to="/subscriptions?tab=downloads"
>
All downloads
<v-icon end size="small">mdi-arrow-right</v-icon>
</v-btn>
</CardHeading>
<v-card-text class="pt-0">
<div
v-for="ev in arrivals" :key="ev.id"
class="fc-arrival"
>
<router-link
v-if="ev.artist_slug"
:to="`/artist/${ev.artist_slug}`"
class="fc-arrival__artist"
>{{ ev.artist_name || ev.artist_slug }}</router-link>
<span v-else class="fc-arrival__artist">{{ ev.artist_name || '—' }}</span>
<span class="fc-arrival__meta">
{{ ev.platform }} ·
{{ ev.files_count ? `${ev.files_count} file(s)` : 'no new files' }}
· {{ formatRelative(ev.started_at) }}
</span>
</div>
</v-card-text>
</v-card>
</template>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import CardHeading from '../common/CardHeading.vue'
import { formatRelative } from '../../utils/date.js'
import { useApi } from '../../composables/useApi.js'
// "What came in?" — the other half of the landing tab's daily answer
// (2026-07-02). Own fetch, own state: the downloads store's event list is
// the Downloads tab's FILTERED feed; borrowing it would couple this card to
// whatever filter the operator left that tab on.
const api = useApi()
const arrivals = ref([])
let pollId = null
async function load() {
try {
const events = await api.get('/api/downloads', {
params: { status: 'ok', limit: 25 },
})
// Real arrivals only — scheduled scans that found nothing are noise here.
arrivals.value = events.filter((e) => (e.files_count || 0) > 0).slice(0, 6)
} catch { /* non-fatal — the card just hides */ }
}
onMounted(() => {
load()
pollId = setInterval(() => { if (!document.hidden) load() }, 60000)
})
onUnmounted(() => { if (pollId) clearInterval(pollId) })
</script>
<style scoped>
.fc-arrival {
display: flex; align-items: baseline; gap: 10px;
padding: 4px 0;
}
.fc-arrival__artist {
font-weight: 600; text-decoration: none;
color: rgb(var(--v-theme-accent));
}
.fc-arrival__artist:hover { text-decoration: underline; }
.fc-arrival__meta {
font-size: 12px;
color: rgb(var(--v-theme-on-surface-variant));
}
</style>
@@ -1,12 +1,20 @@
<template> <template>
<div> <div>
<!-- One extension home (2026-07-02): install/manifest card (moved here
from Settings Overview) + the API key bar it authenticates with.
The extension feeds THIS view cookies + one-click sources so its
setup lives with the rest of the ingestion config. -->
<h3 class="fc-section__title">Browser extension</h3>
<p class="fc-section__hint">Install, session cookies and the API key it authenticates with.</p>
<BrowserExtensionCard class="mb-3" />
<ExtensionKeyBar class="mb-4" /> <ExtensionKeyBar class="mb-4" />
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4"> <v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
{{ String(credentialsStore.error) }} {{ String(credentialsStore.error) }}
</v-alert> </v-alert>
<h3 class="text-h6 mb-3">Platform credentials</h3> <h3 class="fc-section__title mt-6">Platform credentials</h3>
<p class="fc-section__hint">Per-platform cookies/tokens the downloader uses.</p>
<v-row> <v-row>
<v-col <v-col
v-for="p in platformsStore.list" v-for="p in platformsStore.list"
@@ -23,7 +31,8 @@
</v-col> </v-col>
</v-row> </v-row>
<h3 class="text-h6 mb-3 mt-6">Downloader</h3> <h3 class="fc-section__title mt-6">Downloader</h3>
<p class="fc-section__hint">Rate limits and gallery-dl behavior for source checks.</p>
<v-card variant="outlined"> <v-card variant="outlined">
<v-card-text v-if="importStore.settings"> <v-card-text v-if="importStore.settings">
<v-row> <v-row>
@@ -52,7 +61,8 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
<h3 class="text-h6 mb-3 mt-6">External file-host downloads</h3> <h3 class="fc-section__title mt-6">External file-host downloads</h3>
<p class="fc-section__hint">Post-linked mega/gdrive/file-host fetches.</p>
<v-card variant="outlined"> <v-card variant="outlined">
<v-card-text v-if="importStore.settings"> <v-card-text v-if="importStore.settings">
<div class="fc-help mb-2"> <div class="fc-help mb-2">
@@ -98,7 +108,8 @@
</v-card-text> </v-card-text>
</v-card> </v-card>
<h3 class="text-h6 mb-3 mt-6">Schedule defaults</h3> <h3 class="fc-section__title mt-6">Schedule defaults</h3>
<p class="fc-section__hint">Check cadence and failure-badge thresholds for new sources.</p>
<v-card variant="outlined"> <v-card variant="outlined">
<v-card-text v-if="importStore.settings"> <v-card-text v-if="importStore.settings">
<v-row> <v-row>
@@ -179,6 +190,7 @@ import { onMounted, reactive, ref, watch } from 'vue'
import { usePlatformsStore } from '../../stores/platforms.js' import { usePlatformsStore } from '../../stores/platforms.js'
import { useCredentialsStore } from '../../stores/credentials.js' import { useCredentialsStore } from '../../stores/credentials.js'
import { useImportStore } from '../../stores/import.js' import { useImportStore } from '../../stores/import.js'
import BrowserExtensionCard from '../settings/BrowserExtensionCard.vue'
import ExtensionKeyBar from '../credentials/ExtensionKeyBar.vue' import ExtensionKeyBar from '../credentials/ExtensionKeyBar.vue'
import CredentialUploadDialog from '../credentials/CredentialUploadDialog.vue' import CredentialUploadDialog from '../credentials/CredentialUploadDialog.vue'
import CredentialCard from './CredentialCard.vue' import CredentialCard from './CredentialCard.vue'
@@ -240,6 +252,21 @@ async function saveDownloader() {
</script> </script>
<style scoped> <style scoped>
/* Same section-header language as Settings -> Maintenance/Cleanup (2026-07-02
theme unification) so every admin surface reads identically. */
.fc-section__title {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(var(--v-theme-accent));
margin-bottom: 2px;
}
.fc-section__hint {
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 12px;
}
.fc-help { .fc-help {
font-size: 12px; font-size: 12px;
color: rgb(var(--v-theme-on-surface-variant)); color: rgb(var(--v-theme-on-surface-variant));
@@ -2,6 +2,12 @@
<div> <div>
<SchedulerStatusBar :status="store.scheduleStatus" class="fc-subs__sched" /> <SchedulerStatusBar :status="store.scheduleStatus" class="fc-subs__sched" />
<!-- Daily-use ordering (2026-07-02): what needs me what came in
the full source list. Both cards render nothing when there's
nothing to say. -->
<NeedsAttentionCard />
<RecentArrivalsCard />
<div class="fc-subs__bar"> <div class="fc-subs__bar">
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)"> <v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
Add subscription Add subscription
@@ -319,6 +325,8 @@ import { useRoute, useRouter } from 'vue-router'
import { useSourcesStore } from '../../stores/sources.js' import { useSourcesStore } from '../../stores/sources.js'
import { usePlatformsStore } from '../../stores/platforms.js' import { usePlatformsStore } from '../../stores/platforms.js'
import { useImportStore } from '../../stores/import.js' import { useImportStore } from '../../stores/import.js'
import NeedsAttentionCard from './NeedsAttentionCard.vue'
import RecentArrivalsCard from './RecentArrivalsCard.vue'
import SourceRow from './SourceRow.vue' import SourceRow from './SourceRow.vue'
import SourceCard from './SourceCard.vue' import SourceCard from './SourceCard.vue'
import SourceHealthDot from './SourceHealthDot.vue' import SourceHealthDot from './SourceHealthDot.vue'
+4 -2
View File
@@ -101,8 +101,10 @@ export const useAdminStore = defineStore('admin', () => {
}) })
} }
// Destructive: deletes ALL general + character tags so the operator can // Destructive whole-instance reset: deletes ALL general + character tags AND
// re-tag from scratch via auto-suggest. fandom + series preserved. // their applications (the heads' training data included) — fandom + series
// preserved. dry-run returns a `confirm` token; the apply must pass it back
// ({ dryRun: false, confirm }) or the server rejects it.
function resetContentTagging(opts = {}) { function resetContentTagging(opts = {}) {
return _dryRunPost('/api/admin/tags/reset-content', opts) return _dryRunPost('/api/admin/tags/reset-content', opts)
} }
+40
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js' import { useInflightToken } from '../composables/useInflightToken.js'
import { useSourcesStore } from './sources.js'
export const useDownloadsStore = defineStore('downloads', () => { export const useDownloadsStore = defineStore('downloads', () => {
const api = useApi() const api = useApi()
@@ -106,6 +107,44 @@ export const useDownloadsStore = defineStore('downloads', () => {
return failing.value return failing.value
} }
// --- Failing-source retries (shared by the Subscriptions needs-attention
// card and the Downloads maintenance menu — one implementation for both
// surfaces). Data-only: callers own the toasts, per this store's style.
// A single deliberate retry forces past cooldown; BULK retries keep
// cooldown enforcement ON so N failing sources on one platform don't all
// retry into the very rate limit the cooldown is preventing.
async function retrySource(source) {
const sourcesStore = useSourcesStore()
try {
await sourcesStore.checkNow(source.id, { force: true })
return 'queued'
} catch (e) {
if (e?.body?.download_event_id) return 'already_running'
throw e
} finally {
await Promise.all([loadFailing(), loadStats(24)])
}
}
async function retryAllFailing(sources) {
const sourcesStore = useSourcesStore()
const tally = { ok: 0, deferred: 0, conflict: 0 }
try {
for (const s of sources) {
try {
const body = await sourcesStore.checkNow(s.id)
if (body?.status === 'deferred') tally.deferred += 1
else tally.ok += 1
} catch (e) {
if (e?.body?.download_event_id) tally.conflict += 1
}
}
} finally {
await Promise.all([loadFailing(), loadStats(24)])
}
return tally
}
async function loadActive() { async function loadActive() {
const [running, pending] = await Promise.all([ const [running, pending] = await Promise.all([
api.get('/api/downloads', { params: { status: 'running', limit: 50 } }), api.get('/api/downloads', { params: { status: 'running', limit: 50 } }),
@@ -128,5 +167,6 @@ export const useDownloadsStore = defineStore('downloads', () => {
loadFirst, loadMore, loadOne, loadLastForSource, applyFilter, loadFirst, loadMore, loadOne, loadLastForSource, applyFilter,
closeDetail, loadStats, closeDetail, loadStats,
loadActivity, loadFailing, loadActive, recoverStalled, loadActivity, loadFailing, loadActive, recoverStalled,
retrySource, retryAllFailing,
} }
}) })
+8 -132
View File
@@ -1,8 +1,15 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { toast } from '../utils/toast.js' import { toast } from '../utils/toast.js'
import { ref, computed } from 'vue' import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
// Import SETTINGS only. The manual-scan trigger + task-list surfaces retired
// with the Import tab (2026-07-02): imports arrive via downloads/extension and
// heal themselves (Layer-2 auto-refetch sweep); their runs show in Activity.
// The /api/import/trigger + /tasks endpoints remain for direct/API use.
// Consumers: ImportFiltersForm (Maintenance → Ingestion & filters) and the
// Subscriptions Settings tab (downloader/extdl/schedule fields on the same
// ImportSettings row).
export const useImportStore = defineStore('import', () => { export const useImportStore = defineStore('import', () => {
const api = useApi() const api = useApi()
@@ -10,16 +17,6 @@ export const useImportStore = defineStore('import', () => {
const settingsLoading = ref(false) const settingsLoading = ref(false)
const settingsError = ref(null) const settingsError = ref(null)
const activeBatch = ref(null)
const statusLoading = ref(false)
const tasks = ref([])
const tasksNextCursor = ref(null)
const tasksLoading = ref(false)
const tasksFilter = ref({ status: null, limit: 50 })
const triggerError = ref(null)
async function loadSettings() { async function loadSettings() {
settingsLoading.value = true settingsLoading.value = true
settingsError.value = null settingsError.value = null
@@ -43,129 +40,8 @@ export const useImportStore = defineStore('import', () => {
} }
} }
async function refreshStatus() {
statusLoading.value = true
try {
const body = await api.get('/api/import/status')
activeBatch.value = body.active_batch
} finally {
statusLoading.value = false
}
}
async function triggerScan(mode = 'quick') {
if (!['quick', 'deep', 'verify'].includes(mode)) {
throw new Error(`unsupported scan mode: ${mode}`)
}
triggerError.value = null
try {
await api.post('/api/import/trigger', { body: { mode } })
// Acknowledge immediately so the click isn't invisible. scan_directory
// can finalize the batch synchronously when every file in /import is
// already on a non-failed ImportTask (operator-flagged 2026-05-25:
// 233k existing tasks → all paths in skip-set → files_seen=0 →
// batch flashes 'running' for <100ms then 'complete' before the
// first refreshStatus() lands; UI never sees the active state).
const label = mode === 'deep'
? 'Deep scan triggered (re-applying sidecar metadata + filling NULL phash/artist on existing rows)'
: mode === 'verify' ? 'Library verify triggered' : 'Quick scan triggered'
toast({ text: label, type: 'success' })
await refreshStatus()
// Re-poll twice over ~5s and produce an HONEST follow-up toast.
// Operator-flagged 2026-05-25: the prior "no new files" message was
// misleading because deep scan IS doing work (refresh) even when
// there are no new files to import. Surface the real workload count
// (imported + refreshed + queued) instead. For quick scan + zero
// queued work, fall back to "up to date" instead of the old
// implementation-detail-leaking message.
setTimeout(async () => {
await refreshStatus()
await loadTasks(true)
if (activeBatch.value || mode === 'verify') return
// Batch finalized quickly; figure out what actually happened.
// The task list was just refreshed; the freshest row(s) carry
// the batch outcome.
const batchId = tasks.value[0]?.batch_id
const sameBatch = batchId
? tasks.value.filter(t => t.batch_id === batchId)
: []
const refreshedCount = sameBatch.filter(t => t.status === 'complete' && t.result_image_id).length
const newImported = sameBatch.filter(t => t.status === 'complete' && t.result_image_id && !t.error).length
if (mode === 'deep' && sameBatch.length > 0) {
toast({
text: `Deep scan finished — ${sameBatch.length} file(s) processed`,
type: 'info',
})
} else if (mode === 'quick' && sameBatch.length > 0) {
toast({
text: `Quick scan finished — ${newImported} new file(s) queued`,
type: 'info',
})
} else {
toast({ text: 'Library is up to date', type: 'info' })
}
}, 2000)
} catch (e) {
triggerError.value = e.message
toast({ text: `Scan failed: ${e.message}`, type: 'error' })
throw e
}
}
async function loadTasks(reset = true) {
tasksLoading.value = true
try {
const params = { limit: tasksFilter.value.limit }
if (tasksFilter.value.status) params.status = tasksFilter.value.status
if (!reset && tasksNextCursor.value) params.cursor = tasksNextCursor.value
const body = await api.get('/api/import/tasks', { params })
tasks.value = reset ? body.tasks : [...tasks.value, ...body.tasks]
tasksNextCursor.value = body.next_cursor
} finally {
tasksLoading.value = false
}
}
function setStatusFilter(status) {
tasksFilter.value.status = status
}
async function retryFailed() {
await api.post('/api/import/retry-failed')
await loadTasks(true)
}
async function clearCompleted(ageDays = 0) {
await api.post('/api/import/clear-completed', { body: { age_days: ageDays } })
await loadTasks(true)
}
async function clearStuck() {
const body = await api.post('/api/import/clear-stuck')
await loadTasks(true)
await refreshStatus()
return body
}
// Layer-2 one-shot re-download for a failed task's (corrupt) file.
// Returns the endpoint's status dict (refetch_queued / no_source /
// already_refetched). Caller surfaces it as a toast.
async function refetchTask(taskId) {
const body = await api.post(`/api/import/tasks/${taskId}/refetch`)
await loadTasks(true)
return body
}
const hasMore = computed(() => tasksNextCursor.value !== null)
return { return {
settings, settingsLoading, settingsError, settings, settingsLoading, settingsError,
activeBatch, statusLoading,
tasks, tasksLoading, tasksFilter, hasMore,
triggerError,
loadSettings, patchSettings, loadSettings, patchSettings,
refreshStatus, triggerScan,
loadTasks, setStatusFilter, retryFailed, clearCompleted, clearStuck,
refetchTask,
} }
}) })
+20
View File
@@ -39,6 +39,17 @@
<TagMaintenanceCard /> <TagMaintenanceCard />
</div> </div>
</section> </section>
<section class="fc-section fc-danger">
<h3 class="fc-section__title fc-danger__title">Danger zone</h3>
<p class="fc-section__hint">
Whole-instance resets. Each needs a fresh preview and a typed
confirmation code.
</p>
<div class="fc-tile-grid">
<DangerZoneCard />
</div>
</section>
</div> </div>
</template> </template>
@@ -50,6 +61,7 @@ import PostMaintenanceCard from '../components/settings/PostMaintenanceCard.vue'
import VideoDedupCard from '../components/settings/VideoDedupCard.vue' import VideoDedupCard from '../components/settings/VideoDedupCard.vue'
import GatedPurgeCard from '../components/settings/GatedPurgeCard.vue' import GatedPurgeCard from '../components/settings/GatedPurgeCard.vue'
import TagMaintenanceCard from '../components/settings/TagMaintenanceCard.vue' import TagMaintenanceCard from '../components/settings/TagMaintenanceCard.vue'
import DangerZoneCard from '../components/settings/DangerZoneCard.vue'
</script> </script>
<style scoped> <style scoped>
@@ -75,4 +87,12 @@ import TagMaintenanceCard from '../components/settings/TagMaintenanceCard.vue'
gap: 14px; gap: 14px;
align-items: start; align-items: start;
} }
/* Danger zone: visually fenced off from routine cleanup so the reset can't
be mistaken for maintenance. */
.fc-danger {
border-top: 1px solid rgba(var(--v-theme-error), 0.45);
padding-top: 18px;
margin-top: 36px;
}
.fc-danger__title { color: rgb(var(--v-theme-error)); }
</style> </style>
-34
View File
@@ -1,34 +0,0 @@
<template>
<v-container class="pt-3 pb-8">
<h1 class="fc-h1 mb-4">{{ title }}</h1>
<v-alert type="info" variant="tonal" icon="mdi-toolbox">
This surface is a placeholder. It will be implemented in
<strong>{{ surfaceOwner }}</strong>.
</v-alert>
</v-container>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const title = computed(() => route.meta.title ?? 'FabledCurator')
const surfaceOwner = computed(() => {
const fc2 = new Set(['gallery', 'showcase', 'tags', 'settings'])
const fc3 = new Set(['subscriptions', 'credentials', 'downloads'])
if (fc2.has(route.name)) return 'FC-2 (image backbone)'
if (fc3.has(route.name)) return 'FC-3 (subscription backbone)'
return 'a future sub-project'
})
</script>
<style scoped>
.fc-h1 {
font-family: 'Fraunces', Georgia, serif;
font-size: 32px;
font-weight: 500;
color: rgb(var(--v-theme-on-surface));
}
</style>
+23 -47
View File
@@ -17,7 +17,6 @@
> >
<v-tab value="overview">Overview</v-tab> <v-tab value="overview">Overview</v-tab>
<v-tab value="activity">Activity</v-tab> <v-tab value="activity">Activity</v-tab>
<v-tab value="import">Import</v-tab>
<v-tab value="cleanup">Cleanup</v-tab> <v-tab value="cleanup">Cleanup</v-tab>
<v-tab value="maintenance">Maintenance</v-tab> <v-tab value="maintenance">Maintenance</v-tab>
</v-tabs> </v-tabs>
@@ -29,34 +28,24 @@
class="mt-4" class="mt-4"
@open-activity="tab = 'activity'" @open-activity="tab = 'activity'"
/> />
<!-- Health strip (2026-07-02): the same GPU + downloads panels the
Activity tab uses — Overview answers "is everything healthy?"
across ALL systems, not just the Celery queues above. -->
<div class="fc-health-row mt-4">
<GpuActivityPanel @open-maintenance="tab = 'maintenance'" />
<DownloadsActivityPanel />
</div>
<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
> >
{{ system.stats.tasks.pending + system.stats.tasks.queued }} import task(s) pending. {{ system.stats.tasks.pending + system.stats.tasks.queued }} import task(s) pending.
<v-btn variant="text" size="small" @click="tab = 'import'">Go to Import tab</v-btn> <v-btn variant="text" size="small" @click="tab = 'activity'">View in Activity</v-btn>
</v-alert> </v-alert>
<!-- Browser-extension install/download lives on Overview (moved
from Maintenance 2026-05-25). Overview is the discovery
surface for "things to set up"; Maintenance is for
housekeeping of already-set-up systems. -->
<BrowserExtensionCard class="mt-6" />
</v-window-item> </v-window-item>
<v-window-item value="activity"> <v-window-item value="activity">
<SystemActivityTab /> <SystemActivityTab @open-maintenance="tab = 'maintenance'" />
</v-window-item>
<v-window-item value="import">
<!-- Order: filters → trigger → recent tasks. Filters hoisted above the
trigger (operator-flagged 2026-06-04); the task list stays
directly below the trigger so hit/miss feedback is adjacent to the
button that produced it (operator-flagged 2026-05-25). -->
<ImportFiltersForm />
<v-divider class="my-6" />
<ImportTriggerPanel />
<v-divider class="my-6" />
<ImportTaskList />
</v-window-item> </v-window-item>
<v-window-item value="cleanup"> <v-window-item value="cleanup">
@@ -73,21 +62,17 @@
<script setup> <script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue' 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 SystemStatsCards from '../components/settings/SystemStatsCards.vue' import SystemStatsCards from '../components/settings/SystemStatsCards.vue'
import SystemActivitySummary from '../components/settings/SystemActivitySummary.vue' import SystemActivitySummary from '../components/settings/SystemActivitySummary.vue'
import SystemActivityTab from '../components/settings/SystemActivityTab.vue' import SystemActivityTab from '../components/settings/SystemActivityTab.vue'
import BrowserExtensionCard from '../components/settings/BrowserExtensionCard.vue' import GpuActivityPanel from '../components/settings/GpuActivityPanel.vue'
import ImportTriggerPanel from '../components/settings/ImportTriggerPanel.vue' import DownloadsActivityPanel from '../components/settings/DownloadsActivityPanel.vue'
import ImportFiltersForm from '../components/settings/ImportFiltersForm.vue'
import ImportTaskList from '../components/settings/ImportTaskList.vue'
import MaintenancePanel from '../components/settings/MaintenancePanel.vue' import MaintenancePanel from '../components/settings/MaintenancePanel.vue'
import CleanupView from './CleanupView.vue' import CleanupView from './CleanupView.vue'
import { useMLStore } from '../stores/ml.js' import { useMLStore } from '../stores/ml.js'
const tab = ref('overview') const tab = ref('overview')
const system = useSystemStore() const system = useSystemStore()
const importStore = useImportStore()
const mlStore = useMLStore() const mlStore = useMLStore()
let pollId = null let pollId = null
@@ -96,15 +81,7 @@ function startPolling() {
if (pollId) return if (pollId) return
system.refreshStats() system.refreshStats()
pollId = setInterval(() => { pollId = setInterval(() => {
if (!document.hidden) { if (!document.hidden) system.refreshStats()
system.refreshStats()
if (tab.value === 'import') {
importStore.refreshStatus()
// Refresh the task list while a batch is in flight so the UI
// doesn't sit stale next to a ticking imported/skipped counter.
if (importStore.activeBatch) importStore.loadTasks(true)
}
}
}, 5000) }, 5000)
} }
@@ -112,21 +89,11 @@ function stopPolling() {
if (pollId) { clearInterval(pollId); pollId = null } if (pollId) { clearInterval(pollId); pollId = null }
} }
onMounted(() => { onMounted(startPolling)
startPolling()
importStore.loadSettings()
importStore.refreshStatus()
importStore.loadTasks(true)
})
onUnmounted(stopPolling) onUnmounted(stopPolling)
watch(tab, (t) => { watch(tab, (t) => {
if (t === 'import') { if (t === 'maintenance') mlStore.loadSettings()
importStore.refreshStatus()
importStore.loadTasks(true)
} else if (t === 'maintenance') {
mlStore.loadSettings()
}
}) })
</script> </script>
@@ -135,4 +102,13 @@ watch(tab, (t) => {
.fc-settings { .fc-settings {
max-width: 1140px; max-width: 1140px;
} }
/* Overview health strip: the two pulse panels side by side, stacking on
narrow screens. Cancel the panels' own bottom margin the row owns gaps. */
.fc-health-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 16px;
align-items: start;
}
.fc-health-row :deep(.v-card) { margin-bottom: 0 !important; }
</style> </style>
+31 -1
View File
@@ -562,7 +562,7 @@ async def test_trigger_normalize_live_queues(client, monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reset_content_tagging_dry_run_returns_counts(client, db): async def test_reset_content_tagging_dry_run_returns_counts_and_token(client, db):
db.add_all([ db.add_all([
Tag(name="solo", kind=TagKind.general), Tag(name="solo", kind=TagKind.general),
Tag(name="naruto", kind=TagKind.character), Tag(name="naruto", kind=TagKind.character),
@@ -579,3 +579,33 @@ async def test_reset_content_tagging_dry_run_returns_counts(client, db):
assert body["by_kind"] == {"general": 1, "character": 1} assert body["by_kind"] == {"general": 1, "character": 1}
# dry-run leaves the rows in place — fandom + series untouched too. # dry-run leaves the rows in place — fandom + series untouched too.
assert "deleted" not in body assert "deleted" not in body
# The preview arms the apply: an 8-hex confirm token over the live counts.
assert len(body["confirm"]) == 8
@pytest.mark.asyncio
async def test_reset_content_tagging_apply_requires_confirm_token(client, db):
from sqlalchemy import func, select
db.add(Tag(name="solo", kind=TagKind.general))
await db.commit()
# No token / wrong token → rejected, nothing deleted.
for bad in ({"dry_run": False}, {"dry_run": False, "confirm": "deadbeef"}):
resp = await client.post("/api/admin/tags/reset-content", json=bad)
assert resp.status_code == 400
remaining = (await db.execute(
select(func.count()).select_from(Tag)
)).scalar_one()
assert remaining == 1
# The token from a fresh preview unlocks the apply.
token = (await (await client.post(
"/api/admin/tags/reset-content", json={"dry_run": True}
)).get_json())["confirm"]
resp = await client.post(
"/api/admin/tags/reset-content",
json={"dry_run": False, "confirm": token},
)
assert resp.status_code == 200
assert (await resp.get_json())["deleted"] == 1
+31 -1
View File
@@ -127,7 +127,7 @@ async def test_backfill_enqueues_then_is_idempotent(db):
await _img(db, "c" * 64) await _img(db, "c" * 64)
await _img(db, "d" * 64) await _img(db, "d" * 64)
await db.commit() await db.commit()
from backend.app.tasks.ml import enqueue_gpu_backfill from backend.app.tasks.gpu_queue import enqueue_gpu_backfill
n = enqueue_gpu_backfill("ccip") # sync task, own session n = enqueue_gpu_backfill("ccip") # sync task, own session
assert n >= 2 assert n >= 2
@@ -260,3 +260,33 @@ async def test_errors_endpoint_reports_triage_view(client, db):
assert item["reason_class"] == "truncated_or_corrupt" assert item["reason_class"] == "truncated_or_corrupt"
assert item["triage_status"] is None assert item["triage_status"] is None
assert item["image_url"].startswith("/images/") assert item["image_url"].startswith("/images/")
@pytest.mark.asyncio
async def test_cpu_embed_never_blocks_gpu_crop_backfills(db):
"""B3 invariant (operator 2026-07-02): ccip (detect + character) and
siglip (concept crops) completion is judged per-pipeline — gpu_job rows and
image_region state — never inferred from image_record.siglip_embedding. So
an image the CPU fallback already embedded still gets both crop jobs; only
the whole-image 'embed' job (the SAME artifact the CPU path produces) is
satisfied by it."""
from backend.app.models import MLSettings
from backend.app.tasks.gpu_queue import enqueue_gpu_backfill
img = await _img(db, "7" * 64)
cur = (await db.execute(
select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
)).scalar_one()
# As if the CPU fallback already embedded it under the current model.
img.siglip_embedding = [0.1] * 1152
img.siglip_model_version = cur
await db.commit()
assert enqueue_gpu_backfill("ccip") == 1 # crops still open
assert enqueue_gpu_backfill("siglip") == 1 # concept crops still open
assert enqueue_gpu_backfill("embed") == 0 # same artifact — already done
tasks = set((await db.execute(
select(GpuJob.task).where(GpuJob.image_record_id == img.id)
)).scalars().all())
assert tasks == {"ccip", "siglip"}
+1 -1
View File
@@ -922,7 +922,7 @@ async def test_download_enqueues_thumbnail_and_ml_per_attached_image(
lambda image_id: thumb_calls.append(image_id), lambda image_id: thumb_calls.append(image_id),
) )
monkeypatch.setattr( monkeypatch.setattr(
ml_mod.tag_and_embed, "delay", ml_mod.embed_image, "delay",
lambda image_id: ml_calls.append(image_id), lambda image_id: ml_calls.append(image_id),
) )
+2 -2
View File
@@ -125,7 +125,7 @@ def test_refetch_same_link_keeps_canonical_file(db_sync, tmp_path, monkeypatch):
import backend.app.tasks.ml as ml_mod import backend.app.tasks.ml as ml_mod
import backend.app.tasks.thumbnail as thumb_mod import backend.app.tasks.thumbnail as thumb_mod
monkeypatch.setattr(ml_mod.tag_and_embed, "delay", lambda i: None) monkeypatch.setattr(ml_mod.embed_image, "delay", lambda i: None)
monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda i: None) monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda i: None)
out = ext.fetch_external_link(link_id) out = ext.fetch_external_link(link_id)
@@ -234,7 +234,7 @@ def test_downloaded_archive_gets_provenance_and_tagging(db_sync, tmp_path, monke
tagged, thumbed = [], [] tagged, thumbed = [], []
import backend.app.tasks.ml as ml_mod import backend.app.tasks.ml as ml_mod
import backend.app.tasks.thumbnail as thumb_mod import backend.app.tasks.thumbnail as thumb_mod
monkeypatch.setattr(ml_mod.tag_and_embed, "delay", lambda i: tagged.append(i)) monkeypatch.setattr(ml_mod.embed_image, "delay", lambda i: tagged.append(i))
monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda i: thumbed.append(i)) monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda i: thumbed.append(i))
out = ext.fetch_external_link(link_id) out = ext.fetch_external_link(link_id)
+5 -5
View File
@@ -30,7 +30,7 @@ async def test_enqueue_siglip_backfill_gates_on_concept_region(db):
# back-catalogue) and skips ones that already have one — and never double- # back-catalogue) and skips ones that already have one — and never double-
# enqueues an image that already has a pending siglip job. # enqueues an image that already has a pending siglip job.
from backend.app.models import MLSettings from backend.app.models import MLSettings
from backend.app.tasks.ml import enqueue_gpu_backfill from backend.app.tasks.gpu_queue import enqueue_gpu_backfill
cur = (await db.execute( cur = (await db.execute(
select(MLSettings.embedder_model_version).where(MLSettings.id == 1) select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
@@ -71,7 +71,7 @@ async def test_enqueue_embed_backfill_selects_stale_and_unembedded(db):
# stamped under a DIFFERENT model version (an operator swap); skip ones # stamped under a DIFFERENT model version (an operator swap); skip ones
# already at the current version. # already at the current version.
from backend.app.models import MLSettings from backend.app.models import MLSettings
from backend.app.tasks.ml import enqueue_gpu_backfill from backend.app.tasks.gpu_queue import enqueue_gpu_backfill
cur = (await db.execute( cur = (await db.execute(
select(MLSettings.embedder_model_version).where(MLSettings.id == 1) select(MLSettings.embedder_model_version).where(MLSettings.id == 1)
@@ -99,7 +99,7 @@ async def test_enqueue_embed_backfill_selects_stale_and_unembedded(db):
async def test_reprocess_resets_done_jobs_to_pending(db): async def test_reprocess_resets_done_jobs_to_pending(db):
# Re-process (#1202): done/error jobs of a task go back to pending so the # Re-process (#1202): done/error jobs of a task go back to pending so the
# agent re-runs the whole library under the current pipeline. # agent re-runs the whole library under the current pipeline.
from backend.app.tasks.ml import reprocess_gpu_jobs from backend.app.tasks.gpu_queue import reprocess_gpu_jobs
img = await _img(db, "r1" * 32) img = await _img(db, "r1" * 32)
job = await GpuJobService(db).enqueue(img.id, "ccip") job = await GpuJobService(db).enqueue(img.id, "ccip")
@@ -274,7 +274,7 @@ async def test_backfill_skips_errored_images(db):
# An errored job is a TOMBSTONE for its (image, task): no backfill variant # An errored job is a TOMBSTONE for its (image, task): no backfill variant
# re-enqueues it — retry is deliberate-only (/retry_errors). Pre-fix, the # re-enqueues it — retry is deliberate-only (/retry_errors). Pre-fix, the
# hourly ccip run minted a fresh doomed job per bad file forever. # hourly ccip run minted a fresh doomed job per bad file forever.
from backend.app.tasks.ml import enqueue_gpu_backfill from backend.app.tasks.gpu_queue import enqueue_gpu_backfill
img = await _img(db, "f1" * 32) img = await _img(db, "f1" * 32)
svc = GpuJobService(db) svc = GpuJobService(db)
@@ -294,7 +294,7 @@ async def test_backfill_prunes_moot_error_tombstones(db):
# Loop-era duplicates: several error rows for one (image, task), all made # Loop-era duplicates: several error rows for one (image, task), all made
# moot by a later done row. The backfill's dedupe pass removes them, and # moot by a later done row. The backfill's dedupe pass removes them, and
# the done row still blocks re-enqueue. # the done row still blocks re-enqueue.
from backend.app.tasks.ml import enqueue_gpu_backfill from backend.app.tasks.gpu_queue import enqueue_gpu_backfill
img = await _img(db, "f2" * 32) img = await _img(db, "f2" * 32)
for i in range(3): for i in range(3):
+24
View File
@@ -116,6 +116,30 @@ def test_transparent_filter(importer, import_layout):
assert result.skip_reason == SkipReason.too_transparent assert result.skip_reason == SkipReason.too_transparent
def test_single_color_filter(importer, import_layout):
"""The skip_single_color setting existed since FC-2 but was never wired
(the audit module's docstring said so); wired 2026-07-02 using the same
canonical predicate as the Cleanup audit. Solid fill skips when enabled,
imports when disabled (the default)."""
from PIL import Image as PILImage
import_root, _ = import_layout
solid = import_root / "solid.png"
solid.parent.mkdir(parents=True, exist_ok=True)
PILImage.new("RGB", (100, 100), (12, 34, 56)).save(solid)
importer.settings.skip_single_color = True
importer.settings.single_color_threshold = 0.95
result = importer.import_one(solid)
assert result.status == "skipped"
assert result.skip_reason == SkipReason.single_color
importer.settings.skip_single_color = False
solid2 = import_root / "solid2.png"
PILImage.new("RGB", (100, 100), (200, 10, 10)).save(solid2)
assert importer.import_one(solid2).status == "imported"
def test_unsupported_extension(importer, import_layout): def test_unsupported_extension(importer, import_layout):
# FC-2d-iii: non-media is no longer skipped — it's captured as a # FC-2d-iii: non-media is no longer skipped — it's captured as a
# PostAttachment so nothing a post contained is lost. # PostAttachment so nothing a post contained is lost.
+1 -1
View File
@@ -310,7 +310,7 @@ def test_recover_stalled_task_runs_skips_fresh_running(db_sync):
def test_recover_stalled_task_runs_ml_queue_uses_longer_threshold(db_sync): def test_recover_stalled_task_runs_ml_queue_uses_longer_threshold(db_sync):
"""ml-queue tasks (tag_and_embed video branch) legitimately run """ml-queue tasks (embed_image video branch) legitimately run
past the default 5-min threshold. The sweep must NOT flag an past the default 5-min threshold. The sweep must NOT flag an
ml-queue task that's only been running 10 min — the override ml-queue task that's only been running 10 min — the override
threshold (25 min via QUEUE_STUCK_THRESHOLD_MINUTES) protects threshold (25 min via QUEUE_STUCK_THRESHOLD_MINUTES) protects
+2 -2
View File
@@ -46,7 +46,7 @@ def test_reextract_links_archive_members_to_post(db_sync, tmp_path, monkeypatch)
# No broker in this path — the post-import enqueue is best-effort anyway. # No broker in this path — the post-import enqueue is best-effort anyway.
monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda *a, **k: None) monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda *a, **k: None)
monkeypatch.setattr(ml_mod.tag_and_embed, "delay", lambda *a, **k: None) monkeypatch.setattr(ml_mod.embed_image, "delay", lambda *a, **k: None)
images_root = tmp_path / "images" images_root = tmp_path / "images"
images_root.mkdir() images_root.mkdir()
@@ -116,7 +116,7 @@ def test_reextract_timebox_resumes_from_cursor(db_sync, tmp_path, monkeypatch):
from backend.app.tasks import thumbnail as thumb_mod from backend.app.tasks import thumbnail as thumb_mod
monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda *a, **k: None) monkeypatch.setattr(thumb_mod.generate_thumbnail, "delay", lambda *a, **k: None)
monkeypatch.setattr(ml_mod.tag_and_embed, "delay", lambda *a, **k: None) monkeypatch.setattr(ml_mod.embed_image, "delay", lambda *a, **k: None)
images_root = tmp_path / "images" images_root = tmp_path / "images"
images_root.mkdir() images_root.mkdir()
+34 -2
View File
@@ -1,4 +1,4 @@
"""tag_and_embed (embed-only) / backfill task tests. The pure _is_video helper """embed_image (embed-only) / backfill task tests. The pure _is_video helper
is a unit test; the DB-touching backfill query is an integration test with is a unit test; the DB-touching backfill query is an integration test with
monkeypatched dispatch.""" monkeypatched dispatch."""
@@ -23,7 +23,7 @@ async def test_backfill_enqueues_missing(db, monkeypatch):
calls = [] calls = []
monkeypatch.setattr( monkeypatch.setattr(
ml_tasks.tag_and_embed, "delay", lambda image_id: calls.append(image_id) ml_tasks.embed_image, "delay", lambda image_id: calls.append(image_id)
) )
img = ImageRecord( img = ImageRecord(
@@ -38,3 +38,35 @@ async def test_backfill_enqueues_missing(db, monkeypatch):
count = ml_tasks.backfill() count = ml_tasks.backfill()
assert count >= 1 assert count >= 1
assert img.id in calls assert img.id in calls
@pytest.mark.integration
@pytest.mark.asyncio
async def test_backfill_respects_cpu_embed_toggle(db, monkeypatch):
"""B3: with cpu_embed_enabled off (agent-equipped stack, no ml-worker),
the CPU backfill is a no-op — the GPU 'embed' backfill owns whole-image
embeds there. Same gate the import hooks consult before dispatching."""
from sqlalchemy import update
from backend.app.models import ImageRecord, MLSettings
from backend.app.tasks import ml as ml_tasks
calls = []
monkeypatch.setattr(
ml_tasks.embed_image, "delay", lambda image_id: calls.append(image_id)
)
db.add(ImageRecord(
path="/images/o.jpg", sha256="o" * 64, size_bytes=1,
mime="image/jpeg", width=1, height=1,
origin="imported_filesystem", integrity_status="unknown",
siglip_embedding=None,
))
await db.execute(
update(MLSettings).where(MLSettings.id == 1)
.values(cpu_embed_enabled=False)
)
await db.commit()
assert ml_tasks.cpu_embed_enabled() is False
assert ml_tasks.backfill() == 0
assert calls == []