feat(integrations): add ArchiveTeam Warrior widget (#5784)
* feat(integrations): add ArchiveTeam Warrior widget * feat(integrations): add support for basic auth for archive warrior * refactor(integrations): address ArchiveTeam Warrior review feedback * Increase cache duration from 1 to 5 minutes --------- Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com>
This commit is contained in:
1 parent
2ae83458e6
commit
dd57ee2102
14 files changed
+689
No files matched your search
@@ -0,0 +1,39 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
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";
|
||||
|
||||
export const archiveTeamWarriorRouter = createTRPCRouter({
|
||||
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
|
||||
.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();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { lazy } from "@trpc/server";
|
||||
import { createTRPCRouter } from "../../trpc";
|
||||
|
||||
export const widgetRouter = createTRPCRouter({
|
||||
archiveTeamWarrior: lazy(() => import("./archive-team-warrior").then((mod) => mod.archiveTeamWarriorRouter)),
|
||||
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
|
||||
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
|
||||
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
|
||||
|
||||
@@ -489,6 +489,14 @@ export const integrationDefs = {
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
|
||||
defaultPort: 8090,
|
||||
},
|
||||
archiveTeamWarrior: {
|
||||
name: "ArchiveTeam Warrior",
|
||||
secretKinds: [[], ["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
|
||||
category: ["archiving"],
|
||||
defaultUrl: "http://localhost:8001",
|
||||
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",
|
||||
@@ -596,6 +604,7 @@ export const integrationCategories = [
|
||||
"mediaMonitoring",
|
||||
"speedtest",
|
||||
"analytics",
|
||||
"archiving",
|
||||
"ups",
|
||||
"documents",
|
||||
"mediaLibrary",
|
||||
|
||||
@@ -41,6 +41,7 @@ export const widgetKinds = [
|
||||
"uptimeKuma",
|
||||
"audioStats",
|
||||
"umami",
|
||||
"archiveTeamWarrior",
|
||||
"ups",
|
||||
"beszelSystemTable",
|
||||
"beszelSystemGrid",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||
import { BeszelIntegration } from "../beszel/beszel-integration";
|
||||
import { AnchorIntegration } from "../anchor/anchor-integration";
|
||||
import { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration";
|
||||
import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
|
||||
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
||||
import { CoolifyIntegration } from "../coolify/coolify-integration";
|
||||
@@ -136,6 +137,7 @@ export const integrationCreators = {
|
||||
audiobookshelf: AudiobookshelfIntegration,
|
||||
navidrome: NavidromeIntegration,
|
||||
umami: UmamiIntegration,
|
||||
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
|
||||
uptimeKuma: UptimeKumaIntegration,
|
||||
peaNut: PeaNutIntegration,
|
||||
beszel: BeszelIntegration,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// General integrations
|
||||
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
||||
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
|
||||
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
|
||||
export { AnchorIntegration } from "./anchor/anchor-integration";
|
||||
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
|
||||
@@ -74,6 +75,11 @@ export type {
|
||||
AnchorNotesListInput,
|
||||
AnchorNoteSummary,
|
||||
} from "./anchor/anchor-types";
|
||||
export type {
|
||||
ArchiveTeamWarriorBandwidth,
|
||||
ArchiveTeamWarriorItem,
|
||||
ArchiveTeamWarriorStatus,
|
||||
} from "./archive-team-warrior/archive-team-warrior-types";
|
||||
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
|
||||
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
|
||||
export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshelf-types";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import type { ArchiveTeamWarriorStatus } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
|
||||
export const archiveTeamWarriorRequestHandler = createCachedIntegrationRequestHandler<
|
||||
ArchiveTeamWarriorStatus,
|
||||
IntegrationKindByCategory<"archiving">,
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getStatusAsync();
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minute"),
|
||||
queryKey: "archiveTeamWarriorStatus",
|
||||
});
|
||||
@@ -1739,6 +1739,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"archiveTeamWarrior": {
|
||||
"name": "ArchiveTeam Warrior",
|
||||
"description": "Monitor ArchiveTeam Warrior status, projects, and active archival jobs.",
|
||||
"option": {
|
||||
"showBroadcastMessage": {
|
||||
"label": "Show broadcast messages"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dnsHoleSummary": {
|
||||
"name": "DNS Hole Summary",
|
||||
"description": "Displays the summary of your DNS Hole",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
export default function ArchiveTeamWarriorWidget({
|
||||
integrationIds,
|
||||
options,
|
||||
}: WidgetComponentProps<"archiveTeamWarrior">) {
|
||||
const integrationId = integrationIds[0];
|
||||
|
||||
if (!integrationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ArchiveTeamWarriorWidgetContent integrationId={integrationId} options={options} />;
|
||||
}
|
||||
|
||||
const ArchiveTeamWarriorWidgetContent = ({
|
||||
integrationId,
|
||||
options,
|
||||
}: {
|
||||
integrationId: string;
|
||||
options: WidgetComponentProps<"archiveTeamWarrior">["options"];
|
||||
}) => {
|
||||
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({
|
||||
integrationId,
|
||||
});
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
|
||||
clientApi.widget.archiveTeamWarrior.subscribeStatus.useSubscription(
|
||||
{ integrationId },
|
||||
{
|
||||
onData: (next) => {
|
||||
utils.widget.archiveTeamWarrior.getStatus.setData({ integrationId }, next);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const status = data.status;
|
||||
const projectName = status.project?.title ?? status.selectedProject ?? "No project selected";
|
||||
|
||||
return (
|
||||
<Stack p="xs" gap="xs" h="100%">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" miw={0}>
|
||||
<Avatar size="sm" radius="md" src={getIconUrl("archiveTeamWarrior")} />
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{projectName}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Badge size="sm" color={status.status === "Running" ? "green" : "gray"}>
|
||||
{status.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{options.showBroadcastMessage && status.broadcastMessage && (
|
||||
<Card withBorder p="xs">
|
||||
<Text size="xs" lineClamp={3}>
|
||||
{status.broadcastMessage}
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<Metric label="Running" value={status.counts.running} />
|
||||
<Metric label="Done" value={status.counts.completed} />
|
||||
<Metric label="Failed" value={status.counts.failed} />
|
||||
<Metric label="Canceled" value={status.counts.canceled} />
|
||||
</SimpleGrid>
|
||||
|
||||
{status.bandwidth && (
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
<Metric label="Download" value={formatBandwidth(status.bandwidth.receiving)} />
|
||||
<Metric label="Upload" value={formatBandwidth(status.bandwidth.sending)} />
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const Metric = ({ label, value }: { label: string; value: number | string }) => (
|
||||
<Stack gap={0} miw={0}>
|
||||
<Text size="md" fw={700} lineClamp={1}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const formatBandwidth = (value?: number) => humanFileSize(Math.round(value ?? 0), "/s");
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IconArchive } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", {
|
||||
icon: IconArchive,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showBroadcastMessage: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: ["archiveTeamWarrior"],
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -10,6 +10,7 @@ import type { SettingsContextProps } from "@homarr/settings/creator";
|
||||
import * as anchorNote from "./anchor-note";
|
||||
import * as audioStats from "./audio-stats";
|
||||
import * as app from "./app";
|
||||
import * as archiveTeamWarrior from "./archive-team-warrior";
|
||||
import * as bookmarks from "./bookmarks";
|
||||
import * as calendar from "./calendar";
|
||||
import * as clock from "./clock";
|
||||
@@ -67,6 +68,7 @@ export const widgetImports = {
|
||||
clock,
|
||||
weather,
|
||||
app,
|
||||
archiveTeamWarrior,
|
||||
anchorNote,
|
||||
notebook,
|
||||
iframe,
|
||||
|
||||
Reference in new issue
Block a user