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:
18 files changed
+573
-388
No files matched your search
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
@@ -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"));
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user