fix: harden the Homarr provider

This commit is contained in:
ajnart committed 2026-08-12 12:33:04 +02:00
1 parent 0774b02878
commit bffa3e723c
43 files changed
+515 -999

No files matched your search

@@ -1,165 +0,0 @@
.root {
min-width: 0;
overflow: hidden;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 12px;
background: var(--ifm-background-surface-color);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.8rem 1rem;
border-bottom: 1px solid var(--ifm-color-emphasis-300);
}
.title {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
}
.live {
display: inline-flex;
align-items: center;
gap: 0.38rem;
color: var(--ifm-color-emphasis-700);
font-size: 0.75rem;
font-weight: 650;
}
.beep {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--ifm-color-emphasis-500);
}
.live[data-connected] .beep {
background: #2f9e44;
box-shadow: 0 0 0 0 rgb(47 158 68 / 35%);
animation: live-beep 2.2s ease-out infinite;
}
.tableWrap {
overflow-x: auto;
}
.table {
width: 100%;
min-width: 36rem;
margin: 0;
border-collapse: collapse;
font-size: 0.8rem;
}
.table th,
.table td {
padding: 0.58rem 0.75rem;
border: 0;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
text-align: start;
white-space: nowrap;
}
.table th {
color: var(--ifm-color-emphasis-700);
font-size: 0.72rem;
font-weight: 650;
}
.table tbody tr:last-child td {
border-bottom: 0;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-weight: 650;
}
.status[data-status="processing"] {
color: #e67700;
}
.status[data-status="completed"] {
color: #2f9e44;
}
.status[data-status="failed"] {
color: #e03131;
}
.empty {
padding: 1.2rem;
color: var(--ifm-color-emphasis-700);
font-size: 0.85rem;
text-align: center;
}
.compact {
width: 100%;
height: 100%;
border: 0;
border-radius: inherit;
background: transparent;
}
.compact .header {
padding: 0.38rem 0.55rem;
}
.compact .title {
font-size: 0.75rem;
}
.compact .live {
font-size: 0.65rem;
}
.compact .table {
min-width: 0;
font-size: 0.66rem;
}
.compact .table th,
.compact .table td {
padding: 0.3rem 0.5rem;
}
.compact .table th:nth-child(n + 4),
.compact .table td:nth-child(n + 4) {
display: none;
}
.compact .empty {
padding: 0.7rem;
font-size: 0.7rem;
}
@keyframes live-beep {
0% {
box-shadow: 0 0 0 0 rgb(47 158 68 / 35%);
}
55%,
100% {
box-shadow: 0 0 0 0.35rem rgb(47 158 68 / 0%);
}
}
@media (prefers-reduced-motion: reduce) {
.live[data-connected] .beep {
animation: none;
}
}
@media (max-width: 40rem) {
.root:not(.compact) .table th:nth-child(5),
.root:not(.compact) .table td:nth-child(5) {
display: none;
}
}
@@ -1,144 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { IconCheck, IconLoader2, IconX } from "@tabler/icons-react";
import clsx from "clsx";
import type { WorkshopAssistantActivity } from "@homarr/workshop/schema";
import { workshopAssistantActivitySchema } from "@homarr/workshop/schema";
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
import styles from "./assistant-provider-activity.module.css";
const statusIcon = {
processing: IconLoader2,
completed: IconCheck,
failed: IconX,
};
const statusLabel = {
processing: "Processing",
completed: "Completed",
failed: "Failed",
};
const activityDate = (value: string) => new Date(value.includes("T") ? value : value.replace(" ", "T"));
const mergeActivity = (current: WorkshopAssistantActivity[], next: WorkshopAssistantActivity) =>
[next, ...current.filter((item) => item.id !== next.id)]
.toSorted((left, right) => activityDate(right.created).getTime() - activityDate(left.created).getTime())
.slice(0, 10);
const mergeActivities = (current: WorkshopAssistantActivity[], next: WorkshopAssistantActivity[]) =>
next.reduce(mergeActivity, current);
const getEmptyLabel = (loading: boolean, loadFailed: boolean, connected: boolean) => {
if (loading) return "Loading recent activity…";
if (loadFailed) return connected ? "Waiting for live provider activity…" : "Recent provider activity is unavailable.";
return "No provider requests yet today.";
};
export const AssistantProviderActivity = ({ compact = false }: { compact?: boolean }) => {
const { siteConfig } = useDocusaurusContext();
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
const [activities, setActivities] = useState<WorkshopAssistantActivity[]>([]);
const [loading, setLoading] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const [connected, setConnected] = useState(false);
const workshopUrl = useMemo(() => getRuntimeWorkshopApiUrl(configuredWorkshopUrl), [configuredWorkshopUrl]);
useEffect(() => {
let active = true;
let unsubscribe: (() => void) | undefined;
const backend = getWorkshopBackend(workshopUrl);
const load = async () => {
try {
const nextUnsubscribe = await backend.pocketBase.collection("assistant_activity").subscribe("*", (event) => {
const parsed = workshopAssistantActivitySchema.safeParse(event.record);
if (!active || !parsed.success) return;
setConnected(true);
setActivities((current) => mergeActivity(current, parsed.data));
});
if (active) {
unsubscribe = nextUnsubscribe;
setConnected(true);
} else {
nextUnsubscribe();
}
} catch {
if (active) setConnected(false);
}
try {
const initial = await backend.listAssistantActivity(10);
if (active) {
setActivities((current) => mergeActivities(current, initial));
setLoadFailed(false);
}
} catch {
if (active) setLoadFailed(true);
} finally {
if (active) setLoading(false);
}
};
void load();
return () => {
active = false;
unsubscribe?.();
};
}, [workshopUrl]);
const visibleActivities = compact ? activities.slice(0, 3) : activities;
return (
<section className={clsx(styles.root, compact && styles.compact)} aria-label="Live Homarr provider activity">
<header className={styles.header}>
<h3 className={styles.title}>Homarr provider activity</h3>
<span className={styles.live} data-connected={connected || undefined} aria-live="polite">
<span className={styles.beep} aria-hidden />
{connected ? "Live" : loading ? "Connecting" : "Offline"}
</span>
</header>
{visibleActivities.length > 0 ? (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Time</th>
<th>Status</th>
<th>Requests</th>
<th>Tokens</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{visibleActivities.map((activity) => {
const StatusIcon = statusIcon[activity.status];
return (
<tr key={activity.id}>
<td>
<time dateTime={activity.created} title={activityDate(activity.created).toLocaleString()}>
{activityDate(activity.created).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</time>
</td>
<td>
<span className={styles.status} data-status={activity.status}>
<StatusIcon size={13} aria-hidden />
{statusLabel[activity.status]}
</span>
</td>
<td>{activity.requestUnits.toLocaleString()}</td>
<td>{activity.totalTokens > 0 ? activity.totalTokens.toLocaleString() : "–"}</td>
<td>{activity.durationMs > 0 ? `${(activity.durationMs / 1000).toFixed(1)} s` : "–"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className={styles.empty}>{getEmptyLabel(loading, loadFailed, connected)}</div>
)}
</section>
);
};
@@ -3,23 +3,29 @@ import { IconBrandDocker, IconCpu, IconDeviceDesktopAnalytics } from "@tabler/ic
import styles from "../../../../pages/index.module.css";
import { AppWidget } from "./widgets/app-widget";
import { ClockWidget } from "./widgets/clock-widget";
import { AssistantProviderWidget } from "./widgets/assistant-provider-widget";
import { DownloadsWidget } from "./widgets/downloads-widget";
import { WeatherWidget } from "./widgets/weather-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 ${styles.heroCards}`}>
<div className={`argos-ignore hero-cards ${styles.heroCards}`}>
<StockWidget />
<AppWidget name="Plex" />
<AppWidget name="Jellyfin" />
<AppWidget name="Homeassistant" label="Home Assistant" />
<ClockWidget />
<SystemResourcesWidget />
<WeatherWidget />
<ClockWidget />
<DownloadsWidget />
<AssistantProviderWidget />
<DockerWidget />
<EntityStateWidget />
<AppWidget />
<AppWidget name="PiHole" label="Pi-hole" />
<AppWidget name="qBittorrent" />
<DownloadsWidget />
<DockerWidget />
<AppWidget name="Sonarr" />
<AppWidget name="Radarr" />
</div>
);
};
@@ -35,11 +41,12 @@ const SystemResourcesWidget = () => (
</div>
<ResourceMeter icon={<IconCpu size={15} aria-hidden="true" />} label="CPU" value={34} />
<ResourceMeter label="Memory" value={62} />
<ResourceMeter label="Disk" value={98} />
</WidgetCard>
);
const ResourceMeter = ({ icon, label, value }: { icon?: React.ReactNode; label: string; value: number }) => (
<div className={styles.resourceMeter}>
<div className={`${styles.resourceMeter} ${value >= 90 ? styles.resourceMeterCritical : ""}`}>
<div className={styles.resourceLabel}>
<span>
{icon}
@@ -58,18 +65,21 @@ const DockerWidget = () => (
<IconBrandDocker size={18} aria-hidden="true" />
Docker
</span>
<span className={styles.dockerCount}>3 running</span>
<span className={styles.dockerCount}>2 running · 1 failed</span>
</div>
<div className={styles.containerList}>
{[
["immich", "Healthy"],
["jellyfin", "Running"],
["home-assistant", "Running"],
["paperless", "Exited"],
].map(([name, status]) => (
<div className={styles.containerRow} key={name}>
<div className={`${styles.containerRow} ${status === "Exited" ? styles.containerRowFailed : ""}`} key={name}>
<span className={styles.containerName}>{name}</span>
<span className={styles.containerStatus}>
<span className={styles.statusDot} aria-hidden="true" />
<span
className={`${styles.statusDot} ${status === "Exited" ? styles.statusDotFailed : ""}`}
aria-hidden="true"
/>
{status}
</span>
</div>
@@ -57,9 +57,9 @@ export default function HomeHero() {
</Link>
</div>
</div>
<div className={styles.heroPreview} aria-label="Example Homarr dashboard">
<section className={clsx(styles.heroPreview, "hover-animation")} aria-label="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={`${label ?? app.name} icon`} />
<div className="absolute bottom-3 right-3 rounded-full bg-green-500 w-2 h-2" aria-hidden="true" />
</WidgetCard>
);
};
@@ -1,11 +0,0 @@
import { AssistantProviderActivity } from "@site/src/components/assistant-provider/assistant-provider-activity";
import clsx from "clsx";
import type { CommonWidgetProps } from "./card";
import { WidgetCard } from "./card";
export const AssistantProviderWidget = ({ className }: CommonWidgetProps) => (
<WidgetCard width={2} className={clsx("!items-stretch !justify-stretch overflow-hidden !p-0", className)}>
<AssistantProviderActivity compact />
</WidgetCard>
);
@@ -18,11 +18,11 @@ export const WidgetCard = ({ width, children, className, onClick }: WidgetCardPr
<Component
className={clsx(
styles.widgetCard,
width === 2 ? styles.widgetCardWide : styles.widgetCardSingle,
"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 }}
type={onClick ? "button" : undefined}
onClick={onClick}
>
@@ -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"]}
+8 -4
View File
@@ -1,4 +1,4 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap");
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap");
@import "tailwindcss/theme" layer(theme);
@import "tailwindcss/utilities" layer(utilities);
@@ -10,7 +10,7 @@
--breakpoint-3xl: 1697px;
--font-heading: var(--font-sans);
--font-sans: "Inter", sans-serif;
--font-sans: "Work Sans", sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -137,7 +137,7 @@
--sidebar-border: oklch(0.9276 0.0058 264.5313);
--sidebar-ring: oklch(0.56 0.205 25.6509);
--destructive-foreground: oklch(0.9851 0 0);
--font-sans: "Inter", sans-serif;
--font-sans: "Work Sans", sans-serif;
--font-serif: Georgia, serif;
--font-mono: "JetBrains Mono", monospace;
--shadow-color: #000000;
@@ -491,6 +491,10 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
}
@media (prefers-reduced-motion: reduce) {
.hover-animation {
animation: none;
}
.submit-step-enter {
animation: none;
}
@@ -541,7 +545,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
--sidebar-accent-foreground: oklch(0.6646 0.2222 25.6509);
--sidebar-border: oklch(0.2739 0.0055 286.0326);
--sidebar-ring: oklch(0.72 0.16 25.6509);
--font-sans: "Inter", sans-serif;
--font-sans: "Work Sans", sans-serif;
--font-serif: Georgia, serif;
--font-mono: "JetBrains Mono", monospace;
--shadow-color: #000000;
+55 -21
View File
@@ -35,10 +35,10 @@ section {
.heroContent {
display: grid;
grid-template-columns: minmax(24rem, 1fr) minmax(31.25rem, 0.9fr);
grid-template-columns: minmax(25rem, 1fr) minmax(44rem, 48rem);
align-items: center;
gap: clamp(2rem, 5vw, 5rem);
max-width: 90rem;
gap: clamp(1rem, 3vw, 3rem);
max-width: 108rem;
margin-inline: auto;
}
@@ -61,10 +61,10 @@ section {
}
.heroCards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
width: min(100%, 31.25rem);
display: flex;
flex-wrap: wrap;
gap: 1rem 0.75rem;
width: min(100%, 48rem);
margin-inline: auto;
color: #343a40;
}
@@ -74,19 +74,15 @@ section {
}
.widgetCard {
height: 8rem;
flex: none;
min-width: 0;
border-radius: 0.75rem;
background: color-mix(in srgb, var(--ifm-navbar-background-color) 88%, transparent) !important;
box-shadow: 0 4px 8px rgb(31 41 55 / 12%);
}
.widgetCardSingle {
grid-column: span 1;
}
.widgetCardWide {
grid-column: span 2;
border: 1px solid color-mix(in srgb, currentColor 12%, transparent) !important;
color: inherit;
font: inherit;
background: color-mix(in srgb, var(--ifm-navbar-background-color) 76%, transparent) !important;
backdrop-filter: blur(12px);
box-shadow: 0 12px 28px rgb(31 41 55 / 14%);
}
.resourcesWidget,
@@ -135,6 +131,11 @@ section {
gap: 0.25rem;
}
.resourceMeterCritical .resourceLabel {
color: #a51111;
font-weight: 700;
}
.resourceLabel {
font-size: 0.6875rem;
}
@@ -167,6 +168,11 @@ section {
background: var(--ifm-color-primary);
}
.resourceMeterCritical .resourceTrack::-webkit-progress-value,
.resourceMeterCritical .resourceTrack::-moz-progress-bar {
background: #e03131;
}
.downloadProgress {
display: block;
appearance: none;
@@ -219,7 +225,29 @@ section {
background: #12b886;
}
@media screen and (max-width: 996px) {
.containerRowFailed {
color: #a51111;
font-weight: 700;
}
:global([data-theme="dark"]) .resourceMeterCritical .resourceLabel,
:global([data-theme="dark"]) .containerRowFailed {
color: #ffe3e3;
}
.statusDotFailed {
background: #fa5252;
animation: failed-container-pulse 1.4s ease-in-out infinite;
}
@keyframes failed-container-pulse {
50% {
opacity: 0.35;
transform: scale(1.65);
}
}
@media screen and (max-width: 1280px) {
.heroBanner {
padding: 3rem 1.5rem;
}
@@ -234,11 +262,11 @@ section {
}
.heroCards {
width: min(100%, 36rem);
width: min(100%, 48rem);
}
}
@media screen and (max-width: 640px) {
@media screen and (max-width: 996px) {
.heroBanner {
padding: 2.5rem 1rem;
}
@@ -251,3 +279,9 @@ section {
flex: 1 1 auto;
}
}
@media (prefers-reduced-motion: reduce) {
.statusDotFailed {
animation: none;
}
}