diff --git a/.gitignore b/.gitignore index 1bb62e979..61a76879e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies node_modules +.pnpm-store/ .pnp .pnp.js .idea/ @@ -70,6 +71,8 @@ apps/nextjs/public/images/background.png # personal agent config .opencode +.agents/ +skills-lock.json # docusaurus apps/docs/.docusaurus diff --git a/packages/api/src/router/widgets/archive-team-warrior.ts b/packages/api/src/router/widgets/archive-team-warrior.ts index 14dd6b681..0824c165a 100644 --- a/packages/api/src/router/widgets/archive-team-warrior.ts +++ b/packages/api/src/router/widgets/archive-team-warrior.ts @@ -1,37 +1,39 @@ 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 { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -const inputSchema = z.object({ url: z.string().url() }); - export const archiveTeamWarriorRouter = createTRPCRouter({ - getStatus: publicProcedure.input(inputSchema).query(async ({ input }) => { - const handler = archiveTeamWarriorRequestHandler.handler({ url: input.url }); - const { data, timestamp } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + getStatus: publicProcedure + .concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior")) + .query(async ({ ctx }) => { + const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {}); + const { data, timestamp } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); - return { - status: data, - 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(); + return { + status: data, + updatedAt: timestamp, }; - }); - }), + }), + + 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(); + }; + }); + }), }); diff --git a/packages/db/migrations/custom/0003_remove_widget_only_integrations.ts b/packages/db/migrations/custom/0003_remove_widget_only_integrations.ts index 030525639..b98fc35b5 100644 --- a/packages/db/migrations/custom/0003_remove_widget_only_integrations.ts +++ b/packages/db/migrations/custom/0003_remove_widget_only_integrations.ts @@ -14,7 +14,6 @@ const removedIntegrationKinds = [ "gitHubContainerRegistry", "quay", "searchCh", - "archiveTeamWarrior", ] as const; type RemovedIntegrationKind = (typeof removedIntegrationKinds)[number]; @@ -65,7 +64,7 @@ export async function migrateWidgetOnlyIntegrationsToOptionsAsync(db: Database) }, new Map()); 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 }[] = []; @@ -97,11 +96,6 @@ export async function migrateWidgetOnlyIntegrationsToOptionsAsync(db: Database) updatedItems.push({ id: item.id, options: { ...options, baseUrl: linkedIntegration.url } }); 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) { diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts index f7265d36b..ca19d4de6 100644 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ b/packages/definitions/src/docs/integration-doc-slugs.ts @@ -49,5 +49,6 @@ export const integrationDocSlugs: Record = { gluetun: "gluetun", peaNut: "peanut", beszel: "beszel", + archiveTeamWarrior: null, mock: null, }; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index acec307bb..1aac34fa9 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -425,6 +425,13 @@ export const integrationDefs = { category: ["vpn"], 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) mock: { name: "Mock", @@ -532,6 +539,7 @@ export const integrationCategories = [ "speedtest", "analytics", "vpn", + "archiving", "ups", "documents", "mediaLibrary", diff --git a/packages/definitions/src/release-provider.ts b/packages/definitions/src/release-provider.ts index 800e6d3be..3fa53cc8f 100644 --- a/packages/definitions/src/release-provider.ts +++ b/packages/definitions/src/release-provider.ts @@ -73,5 +73,6 @@ export const normalizeReleaseProviderIdentifier = (provider: ReleaseProviderKind ? trimmedIdentifier.slice(registryPrefix.length) : trimmedIdentifier; - return removeContainerImageVersion(withoutRegistry); + // ponytail: only strip container image versions/digests for providers that use them + return registryPrefix ? removeContainerImageVersion(withoutRegistry) : withoutRegistry; }; diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index 5d3597294..deb953781 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -38,6 +38,7 @@ export const widgetIntegrationSupport: Partial; +} + +export class ArchiveTeamWarriorIntegration extends Integration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + 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 { + 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 { + const snapshot: WarriorSnapshotState = { + status: "Unknown", + selectedProject: null, + broadcastMessage: null, + project: null, + bandwidth: null, + items: new Map(), + }; + + await new Promise((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, + itemId: string, + status: ArchiveTeamWarriorItem["status"], + ) { + const item = items.get(itemId); + if (!item) return; + + items.set(itemId, { + ...item, + status, + }); + } +} diff --git a/packages/integrations/src/archive-team-warrior/archive-team-warrior-schemas.ts b/packages/integrations/src/archive-team-warrior/archive-team-warrior-schemas.ts new file mode 100644 index 000000000..3c5f47fab --- /dev/null +++ b/packages/integrations/src/archive-team-warrior/archive-team-warrior-schemas.ts @@ -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; +export type WarriorItem = z.infer; +export type SeesawFrame = z.infer; diff --git a/packages/integrations/src/archive-team-warrior/archive-team-warrior-types.ts b/packages/integrations/src/archive-team-warrior/archive-team-warrior-types.ts new file mode 100644 index 000000000..a21df642d --- /dev/null +++ b/packages/integrations/src/archive-team-warrior/archive-team-warrior-types.ts @@ -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; +} diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 4ef291893..1a84134fc 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -15,6 +15,7 @@ import { SlskdIntegration } from "../download-client/slskd/slskd-integration"; import { TransmissionIntegration } from "../download-client/transmission/transmission-integration"; import { EmbyIntegration } from "../emby/emby-integration"; import { GlancesIntegration } from "../glances/glances-integration"; +import { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration"; import { GluetunIntegration } from "../gluetun/gluetun-integration"; import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; import { ICalIntegration } from "../ical/ical-integration"; @@ -120,6 +121,7 @@ export const integrationCreators = { navidrome: NavidromeIntegration, umami: UmamiIntegration, gluetun: GluetunIntegration, + archiveTeamWarrior: ArchiveTeamWarriorIntegration, uptimeKuma: UptimeKumaIntegration, peaNut: PeaNutIntegration, beszel: BeszelIntegration, diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 73fc3fbd1..8e20bf745 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -1,4 +1,5 @@ // General integrations +export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration"; export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"; export { TechnitiumDnsIntegration } from "./technitium/technitium-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 { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types"; export type { UmamiVisitorStats } from "./umami/umami-types"; +export type { + ArchiveTeamWarriorBandwidth, + ArchiveTeamWarriorItem, + ArchiveTeamWarriorStatus, +} from "./archive-team-warrior/archive-team-warrior-types"; // Schemas export { anchorNotesListInputSchema } from "./anchor/anchor-types"; diff --git a/packages/request-handler/package.json b/packages/request-handler/package.json index e98e0753c..29609d87d 100644 --- a/packages/request-handler/package.json +++ b/packages/request-handler/package.json @@ -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:" } } diff --git a/packages/request-handler/src/archive-team-warrior.ts b/packages/request-handler/src/archive-team-warrior.ts index a9193a995..4038ec9dc 100644 --- a/packages/request-handler/src/archive-team-warrior.ts +++ b/packages/request-handler/src/archive-team-warrior.ts @@ -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; -} - -export const archiveTeamWarriorRequestHandler = { - handler: (itemOptions: { url: string }) => - createCachedRequestHandler({ - async requestAsync(input) { - return await getStatusAsync(input.url); - }, - cacheDuration: dayjs.duration(5, "minute"), - queryKey: "archiveTeamWarriorStatus", - createRedisChannel(input, handlerOptions) { - return createWidgetOptionsChannel( - "archiveTeamWarrior", - handlerOptions.queryKey, - input, - ); - }, - }).handler(itemOptions), -}; - -const getStatusAsync = async (url: string): Promise => { - const baseUrl = url.replace(/\/$/, ""); - await fetchWithTrustedCertificatesAsync(`${baseUrl}/index.html`); - return await readSockJsSnapshotAsync(baseUrl); -}; - -const readSockJsSnapshotAsync = async (baseUrl: string): Promise => { - const snapshot: WarriorSnapshotState = { - status: "Unknown", - selectedProject: null, - broadcastMessage: null, - project: null, - bandwidth: null, - items: new Map(), - }; - - await new Promise((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 +>({ + 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; -type WarriorItem = z.infer; -type SeesawFrame = z.infer; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index d58f1310a..f4904b708 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1755,9 +1755,6 @@ "name": "ArchiveTeam Warrior", "description": "Monitor ArchiveTeam Warrior status, projects, and active archival jobs.", "option": { - "url": { - "label": "Warrior URL" - }, "showBroadcastMessage": { "label": "Show broadcast messages" } diff --git a/packages/widgets/src/archive-team-warrior/component.tsx b/packages/widgets/src/archive-team-warrior/component.tsx index 14a401175..dacac283d 100644 --- a/packages/widgets/src/archive-team-warrior/component.tsx +++ b/packages/widgets/src/archive-team-warrior/component.tsx @@ -4,33 +4,41 @@ import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/co import { clientApi } from "@homarr/api/client"; import { humanFileSize } from "@homarr/common"; +import { getIconUrl } from "@homarr/definitions"; 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">) { - return ; + if (!integrationId) { + return null; + } + + return ; } const ArchiveTeamWarriorWidgetContent = ({ - url, + integrationId, options, }: { - url: string; + integrationId: string; options: WidgetComponentProps<"archiveTeamWarrior">["options"]; }) => { const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ - url, + integrationId, }); const utils = clientApi.useUtils(); clientApi.widget.archiveTeamWarrior.subscribeStatus.useSubscription( - { url }, + { integrationId }, { onData: (next) => { - utils.widget.archiveTeamWarrior.getStatus.setData({ url }, next); + utils.widget.archiveTeamWarrior.getStatus.setData({ integrationId }, next); }, }, ); @@ -42,7 +50,7 @@ const ArchiveTeamWarriorWidgetContent = ({ - + {projectName} diff --git a/packages/widgets/src/archive-team-warrior/index.ts b/packages/widgets/src/archive-team-warrior/index.ts index 1368a6836..5e0998ec2 100644 --- a/packages/widgets/src/archive-team-warrior/index.ts +++ b/packages/widgets/src/archive-team-warrior/index.ts @@ -1,5 +1,4 @@ import { IconArchive } from "@tabler/icons-react"; -import { z } from "zod/v4"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -8,13 +7,10 @@ export const { definition, componentLoader } = createWidgetDefinition("archiveTe icon: IconArchive, createOptions() { return optionsBuilder.from((factory) => ({ - url: factory.text({ - defaultValue: "http://localhost:8001", - validate: z.string().url(), - }), showBroadcastMessage: factory.switch({ defaultValue: true, }), })); }, + supportedIntegrations: ["archiveTeamWarrior"], }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/releases/component.tsx b/packages/widgets/src/releases/component.tsx index 06c1318ee..31f46414a 100644 --- a/packages/widgets/src/releases/component.tsx +++ b/packages/widgets/src/releases/component.tsx @@ -531,7 +531,7 @@ const ExpandedDisplay = ({ p={10} > - + {repository.identifier} @@ -549,7 +549,7 @@ const ExpandedDisplay = ({ {repository.providerMetadata.name} @@ -616,7 +616,7 @@ const ExpandedDisplay = ({ {repository.error && ( <> - + <Title className="releases-repository-expanded-error-title" order={4} ta="center" c="red"> {t("error.label")}