fix: restore ArchiveTeam Warrior as a proper integration

The widget-only integrations refactor incorrectly removed the
ArchiveTeam Warrior integration entirely. It only needed to be
excluded from auto-seeding (by removing defaultUrl), not deleted.

This restores the integration class, reverts the widget/router/handler
to use integration middleware, and removes archiveTeamWarrior from the
widget-only-integrations migration.
This commit is contained in:
Thomas Camlong committed 2026-06-27 10:57:13 +02:00
1 parent d18d07d2f1
commit 8a5ad5861b
18 files changed
+573 -388

No files matched your search

-2
View File
@@ -33,12 +33,10 @@
"octokit": "catalog:",
"superjson": "catalog:",
"undici": "catalog:",
"ws": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "catalog:",
"typescript": "catalog:"
}
}
@@ -1,336 +1,20 @@
import { randomUUID } from "node:crypto";
import { Buffer } from "node:buffer";
import dayjs from "dayjs";
import WebSocket from "ws";
import type { RawData } from "ws";
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createWidgetOptionsChannel } from "@homarr/redis";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { ArchiveTeamWarriorStatus } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createCachedRequestHandler } from "./lib/cached-request-handler";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
const SOCKJS_SNAPSHOT_TIMEOUT_MS = 2500;
export interface ArchiveTeamWarriorItem {
id: string;
name: string;
status: "running" | "completed" | "failed" | "canceled" | "unknown";
project?: string | null;
startTime?: number;
}
export interface ArchiveTeamWarriorBandwidth {
received?: number;
sent?: number;
receiving?: number;
sending?: number;
session_id?: string;
}
export interface ArchiveTeamWarriorProject {
id?: string;
title: string;
}
export interface ArchiveTeamWarriorStatus {
status: string;
runnerStatus?: string;
project?: ArchiveTeamWarriorProject | null;
selectedProject: string | null;
broadcastMessage: string | null;
bandwidth?: ArchiveTeamWarriorBandwidth | null;
items: ArchiveTeamWarriorItem[];
counts: { running: number; completed: number; failed: number; canceled: number };
updatedAt: string;
}
interface WarriorSnapshotState {
status: string;
runnerStatus?: string;
selectedProject: string | null;
broadcastMessage: string | null;
project: ArchiveTeamWarriorStatus["project"];
bandwidth: ArchiveTeamWarriorStatus["bandwidth"];
items: Map<string, ArchiveTeamWarriorItem>;
}
export const archiveTeamWarriorRequestHandler = {
handler: (itemOptions: { url: string }) =>
createCachedRequestHandler<ArchiveTeamWarriorStatus, { url: string }>({
async requestAsync(input) {
return await getStatusAsync(input.url);
},
cacheDuration: dayjs.duration(5, "minute"),
queryKey: "archiveTeamWarriorStatus",
createRedisChannel(input, handlerOptions) {
return createWidgetOptionsChannel<ArchiveTeamWarriorStatus>(
"archiveTeamWarrior",
handlerOptions.queryKey,
input,
);
},
}).handler(itemOptions),
};
const getStatusAsync = async (url: string): Promise<ArchiveTeamWarriorStatus> => {
const baseUrl = url.replace(/\/$/, "");
await fetchWithTrustedCertificatesAsync(`${baseUrl}/index.html`);
return await readSockJsSnapshotAsync(baseUrl);
};
const readSockJsSnapshotAsync = async (baseUrl: string): Promise<ArchiveTeamWarriorStatus> => {
const snapshot: WarriorSnapshotState = {
status: "Unknown",
selectedProject: null,
broadcastMessage: null,
project: null,
bandwidth: null,
items: new Map<string, ArchiveTeamWarriorItem>(),
};
await new Promise<void>((resolve, reject) => {
let isSettled = false;
const socket = new WebSocket(createSockJsWebSocketUrl(baseUrl));
const finish = () => {
if (isSettled) return;
isSettled = true;
clearTimeout(timeout);
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close();
resolve();
};
const fail = () => {
if (isSettled) return;
isSettled = true;
clearTimeout(timeout);
reject(new Error("Failed to connect to ArchiveTeam Warrior websocket"));
};
const timeout = setTimeout(finish, SOCKJS_SNAPSHOT_TIMEOUT_MS);
socket.on("message", (data: RawData) => {
for (const frame of parseSockJsMessage(decodeWebSocketData(data))) {
applyFrameToSnapshot(frame, snapshot);
}
});
socket.on("error", fail);
socket.on("close", finish);
});
return createStatusFromSnapshot(snapshot);
};
const applyFrameToSnapshot = (frame: SeesawFrame, snapshot: WarriorSnapshotState) => {
switch (frame.event_name) {
case "warrior.status":
case "runner.status": {
const message = warriorStatusMessageSchema.safeParse(frame.message);
if (!message.success) return;
if (frame.event_name === "warrior.status") snapshot.status = normalizeDisplayStatus(message.data.status);
else snapshot.runnerStatus = normalizeDisplayStatus(message.data.status);
return;
}
case "warrior.project_selected": {
const message = warriorProjectSelectedMessageSchema.safeParse(frame.message);
if (message.success) snapshot.selectedProject = message.data.project;
return;
}
case "warrior.broadcast_message": {
const message = warriorBroadcastMessageSchema.safeParse(frame.message);
if (message.success) snapshot.broadcastMessage = message.data.message;
return;
}
case "project.refresh": {
const message = projectRefreshMessageSchema.safeParse(frame.message);
if (!message.success) return;
snapshot.project = mapProject(message.data?.project);
snapshot.runnerStatus = message.data?.status
? normalizeDisplayStatus(message.data.status)
: snapshot.runnerStatus;
for (const item of message.data?.items ?? []) snapshot.items.set(item.id, mapItem(item));
return;
}
case "pipeline.start_item": {
const message = pipelineStartItemMessageSchema.safeParse(frame.message);
if (message.success) snapshot.items.set(message.data.item.id, mapItem(message.data.item));
return;
}
case "item.complete":
updateItemStatus(frame, snapshot, "completed");
return;
case "item.fail":
updateItemStatus(frame, snapshot, "failed");
return;
case "item.cancel":
updateItemStatus(frame, snapshot, "canceled");
return;
case "bandwidth": {
const message = bandwidthMessageSchema.safeParse(frame.message);
if (message.success) snapshot.bandwidth = message.data;
}
}
};
const updateItemStatus = (
frame: SeesawFrame,
snapshot: WarriorSnapshotState,
status: ArchiveTeamWarriorItem["status"],
) => {
const message = itemStatusMessageSchema.safeParse(frame.message);
if (!message.success) return;
const item = snapshot.items.get(message.data.item_id);
if (item) snapshot.items.set(message.data.item_id, { ...item, status });
};
const createStatusFromSnapshot = (snapshot: WarriorSnapshotState): ArchiveTeamWarriorStatus => {
const itemList = Array.from(snapshot.items.values());
return {
status: snapshot.status,
runnerStatus: snapshot.runnerStatus,
project: snapshot.project,
selectedProject: snapshot.selectedProject,
broadcastMessage: snapshot.broadcastMessage,
bandwidth: snapshot.bandwidth,
items: itemList,
counts: {
running: itemList.filter((item) => item.status === "running").length,
completed: itemList.filter((item) => item.status === "completed").length,
failed: itemList.filter((item) => item.status === "failed").length,
canceled: itemList.filter((item) => item.status === "canceled").length,
},
updatedAt: new Date().toISOString(),
};
};
const decodeWebSocketData = (data: RawData) => {
if (typeof data === "string") return data;
if (Buffer.isBuffer(data)) return data.toString("utf8");
if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
return Buffer.from(data).toString("utf8");
};
const parseSockJsMessage = (raw: string): SeesawFrame[] => {
if (raw === "o" || raw === "h" || raw.length === 0) return [];
if (!raw.startsWith("a")) return [];
const payload = z.array(z.string()).safeParse(parseJsonSafely(raw.slice(1)));
if (!payload.success) return [];
return payload.data.flatMap((frame) => {
const result = seesawFrameSchema.safeParse(parseJsonSafely(frame));
return result.success ? [result.data] : [];
});
};
const parseJsonSafely = (value: string) => {
try {
return JSON.parse(value) as unknown;
} catch {
return null;
}
};
const createSockJsWebSocketUrl = (baseUrl: string) => {
const url = new URL(baseUrl);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.pathname = `/${Date.now()}/${randomUUID()}/websocket`;
return url.toString();
};
const mapProject = (project?: WarriorProject): ArchiveTeamWarriorStatus["project"] => {
if (!project) return null;
return { id: project.project_id?.toString(), title: project.title ?? "Unknown Project" };
};
const mapItem = (item: WarriorItem): ArchiveTeamWarriorItem => ({
id: item.id,
name: formatItemName(item.name),
status: normalizeItemStatus(item.status),
project: item.project,
startTime: item.start_time,
export const archiveTeamWarriorRequestHandler = createCachedIntegrationRequestHandler<
ArchiveTeamWarriorStatus,
IntegrationKindByCategory<"archiving">,
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatusAsync();
},
cacheDuration: dayjs.duration(5, "minute"),
queryKey: "archiveTeamWarriorStatus",
});
const normalizeDisplayStatus = (status?: string | null) => {
if (!status) return "Unknown";
switch (status) {
case "RUNNING_PROJECT":
return "Running";
case "STOPPED":
return "Stopped";
case "IDLE":
return "Idle";
default:
return status
.replaceAll("_", " ")
.toLowerCase()
.replace(/\b\w/g, (character) => character.toUpperCase());
}
};
const formatItemName = (name: string) => {
const parts = name
.replace(/^Item\s+/i, "")
.split("\u0000")
.filter(Boolean);
const first = parts[0];
if (!first) return "Item";
const label = parts.length === 1 ? first : `${first} + ${parts.length - 1} more`;
return label.length <= 80 ? label : `${label.slice(0, 77)}...`;
};
const normalizeItemStatus = (status?: string | null): ArchiveTeamWarriorItem["status"] => {
switch (status?.toLowerCase()) {
case "running":
case "running_project":
return "running";
case "completed":
return "completed";
case "failed":
case "fail":
return "failed";
case "canceled":
case "cancel":
return "canceled";
case undefined:
return "running";
default:
return "unknown";
}
};
const seesawFrameSchema = z.object({ event_name: z.string(), message: z.unknown() });
const warriorStatusMessageSchema = z.object({ status: z.string() });
const warriorBroadcastMessageSchema = z.object({ message: z.string().nullable() });
const warriorProjectSelectedMessageSchema = z.object({ project: z.string().nullable() });
const warriorProjectSchema = z.object({
project_id: z.number().optional(),
title: z.string().optional(),
project_html: z.string().optional(),
utc_deadline: z.string().nullable().optional(),
});
const warriorItemSchema = z.object({
id: z.string(),
name: z.string(),
status: z.string().nullable().optional(),
project: z.string().nullable().optional(),
start_time: z.number().optional(),
});
const projectRefreshMessageSchema = z
.object({
project: warriorProjectSchema.optional(),
status: z.string().optional(),
items: z.array(warriorItemSchema).optional(),
})
.nullable();
const itemStatusMessageSchema = z.object({ item_id: z.string() });
const pipelineStartItemMessageSchema = z.object({ item: warriorItemSchema });
const bandwidthMessageSchema = z.object({
received: z.number().optional(),
sent: z.number().optional(),
receiving: z.number().optional(),
sending: z.number().optional(),
session_id: z.string().optional(),
});
type WarriorProject = z.infer<typeof warriorProjectSchema>;
type WarriorItem = z.infer<typeof warriorItemSchema>;
type SeesawFrame = z.infer<typeof seesawFrameSchema>;