diff --git a/packages/api/src/router/widgets/archive-team-warrior.ts b/packages/api/src/router/widgets/archive-team-warrior.ts new file mode 100644 index 000000000..0824c165a --- /dev/null +++ b/packages/api/src/router/widgets/archive-team-warrior.ts @@ -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(); + }; + }); + }), +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index c1c574646..70c1aa997 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -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)), diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 195fc81c5..8efa552e4 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -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", diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 1498cbffb..5fbaaacf8 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -41,6 +41,7 @@ export const widgetKinds = [ "uptimeKuma", "audioStats", "umami", + "archiveTeamWarrior", "ups", "beszelSystemTable", "beszelSystemGrid", diff --git a/packages/integrations/src/archive-team-warrior/archive-team-warrior-integration.ts b/packages/integrations/src/archive-team-warrior/archive-team-warrior-integration.ts new file mode 100644 index 000000000..5f95b6d34 --- /dev/null +++ b/packages/integrations/src/archive-team-warrior/archive-team-warrior-integration.ts @@ -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; +} + +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 ca0595651..9d997e7c3 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -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, diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 64d8776bc..66cf4be51 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -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"; diff --git a/packages/request-handler/src/archive-team-warrior.ts b/packages/request-handler/src/archive-team-warrior.ts new file mode 100644 index 000000000..4038ec9dc --- /dev/null +++ b/packages/request-handler/src/archive-team-warrior.ts @@ -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 +>({ + async requestAsync(integration) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getStatusAsync(); + }, + cacheDuration: dayjs.duration(5, "minute"), + queryKey: "archiveTeamWarriorStatus", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 98be3be86..b0a126733 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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", diff --git a/packages/widgets/src/archive-team-warrior/component.tsx b/packages/widgets/src/archive-team-warrior/component.tsx new file mode 100644 index 000000000..dacac283d --- /dev/null +++ b/packages/widgets/src/archive-team-warrior/component.tsx @@ -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 ; +} + +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 ( + + + + + + {projectName} + + + + + {status.status} + + + + {options.showBroadcastMessage && status.broadcastMessage && ( + + + {status.broadcastMessage} + + + )} + + + + + + + + + {status.bandwidth && ( + + + + + )} + + ); +}; + +const Metric = ({ label, value }: { label: string; value: number | string }) => ( + + + {value} + + + {label} + + +); + +const formatBandwidth = (value?: number) => humanFileSize(Math.round(value ?? 0), "/s"); diff --git a/packages/widgets/src/archive-team-warrior/index.ts b/packages/widgets/src/archive-team-warrior/index.ts new file mode 100644 index 000000000..5e0998ec2 --- /dev/null +++ b/packages/widgets/src/archive-team-warrior/index.ts @@ -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")); diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index dd389d719..628c67e32 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -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,