feat: add the community-funded Homarr assistant provider (#6563)

* Add Homarr provider proxy foundation

* Complete Homarr provider integration

* Harden Homarr provider integration

* Address Homarr provider review feedback

* Address CodeRabbit provider feedback

* Localize provider errors

* Handle provider activity load failures

* docs: improve workshop and homepage

* Harden Homarr provider limits

* fix: harden the Homarr provider

* fix: address provider review feedback

* fix: clarify provider request accounting

* feat: complete the Homarr provider endpoint

* fix: terminate invalid provider streams
This commit is contained in:
Thomas authored and ajnart committed 2026-08-14 11:32:11 +02:00
1 parent b8f360c04e
commit 45d8bc9443
77 files changed
+4035 -763

No files matched your search

@@ -0,0 +1,41 @@
.link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.7rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 0.5rem;
background: var(--ifm-background-surface-color);
color: var(--ifm-font-color-base);
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25;
text-decoration: none;
transition:
background-color 150ms ease,
border-color 150ms ease;
}
.link:hover {
border-color: var(--ifm-color-emphasis-500);
background: var(--ifm-color-emphasis-100);
color: var(--ifm-font-color-base);
text-decoration: none;
}
.link:focus-visible {
outline: 2px solid var(--ifm-color-primary);
outline-offset: 2px;
}
.logo {
width: 1.1rem;
height: 1.1rem;
flex: 0 0 auto;
}
@media (prefers-reduced-motion: reduce) {
.link {
transition: none;
}
}
@@ -0,0 +1,10 @@
import Translate from "@docusaurus/Translate";
import styles from "./open-collective-link.module.css";
export const OpenCollectiveLink = () => (
<a className={styles.link} href="https://opencollective.com/homarr">
<img className={styles.logo} src="/img/opencollective.svg" alt="" aria-hidden />
<Translate id="workshop.provider.donate">Donate to Homarr on OpenCollective</Translate>
</a>
);
@@ -1,22 +1,100 @@
import { translate } from "@docusaurus/Translate";
import { IconBrandDocker, IconCpu, IconDeviceDesktopAnalytics } from "@tabler/icons-react";
import styles from "../../../../pages/index.module.css";
import { AppWidget } from "./widgets/app-widget";
import { ClockWidget } from "./widgets/clock-widget";
import { DownloadsWidget } from "./widgets/downloads-widget";
import { WeatherWidget } from "./widgets/weather-widget";
import { StockWidget } from "./widgets/stock-widget";
import { EntityStateWidget } from "./widgets/entity-state-widget";
import { StockWidget } from "./widgets/stock-widget";
import { WeatherWidget } from "./widgets/weather-widget";
import { WidgetCard } from "./widgets/card";
export const HeroCards = () => {
return (
<div className="argos-ignore hero-cards flex flex-wrap max-w-[504px] gap-y-4 gap-x-3 text-gray-700 dark:text-gray-300">
<div className={`argos-ignore hero-cards ${styles.heroCards}`}>
<StockWidget />
<AppWidget />
<AppWidget className="hidden 3xl:block" />
<WeatherWidget />
<DownloadsWidget />
<EntityStateWidget />
<AppWidget />
<AppWidget name="Plex" />
<AppWidget name="Jellyfin" />
<AppWidget name="Homeassistant" label="Home Assistant" />
<ClockWidget />
<SystemResourcesWidget />
<WeatherWidget />
<EntityStateWidget />
<AppWidget name="PiHole" label="Pi-hole" />
<AppWidget name="qBittorrent" />
<DownloadsWidget />
<DockerWidget />
<AppWidget name="Sonarr" />
<AppWidget name="Radarr" />
</div>
);
};
const heroLabels = {
systemResources: translate({ id: "homepage.preview.systemResources", message: "System resources" }),
online: translate({ id: "homepage.preview.online", message: "Online" }),
cpu: translate({ id: "homepage.preview.cpu", message: "CPU" }),
memory: translate({ id: "homepage.preview.memory", message: "Memory" }),
disk: translate({ id: "homepage.preview.disk", message: "Disk" }),
docker: translate({ id: "homepage.preview.docker", message: "Docker" }),
dockerSummary: translate({ id: "homepage.preview.dockerSummary", message: "2 running · 1 failed" }),
healthy: translate({ id: "homepage.preview.healthy", message: "Healthy" }),
running: translate({ id: "homepage.preview.running", message: "Running" }),
exited: translate({ id: "homepage.preview.exited", message: "Exited" }),
};
const SystemResourcesWidget = () => (
<WidgetCard width={2} className={styles.resourcesWidget}>
<div className={styles.widgetHeading}>
<span className={styles.widgetTitle}>
<IconDeviceDesktopAnalytics size={17} aria-hidden="true" />
{heroLabels.systemResources}
</span>
<span className={styles.onlineStatus}>{heroLabels.online}</span>
</div>
<ResourceMeter icon={<IconCpu size={15} aria-hidden="true" />} label={heroLabels.cpu} value={34} />
<ResourceMeter label={heroLabels.memory} value={62} />
<ResourceMeter label={heroLabels.disk} value={98} />
</WidgetCard>
);
const ResourceMeter = ({ icon, label, value }: { icon?: React.ReactNode; label: string; value: number }) => (
<div className={`${styles.resourceMeter} ${value >= 90 ? styles.resourceMeterCritical : ""}`}>
<div className={styles.resourceLabel}>
<span>
{icon}
{label}
</span>
<span>{value}%</span>
</div>
<progress className={styles.resourceTrack} aria-label={`${label} usage`} max={100} value={value} />
</div>
);
const DockerWidget = () => (
<WidgetCard width={2} className={styles.dockerWidget}>
<div className={styles.widgetHeading}>
<span className={styles.widgetTitle}>
<IconBrandDocker size={18} aria-hidden="true" />
{heroLabels.docker}
</span>
<span className={styles.dockerCount}>{heroLabels.dockerSummary}</span>
</div>
<div className={styles.containerList}>
{[
{ name: "immich", status: heroLabels.healthy, failed: false },
{ name: "jellyfin", status: heroLabels.running, failed: false },
{ name: "paperless", status: heroLabels.exited, failed: true },
].map(({ name, status, failed }) => (
<div className={`${styles.containerRow} ${failed ? styles.containerRowFailed : ""}`} key={name}>
<span className={styles.containerName}>{name}</span>
<span className={styles.containerStatus}>
<span className={`${styles.statusDot} ${failed ? styles.statusDotFailed : ""}`} aria-hidden="true" />
{status}
</span>
</div>
))}
</div>
</WidgetCard>
);
@@ -1,4 +1,5 @@
import Link from "@docusaurus/Link";
import { translate } from "@docusaurus/Translate";
import { IconDownload, IconExternalLink } from "@tabler/icons-react";
import clsx from "clsx";
import styles from "../../../../pages/index.module.css";
@@ -7,9 +8,9 @@ import { HeroCards } from "./hero-cards";
export default function HomeHero() {
return (
<header className={clsx("hero hero--primary", styles.heroBanner, styles.hero)}>
<div className="flex">
<div className="ps-0 lg:ps-52">
<h1 className="font-extrabold lg:text-7xl text-3xl">
<div className={styles.heroContent}>
<div className={styles.heroCopy}>
<h1 className={clsx("font-extrabold lg:text-7xl text-3xl", styles.heroTitle)}>
A simple, yet
<br />
powerful dashboard
@@ -22,7 +23,7 @@ export default function HomeHero() {
information.
</p>
<div className={"flex flex-nowrap gap-2"}>
<div className={styles.heroActions}>
<Link
data-attr="Install button"
className={"button button--secondary button--lg rounded-3xl dark:border-zinc-600 dark:bg-zinc-800"}
@@ -57,9 +58,12 @@ export default function HomeHero() {
</Link>
</div>
</div>
<div className="flex-grow-1 w-full lg:block hidden hover-animation">
<section
className={clsx(styles.heroPreview, "hover-animation")}
aria-label={translate({ id: "homepage.preview.label", message: "Example Homarr dashboard" })}
>
<HeroCards />
</div>
</section>
</div>
</header>
);
@@ -1,20 +1,21 @@
import { supportedIntegrations } from "@site/src/constants/supported-integrations";
import { CommonWidgetProps, WidgetCard } from "./card";
import { getRndInteger } from "@site/src/tools/math";
import clsx from "clsx";
export const AppWidget = ({ className }: CommonWidgetProps) => {
const randomApp = supportedIntegrations[getRndInteger(0, supportedIntegrations.length - 1)];
interface AppWidgetProps extends CommonWidgetProps {
name: string;
label?: string;
}
export const AppWidget = ({ name, label, className }: AppWidgetProps) => {
const app = supportedIntegrations.find((integration) => integration.name === name);
if (!app) return null;
return (
<WidgetCard width={1} className={clsx("text-center", className)}>
<span className={"text-sm font-bold"}>{randomApp.name}</span>
<img
src={randomApp.iconUrl}
className="aspect-square scale-[0.6] transition-transform"
alt={`${randomApp.name} Icon`}
/>
<div className="absolute bottom-3 right-3 rounded-full bg-green-500 w-2 h-2"></div>
<WidgetCard width={1} className={clsx("app-card text-center", className)}>
<span className={"text-sm font-bold"}>{label ?? app.name}</span>
<img src={app.iconUrl} className="aspect-square scale-[0.6]" alt="" />
<div className="absolute bottom-3 right-3 rounded-full bg-green-500 w-2 h-2" aria-hidden="true" />
</WidgetCard>
);
};
@@ -1,5 +1,6 @@
import clsx from "clsx";
import { ReactNode } from "react";
import styles from "../../../../../pages/index.module.css";
export interface CommonWidgetProps {
className?: string;
@@ -16,15 +17,13 @@ export const WidgetCard = ({ width, children, className, onClick }: WidgetCardPr
return (
<Component
className={clsx(
"rounded-xl flex flex-col justify-center items-center p-2 relative border-none bg-[var(--ifm-navbar-background-color)]",
styles.widgetCard,
"flex flex-col justify-center items-center p-2 relative border-none bg-[var(--ifm-navbar-background-color)]",
className,
onClick ? "cursor-pointer" : undefined,
)}
style={{
width: (128 * width + (width === 2 ? 16 : 0)) * 0.9,
height: 128,
background: "rgb(from var(--ifm-navbar-background-color) r g b / 80%)",
}}
style={{ width: (128 * width + (width === 2 ? 16 : 0)) * 0.9, height: 128 }}
type={onClick ? "button" : undefined}
onClick={onClick}
>
{children}
@@ -1,26 +1,40 @@
import { getRndInteger } from "@site/src/tools/math";
import { translate } from "@docusaurus/Translate";
import { useEffect, useState } from "react";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
import { generateSlug } from "random-word-slugs";
import styles from "../../../../../pages/index.module.css";
const downloadQueue = ["family-photos.zip", "linux-isos.mkv", "server-backup.zip", "holiday-video.mp4"];
const downloadLabels = {
file: translate({ id: "homepage.preview.file", message: "File" }),
progress: translate({ id: "homepage.preview.progress", message: "Progress" }),
};
export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
const [downloads, setDownloads] = useState<Download[]>([]);
const [downloads, setDownloads] = useState<Download[]>([
{ filename: "photos-backup.zip", progress: 72 },
{ filename: "media-library.mkv", progress: 38 },
]);
useEffect(() => {
const intervalId = setInterval(() => {
setDownloads((prev: Download[]) => {
const filteredPrev = prev.filter((download) => download.progress !== 100);
filteredPrev.forEach((download, index) => {
download.progress = Math.min(100, download.progress + getRndInteger(0, index < 2 ? 16 : 8));
});
const progressedDownloads = filteredPrev.map((download, index) => ({
...download,
progress: Math.min(100, download.progress + (index < 2 ? 8 : 4)),
}));
if (filteredPrev.length < 3 && filteredPrev.length === prev.length) {
filteredPrev.push(generateRandomDownload());
if (progressedDownloads.length < 3 && progressedDownloads.length === prev.length) {
const availableDownloads = downloadQueue.filter(
(filename) => !progressedDownloads.some((download) => download.filename === filename),
);
const filename = availableDownloads[0];
if (filename) progressedDownloads.push({ filename, progress: 0 });
}
return [...filteredPrev];
return progressedDownloads;
});
}, 500);
@@ -34,19 +48,31 @@ export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
<table className="mb-0 w-full h-full text-xs">
<thead className="inline-block w-full">
<tr className="inline-block w-full">
<th className="border-none px-2 py-1 text-start inline-block w-1/2">File</th>
<th className="border-none px-2 py-1 text-start inline-block w-1/2">Progress</th>
<th className="border-none px-2 py-1 text-start inline-block w-1/2">{downloadLabels.file}</th>
<th className="border-none px-2 py-1 text-start inline-block w-1/2">{downloadLabels.progress}</th>
</tr>
</thead>
<tbody>
{downloads.map((file: Download, index: number) => {
{downloads.map((file: Download) => {
return (
<tr key={index}>
<tr key={file.filename}>
<td className="border-none text-nowrap p-2">{file.filename}</td>
<td className="border-none p-2 w-full">
<div className="overflow-hidden rounded-xl h-2 w-full bg-zinc-600">
<div className="bg-green-600 h-full animated-width" style={{ width: `${file.progress}%` }}></div>
</div>
<td
className="border-none p-2 w-full"
aria-label={translate(
{ id: "homepage.preview.downloadComplete", message: "{progress}% complete" },
{ progress: file.progress },
)}
>
<progress
className={styles.downloadProgress}
value={file.progress}
max={100}
aria-label={translate(
{ id: "homepage.preview.downloadProgress", message: "{filename} download progress" },
{ filename: file.filename },
)}
/>
</td>
</tr>
);
@@ -61,15 +87,3 @@ interface Download {
filename: string;
progress: number;
}
const formats = ["mkv", "mp4"];
const generateRandomDownload = (): Download => {
const randomMovie = generateSlug(2);
const randomFormat = formats[getRndInteger(0, formats.length - 1)];
return {
filename: `${randomMovie}.${randomFormat}`,
progress: 0,
};
};
@@ -27,6 +27,7 @@ export const StockWidget = ({ className }: CommonWidgetProps) => {
HOMR
</div>
<Line
ariaLabel="Simulated HOMR stock price trend"
height={128}
width={244}
colors={[upwardTrend ? "green" : "red"]}
@@ -758,7 +758,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
</div>
</div>
<Link
to="/docs/management/workshop/"
to="/docs/workshop/"
className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
Installation guide <IconExternalLink size={13} />