mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: simplify AgentRow interface (#18087)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { API } from "api/api";
|
||||
import { disabledRefetchOptions } from "./util";
|
||||
|
||||
export const deploymentConfigQueryKey = ["deployment", "config"];
|
||||
|
||||
@@ -26,6 +27,7 @@ export const deploymentStats = () => {
|
||||
|
||||
export const deploymentSSHConfig = () => {
|
||||
return {
|
||||
...disabledRefetchOptions,
|
||||
queryKey: ["deployment", "sshConfig"],
|
||||
queryFn: API.getDeploymentSSHConfig,
|
||||
};
|
||||
|
||||
@@ -42,24 +42,24 @@ export const AgentMetadataView: FC<AgentMetadataViewProps> = ({ metadata }) => {
|
||||
|
||||
interface AgentMetadataProps {
|
||||
agent: WorkspaceAgent;
|
||||
storybookMetadata?: WorkspaceAgentMetadata[];
|
||||
initialMetadata?: WorkspaceAgentMetadata[];
|
||||
}
|
||||
|
||||
const maxSocketErrorRetryCount = 3;
|
||||
|
||||
export const AgentMetadata: FC<AgentMetadataProps> = ({
|
||||
agent,
|
||||
storybookMetadata,
|
||||
initialMetadata,
|
||||
}) => {
|
||||
const [activeMetadata, setActiveMetadata] = useState(storybookMetadata);
|
||||
const [activeMetadata, setActiveMetadata] = useState(initialMetadata);
|
||||
useEffect(() => {
|
||||
// This is an unfortunate pitfall with this component's testing setup,
|
||||
// but even though we use the value of storybookMetadata as the initial
|
||||
// but even though we use the value of initialMetadata as the initial
|
||||
// value of the activeMetadata, we cannot put activeMetadata itself into
|
||||
// the dependency array. If we did, we would destroy and rebuild each
|
||||
// connection every single time a new message comes in from the socket,
|
||||
// because the socket has to be wired up to the state setter
|
||||
if (storybookMetadata !== undefined) {
|
||||
if (initialMetadata !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export const AgentMetadata: FC<AgentMetadataProps> = ({
|
||||
window.clearTimeout(timeoutId);
|
||||
activeSocket?.close();
|
||||
};
|
||||
}, [agent.id, storybookMetadata]);
|
||||
}, [agent.id, initialMetadata]);
|
||||
|
||||
if (activeMetadata === undefined) {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { spyOn } from "@storybook/test";
|
||||
import { API } from "api/api";
|
||||
import { getPreferredProxy } from "contexts/ProxyContext";
|
||||
import { chromatic } from "testHelpers/chromatic";
|
||||
import * as M from "testHelpers/entities";
|
||||
@@ -95,8 +97,7 @@ const meta: Meta<typeof AgentRow> = {
|
||||
logs_length: logs.length,
|
||||
},
|
||||
workspace: M.MockWorkspace,
|
||||
showApps: true,
|
||||
storybookAgentMetadata: defaultAgentMetadata,
|
||||
initialMetadata: defaultAgentMetadata,
|
||||
},
|
||||
decorators: [withProxyProvider(), withDashboardProvider, withWebSocket],
|
||||
parameters: {
|
||||
@@ -121,24 +122,6 @@ type Story = StoryObj<typeof AgentRow>;
|
||||
|
||||
export const Example: Story = {};
|
||||
|
||||
export const HideSSHButton: Story = {
|
||||
args: {
|
||||
hideSSHButton: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const HideVSCodeDesktopButton: Story = {
|
||||
args: {
|
||||
hideVSCodeDesktopButton: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const NotShowingApps: Story = {
|
||||
args: {
|
||||
showApps: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const BunchOfApps: Story = {
|
||||
args: {
|
||||
agent: {
|
||||
@@ -155,14 +138,13 @@ export const BunchOfApps: Story = {
|
||||
],
|
||||
},
|
||||
workspace: M.MockWorkspace,
|
||||
showApps: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Connecting: Story = {
|
||||
args: {
|
||||
agent: M.MockWorkspaceAgentConnecting,
|
||||
storybookAgentMetadata: [],
|
||||
initialMetadata: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -190,7 +172,7 @@ export const Started: Story = {
|
||||
export const StartedNoMetadata: Story = {
|
||||
args: {
|
||||
...Started.args,
|
||||
storybookAgentMetadata: [],
|
||||
initialMetadata: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -243,20 +225,30 @@ export const ShowingPortForward: Story = {
|
||||
};
|
||||
|
||||
export const Outdated: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getBuildInfo").mockResolvedValue({
|
||||
...M.MockBuildInfo,
|
||||
version: "v99.999.9999+c1cdf14",
|
||||
agent_api_version: "1.0",
|
||||
});
|
||||
},
|
||||
args: {
|
||||
agent: M.MockWorkspaceAgentOutdated,
|
||||
workspace: M.MockWorkspace,
|
||||
serverVersion: "v99.999.9999+c1cdf14",
|
||||
serverAPIVersion: "1.0",
|
||||
},
|
||||
};
|
||||
|
||||
export const Deprecated: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getBuildInfo").mockResolvedValue({
|
||||
...M.MockBuildInfo,
|
||||
version: "v99.999.9999+c1cdf14",
|
||||
agent_api_version: "2.0",
|
||||
});
|
||||
},
|
||||
args: {
|
||||
agent: M.MockWorkspaceAgentDeprecated,
|
||||
workspace: M.MockWorkspace,
|
||||
serverVersion: "v99.999.9999+c1cdf14",
|
||||
serverAPIVersion: "2.0",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import {
|
||||
MockTemplate,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
MockWorkspaceApp,
|
||||
} from "testHelpers/entities";
|
||||
import {
|
||||
renderWithAuth,
|
||||
waitForLoaderToBeRemoved,
|
||||
} from "testHelpers/renderHelpers";
|
||||
import type { AgentRowProps } from "./AgentRow";
|
||||
import { AgentRow } from "./AgentRow";
|
||||
import { DisplayAppNameMap } from "./AppLink/AppLink";
|
||||
|
||||
jest.mock("modules/resources/AgentMetadata", () => {
|
||||
const AgentMetadata = () => <></>;
|
||||
return { AgentMetadata };
|
||||
});
|
||||
|
||||
describe.each<{
|
||||
result: "visible" | "hidden";
|
||||
props: Partial<AgentRowProps>;
|
||||
}>([
|
||||
{
|
||||
result: "visible",
|
||||
props: {
|
||||
showApps: true,
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
display_apps: ["vscode", "vscode_insiders"],
|
||||
status: "connected",
|
||||
},
|
||||
hideVSCodeDesktopButton: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
result: "hidden",
|
||||
props: {
|
||||
showApps: false,
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
display_apps: ["vscode", "vscode_insiders"],
|
||||
status: "connected",
|
||||
},
|
||||
hideVSCodeDesktopButton: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
result: "hidden",
|
||||
props: {
|
||||
showApps: true,
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
display_apps: [],
|
||||
status: "connected",
|
||||
},
|
||||
hideVSCodeDesktopButton: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
result: "hidden",
|
||||
props: {
|
||||
showApps: true,
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
display_apps: ["vscode", "vscode_insiders"],
|
||||
status: "disconnected",
|
||||
},
|
||||
hideVSCodeDesktopButton: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
result: "hidden",
|
||||
props: {
|
||||
showApps: true,
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
display_apps: ["vscode", "vscode_insiders"],
|
||||
status: "connected",
|
||||
},
|
||||
hideVSCodeDesktopButton: true,
|
||||
},
|
||||
},
|
||||
])("VSCode button visibility", ({ props: testProps, result }) => {
|
||||
const props: AgentRowProps = {
|
||||
agent: MockWorkspaceAgent,
|
||||
workspace: MockWorkspace,
|
||||
template: MockTemplate,
|
||||
showApps: false,
|
||||
serverVersion: "",
|
||||
serverAPIVersion: "",
|
||||
onUpdateAgent: () => {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
...testProps,
|
||||
};
|
||||
|
||||
test(`visibility: ${result}, showApps: ${props.showApps}, hideVSCodeDesktopButton: ${props.hideVSCodeDesktopButton}, display apps: ${props.agent.display_apps}`, async () => {
|
||||
renderWithAuth(<AgentRow {...props} />);
|
||||
await waitForLoaderToBeRemoved();
|
||||
|
||||
if (result === "visible") {
|
||||
expect(screen.getByText(DisplayAppNameMap.vscode)).toBeVisible();
|
||||
} else {
|
||||
expect(screen.queryByText(DisplayAppNameMap.vscode)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.each<{
|
||||
props: Partial<AgentRowProps>;
|
||||
}>([
|
||||
{
|
||||
props: {
|
||||
agent: {
|
||||
...MockWorkspaceAgent,
|
||||
apps: [
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
display_name: `${MockWorkspaceApp.display_name} Not Hidden`,
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
display_name: `${MockWorkspaceApp.display_name} Is Hidden`,
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
])("hidden hides App button", ({ props: testProps }) => {
|
||||
const props: AgentRowProps = {
|
||||
agent: MockWorkspaceAgent,
|
||||
workspace: MockWorkspace,
|
||||
template: MockTemplate,
|
||||
showApps: true,
|
||||
serverVersion: "",
|
||||
serverAPIVersion: "",
|
||||
onUpdateAgent: () => {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
...testProps,
|
||||
};
|
||||
|
||||
test(`apps: ${props.agent.apps}`, async () => {
|
||||
renderWithAuth(<AgentRow {...props} />);
|
||||
await waitForLoaderToBeRemoved();
|
||||
|
||||
for (const app of props.agent.apps) {
|
||||
if (app.hidden) {
|
||||
expect(screen.queryByText(app.display_name as string)).toBeNull();
|
||||
} else {
|
||||
expect(screen.getByText(app.display_name as string)).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
|
||||
import type { Line } from "components/Logs/LogLine";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { AppStatuses } from "pages/WorkspacePage/AppStatuses";
|
||||
import {
|
||||
type FC,
|
||||
@@ -43,46 +44,32 @@ import { TerminalLink } from "./TerminalLink/TerminalLink";
|
||||
import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton";
|
||||
import { useAgentLogs } from "./useAgentLogs";
|
||||
|
||||
export interface AgentRowProps {
|
||||
interface AgentRowProps {
|
||||
agent: WorkspaceAgent;
|
||||
workspace: Workspace;
|
||||
showApps: boolean;
|
||||
showBuiltinApps?: boolean;
|
||||
sshPrefix?: string;
|
||||
hideSSHButton?: boolean;
|
||||
hideVSCodeDesktopButton?: boolean;
|
||||
serverVersion: string;
|
||||
serverAPIVersion: string;
|
||||
onUpdateAgent: () => void;
|
||||
template: Template;
|
||||
storybookAgentMetadata?: WorkspaceAgentMetadata[];
|
||||
initialMetadata?: WorkspaceAgentMetadata[];
|
||||
onUpdateAgent: () => void;
|
||||
}
|
||||
|
||||
export const AgentRow: FC<AgentRowProps> = ({
|
||||
agent,
|
||||
workspace,
|
||||
template,
|
||||
showApps,
|
||||
showBuiltinApps = true,
|
||||
hideSSHButton,
|
||||
hideVSCodeDesktopButton,
|
||||
serverVersion,
|
||||
serverAPIVersion,
|
||||
onUpdateAgent,
|
||||
storybookAgentMetadata,
|
||||
sshPrefix,
|
||||
initialMetadata,
|
||||
}) => {
|
||||
// Apps visibility
|
||||
const { browser_only } = useFeatureVisibility();
|
||||
const visibleApps = agent.apps.filter((app) => !app.hidden);
|
||||
const hasAppsToDisplay = !hideVSCodeDesktopButton || visibleApps.length > 0;
|
||||
const hasAppsToDisplay = !browser_only && visibleApps.length > 0;
|
||||
const shouldDisplayApps =
|
||||
showApps &&
|
||||
((agent.status === "connected" && hasAppsToDisplay) ||
|
||||
agent.status === "connecting");
|
||||
(agent.status === "connected" && hasAppsToDisplay) ||
|
||||
agent.status === "connecting";
|
||||
const hasVSCodeApp =
|
||||
agent.display_apps.includes("vscode") ||
|
||||
agent.display_apps.includes("vscode_insiders");
|
||||
const showVSCode = hasVSCodeApp && !hideVSCodeDesktopButton;
|
||||
const showVSCode = hasVSCodeApp && !browser_only;
|
||||
|
||||
const hasStartupFeatures = Boolean(agent.logs_length);
|
||||
const { proxy } = useProxy();
|
||||
@@ -184,12 +171,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
</div>
|
||||
{agent.status === "connected" && (
|
||||
<>
|
||||
<AgentVersion
|
||||
agent={agent}
|
||||
serverVersion={serverVersion}
|
||||
serverAPIVersion={serverAPIVersion}
|
||||
onUpdate={onUpdateAgent}
|
||||
/>
|
||||
<AgentVersion agent={agent} onUpdate={onUpdateAgent} />
|
||||
<AgentLatency agent={agent} />
|
||||
</>
|
||||
)}
|
||||
@@ -201,28 +183,23 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showBuiltinApps && (
|
||||
<div css={{ display: "flex" }}>
|
||||
{!hideSSHButton && agent.display_apps.includes("ssh_helper") && (
|
||||
<AgentSSHButton
|
||||
workspaceName={workspace.name}
|
||||
agentName={agent.name}
|
||||
sshPrefix={sshPrefix}
|
||||
<div css={{ display: "flex" }}>
|
||||
{!browser_only && agent.display_apps.includes("ssh_helper") && (
|
||||
<AgentSSHButton
|
||||
workspaceName={workspace.name}
|
||||
agentName={agent.name}
|
||||
/>
|
||||
)}
|
||||
{proxy.preferredWildcardHostname !== "" &&
|
||||
agent.display_apps.includes("port_forwarding_helper") && (
|
||||
<PortForwardButton
|
||||
host={proxy.preferredWildcardHostname}
|
||||
workspace={workspace}
|
||||
agent={agent}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
{proxy.preferredWildcardHostname !== "" &&
|
||||
agent.display_apps.includes("port_forwarding_helper") && (
|
||||
<PortForwardButton
|
||||
host={proxy.preferredWildcardHostname}
|
||||
workspaceName={workspace.name}
|
||||
agent={agent}
|
||||
username={workspace.owner_username}
|
||||
workspaceID={workspace.id}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div css={styles.content}>
|
||||
@@ -257,7 +234,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{showBuiltinApps && agent.display_apps.includes("web_terminal") && (
|
||||
{agent.display_apps.includes("web_terminal") && (
|
||||
<TerminalLink
|
||||
workspaceName={workspace.name}
|
||||
agentName={agent.name}
|
||||
@@ -300,10 +277,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
</section>
|
||||
)}
|
||||
|
||||
<AgentMetadata
|
||||
storybookMetadata={storybookAgentMetadata}
|
||||
agent={agent}
|
||||
/>
|
||||
<AgentMetadata initialMetadata={initialMetadata} agent={agent} />
|
||||
</div>
|
||||
|
||||
{hasStartupFeatures && (
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import { buildInfo } from "api/queries/buildInfo";
|
||||
import type { WorkspaceAgent } from "api/typesGenerated";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { agentVersionStatus, getDisplayVersionStatus } from "utils/workspace";
|
||||
import { AgentOutdatedTooltip } from "./AgentOutdatedTooltip";
|
||||
|
||||
interface AgentVersionProps {
|
||||
agent: WorkspaceAgent;
|
||||
serverVersion: string;
|
||||
serverAPIVersion: string;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export const AgentVersion: FC<AgentVersionProps> = ({
|
||||
agent,
|
||||
serverVersion,
|
||||
serverAPIVersion,
|
||||
onUpdate,
|
||||
}) => {
|
||||
export const AgentVersion: FC<AgentVersionProps> = ({ agent, onUpdate }) => {
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const { data: build } = useQuery(buildInfo(metadata["build-info"]));
|
||||
const serverVersion = build?.version ?? "";
|
||||
const apiServerVersion = build?.agent_api_version ?? "";
|
||||
|
||||
const { status } = getDisplayVersionStatus(
|
||||
agent.version,
|
||||
serverVersion,
|
||||
agent.api_version,
|
||||
serverAPIVersion,
|
||||
apiServerVersion,
|
||||
);
|
||||
|
||||
if (status === agentVersionStatus.Updated) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
MockListeningPortsResponse,
|
||||
MockSharedPortsResponse,
|
||||
MockTemplate,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
} from "testHelpers/entities";
|
||||
@@ -14,6 +15,8 @@ const meta: Meta<typeof PortForwardButton> = {
|
||||
decorators: [withDashboardProvider],
|
||||
args: {
|
||||
agent: MockWorkspaceAgent,
|
||||
workspace: MockWorkspace,
|
||||
template: MockTemplate,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -17,9 +17,10 @@ import {
|
||||
} from "api/queries/workspaceportsharing";
|
||||
import {
|
||||
type Template,
|
||||
type UpsertWorkspaceAgentPortShareRequest,
|
||||
type Workspace,
|
||||
type WorkspaceAgent,
|
||||
type WorkspaceAgentListeningPort,
|
||||
type WorkspaceAgentPortShare,
|
||||
type WorkspaceAgentPortShareLevel,
|
||||
type WorkspaceAgentPortShareProtocol,
|
||||
WorkspaceAppSharingLevels,
|
||||
@@ -42,7 +43,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "components/deprecated/Popover/Popover";
|
||||
import { type FormikContextType, useFormik } from "formik";
|
||||
import { useFormik } from "formik";
|
||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
@@ -64,31 +65,40 @@ import * as Yup from "yup";
|
||||
|
||||
interface PortForwardButtonProps {
|
||||
host: string;
|
||||
username: string;
|
||||
workspaceName: string;
|
||||
workspaceID: string;
|
||||
workspace: Workspace;
|
||||
agent: WorkspaceAgent;
|
||||
template: Template;
|
||||
}
|
||||
|
||||
export const PortForwardButton: FC<PortForwardButtonProps> = (props) => {
|
||||
const { agent } = props;
|
||||
export const PortForwardButton: FC<PortForwardButtonProps> = ({
|
||||
host,
|
||||
workspace,
|
||||
template,
|
||||
agent,
|
||||
}) => {
|
||||
const { entitlements } = useDashboard();
|
||||
const paper = useClassName(classNames.paper, []);
|
||||
|
||||
const portsQuery = useQuery({
|
||||
const { data: listeningPorts } = useQuery({
|
||||
queryKey: ["portForward", agent.id],
|
||||
queryFn: () => API.getAgentListeningPorts(agent.id),
|
||||
enabled: agent.status === "connected",
|
||||
refetchInterval: 5_000,
|
||||
select: (res) => res.ports,
|
||||
});
|
||||
|
||||
const { data: sharedPorts, refetch: refetchSharedPorts } = useQuery({
|
||||
...workspacePortShares(workspace.id),
|
||||
enabled: agent.status === "connected",
|
||||
select: (res) => res.shares,
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button disabled={!portsQuery.data} size="sm" variant="subtle">
|
||||
<Spinner loading={!portsQuery.data}>
|
||||
<span css={styles.portCount}>{portsQuery.data?.ports.length}</span>
|
||||
<Button disabled={!listeningPorts} size="sm" variant="subtle">
|
||||
<Spinner loading={!listeningPorts}>
|
||||
<span css={styles.portCount}>{listeningPorts?.length}</span>
|
||||
</Spinner>
|
||||
Open ports
|
||||
<ChevronDownIcon className="size-4" />
|
||||
@@ -96,89 +106,89 @@ export const PortForwardButton: FC<PortForwardButtonProps> = (props) => {
|
||||
</PopoverTrigger>
|
||||
<PopoverContent horizontal="right" classes={{ paper }}>
|
||||
<PortForwardPopoverView
|
||||
{...props}
|
||||
listeningPorts={portsQuery.data?.ports}
|
||||
host={host}
|
||||
agent={agent}
|
||||
workspace={workspace}
|
||||
template={template}
|
||||
sharedPorts={sharedPorts ?? []}
|
||||
listeningPorts={listeningPorts ?? []}
|
||||
portSharingControlsEnabled={
|
||||
entitlements.features.control_shared_ports.enabled
|
||||
}
|
||||
refetchSharedPorts={refetchSharedPorts}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const getValidationSchema = (): Yup.AnyObjectSchema =>
|
||||
const openPortSchema = (): Yup.AnyObjectSchema =>
|
||||
Yup.object({
|
||||
port: Yup.number().required().min(9).max(65535),
|
||||
share_level: Yup.string().required().oneOf(WorkspaceAppSharingLevels),
|
||||
});
|
||||
|
||||
interface PortForwardPopoverViewProps extends PortForwardButtonProps {
|
||||
listeningPorts?: readonly WorkspaceAgentListeningPort[];
|
||||
interface PortForwardPopoverViewProps {
|
||||
host: string;
|
||||
workspace: Workspace;
|
||||
agent: WorkspaceAgent;
|
||||
template: Template;
|
||||
sharedPorts: readonly WorkspaceAgentPortShare[];
|
||||
listeningPorts: readonly WorkspaceAgentListeningPort[];
|
||||
portSharingControlsEnabled: boolean;
|
||||
refetchSharedPorts: () => void;
|
||||
}
|
||||
|
||||
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
|
||||
|
||||
export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||
host,
|
||||
workspaceName,
|
||||
workspaceID,
|
||||
workspace,
|
||||
agent,
|
||||
template,
|
||||
username,
|
||||
sharedPorts,
|
||||
listeningPorts,
|
||||
portSharingControlsEnabled,
|
||||
refetchSharedPorts,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [listeningPortProtocol, setListeningPortProtocol] = useState(
|
||||
getWorkspaceListeningPortsProtocol(workspaceID),
|
||||
getWorkspaceListeningPortsProtocol(workspace.id),
|
||||
);
|
||||
|
||||
const sharedPortsQuery = useQuery({
|
||||
...workspacePortShares(workspaceID),
|
||||
enabled: agent.status === "connected",
|
||||
const upsertSharedPortMutation = useMutation({
|
||||
...upsertWorkspacePortShare(workspace.id),
|
||||
onSuccess: refetchSharedPorts,
|
||||
});
|
||||
const sharedPorts = sharedPortsQuery.data?.shares || [];
|
||||
|
||||
const upsertSharedPortMutation = useMutation(
|
||||
upsertWorkspacePortShare(workspaceID),
|
||||
);
|
||||
const deleteSharedPortMutation = useMutation({
|
||||
...deleteWorkspacePortShare(workspace.id),
|
||||
onSuccess: refetchSharedPorts,
|
||||
});
|
||||
|
||||
const deleteSharedPortMutation = useMutation(
|
||||
deleteWorkspacePortShare(workspaceID),
|
||||
);
|
||||
|
||||
// share port form
|
||||
const {
|
||||
mutateAsync: upsertWorkspacePortShareForm,
|
||||
isPending: isSubmitting,
|
||||
error: submitError,
|
||||
} = useMutation(upsertWorkspacePortShare(workspaceID));
|
||||
const validationSchema = getValidationSchema();
|
||||
// TODO: do partial here
|
||||
const form: FormikContextType<
|
||||
Optional<UpsertWorkspaceAgentPortShareRequest, "port">
|
||||
> = useFormik<Optional<UpsertWorkspaceAgentPortShareRequest, "port">>({
|
||||
} = useMutation({
|
||||
...upsertWorkspacePortShare(workspace.id),
|
||||
onSuccess: refetchSharedPorts,
|
||||
});
|
||||
|
||||
const form = useFormik({
|
||||
initialValues: {
|
||||
agent_name: agent.name,
|
||||
port: undefined,
|
||||
port: "",
|
||||
protocol: "http",
|
||||
share_level: "authenticated",
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: async (values) => {
|
||||
// we need port to be optional in the initialValues so it appears empty instead of 0.
|
||||
// because of this we need to reset the form to clear the port field manually.
|
||||
form.resetForm();
|
||||
await form.setFieldValue("port", "");
|
||||
|
||||
const port = Number(values.port);
|
||||
validationSchema: openPortSchema(),
|
||||
onSubmit: async (values, { resetForm }) => {
|
||||
resetForm();
|
||||
await upsertWorkspacePortShareForm({
|
||||
...values,
|
||||
port,
|
||||
agent_name: values.agent_name,
|
||||
port: Number(values.port),
|
||||
share_level: values.share_level as WorkspaceAgentPortShareLevel,
|
||||
protocol: values.protocol as WorkspaceAgentPortShareProtocol,
|
||||
});
|
||||
await sharedPortsQuery.refetch();
|
||||
},
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers(form, submitError);
|
||||
@@ -188,7 +198,7 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||
(port) => port.agent_name === agent.name,
|
||||
);
|
||||
// we don't want to show listening ports if it's a shared port
|
||||
const filteredListeningPorts = (listeningPorts ?? []).filter((port) =>
|
||||
const filteredListeningPorts = listeningPorts.filter((port) =>
|
||||
filteredSharedPorts.every((sharedPort) => sharedPort.port !== port.port),
|
||||
);
|
||||
// only disable the form if shared port controls are entitled and the template doesn't allow sharing ports
|
||||
@@ -257,7 +267,7 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||
| "https";
|
||||
setListeningPortProtocol(selectedProtocol);
|
||||
saveWorkspaceListeningPortsProtocol(
|
||||
workspaceID,
|
||||
workspace.id,
|
||||
selectedProtocol,
|
||||
);
|
||||
}}
|
||||
@@ -276,8 +286,8 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||
host,
|
||||
port,
|
||||
agent.name,
|
||||
workspaceName,
|
||||
username,
|
||||
workspace.name,
|
||||
workspace.owner_username,
|
||||
listeningPortProtocol,
|
||||
);
|
||||
window.open(url, "_blank");
|
||||
@@ -317,8 +327,8 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||
host,
|
||||
port.port,
|
||||
agent.name,
|
||||
workspaceName,
|
||||
username,
|
||||
workspace.name,
|
||||
workspace.owner_username,
|
||||
listeningPortProtocol,
|
||||
);
|
||||
const label =
|
||||
@@ -371,7 +381,6 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||
protocol: listeningPortProtocol,
|
||||
share_level: "authenticated",
|
||||
});
|
||||
await sharedPortsQuery.refetch();
|
||||
}}
|
||||
>
|
||||
<ShareIcon />
|
||||
@@ -407,8 +416,8 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||
host,
|
||||
share.port,
|
||||
agent.name,
|
||||
workspaceName,
|
||||
username,
|
||||
workspace.name,
|
||||
workspace.owner_username,
|
||||
share.protocol,
|
||||
);
|
||||
const label = share.port;
|
||||
@@ -445,7 +454,6 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||
.value as WorkspaceAgentPortShareProtocol,
|
||||
share_level: share.share_level,
|
||||
});
|
||||
await sharedPortsQuery.refetch();
|
||||
}}
|
||||
>
|
||||
<MenuItem value="http">HTTP</MenuItem>
|
||||
@@ -469,7 +477,6 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||
share_level: event.target
|
||||
.value as WorkspaceAgentPortShareLevel,
|
||||
});
|
||||
await sharedPortsQuery.refetch();
|
||||
}}
|
||||
>
|
||||
<MenuItem value="authenticated">Authenticated</MenuItem>
|
||||
@@ -488,7 +495,6 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||
agent_name: agent.name,
|
||||
port: share.port,
|
||||
});
|
||||
await sharedPortsQuery.refetch();
|
||||
}}
|
||||
>
|
||||
<XIcon
|
||||
|
||||
@@ -28,7 +28,7 @@ const meta: Meta<typeof PortForwardPopoverView> = {
|
||||
args: {
|
||||
agent: MockWorkspaceAgent,
|
||||
template: MockTemplate,
|
||||
workspaceID: MockWorkspace.id,
|
||||
workspace: MockWorkspace,
|
||||
portSharingControlsEnabled: true,
|
||||
host: "coder.com",
|
||||
},
|
||||
@@ -40,14 +40,7 @@ type Story = StoryObj<typeof PortForwardPopoverView>;
|
||||
export const WithPorts: Story = {
|
||||
args: {
|
||||
listeningPorts: MockListeningPortsResponse.ports,
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: ["sharedPorts", MockWorkspace.id],
|
||||
data: MockSharedPortsResponse,
|
||||
},
|
||||
],
|
||||
sharedPorts: MockSharedPortsResponse.shares,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -58,28 +51,14 @@ export const WithManyPorts: Story = {
|
||||
network: "",
|
||||
port: 3000 + i,
|
||||
})),
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: ["sharedPorts", MockWorkspace.id],
|
||||
data: MockSharedPortsResponse,
|
||||
},
|
||||
],
|
||||
sharedPorts: MockSharedPortsResponse.shares,
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
listeningPorts: [],
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: ["sharedPorts", MockWorkspace.id],
|
||||
data: { shares: [] },
|
||||
},
|
||||
],
|
||||
sharedPorts: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -87,20 +66,14 @@ export const AGPLPortSharing: Story = {
|
||||
args: {
|
||||
listeningPorts: MockListeningPortsResponse.ports,
|
||||
portSharingControlsEnabled: false,
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: ["sharedPorts", MockWorkspace.id],
|
||||
data: MockSharedPortsResponse,
|
||||
},
|
||||
],
|
||||
sharedPorts: MockSharedPortsResponse.shares,
|
||||
},
|
||||
};
|
||||
|
||||
export const EnterprisePortSharingControlsOwner: Story = {
|
||||
args: {
|
||||
listeningPorts: MockListeningPortsResponse.ports,
|
||||
sharedPorts: [],
|
||||
template: {
|
||||
...MockTemplate,
|
||||
max_port_share_level: "owner",
|
||||
@@ -115,17 +88,8 @@ export const EnterprisePortSharingControlsAuthenticated: Story = {
|
||||
...MockTemplate,
|
||||
max_port_share_level: "authenticated",
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: ["sharedPorts", MockWorkspace.id],
|
||||
data: {
|
||||
shares: MockSharedPortsResponse.shares.filter((share) => {
|
||||
return share.share_level === "authenticated";
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
sharedPorts: MockSharedPortsResponse.shares.filter((share) => {
|
||||
return share.share_level === "authenticated";
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,12 +19,12 @@ describe("Port Forward Popover View", () => {
|
||||
<PortForwardPopoverView
|
||||
agent={MockWorkspaceAgent}
|
||||
template={MockTemplate}
|
||||
workspaceID={MockWorkspace.id}
|
||||
listeningPorts={MockListeningPortsResponse.ports}
|
||||
portSharingControlsEnabled
|
||||
host="host"
|
||||
username="username"
|
||||
workspaceName="workspaceName"
|
||||
workspace={MockWorkspace}
|
||||
sharedPorts={[]}
|
||||
refetchSharedPorts={jest.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { userEvent, within } from "@storybook/test";
|
||||
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
|
||||
import { spyOn, userEvent, within } from "@storybook/test";
|
||||
import { API } from "api/api";
|
||||
import {
|
||||
MockDeploymentSSH,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
} from "testHelpers/entities";
|
||||
import { withDesktopViewport } from "testHelpers/storybook";
|
||||
import { AgentSSHButton } from "./SSHButton";
|
||||
|
||||
@@ -16,15 +21,16 @@ export const Closed: Story = {
|
||||
args: {
|
||||
workspaceName: MockWorkspace.name,
|
||||
agentName: MockWorkspaceAgent.name,
|
||||
sshPrefix: "coder.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Opened: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "getDeploymentSSHConfig").mockResolvedValue(MockDeploymentSSH);
|
||||
},
|
||||
args: {
|
||||
workspaceName: MockWorkspace.name,
|
||||
agentName: MockWorkspaceAgent.name,
|
||||
sshPrefix: "coder.",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import { deploymentSSHConfig } from "api/queries/deployment";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { CodeExample } from "components/CodeExample/CodeExample";
|
||||
import {
|
||||
@@ -15,20 +16,21 @@ import {
|
||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { docs } from "utils/docs";
|
||||
|
||||
interface AgentSSHButtonProps {
|
||||
workspaceName: string;
|
||||
agentName: string;
|
||||
sshPrefix?: string;
|
||||
}
|
||||
|
||||
export const AgentSSHButton: FC<AgentSSHButtonProps> = ({
|
||||
workspaceName,
|
||||
agentName,
|
||||
sshPrefix,
|
||||
}) => {
|
||||
const paper = useClassName(classNames.paper, []);
|
||||
const { data } = useQuery(deploymentSSHConfig());
|
||||
const sshPrefix = data?.hostname_prefix;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
|
||||
@@ -126,7 +126,7 @@ const TaskPage = () => {
|
||||
</span>
|
||||
<Button size="sm" variant="outline" asChild className="mt-4">
|
||||
<RouterLink
|
||||
to={`/@${task.workspace.owner_name}/${task.workspace.name}/builds/${task.workspace.latest_build.build_number}`}
|
||||
to={`/@${task.workspace.owner_username}/${task.workspace.name}/builds/${task.workspace.latest_build.build_number}`}
|
||||
>
|
||||
View logs
|
||||
</RouterLink>
|
||||
@@ -246,7 +246,8 @@ const TaskPage = () => {
|
||||
<div className="flex flex-col">
|
||||
<h1 className="m-0 text-sm font-medium">{task.prompt}</h1>
|
||||
<span className="text-xs text-content-secondary">
|
||||
Created by {task.workspace.owner_name}{" "}
|
||||
Created by{" "}
|
||||
{task.workspace.owner_name ?? task.workspace.owner_username}{" "}
|
||||
{timeFrom(new Date(task.workspace.created_at))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,6 @@ export const Running: Story = {
|
||||
},
|
||||
handleStart: action("start"),
|
||||
handleStop: action("stop"),
|
||||
buildInfo: Mocks.MockBuildInfo,
|
||||
template: Mocks.MockTemplate,
|
||||
},
|
||||
};
|
||||
@@ -219,7 +218,6 @@ export const RunningWithAppStatuses: Story = {
|
||||
},
|
||||
handleStart: action("start"),
|
||||
handleStop: action("stop"),
|
||||
buildInfo: Mocks.MockBuildInfo,
|
||||
template: Mocks.MockTemplate,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -31,10 +31,6 @@ interface WorkspaceProps {
|
||||
permissions: WorkspacePermissions;
|
||||
isUpdating: boolean;
|
||||
isRestarting: boolean;
|
||||
hideSSHButton?: boolean;
|
||||
hideVSCodeDesktopButton?: boolean;
|
||||
buildInfo?: TypesGen.BuildInfoResponse;
|
||||
sshPrefix?: string;
|
||||
buildLogs?: TypesGen.ProvisionerJobLog[];
|
||||
latestVersion?: TypesGen.TemplateVersion;
|
||||
timings?: TypesGen.WorkspaceBuildTimings;
|
||||
@@ -56,10 +52,6 @@ export const Workspace: FC<WorkspaceProps> = ({
|
||||
workspace,
|
||||
isUpdating,
|
||||
isRestarting,
|
||||
hideSSHButton,
|
||||
hideVSCodeDesktopButton,
|
||||
buildInfo,
|
||||
sshPrefix,
|
||||
template,
|
||||
buildLogs,
|
||||
latestVersion,
|
||||
@@ -252,13 +244,6 @@ export const Workspace: FC<WorkspaceProps> = ({
|
||||
agent={agent}
|
||||
workspace={workspace}
|
||||
template={template}
|
||||
sshPrefix={sshPrefix}
|
||||
showApps={permissions.updateWorkspace}
|
||||
showBuiltinApps={permissions.updateWorkspace}
|
||||
hideSSHButton={hideSSHButton}
|
||||
hideVSCodeDesktopButton={hideVSCodeDesktopButton}
|
||||
serverVersion={buildInfo?.version || ""}
|
||||
serverAPIVersion={buildInfo?.agent_api_version || ""}
|
||||
onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { API } from "api/api";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { buildInfo } from "api/queries/buildInfo";
|
||||
import { deploymentSSHConfig } from "api/queries/deployment";
|
||||
import { templateVersion } from "api/queries/templates";
|
||||
import { workspaceBuildTimings } from "api/queries/workspaceBuilds";
|
||||
import {
|
||||
@@ -18,9 +16,7 @@ import {
|
||||
type ConfirmDialogProps,
|
||||
} from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
|
||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import {
|
||||
WorkspaceUpdateDialogs,
|
||||
useWorkspaceUpdate,
|
||||
@@ -43,10 +39,7 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
|
||||
template,
|
||||
permissions,
|
||||
}) => {
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
|
||||
const queryClient = useQueryClient();
|
||||
const featureVisibility = useFeatureVisibility();
|
||||
|
||||
// Build logs
|
||||
const shouldStreamBuildLogs = workspace.latest_build.status !== "running";
|
||||
@@ -65,9 +58,6 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
|
||||
mutationFn: API.restartWorkspace,
|
||||
});
|
||||
|
||||
// SSH Prefix
|
||||
const sshPrefixQuery = useQuery(deploymentSSHConfig());
|
||||
|
||||
// Favicon
|
||||
const favicon = getFaviconByStatus(workspace.latest_build);
|
||||
const [faviconTheme, setFaviconTheme] = useState<"light" | "dark">("dark");
|
||||
@@ -205,10 +195,6 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
|
||||
isRestarting={isRestarting}
|
||||
workspace={workspace}
|
||||
latestVersion={latestVersion}
|
||||
hideSSHButton={featureVisibility.browser_only}
|
||||
hideVSCodeDesktopButton={featureVisibility.browser_only}
|
||||
buildInfo={buildInfoQuery.data}
|
||||
sshPrefix={sshPrefixQuery.data?.hostname_prefix}
|
||||
template={template}
|
||||
buildLogs={buildLogs}
|
||||
timings={timingsQuery.data}
|
||||
|
||||
Reference in New Issue
Block a user