mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: cleanup <Tabs /> component (#23839)
This refactors `<Tabs />` into two clearer patterns: link tabs for route navigation and Radix tabs for stateful tab panels. That gives us proper accessibility semantics where we need them without overloading simple navigation tabs. As part of that split, this updates several consumers, adds coverage for both variants, and cleans up some nearby styling. - introduce Radix-backed tabs primitives for tabbed content - move router-based tabs to `LinkTabs` - update notifications, IdP sync, and workspace build pages to use semantic tabs - preserve route navigation tabs for groups and templates - add stories/tests for both tab implementations - simplify related layout and styling in touched components
This commit is contained in:
@@ -77,6 +77,7 @@
|
||||
"@radix-ui/react-slider": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@tanstack/react-query-devtools": "5.77.0",
|
||||
"@xterm/addon-canvas": "0.7.0",
|
||||
|
||||
Generated
+32
@@ -130,6 +130,9 @@ importers:
|
||||
'@radix-ui/react-switch':
|
||||
specifier: 1.2.6
|
||||
version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: 1.1.13
|
||||
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: 1.2.8
|
||||
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
|
||||
@@ -2134,6 +2137,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tabs@1.1.13':
|
||||
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==, tarball: https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8':
|
||||
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==, tarball: https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz}
|
||||
peerDependencies:
|
||||
@@ -9097,6 +9113,22 @@ snapshots:
|
||||
'@types/react': 19.2.7
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||
|
||||
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.2)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.2)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.2)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.2)
|
||||
react: 19.2.2
|
||||
react-dom: 19.2.2(react@19.2.2)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type CSSObject, useTheme } from "@emotion/react";
|
||||
import type { FC, PropsWithChildren, ReactNode } from "react";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
interface FullWidthPageHeaderProps {
|
||||
children?: ReactNode;
|
||||
@@ -10,36 +10,15 @@ export const FullWidthPageHeader: FC<FullWidthPageHeaderProps> = ({
|
||||
children,
|
||||
sticky = true,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<header
|
||||
data-testid="header"
|
||||
css={[
|
||||
{
|
||||
...(theme.typography.body2 as CSSObject),
|
||||
padding: 24,
|
||||
background: theme.palette.background.default,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 48,
|
||||
|
||||
zIndex: 10,
|
||||
flexWrap: "wrap",
|
||||
|
||||
[theme.breakpoints.down("lg")]: {
|
||||
position: "unset",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
[theme.breakpoints.down("md")]: {
|
||||
flexDirection: "column",
|
||||
},
|
||||
},
|
||||
sticky && {
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
},
|
||||
]}
|
||||
className={cn(
|
||||
"bg-surface-primary border-0 border-b border-solid border-border",
|
||||
"text-sm p-6 flex items-center gap-12 flex-wrap z-10",
|
||||
"lg:items-center flex-col lg:flex-row",
|
||||
sticky && "sticky top-0",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
@@ -47,19 +26,7 @@ export const FullWidthPageHeader: FC<FullWidthPageHeaderProps> = ({
|
||||
};
|
||||
|
||||
const _PageHeaderActions: FC<PropsWithChildren> = ({ children }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<div
|
||||
css={{
|
||||
marginLeft: "auto",
|
||||
[theme.breakpoints.down("md")]: {
|
||||
marginLeft: "unset",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return <div className="ml-auto md:ml-0">{children}</div>;
|
||||
};
|
||||
|
||||
export const PageHeaderTitle: FC<PropsWithChildren> = ({ children }) => {
|
||||
@@ -67,16 +34,7 @@ export const PageHeaderTitle: FC<PropsWithChildren> = ({ children }) => {
|
||||
};
|
||||
|
||||
export const PageHeaderSubtitle: FC<PropsWithChildren> = ({ children }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<span
|
||||
css={{
|
||||
fontSize: 14,
|
||||
color: theme.palette.text.secondary,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<span className="text-sm text-content-secondary block">{children}</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { TabLink, Tabs, TabsList } from "./Tabs";
|
||||
import {
|
||||
LinkTabs,
|
||||
LinkTabsList,
|
||||
TabLink,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "./Tabs";
|
||||
|
||||
const meta: Meta<typeof Tabs> = {
|
||||
const meta: Meta<typeof LinkTabs> = {
|
||||
title: "components/Tabs",
|
||||
component: Tabs,
|
||||
component: LinkTabs,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Tabs>;
|
||||
type Story = StoryObj<typeof LinkTabs>;
|
||||
|
||||
export const Default: Story = {
|
||||
export const LinkNavigation: Story = {
|
||||
args: {
|
||||
active: "tab-1",
|
||||
children: (
|
||||
<TabsList>
|
||||
<LinkTabsList>
|
||||
<TabLink value="tab-1" to="">
|
||||
Tab 1
|
||||
</TabLink>
|
||||
@@ -23,7 +31,42 @@ export const Default: Story = {
|
||||
<TabLink value="tab-3" to="tab-4">
|
||||
Tab 3
|
||||
</TabLink>
|
||||
</TabsList>
|
||||
</LinkTabsList>
|
||||
),
|
||||
},
|
||||
render: (args) => <LinkTabs {...args} />,
|
||||
};
|
||||
|
||||
export const RadixInsideBox: StoryObj = {
|
||||
render: () => (
|
||||
<Tabs defaultValue="a">
|
||||
<TabsList variant="insideBox">
|
||||
<TabsTrigger value="a">Alpha</TabsTrigger>
|
||||
<TabsTrigger value="b">Beta</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="a" className="p-4">
|
||||
Panel A
|
||||
</TabsContent>
|
||||
<TabsContent value="b" className="p-4">
|
||||
Panel B
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
),
|
||||
};
|
||||
|
||||
export const RadixOutsideBox: StoryObj = {
|
||||
render: () => (
|
||||
<Tabs defaultValue="a">
|
||||
<TabsList variant="outsideBox">
|
||||
<TabsTrigger value="a">Alpha</TabsTrigger>
|
||||
<TabsTrigger value="b">Beta</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="a" className="p-4">
|
||||
Panel A
|
||||
</TabsContent>
|
||||
<TabsContent value="b" className="p-4">
|
||||
Panel B
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { TabLink, Tabs, TabsList } from "./Tabs";
|
||||
import {
|
||||
LinkTabs,
|
||||
LinkTabsList,
|
||||
TabLink,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "./Tabs";
|
||||
|
||||
const renderTabs = (active = "overview") => {
|
||||
const renderLinkTabs = (active = "overview") => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Tabs active={active}>
|
||||
<TabsList>
|
||||
<LinkTabs active={active}>
|
||||
<LinkTabsList>
|
||||
<TabLink to="/overview" value="overview">
|
||||
Overview
|
||||
</TabLink>
|
||||
<TabLink to="/settings" value="settings">
|
||||
Settings
|
||||
</TabLink>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</LinkTabsList>
|
||||
</LinkTabs>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("Tabs", () => {
|
||||
describe("LinkTabs", () => {
|
||||
it("does not expose tablist semantics for link navigation", () => {
|
||||
renderTabs();
|
||||
renderLinkTabs();
|
||||
|
||||
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks only the active tab link as the current page", () => {
|
||||
renderTabs("overview");
|
||||
renderLinkTabs("overview");
|
||||
|
||||
expect(screen.getByRole("link", { name: "Overview" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
@@ -38,3 +46,26 @@ describe("Tabs", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tabs (Radix)", () => {
|
||||
it("exposes tablist semantics for keyboard navigation", () => {
|
||||
render(
|
||||
<Tabs defaultValue="a">
|
||||
<TabsList variant="insideBox" aria-label="Example">
|
||||
<TabsTrigger value="a">Alpha</TabsTrigger>
|
||||
<TabsTrigger value="b">Beta</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="a">A</TabsContent>
|
||||
<TabsContent value="b">B</TabsContent>
|
||||
</Tabs>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("tablist", { name: "Example" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Alpha" })).toHaveAttribute(
|
||||
"data-state",
|
||||
"active",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type FC,
|
||||
type HTMLAttributes,
|
||||
@@ -11,20 +14,101 @@ import {
|
||||
import { Link, type LinkProps } from "react-router";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
// Keeping this for now because of a workaround in WorkspaceBUildPageView
|
||||
// --- Radix tabs (stateful panels) ---
|
||||
|
||||
type TabsProps = ComponentProps<typeof TabsPrimitive.Root>;
|
||||
|
||||
export const Tabs: FC<TabsProps> = ({ ...props }) => {
|
||||
return <TabsPrimitive.Root {...props} />;
|
||||
};
|
||||
|
||||
const tabsListVariants = cva("flex flex-wrap items-center", {
|
||||
variants: {
|
||||
variant: {
|
||||
insideBox: cn(
|
||||
"border-solid border-x-0 border-y",
|
||||
"[&_button[data-state=active]]:bg-surface-secondary",
|
||||
"[&_button]:border-x [&_button]:border-y-0 [&_button]:border-solid",
|
||||
"[&_button]:border-x-transparent [&_button[data-state=active]]:border-x-border",
|
||||
"[&_button]:px-4",
|
||||
"[&_button]:text-content-secondary",
|
||||
"[&_button[data-state=active]]:text-content-primary",
|
||||
),
|
||||
outsideBox: cn(
|
||||
"border-solid border-0 border-b gap-6",
|
||||
"[&_button]:text-content-secondary [&_button[data-state=active]]:text-content-primary",
|
||||
"[&_button]:border-0 [&_button]:border-y [&_button]:border-solid",
|
||||
"[&_button]:border-transparent [&_button[data-state=active]]:border-b-white",
|
||||
"[&_button]:hover:text-content-primary",
|
||||
"[&_button]:px-1",
|
||||
),
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "outsideBox",
|
||||
},
|
||||
});
|
||||
type TabsListProps = ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>;
|
||||
|
||||
export const TabsList: FC<TabsListProps> = ({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type TabsTriggerProps = ComponentProps<typeof TabsPrimitive.Trigger>;
|
||||
|
||||
export const TabsTrigger: FC<TabsTriggerProps> = ({ ...props }) => {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
"border-none py-3 bg-transparent",
|
||||
"text-inherit font-normal text-sm",
|
||||
"inline-flex gap-2 items-center",
|
||||
"cursor-pointer",
|
||||
"transition-colors duration-150 ease-linear",
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type TabsContentProps = ComponentProps<typeof TabsPrimitive.Content>;
|
||||
|
||||
export const TabsContent: FC<TabsContentProps> = ({ ...props }) => {
|
||||
return <TabsPrimitive.Content {...props} />;
|
||||
};
|
||||
|
||||
// --- Router link tabs (URL-driven navigation) ---
|
||||
|
||||
// Keeping this for now because of a workaround in WorkspaceBuildPageView.
|
||||
export const TAB_PADDING_X = 16;
|
||||
|
||||
type TabsContextValue = {
|
||||
type LinkTabsContextValue = {
|
||||
active: string;
|
||||
};
|
||||
|
||||
const TabsContext = createContext<TabsContextValue | undefined>(undefined);
|
||||
const LinkTabsContext = createContext<LinkTabsContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
type TabsProps = HTMLAttributes<HTMLDivElement> & TabsContextValue;
|
||||
type LinkTabsProps = HTMLAttributes<HTMLDivElement> & LinkTabsContextValue;
|
||||
|
||||
export const Tabs: FC<TabsProps> = ({ className, active, ...htmlProps }) => {
|
||||
export const LinkTabs: FC<LinkTabsProps> = ({
|
||||
className,
|
||||
active,
|
||||
...htmlProps
|
||||
}) => {
|
||||
return (
|
||||
<TabsContext.Provider value={{ active }}>
|
||||
<LinkTabsContext.Provider value={{ active }}>
|
||||
<div
|
||||
// Because the Tailwind preflight is not used, its necessary to set border style to solid and
|
||||
// reset all border widths to 0 https://tailwindcss.com/docs/border-width#using-without-preflight
|
||||
@@ -34,14 +118,17 @@ export const Tabs: FC<TabsProps> = ({ className, active, ...htmlProps }) => {
|
||||
)}
|
||||
{...htmlProps}
|
||||
/>
|
||||
</TabsContext.Provider>
|
||||
</LinkTabsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
type TabsListProps = HTMLAttributes<HTMLDivElement>;
|
||||
type LinkTabsListProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TabsList: FC<TabsListProps> = ({ className, ...props }) => {
|
||||
const tabsContext = useContext(TabsContext);
|
||||
export const LinkTabsList: FC<LinkTabsListProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const tabsContext = useContext(LinkTabsContext);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const indicatorRef = useRef<HTMLDivElement>(null);
|
||||
const hasInitialized = useRef(false);
|
||||
@@ -114,9 +201,9 @@ export const TabLink: FC<TabLinkProps> = ({
|
||||
className,
|
||||
...linkProps
|
||||
}) => {
|
||||
const tabsContext = useContext(TabsContext);
|
||||
const tabsContext = useContext(LinkTabsContext);
|
||||
if (!tabsContext) {
|
||||
throw new Error("Tab only can be used inside of Tabs");
|
||||
throw new Error("TabLink must be used inside LinkTabs");
|
||||
}
|
||||
|
||||
const isActive = tabsContext.active === value;
|
||||
|
||||
@@ -88,12 +88,10 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
|
||||
<Fragment key={stage}>
|
||||
<div
|
||||
className={cn(
|
||||
"logs-header text-sm font-semibold font-sans leading-none",
|
||||
"border-solid border-0 border-b bg-surface-primary",
|
||||
"flex items-center",
|
||||
"[&:last-child]:border-b-0 [&:last-child]:rounded-b-lg",
|
||||
"[&:first-of-type]:rounded-t-lg",
|
||||
sticky && "sticky top-0",
|
||||
"logs-header",
|
||||
"flex items-center border-solid border-0 border-b border-border font-sans",
|
||||
"bg-surface-primary text-xs font-semibold leading-none",
|
||||
"first-of-type:pt-4",
|
||||
)}
|
||||
style={{
|
||||
padding: `12px var(--log-line-side-padding, ${DEFAULT_LOG_LINE_SIDE_PADDING}px)`,
|
||||
@@ -101,12 +99,18 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
|
||||
>
|
||||
<div>{stage}</div>
|
||||
{shouldDisplayDuration && (
|
||||
<div className="ml-auto text-content-secondary text-xs">
|
||||
<div className="ml-auto text-xs text-content-secondary">
|
||||
{duration} seconds
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isEmpty && <Logs hideTimestamps={hideTimestamps} lines={lines} />}
|
||||
{!isEmpty && (
|
||||
<Logs
|
||||
className="border-b-border"
|
||||
hideTimestamps={hideTimestamps}
|
||||
lines={lines}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import type { FC } from "react";
|
||||
import { useQueries } from "react-query";
|
||||
import {
|
||||
@@ -14,7 +13,12 @@ import {
|
||||
SettingsHeaderDocsLink,
|
||||
SettingsHeaderTitle,
|
||||
} from "#/components/SettingsHeader/SettingsHeader";
|
||||
import { TabLink, Tabs, TabsList } from "#/components/Tabs/Tabs";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "#/components/Tabs/Tabs";
|
||||
import { useSearchParamsKey } from "#/hooks/useSearchParamsKey";
|
||||
import { useDeploymentConfig } from "#/modules/management/DeploymentConfigProvider";
|
||||
import { castNotificationMethod } from "#/modules/notifications/utils";
|
||||
@@ -25,6 +29,14 @@ import OptionsTable from "../OptionsTable";
|
||||
import { NotificationEvents } from "./NotificationEvents";
|
||||
import { Troubleshooting } from "./Troubleshooting";
|
||||
|
||||
const NOTIFICATION_TABS = ["events", "settings", "troubleshooting"] as const;
|
||||
|
||||
function isNotificationTab(
|
||||
value: string,
|
||||
): value is (typeof NOTIFICATION_TABS)[number] {
|
||||
return (NOTIFICATION_TABS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
const NotificationsPage: FC = () => {
|
||||
const { deploymentConfig } = useDeploymentConfig();
|
||||
const [systemTemplatesByGroup, customTemplatesByGroup, dispatchMethods] =
|
||||
@@ -46,6 +58,10 @@ const NotificationsPage: FC = () => {
|
||||
defaultValue: "events",
|
||||
});
|
||||
|
||||
const activeTab = isNotificationTab(tabState.value)
|
||||
? tabState.value
|
||||
: NOTIFICATION_TABS[0];
|
||||
|
||||
const ready = !!(
|
||||
systemTemplatesByGroup.data &&
|
||||
customTemplatesByGroup.data &&
|
||||
@@ -73,23 +89,16 @@ const NotificationsPage: FC = () => {
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
|
||||
<Tabs active={tabState.value}>
|
||||
<TabsList>
|
||||
<TabLink to="?tab=events" value="events">
|
||||
Events
|
||||
</TabLink>
|
||||
<TabLink to="?tab=settings" value="settings">
|
||||
Settings
|
||||
</TabLink>
|
||||
<TabLink to="?tab=troubleshooting" value="troubleshooting">
|
||||
Troubleshooting
|
||||
</TabLink>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div css={styles.content}>
|
||||
{ready ? (
|
||||
tabState.value === "events" ? (
|
||||
{!ready ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={tabState.setValue}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="events">Events</TabsTrigger>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
<TabsTrigger value="troubleshooting">Troubleshooting</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="events" className="py-6">
|
||||
<NotificationEvents
|
||||
templatesByGroup={allTemplatesByGroup}
|
||||
deploymentConfig={deploymentConfig.config}
|
||||
@@ -100,25 +109,21 @@ const NotificationsPage: FC = () => {
|
||||
castNotificationMethod,
|
||||
)}
|
||||
/>
|
||||
) : tabState.value === "troubleshooting" ? (
|
||||
<Troubleshooting />
|
||||
) : (
|
||||
</TabsContent>
|
||||
<TabsContent value="settings" className="py-6">
|
||||
<OptionsTable
|
||||
options={deploymentConfig.options.filter((o) =>
|
||||
deploymentGroupHasParent(o.group, "Notifications"),
|
||||
)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="troubleshooting" className="py-6">
|
||||
<Troubleshooting />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsPage;
|
||||
|
||||
const styles = {
|
||||
content: { paddingTop: 24 },
|
||||
} as Record<string, Interpolation<Theme>>;
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
SettingsHeaderDescription,
|
||||
SettingsHeaderTitle,
|
||||
} from "#/components/SettingsHeader/SettingsHeader";
|
||||
import { TabLink, Tabs, TabsList } from "#/components/Tabs/Tabs";
|
||||
import { LinkTabs, LinkTabsList, TabLink } from "#/components/Tabs/Tabs";
|
||||
import { usePaginatedQuery } from "#/hooks/usePaginatedQuery";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
|
||||
@@ -135,16 +135,16 @@ const GroupPage: FC = () => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-10 w-full">
|
||||
{canUpdateGroup && (
|
||||
<Tabs active={activeTab}>
|
||||
<TabsList className="w-full justify-start">
|
||||
<LinkTabs active={activeTab}>
|
||||
<LinkTabsList className="w-full justify-start">
|
||||
<TabLink to="." value="members">
|
||||
Group members
|
||||
</TabLink>
|
||||
<TabLink to="settings" value="settings">
|
||||
Group settings
|
||||
</TabLink>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</LinkTabsList>
|
||||
</LinkTabs>
|
||||
)}
|
||||
|
||||
<Outlet
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FC } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type {
|
||||
Group,
|
||||
GroupSyncSettings,
|
||||
@@ -8,7 +9,12 @@ import type {
|
||||
} from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import { TabLink, Tabs, TabsList } from "#/components/Tabs/Tabs";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "#/components/Tabs/Tabs";
|
||||
import { IdpGroupSyncForm } from "./IdpGroupSyncForm";
|
||||
import { IdpRoleSyncForm } from "./IdpRoleSyncForm";
|
||||
|
||||
@@ -43,6 +49,7 @@ const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({
|
||||
onSubmitGroupSyncSettings,
|
||||
onSubmitRoleSyncSettings,
|
||||
}) => {
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
const groupMappingCount = groupSyncSettings?.mapping
|
||||
? Object.entries(groupSyncSettings.mapping).length
|
||||
: 0;
|
||||
@@ -60,39 +67,41 @@ const IdpSyncPageView: FC<IdpSyncPageViewProps> = ({
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{Boolean(error) && <ErrorAlert error={error} />}
|
||||
<Tabs active={tab}>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value: string) => {
|
||||
setSearchParams({ tab: value });
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
<TabLink to="?tab=groups" value="groups">
|
||||
Group sync settings
|
||||
</TabLink>
|
||||
<TabLink to="?tab=roles" value="roles">
|
||||
Role sync settings
|
||||
</TabLink>
|
||||
<TabsTrigger value="groups">Group sync settings</TabsTrigger>
|
||||
<TabsTrigger value="roles">Role sync settings</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="groups">
|
||||
<IdpGroupSyncForm
|
||||
groupSyncSettings={groupSyncSettings}
|
||||
claimFieldValues={claimFieldValues}
|
||||
groupMappingCount={groupMappingCount}
|
||||
legacyGroupMappingCount={legacyGroupMappingCount}
|
||||
groups={groups}
|
||||
groupsMap={groupsMap}
|
||||
organization={organization}
|
||||
onSubmit={onSubmitGroupSyncSettings}
|
||||
onSyncFieldChange={onGroupSyncFieldChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="roles">
|
||||
<IdpRoleSyncForm
|
||||
roleSyncSettings={roleSyncSettings}
|
||||
claimFieldValues={claimFieldValues}
|
||||
roleMappingCount={roleMappingCount}
|
||||
roles={roles || []}
|
||||
organization={organization}
|
||||
onSubmit={onSubmitRoleSyncSettings}
|
||||
onSyncFieldChange={onRoleSyncFieldChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{tab === "groups" ? (
|
||||
<IdpGroupSyncForm
|
||||
groupSyncSettings={groupSyncSettings}
|
||||
claimFieldValues={claimFieldValues}
|
||||
groupMappingCount={groupMappingCount}
|
||||
legacyGroupMappingCount={legacyGroupMappingCount}
|
||||
groups={groups}
|
||||
groupsMap={groupsMap}
|
||||
organization={organization}
|
||||
onSubmit={onSubmitGroupSyncSettings}
|
||||
onSyncFieldChange={onGroupSyncFieldChange}
|
||||
/>
|
||||
) : (
|
||||
<IdpRoleSyncForm
|
||||
roleSyncSettings={roleSyncSettings}
|
||||
claimFieldValues={claimFieldValues}
|
||||
roleMappingCount={roleMappingCount}
|
||||
roles={roles || []}
|
||||
organization={organization}
|
||||
onSubmit={onSubmitRoleSyncSettings}
|
||||
onSyncFieldChange={onRoleSyncFieldChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { AuthorizationRequest } from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import { Margins } from "#/components/Margins/Margins";
|
||||
import { TabLink, Tabs, TabsList } from "#/components/Tabs/Tabs";
|
||||
import { LinkTabs, LinkTabsList, TabLink } from "#/components/Tabs/Tabs";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
|
||||
import {
|
||||
@@ -135,9 +135,9 @@ export const TemplateLayout: FC<PropsWithChildren> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tabs active={activeTab} className="mb-10 -mt-3">
|
||||
<LinkTabs active={activeTab} className="mb-10 -mt-3">
|
||||
<Margins>
|
||||
<TabsList>
|
||||
<LinkTabsList>
|
||||
<TabLink to="docs" value="docs">
|
||||
Docs
|
||||
</TabLink>
|
||||
@@ -166,9 +166,9 @@ export const TemplateLayout: FC<PropsWithChildren> = ({
|
||||
Prebuilds
|
||||
</TabLink>
|
||||
)}
|
||||
</TabsList>
|
||||
</LinkTabsList>
|
||||
</Margins>
|
||||
</Tabs>
|
||||
</LinkTabs>
|
||||
|
||||
<Margins>
|
||||
<TemplateLayoutContext.Provider value={data}>
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import type { FC, HTMLAttributes } from "react";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
export const Sidebar: FC<HTMLAttributes<HTMLElement>> = ({
|
||||
children,
|
||||
...attrs
|
||||
}) => {
|
||||
return (
|
||||
<nav
|
||||
css={(theme) => ({
|
||||
width: 256,
|
||||
flexShrink: 0,
|
||||
borderRight: `1px solid ${theme.palette.divider}`,
|
||||
height: "100%",
|
||||
overflowY: "auto",
|
||||
})}
|
||||
className={cn(
|
||||
"w-64 flex-shrink-0 border-solid border-0 border-r",
|
||||
"h-full py-2 overflow-y-auto",
|
||||
)}
|
||||
{...attrs}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import WS from "jest-websocket-mock";
|
||||
import * as apiModule from "#/api/api";
|
||||
import { API } from "#/api/api";
|
||||
import {
|
||||
MockWorkspace,
|
||||
@@ -8,6 +10,11 @@ import {
|
||||
MockWorkspaceBuild,
|
||||
} from "#/testHelpers/entities";
|
||||
import { renderWithAuth } from "#/testHelpers/renderHelpers";
|
||||
import {
|
||||
createMockWebSocket,
|
||||
type MockWebSocketServer,
|
||||
} from "#/testHelpers/websockets";
|
||||
import { OneWayWebSocket } from "#/utils/OneWayWebSocket";
|
||||
import WorkspaceBuildPage from "./WorkspaceBuildPage";
|
||||
import { LOGS_TAB_KEY } from "./WorkspaceBuildPageView";
|
||||
|
||||
@@ -64,18 +71,46 @@ describe("WorkspaceBuildPage", () => {
|
||||
});
|
||||
|
||||
test("shows selected agent logs", async () => {
|
||||
const server = new WS(
|
||||
`ws://localhost/api/v2/workspaceagents/${
|
||||
MockWorkspaceAgent.id
|
||||
}/logs?follow&after=0`,
|
||||
);
|
||||
let mockServer: MockWebSocketServer | undefined;
|
||||
|
||||
jest
|
||||
.spyOn(apiModule, "watchWorkspaceAgentLogs")
|
||||
.mockImplementation((agentId, params) => {
|
||||
return new OneWayWebSocket({
|
||||
apiRoute: `/api/v2/workspaceagents/${agentId}/logs`,
|
||||
searchParams: new URLSearchParams({
|
||||
follow: "true",
|
||||
after: params?.after?.toString() || "0",
|
||||
}),
|
||||
websocketInit: (url, protocol) => {
|
||||
const [socket, server] = createMockWebSocket(url, protocol);
|
||||
mockServer = server;
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithAuth(<WorkspaceBuildPage />, {
|
||||
route: `/@${MockWorkspace.owner_name}/${MockWorkspace.name}/builds/${MockWorkspace.latest_build.build_number}?${LOGS_TAB_KEY}=${MockWorkspaceAgent.id}`,
|
||||
path: "/:username/:workspace/builds/:buildNumber",
|
||||
});
|
||||
|
||||
await screen.findByText(`Build #${MockWorkspaceBuild.build_number}`);
|
||||
await server.connected;
|
||||
server.send(JSON.stringify(MockWorkspaceAgentLogs));
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("tab", {
|
||||
name: `coder_agent.${MockWorkspaceAgent.name}`,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockServer).toBeDefined();
|
||||
mockServer?.publishMessage(
|
||||
new MessageEvent("message", {
|
||||
data: JSON.stringify(MockWorkspaceAgentLogs),
|
||||
}),
|
||||
);
|
||||
|
||||
await screen.findByText(MockWorkspaceAgentLogs[0].output);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Link, useSearchParams } from "react-router";
|
||||
import type {
|
||||
ProvisionerJobLog,
|
||||
WorkspaceAgent,
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from "#/api/typesGenerated";
|
||||
import { Alert } from "#/components/Alert/Alert";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import { Margins } from "#/components/Margins/Margins";
|
||||
import {
|
||||
@@ -23,8 +24,13 @@ import {
|
||||
} from "#/components/PageHeader/FullWidthPageHeader";
|
||||
import { Stack } from "#/components/Stack/Stack";
|
||||
import { Stats, StatsItem } from "#/components/Stats/Stats";
|
||||
import { TAB_PADDING_X, TabLink, Tabs, TabsList } from "#/components/Tabs/Tabs";
|
||||
import { useSearchParamsKey } from "#/hooks/useSearchParamsKey";
|
||||
import {
|
||||
TAB_PADDING_X,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "#/components/Tabs/Tabs";
|
||||
import { BuildAvatar } from "#/modules/builds/BuildAvatar/BuildAvatar";
|
||||
import { DashboardFullPage } from "#/modules/dashboard/DashboardLayout";
|
||||
import { AgentLogs } from "#/modules/resources/AgentLogs/AgentLogs";
|
||||
@@ -71,10 +77,7 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
builds,
|
||||
activeBuildNumber,
|
||||
}) => {
|
||||
const tabState = useSearchParamsKey({
|
||||
key: LOGS_TAB_KEY,
|
||||
defaultValue: "build",
|
||||
});
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
if (buildError) {
|
||||
return (
|
||||
@@ -88,8 +91,12 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
const agents = build.resources.flatMap((r) => r.agents ?? []);
|
||||
const selectedAgent = agents.find((a) => a.id === tabState.value);
|
||||
const agents = build.resources.flatMap((resource) => resource.agents ?? []);
|
||||
const logsParam = searchParams.get(LOGS_TAB_KEY);
|
||||
const selectedTab =
|
||||
logsParam && agents.some((agent) => agent.id === logsParam)
|
||||
? logsParam
|
||||
: "build";
|
||||
|
||||
return (
|
||||
<DashboardFullPage>
|
||||
@@ -151,86 +158,91 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||
</Sidebar>
|
||||
|
||||
<ScrollArea>
|
||||
<div className="flex items-center justify-between border-0 border-b border-solid border-border">
|
||||
<Tabs active={tabState.value}>
|
||||
<TabsList className="gap-0">
|
||||
<TabLink
|
||||
to={`?${LOGS_TAB_KEY}=build`}
|
||||
value="build"
|
||||
className="px-6 pb-2"
|
||||
>
|
||||
Build
|
||||
</TabLink>
|
||||
|
||||
{agents.map((a) => (
|
||||
<TabLink
|
||||
className="px-6 pb-2"
|
||||
to={`?${LOGS_TAB_KEY}=${a.id}`}
|
||||
value={a.id}
|
||||
key={a.id}
|
||||
>
|
||||
coder_agent.{a.name}
|
||||
</TabLink>
|
||||
<div className="flex items-center justify-between border-0 border-b border-solid border-border relative">
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
onValueChange={(value: string) => {
|
||||
setSearchParams((previous) => {
|
||||
const next = new URLSearchParams(previous);
|
||||
if (value === "build") {
|
||||
next.delete(LOGS_TAB_KEY);
|
||||
} else {
|
||||
next.set(LOGS_TAB_KEY, value);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="w-full -m-px"
|
||||
>
|
||||
<TabsList variant="insideBox">
|
||||
<TabsTrigger value="build">Build</TabsTrigger>
|
||||
{agents.map((agent) => (
|
||||
<TabsTrigger value={agent.id} key={agent.id}>
|
||||
coder_agent.{agent.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabsContent value="build">
|
||||
<div className="p-2 flex justify-end absolute right-0 top-0">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a
|
||||
href={`/api/v2/workspacebuilds/${build.id}/logs?format=text`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View raw logs
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
{build.transition === "delete" &&
|
||||
build.job.status === "failed" && (
|
||||
<Alert
|
||||
severity="error"
|
||||
prominent
|
||||
className="rounded-none border-0 border-b border-solid border-border"
|
||||
>
|
||||
<div>
|
||||
The workspace may have failed to delete due to a
|
||||
Terraform state mismatch. A template admin may run{" "}
|
||||
<code className="font-semibold w-fit inline-block">
|
||||
{`coder rm ${`${build.workspace_owner_name}/${build.workspace_name}`} --orphan`}
|
||||
</code>{" "}
|
||||
to delete the workspace skipping resource destruction.
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
{build?.job?.logs_overflowed && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
prominent
|
||||
className="rounded-none border-0 border-b border-solid border-border"
|
||||
>
|
||||
Provisioner logs exceeded the max size of 1MB. Will not
|
||||
continue to write provisioner logs for workspace build.
|
||||
</Alert>
|
||||
)}
|
||||
<BuildLogsContent logs={logs} build={build} />
|
||||
</TabsContent>
|
||||
{agents.map((agent) => (
|
||||
<TabsContent value={agent.id} key={agent.id}>
|
||||
<div className="p-2 flex justify-end absolute right-0 top-0">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a
|
||||
href={`/api/v2/workspaceagents/${agent.id}/logs?format=text`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View raw logs
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<AgentLogsContent agent={agent} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
{tabState.value === "build" && (
|
||||
<a
|
||||
href={`/api/v2/workspacebuilds/${build.id}/logs?format=text`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 px-4 text-xs text-content-secondary hover:text-content-primary"
|
||||
>
|
||||
View raw logs
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
{tabState.value !== "build" && selectedAgent && (
|
||||
<a
|
||||
href={`/api/v2/workspaceagents/${selectedAgent.id}/logs?format=text`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 px-4 text-xs text-content-secondary hover:text-content-primary"
|
||||
>
|
||||
View raw logs
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{build.transition === "delete" && build.job.status === "failed" && (
|
||||
<Alert
|
||||
severity="error"
|
||||
prominent
|
||||
className="rounded-none border-0 border-b border-solid border-border"
|
||||
>
|
||||
<div>
|
||||
The workspace may have failed to delete due to a Terraform state
|
||||
mismatch. A template admin may run{" "}
|
||||
<code className="font-semibold w-fit inline-block">
|
||||
{`coder rm ${`${build.workspace_owner_name}/${build.workspace_name}`} --orphan`}
|
||||
</code>{" "}
|
||||
to delete the workspace skipping resource destruction.
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{build?.job?.logs_overflowed && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
prominent
|
||||
className="rounded-none border-0 border-b border-solid border-border"
|
||||
>
|
||||
Provisioner logs exceeded the max size of 1MB. Will not continue
|
||||
to write provisioner logs for workspace build.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{tabState.value === "build" && (
|
||||
<BuildLogsContent logs={logs} build={build} />
|
||||
)}
|
||||
{tabState.value !== "build" && selectedAgent && (
|
||||
<AgentLogsContent agent={selectedAgent} />
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</DashboardFullPage>
|
||||
@@ -304,9 +316,7 @@ const BuildLogsContent: FC<{
|
||||
|
||||
return (
|
||||
<WorkspaceBuildLogs
|
||||
// logs header class adds extra spacing to the first log header to
|
||||
// prevent it from being too close to the tabs
|
||||
className="border-none [&_.logs-header:first-of-type]:pt-4"
|
||||
className="border-none"
|
||||
style={{ "--log-line-side-padding": `${TAB_PADDING_X}px` }}
|
||||
build={build}
|
||||
logs={sortLogsByCreatedAt(logs)}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const WorkspaceBuildLogsSection: FC<WorkspaceBuildLogsSectionProps> = ({
|
||||
<WorkspaceBuildLogs
|
||||
sticky
|
||||
logs={logs}
|
||||
css={{ border: 0, borderRadius: 0 }}
|
||||
className="rounded-none border-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
|
||||
Reference in New Issue
Block a user