diff --git a/site/src/modules/dashboard/AIProviderEnvDriftBanner/AIProviderEnvDriftBanner.stories.tsx b/site/src/modules/dashboard/AIProviderEnvDriftBanner/AIProviderEnvDriftBanner.stories.tsx new file mode 100644 index 0000000000..36ee065755 --- /dev/null +++ b/site/src/modules/dashboard/AIProviderEnvDriftBanner/AIProviderEnvDriftBanner.stories.tsx @@ -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 = { + title: "modules/dashboard/AIProviderEnvDriftBanner", + parameters: { chromatic }, + component: AIProviderEnvDriftBannerView, +}; + +export default meta; +type Story = StoryObj; + +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 ( + + + + ); +}; + +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(); + }, +}; diff --git a/site/src/modules/dashboard/AIProviderEnvDriftBanner/AIProviderEnvDriftBanner.tsx b/site/src/modules/dashboard/AIProviderEnvDriftBanner/AIProviderEnvDriftBanner.tsx new file mode 100644 index 0000000000..f70735af60 --- /dev/null +++ b/site/src/modules/dashboard/AIProviderEnvDriftBanner/AIProviderEnvDriftBanner.tsx @@ -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 ( +
+
+
+ +
+
+ + Changes to the deprecated AI provider environment variables + (CODER_AIBRIDGE_*) are ineffective. Manage AI providers through the + dashboard, which is the source of truth.{" "} + + View setup docs + + +
+
+
+ ); +}; + +/** + * 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 ( + + ); +}; diff --git a/site/src/modules/dashboard/DashboardLayout.tsx b/site/src/modules/dashboard/DashboardLayout.tsx index 42d8824a59..fa04dc6617 100644 --- a/site/src/modules/dashboard/DashboardLayout.tsx +++ b/site/src/modules/dashboard/DashboardLayout.tsx @@ -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 && } + {canViewDeployment && }