feat(site): warn admins about ineffective AI provider env config

Add an admin-only dashboard banner that surfaces when deprecated
CODER_AIBRIDGE_* env configuration has drifted from the database
(appearance.ai_providers_env_drift_detected). It stacks beneath the
license banner inside the existing deployment-config gate and links to
the AI gateway setup docs. Presentation lives in a pure subcomponent
covered by a Storybook story.
This commit is contained in:
Danny Kopping
2026-06-01 09:32:04 +00:00
parent 56c9d0dcbb
commit 6509c64f7d
3 changed files with 142 additions and 0 deletions
@@ -0,0 +1,77 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { chromatic } from "#/testHelpers/chromatic";
import {
MockAppearanceConfig,
MockBuildInfo,
MockDefaultOrganization,
MockEntitlements,
MockExperiments,
} from "#/testHelpers/entities";
import { docs } from "#/utils/docs";
import { DashboardContext, type DashboardValue } from "../DashboardProvider";
import {
AIProviderEnvDriftBanner,
AIProviderEnvDriftBannerView,
} from "./AIProviderEnvDriftBanner";
const meta: Meta<typeof AIProviderEnvDriftBannerView> = {
title: "modules/dashboard/AIProviderEnvDriftBanner",
parameters: { chromatic },
component: AIProviderEnvDriftBannerView,
};
export default meta;
type Story = StoryObj<typeof AIProviderEnvDriftBannerView>;
export const Default: Story = {
args: {
docsHref: docs("/ai-coder/ai-gateway/setup"),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("status")).toBeInTheDocument();
await expect(
canvas.getByRole("link", { name: /View setup docs/i }),
).toHaveAttribute("href", docs("/ai-coder/ai-gateway/setup"));
},
};
const renderBannerWithDrift = (driftDetected: boolean) => {
const value: DashboardValue = {
entitlements: MockEntitlements,
experiments: MockExperiments,
appearance: {
...MockAppearanceConfig,
ai_providers_env_drift_detected: driftDetected,
},
buildInfo: MockBuildInfo,
organizations: [MockDefaultOrganization],
showOrganizations: false,
canViewOrganizationSettings: false,
};
return (
<DashboardContext.Provider value={value}>
<AIProviderEnvDriftBanner />
</DashboardContext.Provider>
);
};
export const DriftDetected: Story = {
render: () => renderBannerWithDrift(true),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole("status")).toBeInTheDocument();
await expect(
canvas.getByRole("link", { name: /View setup docs/i }),
).toHaveAttribute("href", docs("/ai-coder/ai-gateway/setup"));
},
};
export const NoDrift: Story = {
render: () => renderBannerWithDrift(false),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByRole("status")).not.toBeInTheDocument();
},
};
@@ -0,0 +1,63 @@
import { TriangleAlertIcon } from "lucide-react";
import type { FC } from "react";
import { Link } from "#/components/Link/Link";
import { useDashboard } from "#/modules/dashboard/useDashboard";
import { docs } from "#/utils/docs";
interface AIProviderEnvDriftBannerViewProps {
docsHref: string;
}
/**
* AIProviderEnvDriftBannerView is the presentational banner. It is pure
* (props only) so it can be exercised in Storybook without dashboard
* context. Styling mirrors the single-message LicenseBannerView so the
* two banners stack consistently.
*/
export const AIProviderEnvDriftBannerView: FC<
AIProviderEnvDriftBannerViewProps
> = ({ docsHref }) => {
return (
<div role="status" className="flex items-center p-3 bg-surface-secondary">
<div className="flex min-w-0 flex-1 items-start gap-2">
<div className="flex h-6 items-center">
<TriangleAlertIcon className="size-4 text-content-warning" />
</div>
<div className="flex min-h-6 min-w-0 flex-1 items-center text-xs leading-4 text-content-primary">
<span>
Changes to the deprecated AI provider environment variables
(CODER_AIBRIDGE_*) are ineffective. Manage AI providers through the
dashboard, which is the source of truth.{" "}
<Link
className="text-xs font-medium !text-content-link"
href={docsHref}
target="_blank"
>
View setup docs
</Link>
</span>
</div>
</div>
</div>
);
};
/**
* AIProviderEnvDriftBanner warns admins when deprecated CODER_AIBRIDGE_*
* env configuration drifts from the AI provider rows in the database,
* which makes those env changes ineffective. It renders nothing when no
* drift was detected at startup.
*/
export const AIProviderEnvDriftBanner: FC = () => {
const { appearance } = useDashboard();
if (!appearance.ai_providers_env_drift_detected) {
return null;
}
return (
<AIProviderEnvDriftBannerView
docsHref={docs("/ai-coder/ai-gateway/setup")}
/>
);
};
@@ -6,6 +6,7 @@ import { Outlet } from "react-router";
import { Button } from "#/components/Button/Button";
import { Loader } from "#/components/Loader/Loader";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { AIProviderEnvDriftBanner } from "#/modules/dashboard/AIProviderEnvDriftBanner/AIProviderEnvDriftBanner";
import { AnnouncementBanners } from "#/modules/dashboard/AnnouncementBanners/AnnouncementBanners";
import { LicenseBanner } from "#/modules/dashboard/LicenseBanner/LicenseBanner";
import { cn } from "#/utils/cn";
@@ -22,6 +23,7 @@ export const DashboardLayout: FC = () => {
return (
<>
{canViewDeployment && <LicenseBanner />}
{canViewDeployment && <AIProviderEnvDriftBanner />}
<AnnouncementBanners />
<div className="flex flex-col min-h-screen justify-between">