mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-07-22 23:12:02 +00:00
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:
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable: track the forum topic currently being processed on the live run
|
||||||
|
-- so the worker status panel can offer a "skip & disable topic" action.
|
||||||
|
-- Additive, nullable — no data change for existing rows.
|
||||||
|
ALTER TABLE "ingestion_runs"
|
||||||
|
ADD COLUMN "currentTopicId" BIGINT,
|
||||||
|
ADD COLUMN "currentAccountChannelMapId" TEXT;
|
||||||
@@ -582,6 +582,8 @@ model IngestionRun {
|
|||||||
totalBytes BigInt? // Total size of current download
|
totalBytes BigInt? // Total size of current download
|
||||||
downloadPercent Int? // 0-100
|
downloadPercent Int? // 0-100
|
||||||
lastActivityAt DateTime? // When activity was last updated
|
lastActivityAt DateTime? // When activity was last updated
|
||||||
|
currentTopicId BigInt? // Forum topic currently being processed (live "skip topic")
|
||||||
|
currentAccountChannelMapId String? // AccountChannelMap owning the topic being processed
|
||||||
|
|
||||||
account TelegramAccount @relation(fields: [accountId], references: [id])
|
account TelegramAccount @relation(fields: [accountId], references: [id])
|
||||||
packages Package[]
|
packages Package[]
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
SkipForward,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { triggerIngestion } from "../actions";
|
import { triggerIngestion, disableActiveTopic } from "../actions";
|
||||||
import type { IngestionAccountStatus } from "@/lib/telegram/types";
|
import type { IngestionAccountStatus } from "@/lib/telegram/types";
|
||||||
|
|
||||||
interface WorkerStatusPanelProps {
|
interface WorkerStatusPanelProps {
|
||||||
@@ -218,6 +219,24 @@ function RunningStatus({
|
|||||||
}: {
|
}: {
|
||||||
run: NonNullable<IngestionAccountStatus["currentRun"]>;
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -273,6 +292,27 @@ function RunningStatus({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 & disable this topic
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ──
|
// ── Account-Channel link actions ──
|
||||||
|
|
||||||
export async function linkChannel(
|
export async function linkChannel(
|
||||||
|
|||||||
@@ -550,6 +550,8 @@ export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> {
|
|||||||
totalBytes: currentRun.totalBytes?.toString() ?? null,
|
totalBytes: currentRun.totalBytes?.toString() ?? null,
|
||||||
downloadPercent: currentRun.downloadPercent,
|
downloadPercent: currentRun.downloadPercent,
|
||||||
lastActivityAt: currentRun.lastActivityAt?.toISOString() ?? null,
|
lastActivityAt: currentRun.lastActivityAt?.toISOString() ?? null,
|
||||||
|
currentTopicId: currentRun.currentTopicId?.toString() ?? null,
|
||||||
|
currentAccountChannelMapId: currentRun.currentAccountChannelMapId,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -123,5 +123,7 @@ export interface IngestionAccountStatus {
|
|||||||
totalBytes: string | null; // BigInt serialized as string
|
totalBytes: string | null; // BigInt serialized as string
|
||||||
downloadPercent: number | null;
|
downloadPercent: number | null;
|
||||||
lastActivityAt: string | null;
|
lastActivityAt: string | null;
|
||||||
|
currentTopicId: string | null; // BigInt serialized as string
|
||||||
|
currentAccountChannelMapId: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,6 +369,8 @@ export interface ActivityUpdate {
|
|||||||
downloadedBytes?: bigint | null;
|
downloadedBytes?: bigint | null;
|
||||||
totalBytes?: bigint | null;
|
totalBytes?: bigint | null;
|
||||||
downloadPercent?: number | null;
|
downloadPercent?: number | null;
|
||||||
|
currentTopicId?: bigint | null;
|
||||||
|
currentAccountChannelMapId?: string | null;
|
||||||
messagesScanned?: number;
|
messagesScanned?: number;
|
||||||
zipsFound?: number;
|
zipsFound?: number;
|
||||||
zipsDuplicate?: number;
|
zipsDuplicate?: number;
|
||||||
@@ -396,6 +398,10 @@ export async function updateRunActivity(
|
|||||||
...(activity.zipsFound !== undefined && { zipsFound: activity.zipsFound }),
|
...(activity.zipsFound !== undefined && { zipsFound: activity.zipsFound }),
|
||||||
...(activity.zipsDuplicate !== undefined && { zipsDuplicate: activity.zipsDuplicate }),
|
...(activity.zipsDuplicate !== undefined && { zipsDuplicate: activity.zipsDuplicate }),
|
||||||
...(activity.zipsIngested !== undefined && { zipsIngested: activity.zipsIngested }),
|
...(activity.zipsIngested !== undefined && { zipsIngested: activity.zipsIngested }),
|
||||||
|
...(activity.currentTopicId !== undefined && { currentTopicId: activity.currentTopicId }),
|
||||||
|
...(activity.currentAccountChannelMapId !== undefined && {
|
||||||
|
currentAccountChannelMapId: activity.currentAccountChannelMapId,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -410,6 +416,8 @@ const CLEAR_ACTIVITY = {
|
|||||||
downloadedBytes: null,
|
downloadedBytes: null,
|
||||||
totalBytes: null,
|
totalBytes: null,
|
||||||
downloadPercent: null,
|
downloadPercent: null,
|
||||||
|
currentTopicId: null,
|
||||||
|
currentAccountChannelMapId: null,
|
||||||
lastActivityAt: new Date(),
|
lastActivityAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -538,6 +538,8 @@ export async function runWorkerForAccount(
|
|||||||
currentActivity: `Enumerating topics in "${channelLabel}"`,
|
currentActivity: `Enumerating topics in "${channelLabel}"`,
|
||||||
currentStep: "scanning",
|
currentStep: "scanning",
|
||||||
currentChannel: channelLabel,
|
currentChannel: channelLabel,
|
||||||
|
currentTopicId: null,
|
||||||
|
currentAccountChannelMapId: null,
|
||||||
currentFile: null,
|
currentFile: null,
|
||||||
currentFileNum: null,
|
currentFileNum: null,
|
||||||
totalFiles: null,
|
totalFiles: null,
|
||||||
@@ -750,6 +752,8 @@ export async function runWorkerForAccount(
|
|||||||
currentActivity: `Scanning "${topicLabel}"${topicProgress}`,
|
currentActivity: `Scanning "${topicLabel}"${topicProgress}`,
|
||||||
currentStep: "scanning",
|
currentStep: "scanning",
|
||||||
currentChannel: channelLabel,
|
currentChannel: channelLabel,
|
||||||
|
currentTopicId: topic.topicId,
|
||||||
|
currentAccountChannelMapId: mapping.id,
|
||||||
currentFile: null,
|
currentFile: null,
|
||||||
currentFileNum: null,
|
currentFileNum: null,
|
||||||
totalFiles: null,
|
totalFiles: null,
|
||||||
@@ -830,7 +834,11 @@ export async function runWorkerForAccount(
|
|||||||
topic.name,
|
topic.name,
|
||||||
messageId
|
messageId
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
// shouldStop: re-read the live fetch flag before each archive
|
||||||
|
// set. A mid-run "disable topic" lets the current file finish,
|
||||||
|
// then skips the rest of this topic's archives.
|
||||||
|
async () => !(await isTopicFetchEnabled(mapping.id, topic.topicId))
|
||||||
);
|
);
|
||||||
// Sync client back in case it was recreated during upload stall recovery
|
// Sync client back in case it was recreated during upload stall recovery
|
||||||
client = pipelineCtx.client;
|
client = pipelineCtx.client;
|
||||||
@@ -922,6 +930,8 @@ export async function runWorkerForAccount(
|
|||||||
currentActivity: `Scanning "${channelLabel}" for new archives`,
|
currentActivity: `Scanning "${channelLabel}" for new archives`,
|
||||||
currentStep: "scanning",
|
currentStep: "scanning",
|
||||||
currentChannel: channelLabel,
|
currentChannel: channelLabel,
|
||||||
|
currentTopicId: null,
|
||||||
|
currentAccountChannelMapId: null,
|
||||||
currentFile: null,
|
currentFile: null,
|
||||||
currentFileNum: null,
|
currentFileNum: null,
|
||||||
totalFiles: null,
|
totalFiles: null,
|
||||||
@@ -1203,7 +1213,12 @@ async function processArchiveSets(
|
|||||||
* below any failed message ID in this scan). Used by the caller to
|
* below any failed message ID in this scan). Used by the caller to
|
||||||
* advance the channel/topic watermark incrementally — otherwise a long
|
* advance the channel/topic watermark incrementally — otherwise a long
|
||||||
* scan that gets killed by worker restart loses all progress. */
|
* scan that gets killed by worker restart loses all progress. */
|
||||||
onWatermarkAdvance?: (messageId: bigint) => Promise<void>
|
onWatermarkAdvance?: (messageId: bigint) => Promise<void>,
|
||||||
|
/** Optional cancellation check, polled before each archive set. When it
|
||||||
|
* resolves true, processing stops after the set currently in flight (that
|
||||||
|
* one completes; remaining sets in this scan are skipped). Used by the
|
||||||
|
* forum branch to honour a mid-run "disable topic". */
|
||||||
|
shouldStop?: () => Promise<boolean>
|
||||||
): Promise<{ maxProcessedId: bigint | null; minFailedId: bigint | null }> {
|
): Promise<{ maxProcessedId: bigint | null; minFailedId: bigint | null }> {
|
||||||
const { client, runId, channelTitle, channel, throttled, counters, accountLog } = ctx;
|
const { client, runId, channelTitle, channel, throttled, counters, accountLog } = ctx;
|
||||||
|
|
||||||
@@ -1281,6 +1296,16 @@ async function processArchiveSets(
|
|||||||
const indexedPackageRefs: IndexedPackageRef[] = [];
|
const indexedPackageRefs: IndexedPackageRef[] = [];
|
||||||
|
|
||||||
for (let setIdx = 0; setIdx < archiveSets.length; setIdx++) {
|
for (let setIdx = 0; setIdx < archiveSets.length; setIdx++) {
|
||||||
|
// Cooperative cancellation: if the caller signals stop (e.g. the topic was
|
||||||
|
// disabled mid-run), skip the remaining archive sets in this scan. The set
|
||||||
|
// processed in the previous iteration has already completed.
|
||||||
|
if (shouldStop && (await shouldStop())) {
|
||||||
|
accountLog.info(
|
||||||
|
{ channel: channelTitle, processed: setIdx, total: archiveSets.length },
|
||||||
|
"Stop signal received (topic disabled) — skipping remaining archive sets in this scan"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const packageId = await processOneArchiveSet(
|
const packageId = await processOneArchiveSet(
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
Reference in New Issue
Block a user