fix: harden the Homarr provider
This commit is contained in:
1 parent
0774b02878
commit
bffa3e723c
43 files changed
+515
-999
No files matched your search
@@ -22,6 +22,8 @@ Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permi
|
||||
|
||||
## Exporting a Backup
|
||||
|
||||

|
||||
|
||||
Click the **Export** button in the export card. You'll be prompted to type `I understand` to confirm. Once confirmed, a ZIP file downloads containing:
|
||||
|
||||
| File | Description |
|
||||
@@ -33,6 +35,8 @@ The backup contains the complete database. It includes Custom Widgets and all ot
|
||||
|
||||
## Restoring a Backup
|
||||
|
||||

|
||||
|
||||
### 1. Upload
|
||||
|
||||
Drag and drop or select a `.zip` backup file. The import card validates that the file contains a valid `db.sqlite` and metadata.
|
||||
|
||||
@@ -130,12 +130,12 @@ The same suites can target the Homarr provider with a Workshop PocketBase token
|
||||
```sh
|
||||
AI_PROVIDER_BASE_URL=https://homarr.dev/api/ai/v1 \
|
||||
AI_PROVIDER_API_KEY=<workshop-token> \
|
||||
AI_PROVIDER_MODEL=homarr/deepseek-v4-flash-latest \
|
||||
AI_PROVIDER_MODEL=homarr/model \
|
||||
pnpm --filter @homarr/custom-widgets test:assistant-ai
|
||||
```
|
||||
|
||||
`AI_PROVIDER_JUDGE_MODEL` can select a separate judge when the configured endpoint offers one. The Homarr provider
|
||||
offers exactly one model, so generation and review both use `homarr/deepseek-v4-flash-latest`.
|
||||
offers exactly one model, so generation and review both use `homarr/model`.
|
||||
|
||||
The assistant test exposes the same lazy skill, live schema, component catalog, targeted component references,
|
||||
validation, preview-query, and create-from-preview tools as the product. It refuses to save a definition that was not
|
||||
@@ -143,8 +143,8 @@ validated exactly or whose preview queries were not all tested. Multi-request fi
|
||||
each documented path and require manual-query parameters. Use `--case=pokedex`, `--case=coinmarketcap-keyless`,
|
||||
`--case=bored-activity`, or `--case=agify-name` for a focused run.
|
||||
|
||||
Both suites default generation and harsh review to OpenRouter's `~deepseek/deepseek-v4-flash-latest` route. They save
|
||||
tool traces, validated manifests, raw reviews, and detailed category scores under
|
||||
Both suites use the configured provider models for generation and harsh review. They save tool traces, validated
|
||||
manifests, raw reviews, and detailed category scores under
|
||||
`packages/custom-widgets/.ai-evaluations/`. These live tests are maintainer-invoked rather than CI-required because
|
||||
they consume a configured provider account and exercise a nondeterministic model. The repository's ordinary tests
|
||||
cover the lifecycle enforcement, fixture routing, score calculation, and failure behavior without network access.
|
||||
|
||||
@@ -8,15 +8,13 @@ tags:
|
||||
- Community
|
||||
---
|
||||
|
||||
import { AssistantProviderActivity } from "@site/src/components/assistant-provider/assistant-provider-activity";
|
||||
import { OpenCollectiveLink } from "@site/src/components/assistant-provider/open-collective-link";
|
||||
|
||||
# Homarr provider
|
||||
|
||||
The Homarr provider gives Workshop users access to **DeepSeek V4 Flash Latest** without an administrator API key.
|
||||
Each user signs in with GitHub and receives a separate daily allowance.
|
||||
|
||||

|
||||
The Homarr provider gives Workshop users access to the **Homarr model** without an administrator API key. The Homarr
|
||||
team selects a capable model based on quality, reliability, and cost, and can change the upstream model without
|
||||
changing the user-facing provider. Each user signs in with GitHub and receives a separate daily allowance.
|
||||
|
||||
## Set it up
|
||||
|
||||
@@ -29,25 +27,26 @@ Each user signs in with GitHub and receives a separate daily allowance.
|
||||
## Daily allowance
|
||||
|
||||
- Each user starts with **50 request units per UTC day**.
|
||||
- A model request uses one unit.
|
||||
- Each completed Homarr tool call uses one additional unit.
|
||||
- Each model request uses one unit. Agent tool loops can make multiple model requests.
|
||||
- The allowance resets at 00:00 UTC.
|
||||
|
||||
The battery beside the model selector shows the remaining allowance and reset time. The Workshop server enforces the
|
||||
limit before it sends a request to OpenRouter.
|
||||
|
||||
<AssistantProviderActivity />
|
||||
|
||||
## Data and cost
|
||||
|
||||
The Workshop keeps the OpenRouter API key on its server. It does not send that key to Homarr or the browser.
|
||||
|
||||
Prompts, attachments, tool definitions, and tool results are sent to OpenRouter to generate a response. The Workshop
|
||||
does not store this content. It stores only request status, model, usage, duration, cost, and timestamps.
|
||||
Prompts, attachments, tool definitions, and tool results are sent through OpenRouter to generate a response. The
|
||||
Workshop proxy does not retain that content, responses, or per-request telemetry. It stores only the Workshop user,
|
||||
UTC day, and counters required to enforce per-user and global daily allowances. Requests require OpenRouter routing
|
||||
with zero data retention and data collection disabled. Homarr does not use this content to train language models.
|
||||
|
||||
We would rather become millionaires in a more ethical manner.
|
||||
|
||||
The Homarr community pays for these requests. Donations help cover the cost.
|
||||
|
||||
<OpenCollectiveLink />
|
||||
|
||||
Workshop operators can change the upstream, default allowance, and security limits in the
|
||||
Workshop operators can change the upstream model, per-user allowance, global allowance, and security limits in the
|
||||
[Workshop operator guide](./operator#homarr-provider-proxy).
|
||||
@@ -37,16 +37,18 @@ store duplicate profile metadata or avatar files. Configure SMTP for comment, re
|
||||
|
||||
The public URL contract and Workshop-only server configuration are:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| ------------------------------- | ------------------------------------------------- | ------------------------------ |
|
||||
| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` |
|
||||
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` |
|
||||
| `WORKSHOP_WEB_URL` | Public Workshop page linked from Homarr | `HOMARR_WEBSITE_URL/workshop` |
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
|
||||
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
|
||||
| `OPENROUTER_API_KEY` | Server-only key for the Homarr provider proxy | Provider disabled when empty |
|
||||
| `HOMARR_AI_DAILY_REQUEST_LIMIT` | Initial per-user daily provider allowance | `50` |
|
||||
| `HOMARR_AI_OPENROUTER_BASE_URL` | OpenRouter-compatible proxy upstream | `https://openrouter.ai/api/v1` |
|
||||
| Variable | Purpose | Default |
|
||||
| -------------------------------------- | ------------------------------------------------- | ------------------------------ |
|
||||
| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` |
|
||||
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` |
|
||||
| `WORKSHOP_WEB_URL` | Public Workshop page linked from Homarr | `HOMARR_WEBSITE_URL/workshop` |
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
|
||||
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
|
||||
| `OPENROUTER_API_KEY` | Server-only key for the Homarr provider proxy | Provider disabled when empty |
|
||||
| `HOMARR_AI_DAILY_REQUEST_LIMIT` | Per-user daily provider allowance | `50` |
|
||||
| `HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT` | Shared daily ceiling across all users | `10000` |
|
||||
| `HOMARR_AI_OPENROUTER_BASE_URL` | OpenRouter-compatible proxy upstream | `https://openrouter.ai/api/v1` |
|
||||
| `HOMARR_AI_OPENROUTER_MODEL` | Server-selected upstream model | Selected by the Homarr team |
|
||||
|
||||
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
|
||||
trailing slashes when Homarr or Docusaurus starts. The deprecated docs-only `WORKSHOP_URL` alias remains for one
|
||||
@@ -57,13 +59,18 @@ container after changing these values, without rebuilding it.
|
||||
### Homarr provider proxy
|
||||
|
||||
The [Homarr provider](./homarr-provider) proxy is part of the Workshop image. `OPENROUTER_API_KEY` enables the proxy,
|
||||
`HOMARR_AI_OPENROUTER_BASE_URL` selects its OpenRouter-compatible upstream, and `HOMARR_AI_DAILY_REQUEST_LIMIT` sets the
|
||||
initial limit stored for each new user. Existing users keep their assigned limits. The upstream URL cannot contain
|
||||
embedded credentials, a query, or a fragment. Use HTTPS for any upstream outside a trusted private network.
|
||||
`HOMARR_AI_OPENROUTER_BASE_URL` selects its OpenRouter-compatible upstream, `HOMARR_AI_OPENROUTER_MODEL` selects the
|
||||
private upstream model behind the stable `homarr/model` alias, and the two allowance variables provide per-user and
|
||||
shared cost ceilings. Lowering either allowance takes effect for existing users. The upstream URL cannot contain
|
||||
embedded credentials, a query, or a fragment and must use HTTPS. Tests can explicitly opt into an insecure local mock
|
||||
with `HOMARR_AI_ALLOW_INSECURE_UPSTREAM=true`; do not use that setting with a real API key.
|
||||
|
||||
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic quota enforcement, failed-request
|
||||
accounting, input and output controls, tool-call accounting, private records, public activity, realtime delivery, and
|
||||
migration rollback.
|
||||
The proxy overwrites client routing and generation limits. It requires OpenRouter zero-data-retention routing, denies
|
||||
data-collecting providers, limits responses to 32,768 completion tokens, and does not retain prompts, responses, or
|
||||
per-request telemetry. Keep an OpenRouter account-level credit limit as the final operational backstop.
|
||||
|
||||
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement,
|
||||
failed-request accounting, input and output controls, privacy routing, absence of request logs, and migration rollback.
|
||||
|
||||
The Custom Widget AI tests can use this proxy. Set `AI_PROVIDER_BASE_URL` to the `/api/ai/v1` endpoint and
|
||||
`AI_PROVIDER_API_KEY` to a temporary Workshop user token. See
|
||||
|
||||
@@ -362,7 +362,7 @@ html[data-workshop-detail-loading] #workshop-detail-route-fallback {
|
||||
box-sizing: border-box;
|
||||
color: #4b5563;
|
||||
display: flex;
|
||||
font: 500 0.875rem/1.5 Inter, system-ui, sans-serif;
|
||||
font: 500 0.875rem/1.5 "Work Sans", system-ui, sans-serif;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 100 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Reference in new issue
Block a user