feat(telegram): add "skip & disable topic" on the live worker panel

Track the forum topic currently being processed on IngestionRun
(currentTopicId + currentAccountChannelMapId; additive migration) and
expose it on the live status. processArchiveSets gains an optional
shouldStop callback polled before each archive set; the forum branch
passes a live isTopicFetchEnabled check, so disabling a topic mid-run
lets the in-flight file finish, then skips the rest of that topic.

A new disableActiveTopic server action sets the topic's fetchEnabled
false (upsert), and the worker status panel shows a "Skip & disable
this topic" button while a topic is being processed. Future runs skip
the topic via the existing live per-topic read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 12:10:56 +02:00
parent c749d03376
commit 23f8e91c50
8 changed files with 131 additions and 3 deletions

View File

@@ -9,13 +9,14 @@ import {
Radio,
AlertTriangle,
RefreshCw,
SkipForward,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { triggerIngestion } from "../actions";
import { triggerIngestion, disableActiveTopic } from "../actions";
import type { IngestionAccountStatus } from "@/lib/telegram/types";
interface WorkerStatusPanelProps {
@@ -218,6 +219,24 @@ function RunningStatus({
}: {
run: NonNullable<IngestionAccountStatus["currentRun"]>;
}) {
const [isDisabling, startDisable] = useTransition();
const handleSkipTopic = () => {
const acmId = run.currentAccountChannelMapId;
const topicId = run.currentTopicId;
if (!acmId || !topicId) return;
startDisable(async () => {
const result = await disableActiveTopic(acmId, topicId);
if (result.success) {
toast.success(
"Topic disabled — the current file finishes, then the rest is skipped"
);
} else {
toast.error(result.error ?? "Failed to disable topic");
}
});
};
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
@@ -273,6 +292,27 @@ function RunningStatus({
</span>
)}
</div>
{/* Skip & disable the topic currently being processed */}
{run.currentTopicId && run.currentAccountChannelMapId && (
<div className="pl-6 pt-1">
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 text-xs"
onClick={handleSkipTopic}
disabled={isDisabling}
title="Finish the current file, skip the rest of this topic, and don't fetch it in future runs"
>
{isDisabling ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<SkipForward className="h-3 w-3" />
)}
Skip &amp; disable this topic
</Button>
</div>
)}
</div>
);
}

View File

@@ -462,6 +462,49 @@ export async function setTopicFetchEnabled(
}
}
/**
* Disable the topic currently being processed by the worker, identified by its
* account-channel mapping + Telegram topic id (as exposed on the live run
* status). Upserts so a disabled row exists even if the worker hasn't persisted
* the topic yet. The worker honours this live: it finishes the in-flight file
* then skips the rest of the topic, and future runs skip it entirely.
*/
export async function disableActiveTopic(
accountChannelMapId: string,
topicId: string
): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
let topicIdBig: bigint;
try {
topicIdBig = BigInt(topicId);
} catch {
return { success: false, error: "Invalid topic id" };
}
try {
await prisma.topicProgress.upsert({
where: {
accountChannelMapId_topicId: {
accountChannelMapId,
topicId: topicIdBig,
},
},
create: {
accountChannelMapId,
topicId: topicIdBig,
fetchEnabled: false,
},
update: { fetchEnabled: false },
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to disable topic" };
}
}
// ── Account-Channel link actions ──
export async function linkChannel(