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

+4
View File
@@ -22,6 +22,8 @@ Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permi
## Exporting a Backup
![Export database card with the Download Backup action](/img/backup/export-database.png)
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
![Restore database card with the backup ZIP drop zone](/img/backup/restore-database.png)
### 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.
+11 -12
View File
@@ -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.
![Homarr provider configuration](/img/assistant/homarr-provider.png)
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).
+23 -16
View File
@@ -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
+1 -1
View File
@@ -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"]}
+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;
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB