Compare commits

...
Author SHA1 Message Date
manuel-rw db31f880c2 style: auto-fix lint and format 2026-07-01 09:36:02 +00:00
Manuel b902f93a3c feat: add widgets 2026-07-01 11:34:43 +02:00
manuel-rw 973baedfcb style: auto-fix lint and format 2026-07-01 09:05:27 +00:00
Manuel 629f3b0955 feat: add dawarich integration 2026-07-01 11:04:12 +02:00
23 changed files with 392 additions and 0 deletions

No files matched your search

@@ -0,0 +1,27 @@
import { z } from "zod/v4";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const dawarichRouter = createTRPCRouter({
getStatistics: publicProcedure.concat(createOneIntegrationMiddleware("query", "dawarich")).query(async ({ ctx }) => {
const { dawarichStatisticsRequestHandler } = await import("@homarr/request-handler/dawarich");
const innerHandler = dawarichStatisticsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data.data;
}),
getPlaces: publicProcedure
.concat(createOneIntegrationMiddleware("query", "dawarich"))
.input(
z.object({
limit: z.number().min(1).max(50).default(10),
}),
)
.query(async ({ ctx, input }) => {
const { dawarichPlacesRequestHandler } = await import("@homarr/request-handler/dawarich");
const innerHandler = dawarichPlacesRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data.slice(0, input.limit);
}),
});
+1
View File
@@ -39,4 +39,5 @@ export const widgetRouter = createTRPCRouter({
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
dawarich: lazy(() => import("./dawarich").then((mod) => mod.dawarichRouter)),
});
@@ -343,6 +343,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/gluetun"
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/integrations/dawarich"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
| "/docs/widgets/app"
@@ -59,5 +59,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
peaNut: "peanut",
beszel: "beszel",
archiveTeamWarrior: null,
dawarich: "dawarich",
mock: null,
};
@@ -51,4 +51,6 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
beszelAlerts: "beszel-alerts",
beszelSystemStats: "beszel-system-stats",
customApi: "custom-api",
dawarichStatistics: null,
dawarichPlaces: null,
};
+8
View File
@@ -504,6 +504,13 @@ export const integrationDefs = {
defaultUrl: "http://localhost:8001",
documentationUrl: createDocumentationLink("/docs/integrations/archiveteam-warrior"),
},
dawarich: {
name: "Dawarch",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/dawarich.png",
category: ["geolocationTracking"],
documentationUrl: createDocumentationLink("/docs/integrations/dawarich"),
},
// 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",
@@ -617,6 +624,7 @@ export const integrationCategories = [
"documents",
"mediaLibrary",
"uptimeMonitoring",
"geolocationTracking",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
@@ -40,6 +40,8 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
audioStats: ["navidrome", "audiobookshelf"],
vpn: getIntegrationKindsByCategory("vpn"),
anchorNote: ["anchor"],
dawarichStatistics: ["dawarich"],
dawarichPlaces: ["dawarich"],
};
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
+2
View File
@@ -49,6 +49,8 @@ export const widgetKinds = [
"beszelAlerts",
"beszelSystemStats",
"customApi",
"dawarichStatistics",
"dawarichPlaces",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -60,6 +60,7 @@ import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import { UnraidIntegration } from "../unraid/unraid-integration";
import type { Integration, IntegrationInput } from "./integration";
import { DawarichIntegration } from "../dawarich/dawarich-integration";
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
integration: IntegrationInput & { kind: TKind },
@@ -143,6 +144,7 @@ export const integrationCreators = {
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
dawarich: DawarichIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
@@ -0,0 +1,52 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { ResponseError } from "@homarr/common/server";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { dawarichPlacesSchema, dawarichStatisticsSchema } from "./dawarich-schemas";
import type { z } from "zod/v4";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
export class DawarichIntegration extends Integration {
constructor(integration: IntegrationInput) {
super(integration);
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/users/me`), {
headers: { Authorization: `Bearer ${this.getSecretValue("apiKey")}` },
});
if (response.status === 200) {
return { success: true };
}
return TestConnectionError.StatusResult(response);
}
async getStatisticsAsync(): Promise<{
totalDistanceKm: number;
totalPointsTracked: number;
totalReverseGeocodedPoints: number;
totalCountriesVisited: number;
totalCitiesVisited: number;
}> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/stats"), {
headers: { Authorization: `Bearer ${this.getSecretValue("apiKey")}` },
});
if (!response.ok) throw new ResponseError(response);
return dawarichStatisticsSchema.parseAsync(await response.json());
}
async getPlacesAsync(): Promise<z.infer<typeof dawarichPlacesSchema>> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/places"), {
headers: { Authorization: `Bearer ${this.getSecretValue("apiKey")}` },
});
if (!response.ok) throw new ResponseError(response);
return dawarichPlacesSchema.parseAsync(await response.json());
}
}
@@ -0,0 +1,31 @@
import { z } from "zod/v4";
export const dawarichStatisticsSchema = z.object({
totalDistanceKm: z.number(),
totalPointsTracked: z.number(),
totalReverseGeocodedPoints: z.number(),
totalCountriesVisited: z.number(),
totalCitiesVisited: z.number(),
});
export const dawarichPlaceTagSchema = z.object({
id: z.number(),
name: z.string(),
icon: z.string(),
color: z.string(),
});
export const dawarichPlaceSchema = z.object({
id: z.number(),
name: z.string(),
latitude: z.number(),
longitude: z.number(),
source: z.string().optional(),
icon: z.string().nullable(),
color: z.string().nullable(),
visits_count: z.number(),
created_at: z.string(),
tags: z.array(dawarichPlaceTagSchema),
});
export const dawarichPlacesSchema = z.array(dawarichPlaceSchema);
@@ -0,0 +1,5 @@
import type { z } from "zod/v4";
import type { dawarichPlacesSchema, dawarichStatisticsSchema } from "./dawarich-schemas";
export type DawarichStatistics = z.infer<typeof dawarichStatisticsSchema>;
export type DawarichPlace = z.infer<typeof dawarichPlacesSchema>[number];
+2
View File
@@ -41,6 +41,7 @@ export { NavidromeIntegration } from "./navidrome/navidrome-integration";
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
export { DawarichIntegration } from "./dawarich/dawarich-integration";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -86,6 +87,7 @@ export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshel
export type { NavidromeDashboardData } from "./navidrome/navidrome-types";
export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types";
export type { UmamiVisitorStats } from "./umami/umami-types";
export type { DawarichStatistics, DawarichPlace } from "./dawarich/dawarich-types";
// Schemas
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
+1
View File
@@ -25,3 +25,4 @@ export * from "./paperless-ngx/paperless-ngx-types";
export * from "./umami/umami-types";
export * from "./gluetun/gluetun-types";
export * from "./beszel/beszel-types";
export * from "./dawarich/dawarich-types";
+32
View File
@@ -0,0 +1,32 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { DawarichPlace, DawarichStatistics } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const dawarichStatisticsRequestHandler = createCachedIntegrationRequestHandler<
DawarichStatistics,
"dawarich",
Record<string, never>
>({
async requestAsync(integration) {
const instance = await createIntegrationAsync(integration);
return await instance.getStatisticsAsync();
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "dawarichStatistics",
});
export const dawarichPlacesRequestHandler = createCachedIntegrationRequestHandler<
DawarichPlace[],
"dawarich",
Record<string, never>
>({
async requestAsync(integration) {
const instance = await createIntegrationAsync(integration);
return await instance.getPlacesAsync();
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "dawarichPlaces",
});
+36
View File
@@ -3545,6 +3545,42 @@
"executeSuccess": "Action executed successfully",
"executeFailed": "Action failed. Check the widget configuration."
},
"dawarichStatistics": {
"name": "Dawarich Statistics",
"description": "Display travel statistics from Dawarich",
"option": {
"showTotalDistance": {
"label": "Show total distance"
},
"showTotalPoints": {
"label": "Show total points tracked"
},
"showReverseGeocoded": {
"label": "Show reverse geocoded points"
},
"showCountries": {
"label": "Show countries visited"
},
"showCities": {
"label": "Show cities visited"
}
},
"totalDistance": "Total distance",
"totalPoints": "Points tracked",
"reverseGeocoded": "Reverse geocoded",
"countries": "Countries",
"cities": "Cities"
},
"dawarichPlaces": {
"name": "Dawarich Places",
"description": "Display recently visited places from Dawarich",
"option": {
"maxPlaces": {
"label": "Max places to show"
}
},
"visits": "visits"
},
"beszelSystemTable": {
"name": "Beszel Systems (Table)",
"description": "Table view of all Beszel-monitored systems with sortable columns and status indicators",
@@ -0,0 +1,10 @@
.placeItem {
padding: 8px 12px;
border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
transition: all 0.2s ease;
&:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
@@ -0,0 +1,40 @@
"use client";
import { Anchor, Group, Stack, Text } from "@mantine/core";
import { IconMapPin } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
export default function DawarichPlacesWidget({ integrationIds, options }: WidgetComponentProps<"dawarichPlaces">) {
const t = useI18n();
const { data: places } = clientApi.widget.dawarich.getPlaces.useQuery(
{
integrationId: integrationIds[0] ?? "",
limit: options.maxPlaces,
},
{ staleTime: 5 * 60 * 1000 },
);
if (!places || places.length === 0) return <WidgetEmptyState />;
return (
<Stack gap="xs" h="100%" p="md">
{places.map((place) => (
<Group key={place.id} gap="sm" align="center" className={classes.placeItem}>
<IconMapPin size={16} />
<Text size="sm" fw={500} style={{ flex: 1 }}>
{place.name}
</Text>
<Text size="xs" c="dimmed">
{place.visits_count} {t("widget.dawarichPlaces.visits")}
</Text>
</Group>
))}
</Stack>
);
}
@@ -0,0 +1,16 @@
import { IconMapPin } from "@tabler/icons-react";
import z from "zod";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("dawarichPlaces", {
icon: IconMapPin,
supportedIntegrations: ["dawarich"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
maxPlaces: factory.number({ defaultValue: 10, validate: z.number().min(1).max(50) }),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,10 @@
.statItem {
padding: 12px;
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
transition: all 0.2s ease;
&:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
@@ -0,0 +1,88 @@
"use client";
import { Group, Stack, Text } from "@mantine/core";
import { IconMap, IconMapPin, IconRoute, IconWorld, IconBuildingSkyscraper } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
export default function DawarichStatisticsWidget({
integrationIds,
options,
}: WidgetComponentProps<"dawarichStatistics">) {
const t = useI18n();
const { data: stats } = clientApi.widget.dawarich.getStatistics.useQuery(
{
integrationId: integrationIds[0] ?? "",
},
{ staleTime: 5 * 60 * 1000 },
);
if (!stats) return <WidgetEmptyState />;
return (
<Stack gap="md" h="100%" p="md">
{options.showTotalDistance && (
<StatItem
icon={<IconRoute size={20} />}
label={t("widget.dawarichStatistics.totalDistance")}
value={`${stats.totalDistanceKm.toLocaleString(undefined, { maximumFractionDigits: 1 })} km`}
/>
)}
{options.showTotalPoints && (
<StatItem
icon={<IconMap size={20} />}
label={t("widget.dawarichStatistics.totalPoints")}
value={stats.totalPointsTracked.toLocaleString()}
/>
)}
{options.showReverseGeocoded && (
<StatItem
icon={<IconMapPin size={20} />}
label={t("widget.dawarichStatistics.reverseGeocoded")}
value={stats.totalReverseGeocodedPoints.toLocaleString()}
/>
)}
{options.showCountries && (
<StatItem
icon={<IconWorld size={20} />}
label={t("widget.dawarichStatistics.countries")}
value={stats.totalCountriesVisited.toLocaleString()}
/>
)}
{options.showCities && (
<StatItem
icon={<IconBuildingSkyscraper size={20} />}
label={t("widget.dawarichStatistics.cities")}
value={stats.totalCitiesVisited.toLocaleString()}
/>
)}
</Stack>
);
}
interface StatItemProps {
icon: React.ReactNode;
label: string;
value: string | number;
}
function StatItem({ icon, label, value }: StatItemProps) {
return (
<Group justify="space-between" align="center" className={classes.statItem}>
<Group gap="sm" align="center">
{icon}
<Text size="sm" fw={500}>
{label}
</Text>
</Group>
<Text size="sm" fw={700} c="var(--mantine-primary-color)">
{value}
</Text>
</Group>
);
}
@@ -0,0 +1,19 @@
import { IconChartBar } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("dawarichStatistics", {
icon: IconChartBar,
supportedIntegrations: ["dawarich"],
integrationsRequired: true,
createOptions() {
return optionsBuilder.from((factory) => ({
showTotalDistance: factory.switch({ defaultValue: true }),
showTotalPoints: factory.switch({ defaultValue: true }),
showReverseGeocoded: factory.switch({ defaultValue: true }),
showCountries: factory.switch({ defaultValue: true }),
showCities: factory.switch({ defaultValue: true }),
}));
},
}).withDynamicImport(() => import("./component"));
+4
View File
@@ -60,6 +60,8 @@ import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video";
import * as weather from "./weather";
import * as customApi from "./custom-api";
import * as dawarichStatistics from "./dawarich-statistics";
import * as dawarichPlaces from "./dawarich-places";
export type {
WidgetDefinition,
@@ -121,6 +123,8 @@ export const widgetImports = {
beszelAlerts,
beszelSystemStats,
customApi,
dawarichStatistics,
dawarichPlaces,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;