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

+3
View File
@@ -2,6 +2,7 @@
# dependencies # dependencies
node_modules node_modules
.pnpm-store/
.pnp .pnp
.pnp.js .pnp.js
.idea/ .idea/
@@ -70,6 +71,8 @@ apps/nextjs/public/images/background.png
# personal agent config # personal agent config
.opencode .opencode
.agents/
skills-lock.json
# docusaurus # docusaurus
apps/docs/.docusaurus apps/docs/.docusaurus
@@ -1,37 +1,39 @@
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import type { ArchiveTeamWarriorStatus } from "@homarr/request-handler/archive-team-warrior"; import type { ArchiveTeamWarriorStatus } from "@homarr/integrations";
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior"; import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const inputSchema = z.object({ url: z.string().url() });
export const archiveTeamWarriorRouter = createTRPCRouter({ export const archiveTeamWarriorRouter = createTRPCRouter({
getStatus: publicProcedure.input(inputSchema).query(async ({ input }) => { getStatus: publicProcedure
const handler = archiveTeamWarriorRequestHandler.handler({ url: input.url }); .concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
const { data, timestamp } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); .query(async ({ ctx }) => {
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
const { data, timestamp } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { return {
status: data, status: data,
updatedAt: timestamp, updatedAt: timestamp,
};
}),
subscribeStatus: publicProcedure.input(inputSchema).subscription(({ input }) => {
return observable<{ status: ArchiveTeamWarriorStatus; updatedAt: Date }>((emit) => {
const handler = archiveTeamWarriorRequestHandler.handler({ url: input.url });
const unsubscribe = handler.subscribe((status) => {
emit.next({
status,
updatedAt: new Date(),
});
});
return () => {
unsubscribe();
}; };
}); }),
}),
subscribeStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
.subscription(({ ctx }) => {
return observable<{ status: ArchiveTeamWarriorStatus; updatedAt: Date }>((emit) => {
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
const unsubscribe = handler.subscribe((status) => {
emit.next({
status,
updatedAt: new Date(),
});
});
return () => {
unsubscribe();
};
});
}),
}); });
@@ -14,7 +14,6 @@ const removedIntegrationKinds = [
"gitHubContainerRegistry", "gitHubContainerRegistry",
"quay", "quay",
"searchCh", "searchCh",
"archiveTeamWarrior",
] as const; ] as const;
type RemovedIntegrationKind = (typeof removedIntegrationKinds)[number]; type RemovedIntegrationKind = (typeof removedIntegrationKinds)[number];
@@ -65,7 +64,7 @@ export async function migrateWidgetOnlyIntegrationsToOptionsAsync(db: Database)
}, new Map()); }, new Map());
const existingItems = await db.query.items.findMany({ const existingItems = await db.query.items.findMany({
where: (items, { inArray }) => inArray(items.kind, ["releases", "timetable", "archiveTeamWarrior"]), where: (items, { inArray }) => inArray(items.kind, ["releases", "timetable"]),
}); });
const updatedItems: { id: string; options: object }[] = []; const updatedItems: { id: string; options: object }[] = [];
@@ -97,11 +96,6 @@ export async function migrateWidgetOnlyIntegrationsToOptionsAsync(db: Database)
updatedItems.push({ id: item.id, options: { ...options, baseUrl: linkedIntegration.url } }); updatedItems.push({ id: item.id, options: { ...options, baseUrl: linkedIntegration.url } });
unlinkItemIds.push(item.id); unlinkItemIds.push(item.id);
} }
if (item.kind === "archiveTeamWarrior" && linkedIntegration?.kind === "archiveTeamWarrior") {
updatedItems.push({ id: item.id, options: { ...options, url: linkedIntegration.url } });
unlinkItemIds.push(item.id);
}
} }
for (const update of updatedItems) { for (const update of updatedItems) {
@@ -49,5 +49,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
gluetun: "gluetun", gluetun: "gluetun",
peaNut: "peanut", peaNut: "peanut",
beszel: "beszel", beszel: "beszel",
archiveTeamWarrior: null,
mock: null, mock: null,
}; };
+8
View File
@@ -425,6 +425,13 @@ export const integrationDefs = {
category: ["vpn"], category: ["vpn"],
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"), documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
}, },
archiveTeamWarrior: {
name: "ArchiveTeam Warrior",
secretKinds: [[], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"],
documentationUrl: null,
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: { mock: {
name: "Mock", name: "Mock",
@@ -532,6 +539,7 @@ export const integrationCategories = [
"speedtest", "speedtest",
"analytics", "analytics",
"vpn", "vpn",
"archiving",
"ups", "ups",
"documents", "documents",
"mediaLibrary", "mediaLibrary",
+2 -1
View File
@@ -73,5 +73,6 @@ export const normalizeReleaseProviderIdentifier = (provider: ReleaseProviderKind
? trimmedIdentifier.slice(registryPrefix.length) ? trimmedIdentifier.slice(registryPrefix.length)
: trimmedIdentifier; : trimmedIdentifier;
return removeContainerImageVersion(withoutRegistry); // ponytail: only strip container image versions/digests for providers that use them
return registryPrefix ? removeContainerImageVersion(withoutRegistry) : withoutRegistry;
}; };
@@ -38,6 +38,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
uptimeKuma: ["uptimeKuma"], uptimeKuma: ["uptimeKuma"],
audioStats: ["navidrome", "audiobookshelf"], audioStats: ["navidrome", "audiobookshelf"],
vpn: getIntegrationKindsByCategory("vpn"), vpn: getIntegrationKindsByCategory("vpn"),
archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"], anchorNote: ["anchor"],
}; };
@@ -0,0 +1,386 @@
import { Buffer } from "node:buffer";
import { randomUUID } from "node:crypto";
import WebSocket from "ws";
import type { RawData } from "ws";
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import {
bandwidthMessageSchema,
itemStatusMessageSchema,
pipelineStartItemMessageSchema,
projectRefreshMessageSchema,
seesawFrameSchema,
warriorBroadcastMessageSchema,
warriorProjectSelectedMessageSchema,
warriorStatusMessageSchema,
} from "./archive-team-warrior-schemas";
import type { SeesawFrame, WarriorItem, WarriorProject } from "./archive-team-warrior-schemas";
import type { ArchiveTeamWarriorItem, ArchiveTeamWarriorStatus } from "./archive-team-warrior-types";
const SOCKJS_SNAPSHOT_TIMEOUT_MS = 2500;
interface WarriorSnapshotState {
status: string;
runnerStatus?: string;
selectedProject: string | null;
broadcastMessage: string | null;
project: ArchiveTeamWarriorStatus["project"];
bandwidth: ArchiveTeamWarriorStatus["bandwidth"];
items: Map<string, ArchiveTeamWarriorItem>;
}
export class ArchiveTeamWarriorIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/index.html"), {
headers: this.createAuthHeaders(),
});
if (!response.ok) return TestConnectionError.StatusResult(response);
return { success: true };
}
public async getStatusAsync(): Promise<ArchiveTeamWarriorStatus> {
const baseUrl = this.url("/").toString().replace(/\/$/, "");
await fetchWithTrustedCertificatesAsync(`${baseUrl}/index.html`, {
headers: this.createAuthHeaders(),
});
return await this.readSockJsSnapshotAsync(baseUrl);
}
private async readSockJsSnapshotAsync(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(this.createSockJsWebSocketUrl(baseUrl), {
headers: this.createAuthHeaders(),
});
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) => {
const frames = this.parseSockJsMessage(this.decodeWebSocketData(data));
for (const frame of frames) {
this.applyFrameToSnapshot(frame, snapshot);
}
});
socket.on("error", fail);
socket.on("close", finish);
});
return this.createStatusFromSnapshot(snapshot);
}
private applyFrameToSnapshot(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
switch (frame.event_name) {
case "warrior.status":
this.applyWarriorStatusFrame(frame, snapshot);
break;
case "warrior.project_selected":
this.applyProjectSelectedFrame(frame, snapshot);
break;
case "warrior.broadcast_message":
this.applyBroadcastMessageFrame(frame, snapshot);
break;
case "runner.status":
this.applyRunnerStatusFrame(frame, snapshot);
break;
case "project.refresh":
this.applyProjectRefreshFrame(frame, snapshot);
break;
case "pipeline.start_item":
this.applyPipelineStartItemFrame(frame, snapshot);
break;
case "item.complete":
this.applyItemStatusFrame(frame, snapshot, "completed");
break;
case "item.fail":
this.applyItemStatusFrame(frame, snapshot, "failed");
break;
case "item.cancel":
this.applyItemStatusFrame(frame, snapshot, "canceled");
break;
case "bandwidth":
this.applyBandwidthFrame(frame, snapshot);
break;
}
}
private applyWarriorStatusFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
const message = warriorStatusMessageSchema.safeParse(frame.message);
if (!message.success) return;
snapshot.status = this.normalizeDisplayStatus(message.data.status);
}
private applyProjectSelectedFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
const message = warriorProjectSelectedMessageSchema.safeParse(frame.message);
if (!message.success) return;
snapshot.selectedProject = message.data.project;
}
private applyBroadcastMessageFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
const message = warriorBroadcastMessageSchema.safeParse(frame.message);
if (!message.success) return;
snapshot.broadcastMessage = message.data.message;
}
private applyRunnerStatusFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
const message = warriorStatusMessageSchema.safeParse(frame.message);
if (!message.success) return;
snapshot.runnerStatus = this.normalizeDisplayStatus(message.data.status);
}
private applyProjectRefreshFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
const message = projectRefreshMessageSchema.safeParse(frame.message);
if (!message.success) return;
snapshot.project = this.mapProject(message.data?.project);
snapshot.runnerStatus = message.data?.status
? this.normalizeDisplayStatus(message.data.status)
: snapshot.runnerStatus;
for (const item of message.data?.items ?? []) {
snapshot.items.set(item.id, this.mapItem(item));
}
}
private applyPipelineStartItemFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
const message = pipelineStartItemMessageSchema.safeParse(frame.message);
if (!message.success) return;
snapshot.items.set(message.data.item.id, this.mapItem(message.data.item));
}
private applyItemStatusFrame(
frame: SeesawFrame,
snapshot: WarriorSnapshotState,
status: ArchiveTeamWarriorItem["status"],
) {
const message = itemStatusMessageSchema.safeParse(frame.message);
if (!message.success) return;
this.updateItemStatus(snapshot.items, message.data.item_id, status);
}
private applyBandwidthFrame(frame: SeesawFrame, snapshot: WarriorSnapshotState) {
const message = bandwidthMessageSchema.safeParse(frame.message);
if (!message.success) return;
snapshot.bandwidth = message.data;
}
private 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(),
};
}
private createAuthHeaders() {
const authorization = this.createBasicAuthHeader();
return authorization ? { Authorization: authorization } : undefined;
}
private createBasicAuthHeader() {
if (!this.hasSecretValue("username") || !this.hasSecretValue("password")) return undefined;
const username = this.getSecretValue("username");
const password = this.getSecretValue("password");
const credentials = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
return `Basic ${credentials}`;
}
private 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");
}
private parseSockJsMessage(raw: string): SeesawFrame[] {
if (raw === "o" || raw === "h" || raw.length === 0) return [];
if (!raw.startsWith("a")) return [];
const rawPayload = this.parseJsonSafely(raw.slice(1));
const payload = z.array(z.string()).safeParse(rawPayload);
if (!payload.success) return [];
return payload.data.flatMap((frame) => {
const rawFrame = this.parseJsonSafely(frame);
const result = seesawFrameSchema.safeParse(rawFrame);
return result.success ? [result.data] : [];
});
}
private parseJsonSafely(value: string) {
try {
return JSON.parse(value) as unknown;
} catch {
return null;
}
}
private createSockJsWebSocketUrl(baseUrl: string) {
const url = new URL(baseUrl);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.pathname = `/${Date.now()}/${randomUUID()}/websocket`;
return url.toString();
}
private mapProject(project?: WarriorProject): ArchiveTeamWarriorStatus["project"] {
if (!project) return null;
return {
id: project.project_id?.toString(),
title: project.title ?? "Unknown Project",
};
}
private mapItem(item: WarriorItem): ArchiveTeamWarriorItem {
return {
id: item.id,
name: this.formatItemName(item.name),
status: this.normalizeItemStatus(item.status),
project: item.project,
startTime: item.start_time,
};
}
private 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());
}
}
private formatItemName(name: string) {
const cleaned = name.replace(/^Item\s+/i, "");
const parts = cleaned.split("\u0000").filter(Boolean);
if (parts.length === 0) return "Item";
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)}...`;
}
private normalizeItemStatus(status?: string | null): ArchiveTeamWarriorItem["status"] {
if (!status) return "running";
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";
default:
return "unknown";
}
}
private updateItemStatus(
items: Map<string, ArchiveTeamWarriorItem>,
itemId: string,
status: ArchiveTeamWarriorItem["status"],
) {
const item = items.get(itemId);
if (!item) return;
items.set(itemId, {
...item,
status,
});
}
}
@@ -0,0 +1,61 @@
import { z } from "zod/v4";
export const seesawFrameSchema = z.object({
event_name: z.string(),
message: z.unknown(),
});
export const warriorStatusMessageSchema = z.object({
status: z.string(),
});
export const warriorBroadcastMessageSchema = z.object({
message: z.string().nullable(),
});
export const warriorProjectSelectedMessageSchema = z.object({
project: z.string().nullable(),
});
export const warriorProjectSchema = z.object({
project_id: z.number().optional(),
title: z.string().optional(),
project_html: z.string().optional(),
utc_deadline: z.string().nullable().optional(),
});
export 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(),
});
export const projectRefreshMessageSchema = z
.object({
project: warriorProjectSchema.optional(),
status: z.string().optional(),
items: z.array(warriorItemSchema).optional(),
})
.nullable();
export const itemStatusMessageSchema = z.object({
item_id: z.string(),
});
export const pipelineStartItemMessageSchema = z.object({
item: warriorItemSchema,
});
export 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(),
});
export type WarriorProject = z.infer<typeof warriorProjectSchema>;
export type WarriorItem = z.infer<typeof warriorItemSchema>;
export type SeesawFrame = z.infer<typeof seesawFrameSchema>;
@@ -0,0 +1,37 @@
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;
}
@@ -15,6 +15,7 @@ import { SlskdIntegration } from "../download-client/slskd/slskd-integration";
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration"; import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
import { EmbyIntegration } from "../emby/emby-integration"; import { EmbyIntegration } from "../emby/emby-integration";
import { GlancesIntegration } from "../glances/glances-integration"; import { GlancesIntegration } from "../glances/glances-integration";
import { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration";
import { GluetunIntegration } from "../gluetun/gluetun-integration"; import { GluetunIntegration } from "../gluetun/gluetun-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { ICalIntegration } from "../ical/ical-integration"; import { ICalIntegration } from "../ical/ical-integration";
@@ -120,6 +121,7 @@ export const integrationCreators = {
navidrome: NavidromeIntegration, navidrome: NavidromeIntegration,
umami: UmamiIntegration, umami: UmamiIntegration,
gluetun: GluetunIntegration, gluetun: GluetunIntegration,
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
uptimeKuma: UptimeKumaIntegration, uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration, peaNut: PeaNutIntegration,
beszel: BeszelIntegration, beszel: BeszelIntegration,
+6
View File
@@ -1,4 +1,5 @@
// General integrations // General integrations
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"; export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration"; export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
export { AnchorIntegration } from "./anchor/anchor-integration"; export { AnchorIntegration } from "./anchor/anchor-integration";
@@ -79,6 +80,11 @@ export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshel
export type { NavidromeDashboardData } from "./navidrome/navidrome-types"; export type { NavidromeDashboardData } from "./navidrome/navidrome-types";
export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types"; export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types";
export type { UmamiVisitorStats } from "./umami/umami-types"; export type { UmamiVisitorStats } from "./umami/umami-types";
export type {
ArchiveTeamWarriorBandwidth,
ArchiveTeamWarriorItem,
ArchiveTeamWarriorStatus,
} from "./archive-team-warrior/archive-team-warrior-types";
// Schemas // Schemas
export { anchorNotesListInputSchema } from "./anchor/anchor-types"; export { anchorNotesListInputSchema } from "./anchor/anchor-types";
-2
View File
@@ -33,12 +33,10 @@
"octokit": "catalog:", "octokit": "catalog:",
"superjson": "catalog:", "superjson": "catalog:",
"undici": "catalog:", "undici": "catalog:",
"ws": "catalog:",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }
@@ -1,336 +1,20 @@
import { randomUUID } from "node:crypto";
import { Buffer } from "node:buffer";
import dayjs from "dayjs"; 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 type { IntegrationKindByCategory } from "@homarr/definitions";
import { createWidgetOptionsChannel } from "@homarr/redis"; 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 const archiveTeamWarriorRequestHandler = createCachedIntegrationRequestHandler<
ArchiveTeamWarriorStatus,
export interface ArchiveTeamWarriorItem { IntegrationKindByCategory<"archiving">,
id: string; Record<string, never>
name: string; >({
status: "running" | "completed" | "failed" | "canceled" | "unknown"; async requestAsync(integration) {
project?: string | null; const integrationInstance = await createIntegrationAsync(integration);
startTime?: number; return await integrationInstance.getStatusAsync();
} },
cacheDuration: dayjs.duration(5, "minute"),
export interface ArchiveTeamWarriorBandwidth { queryKey: "archiveTeamWarriorStatus",
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,
}); });
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>;
-3
View File
@@ -1755,9 +1755,6 @@
"name": "ArchiveTeam Warrior", "name": "ArchiveTeam Warrior",
"description": "Monitor ArchiveTeam Warrior status, projects, and active archival jobs.", "description": "Monitor ArchiveTeam Warrior status, projects, and active archival jobs.",
"option": { "option": {
"url": {
"label": "Warrior URL"
},
"showBroadcastMessage": { "showBroadcastMessage": {
"label": "Show broadcast messages" "label": "Show broadcast messages"
} }
@@ -4,33 +4,41 @@ import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/co
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { humanFileSize } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
const archiveTeamWarriorIconUrl = "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png"; export default function ArchiveTeamWarriorWidget({
integrationIds,
options,
}: WidgetComponentProps<"archiveTeamWarrior">) {
const integrationId = integrationIds[0];
export default function ArchiveTeamWarriorWidget({ options }: WidgetComponentProps<"archiveTeamWarrior">) { if (!integrationId) {
return <ArchiveTeamWarriorWidgetContent url={options.url} options={options} />; return null;
}
return <ArchiveTeamWarriorWidgetContent integrationId={integrationId} options={options} />;
} }
const ArchiveTeamWarriorWidgetContent = ({ const ArchiveTeamWarriorWidgetContent = ({
url, integrationId,
options, options,
}: { }: {
url: string; integrationId: string;
options: WidgetComponentProps<"archiveTeamWarrior">["options"]; options: WidgetComponentProps<"archiveTeamWarrior">["options"];
}) => { }) => {
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({
url, integrationId,
}); });
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
clientApi.widget.archiveTeamWarrior.subscribeStatus.useSubscription( clientApi.widget.archiveTeamWarrior.subscribeStatus.useSubscription(
{ url }, { integrationId },
{ {
onData: (next) => { onData: (next) => {
utils.widget.archiveTeamWarrior.getStatus.setData({ url }, next); utils.widget.archiveTeamWarrior.getStatus.setData({ integrationId }, next);
}, },
}, },
); );
@@ -42,7 +50,7 @@ const ArchiveTeamWarriorWidgetContent = ({
<Stack p="xs" gap="xs" h="100%"> <Stack p="xs" gap="xs" h="100%">
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap" miw={0}> <Group gap="xs" wrap="nowrap" miw={0}>
<Avatar size="sm" radius="md" src={archiveTeamWarriorIconUrl} /> <Avatar size="sm" radius="md" src={getIconUrl("archiveTeamWarrior")} />
<Text size="sm" c="dimmed" lineClamp={1}> <Text size="sm" c="dimmed" lineClamp={1}>
{projectName} {projectName}
</Text> </Text>
@@ -1,5 +1,4 @@
import { IconArchive } from "@tabler/icons-react"; import { IconArchive } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition"; import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options"; import { optionsBuilder } from "../options";
@@ -8,13 +7,10 @@ export const { definition, componentLoader } = createWidgetDefinition("archiveTe
icon: IconArchive, icon: IconArchive,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
url: factory.text({
defaultValue: "http://localhost:8001",
validate: z.string().url(),
}),
showBroadcastMessage: factory.switch({ showBroadcastMessage: factory.switch({
defaultValue: true, defaultValue: true,
}), }),
})); }));
}, },
supportedIntegrations: ["archiveTeamWarrior"],
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));
+3 -3
View File
@@ -531,7 +531,7 @@ const ExpandedDisplay = ({
p={10} p={10}
> >
<Group className="releases-repository-expanded-header" justify="space-between" align="center" gap="xs"> <Group className="releases-repository-expanded-header" justify="space-between" align="center" gap="xs">
<Text className="releases-repository-expanded-header-identifier" size="xs" c="iconColor" ff="monospace"> <Text className="releases-repository-expanded-header-identifier" size="xs" c="dimmed" ff="monospace">
{repository.identifier} {repository.identifier}
</Text> </Text>
@@ -549,7 +549,7 @@ const ExpandedDisplay = ({
<Text <Text
className="releases-repository-expanded-header-provider-name" className="releases-repository-expanded-header-provider-name"
size="xs" size="xs"
c="iconColor" c="dimmed"
ff="monospace" ff="monospace"
> >
{repository.providerMetadata.name} {repository.providerMetadata.name}
@@ -616,7 +616,7 @@ const ExpandedDisplay = ({
{repository.error && ( {repository.error && (
<> <>
<Divider className="releases-repository-expanded-error-divider" mx="30%" /> <Divider className="releases-repository-expanded-error-divider" mx="30%" />
<Title className="releases-repository-expanded-error-title" order={4} ta="center"> <Title className="releases-repository-expanded-error-title" order={4} ta="center" c="red">
{t("error.label")} {t("error.label")}
</Title> </Title>
<Text <Text