mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-10 22:01:16 +00:00
Add Rescan Channel option to channels tab
Co-authored-by: xCyanGrizzly <53275238+xCyanGrizzly@users.noreply.github.com>
This commit is contained in:
2
package-lock.json
generated
2
package-lock.json
generated
@@ -49,7 +49,7 @@
|
|||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
|||||||
@@ -58,6 +58,6 @@
|
|||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Power,
|
Power,
|
||||||
ArrowDownToLine,
|
ArrowDownToLine,
|
||||||
ArrowUpFromLine,
|
ArrowUpFromLine,
|
||||||
|
RefreshCcw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -23,12 +24,14 @@ interface ChannelColumnsProps {
|
|||||||
onToggleActive: (id: string) => void;
|
onToggleActive: (id: string) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
|
onSetType: (id: string, type: "SOURCE" | "DESTINATION") => void;
|
||||||
|
onRescan: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChannelColumns({
|
export function getChannelColumns({
|
||||||
onToggleActive,
|
onToggleActive,
|
||||||
onDelete,
|
onDelete,
|
||||||
onSetType,
|
onSetType,
|
||||||
|
onRescan,
|
||||||
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
|
}: ChannelColumnsProps): ColumnDef<ChannelRow, unknown>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -121,6 +124,14 @@ export function getChannelColumns({
|
|||||||
Set as Source
|
Set as Source
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{row.original.type === "SOURCE" && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onRescan(row.original.id)}
|
||||||
|
>
|
||||||
|
<RefreshCcw className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Rescan Channel
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onToggleActive(row.original.id)}
|
onClick={() => onToggleActive(row.original.id)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
deleteChannel,
|
deleteChannel,
|
||||||
toggleChannelActive,
|
toggleChannelActive,
|
||||||
setChannelType,
|
setChannelType,
|
||||||
|
rescanChannel,
|
||||||
} from "../actions";
|
} from "../actions";
|
||||||
import { DataTable } from "@/components/shared/data-table";
|
import { DataTable } from "@/components/shared/data-table";
|
||||||
import { DeleteDialog } from "@/components/shared/delete-dialog";
|
import { DeleteDialog } from "@/components/shared/delete-dialog";
|
||||||
@@ -22,6 +23,7 @@ interface ChannelsTabProps {
|
|||||||
export function ChannelsTab({ channels, globalDestination }: ChannelsTabProps) {
|
export function ChannelsTab({ channels, globalDestination }: ChannelsTabProps) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const [rescanId, setRescanId] = useState<string | null>(null);
|
||||||
|
|
||||||
const columns = getChannelColumns({
|
const columns = getChannelColumns({
|
||||||
onToggleActive: (id) => {
|
onToggleActive: (id) => {
|
||||||
@@ -39,6 +41,7 @@ export function ChannelsTab({ channels, globalDestination }: ChannelsTabProps) {
|
|||||||
else toast.error(result.error);
|
else toast.error(result.error);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onRescan: (id) => setRescanId(id),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { table } = useDataTable({
|
const { table } = useDataTable({
|
||||||
@@ -60,6 +63,19 @@ export function ChannelsTab({ channels, globalDestination }: ChannelsTabProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRescan = () => {
|
||||||
|
if (!rescanId) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await rescanChannel(rescanId);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Channel scan progress reset — it will be fully rescanned on the next sync");
|
||||||
|
setRescanId(null);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<DestinationCard destination={globalDestination} />
|
<DestinationCard destination={globalDestination} />
|
||||||
@@ -83,6 +99,16 @@ export function ChannelsTab({ channels, globalDestination }: ChannelsTabProps) {
|
|||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteDialog
|
||||||
|
open={!!rescanId}
|
||||||
|
onOpenChange={(open) => !open && setRescanId(null)}
|
||||||
|
title="Rescan Channel"
|
||||||
|
description="This will reset all scan progress for this channel. On the next sync the worker will re-process every message from the beginning. Packages that are already in the library will be skipped (deduplication by hash), but any missing files will be re-downloaded and re-uploaded. This may take a long time for large channels."
|
||||||
|
confirmLabel="Rescan"
|
||||||
|
onConfirm={handleRescan}
|
||||||
|
isLoading={isPending}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,6 +297,52 @@ export async function triggerChannelSync(): Promise<ActionResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all scan progress for a channel so the worker will re-process it
|
||||||
|
* from the very beginning on the next ingestion cycle.
|
||||||
|
*
|
||||||
|
* This clears:
|
||||||
|
* - `lastProcessedMessageId` on every AccountChannelMap linked to this channel
|
||||||
|
* - All TopicProgress records for those maps (for forum channels)
|
||||||
|
*/
|
||||||
|
export async function rescanChannel(channelId: string): Promise<ActionResult> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin.success) return admin;
|
||||||
|
|
||||||
|
const channel = await prisma.telegramChannel.findUnique({
|
||||||
|
where: { id: channelId },
|
||||||
|
});
|
||||||
|
if (!channel) return { success: false, error: "Channel not found" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find all account-channel maps for this channel
|
||||||
|
const maps = await prisma.accountChannelMap.findMany({
|
||||||
|
where: { channelId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapIds = maps.map((m) => m.id);
|
||||||
|
|
||||||
|
// Delete all topic progress records for these maps (forum channels)
|
||||||
|
if (mapIds.length > 0) {
|
||||||
|
await prisma.topicProgress.deleteMany({
|
||||||
|
where: { accountChannelMapId: { in: mapIds } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the scan cursor so the worker re-processes from the start
|
||||||
|
await prisma.accountChannelMap.updateMany({
|
||||||
|
where: { channelId },
|
||||||
|
data: { lastProcessedMessageId: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(REVALIDATE_PATH);
|
||||||
|
return { success: true, data: undefined };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to reset channel scan progress" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Account-Channel link actions ──
|
// ── Account-Channel link actions ──
|
||||||
|
|
||||||
export async function linkChannel(
|
export async function linkChannel(
|
||||||
@@ -377,7 +423,7 @@ export async function triggerIngestion(
|
|||||||
try {
|
try {
|
||||||
await prisma.$queryRawUnsafe(
|
await prisma.$queryRawUnsafe(
|
||||||
`SELECT pg_notify('ingestion_trigger', $1)`,
|
`SELECT pg_notify('ingestion_trigger', $1)`,
|
||||||
accounts.map((a) => a.id).join(",")
|
accounts.map((a: { id: string }) => a.id).join(",")
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort
|
// Best-effort
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default async function TelegramPage() {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const serializedHistory = sendHistory.map((r) => ({
|
const serializedHistory = sendHistory.map((r: typeof sendHistory[number]) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
packageName: r.package.fileName,
|
packageName: r.package.fileName,
|
||||||
recipientName: r.telegramLink.telegramName,
|
recipientName: r.telegramLink.telegramName,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
await prisma.$queryRawUnsafe(
|
await prisma.$queryRawUnsafe(
|
||||||
`SELECT pg_notify('ingestion_trigger', $1)`,
|
`SELECT pg_notify('ingestion_trigger', $1)`,
|
||||||
accounts.map((a) => a.id).join(",")
|
accounts.map((a: { id: string }) => a.id).join(",")
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// pg_notify is best-effort — worker will pick up on next scheduled cycle anyway
|
// pg_notify is best-effort — worker will pick up on next scheduled cycle anyway
|
||||||
@@ -58,7 +58,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
triggered: true,
|
triggered: true,
|
||||||
accountIds: accounts.map((a) => a.id),
|
accountIds: accounts.map((a: { id: string }) => a.id),
|
||||||
message: `Ingestion triggered for ${accounts.length} account(s)`,
|
message: `Ingestion triggered for ${accounts.length} account(s)`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface DeleteDialogProps {
|
|||||||
description?: string;
|
description?: string;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
confirmLabel?: string;
|
||||||
|
confirmLoadingLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeleteDialog({
|
export function DeleteDialog({
|
||||||
@@ -27,6 +29,8 @@ export function DeleteDialog({
|
|||||||
description = "This action cannot be undone.",
|
description = "This action cannot be undone.",
|
||||||
onConfirm,
|
onConfirm,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
confirmLabel = "Delete",
|
||||||
|
confirmLoadingLabel,
|
||||||
}: DeleteDialogProps) {
|
}: DeleteDialogProps) {
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -42,7 +46,7 @@ export function DeleteDialog({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
{isLoading ? "Deleting..." : "Delete"}
|
{isLoading ? (confirmLoadingLabel ?? `${confirmLabel}...`) : confirmLabel}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user