Merge refreshed v2 stack into release roll-up
This commit is contained in:
32 files changed
+726
-61
No files matched your search
@@ -23,8 +23,9 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @homarr/workshop typecheck
|
||||
- run: pnpm --filter @homarr/docs typecheck
|
||||
- run: docker compose --env-file apps/workshop/.env.staging.example -f apps/workshop/docker-compose.staging.yml config --quiet
|
||||
- run: pnpm test:workshop
|
||||
- run: docker build --target production -f apps/workshop/Dockerfile .
|
||||
- run: pnpm test:workshop-image
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'main')
|
||||
|
||||
@@ -112,8 +112,8 @@ The advanced deployments environment variables should only be used if you know w
|
||||
| Environment Variable | Description |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. |
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production. |
|
||||
| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. The central public community defaults to `*` so self-hosted Homarr instances can use OAuth. |
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production and must match the externally reachable Workshop origin. |
|
||||
| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. Use `*` for the central public community so arbitrary self-hosted Homarr instances can use the API and OAuth; private staging may use a comma-separated origin allowlist. |
|
||||
|
||||
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
|
||||
trailing slashes when Homarr or Docusaurus starts. The Docusaurus-only `WORKSHOP_URL` name is accepted as a deprecated alias for
|
||||
|
||||
@@ -148,12 +148,15 @@ moving through long lists. The selector below the composer combines searchable,
|
||||
their description, context size and image-input capability, plus the thinking-level control. Model selection is
|
||||
stored on the conversation after a completed request. Thinking mode can be left on **Auto**, disabled, or set
|
||||
from minimal through high; explicit levels are sent using the AI SDK's provider-neutral reasoning setting and
|
||||
still depend on support from the chosen model and endpoint. Reasoning text is shown live when the provider
|
||||
streams it through assistant-ui's compact chain-of-thought disclosure. The **Thinking** disclosure renders
|
||||
Markdown, can be opened or closed without interrupting the response, and keeps the user's collapsed state when
|
||||
another message starts. A compact dot matrix distinguishes an initial thinking pass from response streaming and
|
||||
settles into waiting, success, or error states in the minimized activity bar. The state always keeps its text label,
|
||||
and the animation is disabled when the operating system requests reduced motion.
|
||||
still depend on support from the chosen model and endpoint. Reasoning text is shown live when the provider streams
|
||||
it through assistant-ui's grouped-parts and chain-of-thought primitives. The compact **Thinking** trace groups
|
||||
adjacent reasoning with ordinary tool calls, shows each tool's status, duration and safe human-readable target,
|
||||
and expands raw input, output and result previews only on request. Approval and question tools remain full-width
|
||||
outside the trace. The disclosure renders Markdown, can be opened or closed without interrupting the response,
|
||||
and keeps the user's collapsed state when another message starts. A compact dot matrix distinguishes an initial
|
||||
thinking pass from response streaming and settles into waiting, success, or error states in the minimized activity
|
||||
bar. The state always keeps its text label, and the animation is disabled when the operating system requests
|
||||
reduced motion.
|
||||
|
||||
A circular context ring beside the model selector gives a quick glance at cumulative conversation usage without
|
||||
expanding the composer toolbar. Its accessible label includes cumulative tokens and cost when available, while
|
||||
@@ -161,7 +164,8 @@ its popover keeps the latest complete context reading as a new response starts a
|
||||
turn with its model, provider, input, output, cached-input and reasoning tokens, context use, duration, speed,
|
||||
agent steps, routing details, and cost when reported. Before usage is available, the control remains a compact
|
||||
ring instead of showing a redundant missing-data label. Closing and reopening the popover reads persisted
|
||||
conversation metadata instead of resetting the display to zero.
|
||||
conversation metadata instead of resetting the display to zero. A larger ring inside the popover breaks the latest
|
||||
reported context into used, remaining and total capacity plus input, cached-input, output and reasoning tokens.
|
||||
|
||||
Each assistant response places a small provider logo in the same stable action toolbar as copy, export,
|
||||
feedback, and regenerate. Hovering the logo shows the provider, exact model, timing, throughput, and cost without
|
||||
|
||||
@@ -108,8 +108,45 @@ The public URL contract is:
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
|
||||
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
|
||||
|
||||
The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP_API_URL` takes precedence. Restart
|
||||
services after changing these values.
|
||||
The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP_API_URL` takes precedence. The
|
||||
combined Workshop image writes these public URLs into its browser runtime configuration whenever the container starts;
|
||||
the Docker build arguments remain fallbacks. Restart the container after changing these values, without rebuilding it.
|
||||
|
||||
### Staging and demo deployment
|
||||
|
||||
Use `apps/workshop/docker-compose.staging.yml` with the published `ghcr.io/homarr-labs/workshop:v2` image for a v2
|
||||
preview. Copy `apps/workshop/.env.staging.example` to `.env.staging`; its default public contract is:
|
||||
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev
|
||||
WORKSHOP_API_URL=https://v2.preview.homarr.dev
|
||||
WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
|
||||
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
```
|
||||
|
||||
Start or update it from the repository root:
|
||||
|
||||
```sh
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml pull
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml up --detach
|
||||
curl --fail http://127.0.0.1:8090/api/health
|
||||
```
|
||||
|
||||
The service binds to `127.0.0.1:8090`. Terminate TLS at a reverse proxy and forward the **entire hostname**, including
|
||||
`/api`, `/_/`, and `/workshop-runtime-config.js`; forwarding only `/workshop` breaks PocketBase and runtime URL loading.
|
||||
This combined image serves both the documentation site and PocketBase, so use a dedicated Workshop hostname if the
|
||||
chosen hostname already serves Homarr itself. In that case, replace the hostname in all four URL values and keep
|
||||
`/workshop` only on `WORKSHOP_WEB_URL`.
|
||||
|
||||
The Compose volume preserves PocketBase data across pulls and container recreation. The mutable `v2` tag is suitable
|
||||
for previews; use the `sha-<commit>` tag for promotion. For the public central Workshop, `PB_ALLOWED_ORIGINS=*` allows
|
||||
self-hosted Homarr origins to use the API and OAuth. A private demo may use a comma-separated origin allowlist instead.
|
||||
GitHub OAuth requires both credentials and the callback
|
||||
`https://v2.preview.homarr.dev/api/oauth2-redirect`. Restart after changing environment values, then verify the health
|
||||
endpoint, `/workshop`, a public listing request, GitHub sign-in, and an installation from the Homarr preview.
|
||||
|
||||
Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the
|
||||
exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest.
|
||||
|
||||
@@ -12,6 +12,7 @@ if (deprecatedWorkshopUrl && !process.env.WORKSHOP_API_URL) {
|
||||
const publicUrls = resolveHomarrUrlConfig({
|
||||
homarrWebsiteUrl: process.env.HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: process.env.WORKSHOP_API_URL ?? deprecatedWorkshopUrl,
|
||||
workshopWebUrl: process.env.WORKSHOP_WEB_URL,
|
||||
});
|
||||
const config: Config = {
|
||||
title: "Homarr documentation",
|
||||
@@ -24,7 +25,9 @@ const config: Config = {
|
||||
projectName: "homarr",
|
||||
customFields: {
|
||||
workshopUrl: publicUrls.workshopApiUrl,
|
||||
workshopWebUrl: publicUrls.workshopWebUrl,
|
||||
},
|
||||
scripts: [{ src: "/workshop-runtime-config.js" }],
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
|
||||
@@ -29,6 +29,7 @@ import { toast } from "sonner";
|
||||
|
||||
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
|
||||
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
|
||||
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
|
||||
import type { SubmissionType } from "@site/src/lib/workshop-schema";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
@@ -1028,7 +1029,7 @@ export default function MarketplaceDetailPage() {
|
||||
<BrowserOnly fallback={<DetailSkeleton />}>
|
||||
{() => (
|
||||
<WorkshopErrorBoundary>
|
||||
<MarketplaceDetail workshopUrl={configuredWorkshopUrl || window.location.origin} />
|
||||
<MarketplaceDetail workshopUrl={getRuntimeWorkshopApiUrl(configuredWorkshopUrl)} />
|
||||
</WorkshopErrorBoundary>
|
||||
)}
|
||||
</BrowserOnly>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { getRuntimeWorkshopApiUrl } from "./runtime-config";
|
||||
|
||||
describe("Workshop runtime configuration", () => {
|
||||
afterEach(() => {
|
||||
delete window.homarrRuntimeConfig;
|
||||
});
|
||||
|
||||
test("prefers the URL supplied when the container starts", () => {
|
||||
window.homarrRuntimeConfig = { workshopApiUrl: "https://v2.preview.homarr.dev" };
|
||||
|
||||
expect(getRuntimeWorkshopApiUrl("https://homarr.dev")).toBe("https://v2.preview.homarr.dev");
|
||||
});
|
||||
|
||||
test("keeps the build-time URL as a fallback", () => {
|
||||
expect(getRuntimeWorkshopApiUrl("https://homarr.dev")).toBe("https://homarr.dev");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
interface HomarrRuntimeConfig {
|
||||
homarrWebsiteUrl?: string;
|
||||
workshopApiUrl?: string;
|
||||
workshopWebUrl?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
homarrRuntimeConfig?: Readonly<HomarrRuntimeConfig>;
|
||||
}
|
||||
}
|
||||
|
||||
export const getRuntimeWorkshopApiUrl = (configuredUrl: string): string => {
|
||||
if (typeof window === "undefined") return configuredUrl;
|
||||
return window.homarrRuntimeConfig?.workshopApiUrl || configuredUrl || window.location.origin;
|
||||
};
|
||||
@@ -3,12 +3,14 @@ import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
import { WorkshopAdmin } from "@site/src/components/workshop/WorkshopAdmin";
|
||||
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
|
||||
|
||||
export default function WorkshopAdminRoute() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const workshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
|
||||
return (
|
||||
<Layout title="Workshop moderation" noFooter>
|
||||
<WorkshopAdmin workshopUrl={workshopUrl} />
|
||||
<WorkshopAdmin workshopUrl={getRuntimeWorkshopApiUrl(workshopUrl)} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Layout from "@theme/Layout";
|
||||
|
||||
import { WorkshopApp, WorkshopListingFallback } from "@site/src/components/workshop/WorkshopApp";
|
||||
import { WorkshopErrorBoundary } from "@site/src/components/workshop/WorkshopErrorBoundary";
|
||||
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
|
||||
|
||||
export default function WorkshopPage() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
@@ -16,7 +17,7 @@ export default function WorkshopPage() {
|
||||
<BrowserOnly fallback={<WorkshopListingFallback />}>
|
||||
{() => (
|
||||
<WorkshopErrorBoundary>
|
||||
<WorkshopApp workshopUrl={configuredWorkshopUrl || window.location.origin} />
|
||||
<WorkshopApp workshopUrl={getRuntimeWorkshopApiUrl(configuredWorkshopUrl)} />
|
||||
</WorkshopErrorBoundary>
|
||||
)}
|
||||
</BrowserOnly>
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
globalThis.homarrRuntimeConfig ??= Object.freeze({});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { groupPartByType } from "@assistant-ui/react";
|
||||
|
||||
/**
|
||||
* Keep model reasoning and ordinary tool calls in one trace. Human tools and
|
||||
* other standalone tool UIs stay outside the trace so approvals and questions
|
||||
* retain the full conversation width.
|
||||
*/
|
||||
export const assistantMessageGroupBy = groupPartByType({
|
||||
reasoning: ["group-agent-trace", "group-reasoning"],
|
||||
"tool-call": ["group-agent-trace", "group-tool"],
|
||||
"standalone-tool-call": [],
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getAssistantContextBreakdown,
|
||||
getAssistantConversationUsage,
|
||||
getAssistantTelemetry,
|
||||
getAssistantUsage,
|
||||
@@ -262,3 +263,53 @@ describe("getAssistantConversationUsage", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssistantContextBreakdown", () => {
|
||||
test("combines the latest context reading with the latest reported token categories", () => {
|
||||
const usage = getAssistantConversationUsage([
|
||||
createUsageMetadata({
|
||||
requestId: "reported",
|
||||
inputTokens: 12_777,
|
||||
outputTokens: 55,
|
||||
cost: 0.00041,
|
||||
contextUsed: 13_343,
|
||||
}),
|
||||
{
|
||||
custom: {
|
||||
telemetry: {
|
||||
requestId: "streaming",
|
||||
provider: "openrouter",
|
||||
modelId: "example/model",
|
||||
startedAt: "2026-08-04T10:00:02.000Z",
|
||||
steps: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getAssistantContextBreakdown(usage)).toMatchObject({
|
||||
contextUsed: 13_343,
|
||||
contextLength: 128_000,
|
||||
remaining: 114_657,
|
||||
percentage: 10,
|
||||
inputTokens: 12_777,
|
||||
outputTokens: 55,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not report an invalid percentage for an unavailable capacity", () => {
|
||||
expect(
|
||||
getAssistantContextBreakdown({
|
||||
turns: [],
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
cost: 0,
|
||||
contextUsed: 10,
|
||||
contextLength: 0,
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -122,6 +122,17 @@ export type AssistantConversationUsage = {
|
||||
contextLength?: number;
|
||||
};
|
||||
|
||||
export type AssistantContextBreakdown = {
|
||||
contextUsed?: number;
|
||||
contextLength?: number;
|
||||
remaining?: number;
|
||||
percentage?: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
};
|
||||
|
||||
const getFiniteNonNegativeNumber = (value: unknown) =>
|
||||
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
||||
|
||||
@@ -412,6 +423,35 @@ export const getAssistantConversationUsage = (messageMetadata: unknown[]): Assis
|
||||
};
|
||||
};
|
||||
|
||||
export const getAssistantContextBreakdown = (usage: AssistantConversationUsage): AssistantContextBreakdown => {
|
||||
const latestTokenTurn = usage.turns.findLast(
|
||||
(turn) =>
|
||||
turn.inputTokens !== undefined ||
|
||||
turn.outputTokens !== undefined ||
|
||||
turn.cachedInputTokens !== undefined ||
|
||||
turn.reasoningTokens !== undefined,
|
||||
);
|
||||
const { contextUsed, contextLength } = usage;
|
||||
const hasContext = contextUsed !== undefined && contextLength !== undefined && contextLength > 0;
|
||||
|
||||
return {
|
||||
...(hasContext
|
||||
? {
|
||||
contextUsed,
|
||||
contextLength,
|
||||
remaining: Math.max(0, contextLength - contextUsed),
|
||||
percentage: Math.min(100, Math.round((contextUsed / contextLength) * 100)),
|
||||
}
|
||||
: {}),
|
||||
...(latestTokenTurn?.inputTokens !== undefined ? { inputTokens: latestTokenTurn.inputTokens } : {}),
|
||||
...(latestTokenTurn?.outputTokens !== undefined ? { outputTokens: latestTokenTurn.outputTokens } : {}),
|
||||
...(latestTokenTurn?.cachedInputTokens !== undefined
|
||||
? { cachedInputTokens: latestTokenTurn.cachedInputTokens }
|
||||
: {}),
|
||||
...(latestTokenTurn?.reasoningTokens !== undefined ? { reasoningTokens: latestTokenTurn.reasoningTokens } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveAssistantContextWindowTelemetry = (
|
||||
current: AssistantRequestTelemetry,
|
||||
previousMetadata: unknown[],
|
||||
|
||||
@@ -663,6 +663,17 @@
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.tool[data-compact] {
|
||||
margin-block: 0;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.tool[data-compact] .toolHeader > :global(.mantine-Group-root) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.approvalPanel {
|
||||
margin-top: 0.8rem;
|
||||
padding-top: 0.8rem;
|
||||
@@ -961,6 +972,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agentTraceTools {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1191,6 +1206,43 @@
|
||||
background: var(--mantine-color-default);
|
||||
}
|
||||
|
||||
.contextBreakdown {
|
||||
display: grid;
|
||||
grid-template-columns: 4.75rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.6rem 0.75rem;
|
||||
padding: 0.55rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: var(--mantine-color-default);
|
||||
}
|
||||
|
||||
.contextBreakdownRing {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.contextBreakdownWindow {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.contextBreakdownWindow > div,
|
||||
.contextBreakdownCategories > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contextBreakdownCategories {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.conversationTurn {
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.55rem;
|
||||
@@ -1626,6 +1678,14 @@
|
||||
}
|
||||
|
||||
.conversationUsageSummary {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.contextBreakdownWindow {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.contextBreakdownCategories {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ActionBarPrimitive,
|
||||
AttachmentPrimitive,
|
||||
BranchPickerPrimitive,
|
||||
ChainOfThoughtByIndicesProvider,
|
||||
ChainOfThoughtPrimitive,
|
||||
ComposerPrimitive,
|
||||
ErrorPrimitive,
|
||||
@@ -128,14 +129,20 @@ import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./as
|
||||
import type { AssistantDirectiveEntity } from "./assistant-markdown-directives";
|
||||
import { normalizeAssistantMarkdown } from "./assistant-markdown";
|
||||
import { getSafeAssistantAttachmentImageSource, getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image";
|
||||
import { getAssistantConversationUsage, getAssistantTelemetry } from "./assistant-message-metadata";
|
||||
import type { AssistantConversationTurnUsage } from "./assistant-message-metadata";
|
||||
import {
|
||||
getAssistantContextBreakdown,
|
||||
getAssistantConversationUsage,
|
||||
getAssistantTelemetry,
|
||||
} from "./assistant-message-metadata";
|
||||
import type { AssistantContextBreakdown, AssistantConversationTurnUsage } from "./assistant-message-metadata";
|
||||
import { assistantMessageGroupBy } from "./assistant-message-grouping";
|
||||
import type { AssistantPendingAction } from "./assistant-pending-action";
|
||||
import { AssistantQuestionPortalProvider } from "./assistant-question-portal";
|
||||
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
|
||||
import { useAssistantReasoningState } from "./assistant-reasoning-state";
|
||||
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
|
||||
import { getToolResultPresentation } from "./assistant-tool-result";
|
||||
import { getAssistantToolTraceTarget } from "./assistant-tool-trace";
|
||||
import { getSafeAssistantHttpUrl } from "./assistant-url";
|
||||
|
||||
// Shiki's web grammar bundle is intentionally loaded only when a settled response contains a code
|
||||
@@ -800,6 +807,8 @@ const ToolResultPreview = ({ result, toolName }: { result: unknown; toolName: st
|
||||
);
|
||||
};
|
||||
|
||||
const AgentTraceToolContext = createContext(false);
|
||||
|
||||
const ToolPart = ({
|
||||
toolCallId,
|
||||
toolName,
|
||||
@@ -812,6 +821,7 @@ const ToolPart = ({
|
||||
timing,
|
||||
}: ToolCallMessagePartProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const compact = useContext(AgentTraceToolContext);
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null);
|
||||
const completed = status?.type === "complete";
|
||||
@@ -823,6 +833,8 @@ const ToolPart = ({
|
||||
status?.type === "incomplete" ||
|
||||
(typeof result === "object" && result !== null && "error" in result));
|
||||
const successful = completed && !denied && !failed;
|
||||
const compactPresentation = compact && !awaitingApproval;
|
||||
const traceTarget = getAssistantToolTraceTarget(args);
|
||||
const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined;
|
||||
const autoApprovalInProgress = useAssistantAutomaticAction({
|
||||
toolCallId,
|
||||
@@ -835,7 +847,7 @@ const ToolPart = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<Box className={classes.tool}>
|
||||
<Box className={classes.tool} data-compact={compactPresentation || undefined}>
|
||||
<UnstyledButton
|
||||
className={classes.toolHeader}
|
||||
onClick={() => setOpened((current) => !current)}
|
||||
@@ -851,9 +863,16 @@ const ToolPart = ({
|
||||
>
|
||||
{denied || failed ? <IconX size={13} /> : successful ? <IconCheck size={13} /> : <IconRobot size={13} />}
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
{toolName.replaceAll("_", " ")}
|
||||
</Text>
|
||||
<Box miw={0}>
|
||||
<Text size={compactPresentation ? "xs" : "sm"} fw={650} lineClamp={1}>
|
||||
{toolName.replaceAll("_", " ")}
|
||||
</Text>
|
||||
{compactPresentation && traceTarget && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{traceTarget}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{duration !== undefined && (
|
||||
@@ -926,9 +945,14 @@ const ToolPart = ({
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{successful && result !== undefined && <ToolResultPreview result={result} toolName={toolName} />}
|
||||
{!compactPresentation && successful && result !== undefined && (
|
||||
<ToolResultPreview result={result} toolName={toolName} />
|
||||
)}
|
||||
<Collapse expanded={opened}>
|
||||
<Stack gap="xs" mt="sm">
|
||||
{compactPresentation && successful && result !== undefined && (
|
||||
<ToolResultPreview result={result} toolName={toolName} />
|
||||
)}
|
||||
<Box>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("toolInput")}
|
||||
@@ -953,13 +977,21 @@ const ToolPart = ({
|
||||
);
|
||||
};
|
||||
|
||||
const AgentTraceToolGroup = ({ children }: { children?: ReactNode }) => (
|
||||
<AgentTraceToolContext.Provider value>
|
||||
<Stack className={classes.agentTraceTools} gap={4}>
|
||||
{children}
|
||||
</Stack>
|
||||
</AgentTraceToolContext.Provider>
|
||||
);
|
||||
|
||||
const ChainOfThoughtLayout = ({ children }: { children?: ReactNode }) => (
|
||||
<Stack className={classes.reasoningParts} gap="xs">
|
||||
<Stack className={classes.reasoningParts} gap={6}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const AssistantChainOfThought = () => {
|
||||
const AssistantChainOfThought = ({ children }: { children?: ReactNode }) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const contentId = useId();
|
||||
const { collapsed: preferredCollapsed, setCollapsed: setPreferredCollapsed } = useContext(ReasoningVisibilityContext);
|
||||
@@ -988,9 +1020,7 @@ const AssistantChainOfThought = () => {
|
||||
</UnstyledButton>
|
||||
</ChainOfThoughtPrimitive.AccordionTrigger>
|
||||
<Box id={contentId} className={classes.reasoningContent} aria-busy={running} hidden={collapsed}>
|
||||
<ChainOfThoughtPrimitive.Parts
|
||||
components={{ Reasoning: ReasoningPart, tools: { Fallback: ToolPart }, Layout: ChainOfThoughtLayout }}
|
||||
/>
|
||||
<ChainOfThoughtLayout>{children}</ChainOfThoughtLayout>
|
||||
</Box>
|
||||
</ChainOfThoughtPrimitive.Root>
|
||||
);
|
||||
@@ -1512,6 +1542,64 @@ const ConversationTurn = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ConversationContextBreakdown = ({ breakdown }: { breakdown: AssistantContextBreakdown }) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const hasContext = breakdown.percentage !== undefined;
|
||||
const categories = [
|
||||
{ label: t("usage.input"), value: breakdown.inputTokens },
|
||||
{ label: t("usage.cached"), value: breakdown.cachedInputTokens },
|
||||
{ label: t("usage.output"), value: breakdown.outputTokens },
|
||||
{ label: t("usage.reasoning"), value: breakdown.reasoningTokens },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box className={classes.contextBreakdown}>
|
||||
<RingProgress
|
||||
className={classes.contextBreakdownRing}
|
||||
size={76}
|
||||
thickness={7}
|
||||
roundCaps
|
||||
sections={
|
||||
hasContext ? [{ value: breakdown.percentage ?? 0, color: getContextColor(breakdown.percentage ?? 0) }] : []
|
||||
}
|
||||
label={
|
||||
<Text ta="center" size="xs" fw={700}>
|
||||
{hasContext ? `${breakdown.percentage}%` : "–"}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Box className={classes.contextBreakdownWindow}>
|
||||
{[
|
||||
{ label: t("usage.used"), value: breakdown.contextUsed },
|
||||
{ label: t("usage.remaining"), value: breakdown.remaining },
|
||||
{ label: t("usage.capacity"), value: breakdown.contextLength },
|
||||
].map((item) => (
|
||||
<Box key={item.label}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="sm" fw={650}>
|
||||
{item.value?.toLocaleString() ?? t("usage.notReported")}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box className={classes.contextBreakdownCategories}>
|
||||
{categories.map((category) => (
|
||||
<Box key={category.label}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{category.label}
|
||||
</Text>
|
||||
<Text size="xs" fw={650}>
|
||||
{category.value?.toLocaleString() ?? t("usage.notReported")}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const ConversationContext = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const [opened, setOpened] = useState(false);
|
||||
@@ -1529,12 +1617,11 @@ const ConversationContext = () => {
|
||||
[messages],
|
||||
);
|
||||
const usage = useMemo(() => getAssistantConversationUsage(metadata), [metadata]);
|
||||
const breakdown = useMemo(() => getAssistantContextBreakdown(usage), [usage]);
|
||||
const hasTokenUsage = usage.turns.some((turn) => turn.totalTokens !== undefined);
|
||||
const hasCost = usage.turns.some((turn) => turn.cost !== undefined);
|
||||
const hasContext = usage.contextUsed !== undefined && usage.contextLength !== undefined;
|
||||
const contextPercentage = hasContext
|
||||
? Math.min(100, Math.round(((usage.contextUsed ?? 0) / Math.max(usage.contextLength ?? 1, 1)) * 100))
|
||||
: 0;
|
||||
const hasContext = breakdown.percentage !== undefined;
|
||||
const contextPercentage = breakdown.percentage ?? 0;
|
||||
const quickLabel = [
|
||||
hasTokenUsage ? `${usage.totalTokens.toLocaleString()} ${t("usage.tokens")}` : undefined,
|
||||
hasCost ? formatCost(usage.cost) : undefined,
|
||||
@@ -1607,20 +1694,16 @@ const ConversationContext = () => {
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.used")}
|
||||
{t("usage.agentSteps")}
|
||||
</Text>
|
||||
<Text size="sm" fw={650}>
|
||||
{usage.contextUsed?.toLocaleString() ?? t("usage.notReported")}
|
||||
{usage.turns.reduce((total, turn) => total + turn.telemetry.steps.length, 0).toLocaleString()}
|
||||
</Text>
|
||||
{usage.contextLength !== undefined && (
|
||||
<Text size="xs" c="dimmed">
|
||||
/ {usage.contextLength.toLocaleString()}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
<ConversationContextBreakdown breakdown={breakdown} />
|
||||
<Divider />
|
||||
<ScrollArea.Autosize mah="min(21rem, 48dvh)" type="auto" offsetScrollbars>
|
||||
<ScrollArea.Autosize mah="min(14rem, 30dvh)" type="auto" offsetScrollbars>
|
||||
<Stack gap={5}>
|
||||
{usage.turns.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" py="xs">
|
||||
@@ -1739,17 +1822,44 @@ const WebSearchActivity = () => {
|
||||
|
||||
const AssistantMessage = () => (
|
||||
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
|
||||
<MessagePrimitive.Parts
|
||||
unstable_showEmptyOnNonTextEnd={false}
|
||||
components={{
|
||||
Empty: AssistantMessagePending,
|
||||
Text: AssistantTextPart,
|
||||
Source: SourcePart,
|
||||
File: FilePart,
|
||||
Image: ImagePart,
|
||||
ChainOfThought: AssistantChainOfThought,
|
||||
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
|
||||
{({ part, children }) => {
|
||||
switch (part.type) {
|
||||
case "group-agent-trace": {
|
||||
const startIndex = part.indices[0];
|
||||
const endIndex = part.indices.at(-1);
|
||||
if (startIndex === undefined || endIndex === undefined) return null;
|
||||
return (
|
||||
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
|
||||
<AssistantChainOfThought>{children}</AssistantChainOfThought>
|
||||
</ChainOfThoughtByIndicesProvider>
|
||||
);
|
||||
}
|
||||
case "group-reasoning":
|
||||
return <>{children}</>;
|
||||
case "group-tool":
|
||||
return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
|
||||
case "indicator":
|
||||
return <AssistantMessagePending status={{ type: "running" }} />;
|
||||
case "text":
|
||||
return <AssistantTextPart />;
|
||||
case "reasoning":
|
||||
return <ReasoningPart {...part} />;
|
||||
case "tool-call":
|
||||
return part.toolUI ?? <ToolPart {...part} />;
|
||||
case "source":
|
||||
return <SourcePart {...part} />;
|
||||
case "file":
|
||||
return <FilePart {...part} />;
|
||||
case "image":
|
||||
return <ImagePart {...part} />;
|
||||
case "data":
|
||||
return part.dataRendererUI;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</MessagePrimitive.GroupedParts>
|
||||
<RuntimeError />
|
||||
<WebSearchActivity />
|
||||
<AssistantMessageActions />
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { act, createElement, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AssistantClient, ChatModelAdapter, ThreadMessageLike } from "@assistant-ui/react";
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
ChainOfThoughtByIndicesProvider,
|
||||
ChainOfThoughtPrimitive,
|
||||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
} from "@assistant-ui/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { assistantMessageGroupBy } from "./assistant-message-grouping";
|
||||
import { useAssistantReasoningState } from "./assistant-reasoning-state";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -27,7 +30,13 @@ afterEach(() => {
|
||||
const ReasoningPart = ({ text }: { text: string }) => <span data-testid="reasoning-part">{text}</span>;
|
||||
const ToolPart = ({ toolName }: { toolName: string }) => <span data-testid="tool-part">{toolName}</span>;
|
||||
|
||||
const TestChainOfThought = ({ preferredCollapsed = true }: { preferredCollapsed?: boolean }) => {
|
||||
const TestChainOfThought = ({
|
||||
preferredCollapsed = true,
|
||||
children,
|
||||
}: {
|
||||
preferredCollapsed?: boolean;
|
||||
children?: ReactNode;
|
||||
}) => {
|
||||
const { chainStatus, collapsed } = useAssistantReasoningState(preferredCollapsed);
|
||||
|
||||
return (
|
||||
@@ -36,14 +45,38 @@ const TestChainOfThought = ({ preferredCollapsed = true }: { preferredCollapsed?
|
||||
data-status={chainStatus.type}
|
||||
data-collapsed={String(collapsed)}
|
||||
>
|
||||
<ChainOfThoughtPrimitive.Parts components={{ Reasoning: ReasoningPart, tools: { Fallback: ToolPart } }} />
|
||||
{children}
|
||||
</ChainOfThoughtPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const TestMessage = () => (
|
||||
<MessagePrimitive.Root>
|
||||
<MessagePrimitive.Parts components={{ ChainOfThought: TestChainOfThought }} />
|
||||
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="never">
|
||||
{({ part, children }) => {
|
||||
switch (part.type) {
|
||||
case "group-agent-trace": {
|
||||
const startIndex = part.indices[0];
|
||||
const endIndex = part.indices.at(-1);
|
||||
if (startIndex === undefined || endIndex === undefined) return null;
|
||||
return (
|
||||
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
|
||||
<TestChainOfThought>{children}</TestChainOfThought>
|
||||
</ChainOfThoughtByIndicesProvider>
|
||||
);
|
||||
}
|
||||
case "group-reasoning":
|
||||
case "group-tool":
|
||||
return <>{children}</>;
|
||||
case "reasoning":
|
||||
return <ReasoningPart text={part.text} />;
|
||||
case "tool-call":
|
||||
return <ToolPart toolName={part.toolName} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}}
|
||||
</MessagePrimitive.GroupedParts>
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getAssistantToolTraceTarget } from "./assistant-tool-trace";
|
||||
|
||||
describe("getAssistantToolTraceTarget", () => {
|
||||
test("uses a human-readable tool argument instead of an opaque id", () => {
|
||||
expect(getAssistantToolTraceTarget({ id: "app-1", name: " Wikipedia " })).toBe("Wikipedia");
|
||||
expect(getAssistantToolTraceTarget({ integrationIds: ["integration-1"], searchText: " discord icon " })).toBe(
|
||||
"discord icon",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not expose arbitrary argument values in the collapsed trace", () => {
|
||||
expect(getAssistantToolTraceTarget({ id: "secret-looking-id", apiKey: "do-not-show" })).toBeNull();
|
||||
});
|
||||
|
||||
test("bounds long labels so the trace remains compact", () => {
|
||||
expect(getAssistantToolTraceTarget({ title: "a".repeat(100) })).toBe(`${"a".repeat(77)}…`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
const targetKeys = ["name", "title", "query", "searchText", "appName", "widgetName"] as const;
|
||||
|
||||
export const getAssistantToolTraceTarget = (args: unknown): string | null => {
|
||||
if (!args || typeof args !== "object" || Array.isArray(args)) return null;
|
||||
const record = args as Record<string, unknown>;
|
||||
|
||||
for (const key of targetKeys) {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string") continue;
|
||||
const normalized = value.trim().replaceAll(/\s+/gu, " ");
|
||||
if (normalized.length === 0) continue;
|
||||
return normalized.length > 80 ? `${normalized.slice(0, 77)}…` : normalized;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1324,7 +1324,7 @@ const configureEditorPlugins = (defaults: Plugins<DragDropManager>) =>
|
||||
dropAnimation: null,
|
||||
keyboardTransition: { duration: 100 },
|
||||
});
|
||||
if (plugin === AutoScroller) return AutoScroller.configure({ acceleration: 24, threshold: { x: 0.16, y: 0.16 } });
|
||||
if (plugin === AutoScroller) return AutoScroller.configure({ acceleration: 24, threshold: { x: 0, y: 0.16 } });
|
||||
return plugin;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
WORKSHOP_IMAGE=ghcr.io/homarr-labs/workshop:v2
|
||||
WORKSHOP_EXPOSE_PORT=8090
|
||||
|
||||
HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev
|
||||
WORKSHOP_API_URL=https://v2.preview.homarr.dev
|
||||
WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
|
||||
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
|
||||
# Optional GitHub OAuth application for the preview hostname.
|
||||
# GITHUB_CLIENT_ID=
|
||||
# GITHUB_CLIENT_SECRET=
|
||||
@@ -18,15 +18,17 @@ RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store pnpm config set
|
||||
FROM dependencies AS website
|
||||
ARG HOMARR_WEBSITE_URL=https://homarr.dev
|
||||
ARG WORKSHOP_API_URL=https://homarr.dev
|
||||
ARG WORKSHOP_WEB_URL=https://homarr.dev/workshop
|
||||
ENV HOMARR_WEBSITE_URL=$HOMARR_WEBSITE_URL
|
||||
ENV WORKSHOP_API_URL=$WORKSHOP_API_URL
|
||||
ENV WORKSHOP_WEB_URL=$WORKSHOP_WEB_URL
|
||||
RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
|
||||
|
||||
FROM ${POCKETBASE_IMAGE} AS pocketbase-runtime
|
||||
ENV PORT=8090
|
||||
RUN addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
|
||||
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
|
||||
RUN chmod 755 /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data
|
||||
RUN chmod 755 /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data /pb_public
|
||||
ENTRYPOINT ["workshop-entrypoint"]
|
||||
|
||||
FROM pocketbase-runtime AS development
|
||||
@@ -34,6 +36,12 @@ EXPOSE 8090
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider "http://127.0.0.1:${PORT}/api/health" || exit 1
|
||||
|
||||
FROM pocketbase-runtime AS production
|
||||
ARG HOMARR_WEBSITE_URL=https://homarr.dev
|
||||
ARG WORKSHOP_API_URL=https://homarr.dev
|
||||
ARG WORKSHOP_WEB_URL=https://homarr.dev/workshop
|
||||
ENV HOMARR_WEBSITE_URL=$HOMARR_WEBSITE_URL
|
||||
ENV WORKSHOP_API_URL=$WORKSHOP_API_URL
|
||||
ENV WORKSHOP_WEB_URL=$WORKSHOP_WEB_URL
|
||||
COPY --from=website /app/apps/docs/build /pb_public
|
||||
COPY apps/workshop/pb_migrations /pb_migrations
|
||||
COPY apps/workshop/pb_hooks /pb_hooks
|
||||
|
||||
+45
-1
@@ -70,7 +70,8 @@ pnpm dev
|
||||
|
||||
`WORKSHOP_API_URL` controls API calls. `WORKSHOP_WEB_URL` controls links from Homarr. `HOMARR_WEBSITE_URL` is the
|
||||
documentation/site base. All three must be absolute HTTP(S) URLs. The old Docusaurus-only `WORKSHOP_URL` variable
|
||||
remains a deprecated alias for one release.
|
||||
remains a deprecated alias for one release. The production image writes these values into the browser runtime
|
||||
configuration at container startup, so changing them requires a restart but not an image rebuild.
|
||||
|
||||
## Optional GitHub OAuth
|
||||
|
||||
@@ -161,6 +162,45 @@ WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
```
|
||||
|
||||
### Deploy a v2 staging or demo instance
|
||||
|
||||
The staging Compose file runs the published `ghcr.io/homarr-labs/workshop:v2` image, persists `/pb_data`, binds only to
|
||||
the host loopback interface, and expects TLS to terminate at a reverse proxy. It does not need a repository checkout to
|
||||
run; copy `docker-compose.staging.yml` and `.env.staging.example` to the deployment host, then:
|
||||
|
||||
```sh
|
||||
cp .env.staging.example .env.staging
|
||||
docker compose --env-file .env.staging -f docker-compose.staging.yml pull
|
||||
docker compose --env-file .env.staging -f docker-compose.staging.yml up --detach
|
||||
docker compose --env-file .env.staging -f docker-compose.staging.yml ps
|
||||
curl --fail http://127.0.0.1:8090/api/health
|
||||
```
|
||||
|
||||
The checked-in example is ready for `https://v2.preview.homarr.dev`:
|
||||
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev
|
||||
WORKSHOP_API_URL=https://v2.preview.homarr.dev
|
||||
WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
|
||||
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
```
|
||||
|
||||
Proxy the entire `v2.preview.homarr.dev` hostname to `http://127.0.0.1:8090`, including `/api`, `/_/`, and
|
||||
`/workshop-runtime-config.js`; do not proxy only `/workshop`. The combined image owns both the Docusaurus pages and
|
||||
PocketBase routes. If the hostname already serves the Homarr application, use a dedicated Workshop hostname instead
|
||||
and set all four public URL variables to that hostname (with `/workshop` only on `WORKSHOP_WEB_URL`).
|
||||
|
||||
The `v2` image tag is intentionally convenient and mutable for previews. Run `pull` followed by `up --detach` to test a
|
||||
new v2 image while retaining the named PocketBase volume. Use the immutable `sha-<commit>` image tag when promoting a
|
||||
tested build. The public central Workshop needs `PB_ALLOWED_ORIGINS=*` because Homarr instances on arbitrary origins
|
||||
call the API and open the OAuth flow; a private demo can instead use a comma-separated allowlist of its browser origins.
|
||||
|
||||
After startup, open `https://v2.preview.homarr.dev/_/` to create the first PocketBase superuser. If GitHub OAuth is
|
||||
enabled, configure its callback as `https://v2.preview.homarr.dev/api/oauth2-redirect`, set both GitHub credentials, and
|
||||
restart the service. Verify `/workshop`, `/api/health`, listing requests, sign-in, and one installation from the Homarr
|
||||
preview before sharing the demo.
|
||||
|
||||
Build and smoke-test the combined image locally:
|
||||
|
||||
```sh
|
||||
@@ -171,6 +211,10 @@ curl --fail http://127.0.0.1:3003/workshop
|
||||
docker compose -f apps/workshop/docker-compose.yml --profile docs stop docs
|
||||
```
|
||||
|
||||
`pnpm test:workshop-image` builds the production target, starts it with the preview URL contract, and proves that the
|
||||
browser runtime configuration, Workshop page, health endpoint, migrations, public listing endpoint, and cross-origin
|
||||
browser access all work.
|
||||
|
||||
Normal `up`, `start`, `stop`, and `down` operations retain the named volume. The following command is an intentional,
|
||||
irreversible local reset:
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name: homarr-workshop-staging
|
||||
|
||||
services:
|
||||
workshop:
|
||||
image: ${WORKSHOP_IMAGE:-ghcr.io/homarr-labs/workshop:v2}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:?Set HOMARR_WEBSITE_URL in the Workshop environment file}
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
|
||||
PORT: 8090
|
||||
WORKSHOP_API_URL: ${WORKSHOP_API_URL:?Set WORKSHOP_API_URL in the Workshop environment file}
|
||||
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:?Set WORKSHOP_PUBLIC_ORIGIN in the Workshop environment file}
|
||||
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:?Set WORKSHOP_WEB_URL in the Workshop environment file}
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
ports:
|
||||
- "127.0.0.1:${WORKSHOP_EXPOSE_PORT:-8090}:8090"
|
||||
volumes:
|
||||
- pb_data:/pb_data
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
stop_grace_period: 30s
|
||||
|
||||
volumes:
|
||||
pb_data:
|
||||
@@ -8,8 +8,10 @@ services:
|
||||
target: development
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003}
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
|
||||
PORT: ${PB_EXPOSE_PORT:-8090}
|
||||
WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:8090}
|
||||
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop}
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
@@ -33,9 +35,12 @@ services:
|
||||
args:
|
||||
HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003}
|
||||
WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:3003}
|
||||
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop}
|
||||
environment:
|
||||
HOMARR_WEBSITE_URL: ${HOMARR_WEBSITE_URL:-http://localhost:3003}
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
|
||||
PORT: ${DOCS_EXPOSE_PORT:-3003}
|
||||
WORKSHOP_API_URL: ${WORKSHOP_API_URL:-http://localhost:3003}
|
||||
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop}
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
|
||||
@@ -6,6 +6,24 @@ if [ "$(id -u)" -eq 0 ]; then
|
||||
exec su -p -s /bin/sh pocketbase -c 'exec /usr/local/bin/workshop-entrypoint'
|
||||
fi
|
||||
|
||||
HOMARR_WEBSITE_URL=${HOMARR_WEBSITE_URL:-https://homarr.dev}
|
||||
WORKSHOP_API_URL=${WORKSHOP_API_URL:-$HOMARR_WEBSITE_URL}
|
||||
WORKSHOP_WEB_URL=${WORKSHOP_WEB_URL:-$HOMARR_WEBSITE_URL/workshop}
|
||||
|
||||
encode_runtime_value() {
|
||||
printf '%s' "$1" | base64 | tr -d '\n'
|
||||
}
|
||||
|
||||
cat >/pb_public/workshop-runtime-config.js <<EOF
|
||||
const decodeRuntimeValue = (value) =>
|
||||
new TextDecoder().decode(Uint8Array.from(atob(value), (character) => character.charCodeAt(0)));
|
||||
globalThis.homarrRuntimeConfig = Object.freeze({
|
||||
homarrWebsiteUrl: decodeRuntimeValue("$(encode_runtime_value "$HOMARR_WEBSITE_URL")"),
|
||||
workshopApiUrl: decodeRuntimeValue("$(encode_runtime_value "$WORKSHOP_API_URL")"),
|
||||
workshopWebUrl: decodeRuntimeValue("$(encode_runtime_value "$WORKSHOP_WEB_URL")")
|
||||
});
|
||||
EOF
|
||||
|
||||
exec pocketbase serve \
|
||||
--http="0.0.0.0:${PORT:-8090}" \
|
||||
--origins="${PB_ALLOWED_ORIGINS:-*}" \
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
WORKSHOP_IMAGE_TEST_PORT=${WORKSHOP_IMAGE_TEST_PORT:-18091}
|
||||
WORKSHOP_IMAGE_TEST_NAME="homarr-workshop-image-test-$$"
|
||||
WORKSHOP_IMAGE_TEST_TAG="homarr-workshop:test-$$"
|
||||
PREVIEW_ORIGIN=https://v2.preview.homarr.dev
|
||||
|
||||
cleanup() {
|
||||
docker rm --force "$WORKSHOP_IMAGE_TEST_NAME" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
docker build --target production -f apps/workshop/Dockerfile -t "$WORKSHOP_IMAGE_TEST_TAG" .
|
||||
docker run --detach --name "$WORKSHOP_IMAGE_TEST_NAME" \
|
||||
--publish "127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT:8090" \
|
||||
--env HOMARR_WEBSITE_URL="$PREVIEW_ORIGIN" \
|
||||
--env WORKSHOP_API_URL="$PREVIEW_ORIGIN" \
|
||||
--env WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \
|
||||
--env WORKSHOP_PUBLIC_ORIGIN="$PREVIEW_ORIGIN" \
|
||||
--env PB_ALLOWED_ORIGINS='*' \
|
||||
"$WORKSHOP_IMAGE_TEST_TAG" >/dev/null
|
||||
|
||||
for attempt in $(seq 1 60); do
|
||||
if curl --fail --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/health" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 60 ]; then
|
||||
docker logs "$WORKSHOP_IMAGE_TEST_NAME"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT" \
|
||||
EXPECTED_HOMARR_WEBSITE_URL="$PREVIEW_ORIGIN" \
|
||||
EXPECTED_WORKSHOP_API_URL="$PREVIEW_ORIGIN" \
|
||||
EXPECTED_WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \
|
||||
node apps/workshop/tests/runtime-config.integration.mjs
|
||||
|
||||
curl --fail --location --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/workshop" >/dev/null
|
||||
curl --fail --silent \
|
||||
"http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" >/dev/null
|
||||
curl --fail --silent --dump-header - --output /dev/null \
|
||||
--header 'Origin: https://homarr.example' \
|
||||
"http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" \
|
||||
| tr -d '\r' \
|
||||
| grep -qi '^Access-Control-Allow-Origin: \*$'
|
||||
|
||||
echo "Workshop production image passed"
|
||||
+10
-1
@@ -14,7 +14,10 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
HOMARR_WEBSITE_URL=https://preview.example.invalid \
|
||||
WORKSHOP_API_URL=https://api.preview.example.invalid \
|
||||
WORKSHOP_WEB_URL=https://preview.example.invalid/workshop \
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml up --build -d
|
||||
|
||||
for attempt in $(seq 1 60); do
|
||||
@@ -29,6 +32,12 @@ for attempt in $(seq 1 60); do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_TEST_PORT" \
|
||||
EXPECTED_HOMARR_WEBSITE_URL=https://preview.example.invalid \
|
||||
EXPECTED_WORKSHOP_API_URL=https://api.preview.example.invalid \
|
||||
EXPECTED_WORKSHOP_WEB_URL=https://preview.example.invalid/workshop \
|
||||
node apps/workshop/tests/runtime-config.integration.mjs
|
||||
|
||||
docker compose -p "$WORKSHOP_TEST_PROJECT" -f apps/workshop/docker-compose.yml exec -T workshop \
|
||||
pocketbase superuser create \
|
||||
workshop-test@example.invalid 'WorkshopLocalTest123!' --dir=/pb_data
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import vm from "node:vm";
|
||||
|
||||
const baseUrl = process.env.WORKSHOP_TEST_URL;
|
||||
if (!baseUrl) throw new Error("WORKSHOP_TEST_URL is required");
|
||||
|
||||
const response = await fetch(`${baseUrl}/workshop-runtime-config.js`);
|
||||
if (!response.ok) throw new Error(`Runtime configuration returned ${response.status}`);
|
||||
|
||||
const context = { TextDecoder, Uint8Array, atob };
|
||||
context.globalThis = context;
|
||||
vm.runInNewContext(await response.text(), context);
|
||||
|
||||
const actual = JSON.parse(JSON.stringify(context.homarrRuntimeConfig));
|
||||
const expected = {
|
||||
homarrWebsiteUrl: process.env.EXPECTED_HOMARR_WEBSITE_URL,
|
||||
workshopApiUrl: process.env.EXPECTED_WORKSHOP_API_URL,
|
||||
workshopWebUrl: process.env.EXPECTED_WORKSHOP_WEB_URL,
|
||||
};
|
||||
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(`Unexpected Workshop runtime configuration: ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
console.log("Workshop runtime configuration passed");
|
||||
@@ -54,4 +54,15 @@ for (const required of [
|
||||
const workflow = await read(".github/workflows/workshop.yml");
|
||||
if (/\n\s+paths:/u.test(workflow)) throw new Error("Workshop workflow must not filter out Docker build inputs");
|
||||
|
||||
const entrypoint = await read("apps/workshop/entrypoint.sh");
|
||||
if (!entrypoint.includes("/pb_public/workshop-runtime-config.js") || !entrypoint.includes("WORKSHOP_API_URL")) {
|
||||
throw new Error("Workshop must publish its API URL when the container starts");
|
||||
}
|
||||
|
||||
const docsConfig = await read("apps/docs/docusaurus.config.ts");
|
||||
if (!docsConfig.includes('scripts: [{ src: "/workshop-runtime-config.js" }]')) {
|
||||
throw new Error("Workshop must load the container runtime configuration before the documentation bundle");
|
||||
}
|
||||
await access(resolve("apps/docs/static/workshop-runtime-config.js"));
|
||||
|
||||
console.log("Workshop static contracts passed");
|
||||
+36
-5
@@ -253,6 +253,38 @@ describe("Board grid", () => {
|
||||
expect(await rail.evaluate((element) => getComputedStyle(element, "::before").content.replaceAll('"', ""))).toBe(
|
||||
"Left dashboard rail",
|
||||
);
|
||||
const railGrid = rail.locator("[data-grid-section-id]").first();
|
||||
const railSnapshot = await readEditorGridSnapshotAsync(railGrid);
|
||||
await rail.evaluate((element) => {
|
||||
const overflowFixture = document.createElement("div");
|
||||
overflowFixture.dataset.e2eRailOverflowFixture = "true";
|
||||
overflowFixture.style.position = "absolute";
|
||||
overflowFixture.style.insetInlineStart = "100%";
|
||||
overflowFixture.style.width = "500px";
|
||||
overflowFixture.style.height = "1px";
|
||||
overflowFixture.style.pointerEvents = "none";
|
||||
element.appendChild(overflowFixture);
|
||||
});
|
||||
await expect
|
||||
.poll(async () => await rail.evaluate((element) => element.scrollWidth - element.clientWidth))
|
||||
.toBeGreaterThan(0);
|
||||
const railItemBox = await expectBoundingBoxAsync(railItem);
|
||||
const railDragStart = {
|
||||
x: railItemBox.x + railItemBox.width / 2,
|
||||
y: railItemBox.y + railItemBox.height / 2,
|
||||
};
|
||||
await page.mouse.move(railDragStart.x, railDragStart.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(railDragStart.x + 8, railDragStart.y);
|
||||
await expect(railItem).toHaveAttribute("data-dnd-drag-source", "true");
|
||||
await page.mouse.move(railBox.x + railBox.width - 2, railDragStart.y, { steps: 12 });
|
||||
await page.waitForTimeout(250);
|
||||
expect(await rail.evaluate((element) => element.scrollLeft)).toBe(0);
|
||||
expect((await expectBoundingBoxAsync(rail)).x).toBeCloseTo(railBox.x, 1);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(railSnapshot);
|
||||
await rail.locator('[data-e2e-rail-overflow-fixture="true"]').evaluate((element) => element.remove());
|
||||
await expect(logicalTile).toHaveCSS("overflow-x", "hidden");
|
||||
await expect(logicalTile).toHaveCSS("overflow-y", "auto");
|
||||
await logicalTile.evaluate((element) => {
|
||||
@@ -566,9 +598,8 @@ describe("Board grid", () => {
|
||||
});
|
||||
const containerGrip = containerSection.locator("[data-grid-container-drag-handle]");
|
||||
await expectTargetSizeAsync(containerGrip);
|
||||
const railEditor = rail.locator("[data-grid-section-id]").first();
|
||||
const railGridBox = await expectBoundingBoxAsync(railEditor);
|
||||
await dragHandleToGridPositionAsync(page, containerGrip, containerSection, railEditor, 0, 0, canvasScale);
|
||||
const railGridBox = await expectBoundingBoxAsync(railGrid);
|
||||
await dragHandleToGridPositionAsync(page, containerGrip, containerSection, railGrid, 0, 0, canvasScale);
|
||||
await expect(containerSection.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left");
|
||||
await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute(
|
||||
"data-grid-section-id",
|
||||
@@ -677,7 +708,7 @@ describe("Board grid", () => {
|
||||
await railKeyboardEntry.focus();
|
||||
await railKeyboardEntry.press("Enter");
|
||||
const mixedInputSnapshot = await readEditorGridSnapshotAsync(mainGrid);
|
||||
const mixedInputRailSnapshot = await readEditorGridSnapshotAsync(railEditor);
|
||||
const mixedInputRailSnapshot = await readEditorGridSnapshotAsync(railGrid);
|
||||
const mixedInputHandleBox = await expectBoundingBoxAsync(eastResizeHandle);
|
||||
await page.mouse.move(
|
||||
mixedInputHandleBox.x + mixedInputHandleBox.width / 2,
|
||||
@@ -690,7 +721,7 @@ describe("Board grid", () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => await readEditorGridSnapshotAsync(mainGrid)).toEqual(mixedInputSnapshot);
|
||||
await expect.poll(async () => await readEditorGridSnapshotAsync(railEditor)).toEqual(mixedInputRailSnapshot);
|
||||
await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(mixedInputRailSnapshot);
|
||||
await expectTouchResizeCancellationRestoresAsync(
|
||||
page,
|
||||
mainGrid,
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --retry 2 --coverage.enabled ",
|
||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e --retry 2 --maxWorkers 2",
|
||||
"test:workshop": "sh apps/workshop/test.sh",
|
||||
"test:workshop-image": "sh apps/workshop/test-image.sh",
|
||||
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled",
|
||||
"typecheck": "turbo typecheck",
|
||||
"with-env": "dotenv -e .env --"
|
||||
|
||||
Reference in new issue
Block a user