mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: extract app access logic for reuse (#17724)
We are starting to add app links in many places in the UI, and to make it consistent, this PR extracts the most core logic into the modules/apps for reuse. Related to https://github.com/coder/coder/issues/17311
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
MockWorkspaceApp,
|
||||
} from "testHelpers/entities";
|
||||
import { SESSION_TOKEN_PLACEHOLDER, getAppHref } from "./apps";
|
||||
|
||||
describe("getAppHref", () => {
|
||||
it("returns the URL without changes when external app has regular URL", () => {
|
||||
const externalApp = {
|
||||
...MockWorkspaceApp,
|
||||
external: true,
|
||||
url: "https://example.com",
|
||||
};
|
||||
const href = getAppHref(externalApp, {
|
||||
host: "*.apps-host.tld",
|
||||
path: "/path-base",
|
||||
agent: MockWorkspaceAgent,
|
||||
workspace: MockWorkspace,
|
||||
});
|
||||
expect(href).toBe(externalApp.url);
|
||||
});
|
||||
|
||||
it("returns the URL with the session token replaced when external app needs session token", () => {
|
||||
const externalApp = {
|
||||
...MockWorkspaceApp,
|
||||
external: true,
|
||||
url: `vscode://example.com?token=${SESSION_TOKEN_PLACEHOLDER}`,
|
||||
};
|
||||
const href = getAppHref(externalApp, {
|
||||
host: "*.apps-host.tld",
|
||||
path: "/path-base",
|
||||
agent: MockWorkspaceAgent,
|
||||
workspace: MockWorkspace,
|
||||
token: "user-session-token",
|
||||
});
|
||||
expect(href).toBe("vscode://example.com?token=user-session-token");
|
||||
});
|
||||
|
||||
it("doesn't return the URL with the session token replaced when using the HTTP protocol", () => {
|
||||
const externalApp = {
|
||||
...MockWorkspaceApp,
|
||||
external: true,
|
||||
url: `https://example.com?token=${SESSION_TOKEN_PLACEHOLDER}`,
|
||||
};
|
||||
const href = getAppHref(externalApp, {
|
||||
host: "*.apps-host.tld",
|
||||
path: "/path-base",
|
||||
agent: MockWorkspaceAgent,
|
||||
workspace: MockWorkspace,
|
||||
token: "user-session-token",
|
||||
});
|
||||
expect(href).toBe(externalApp.url);
|
||||
});
|
||||
|
||||
it("returns a path when app doesn't use a subdomain", () => {
|
||||
const app = {
|
||||
...MockWorkspaceApp,
|
||||
subdomain: false,
|
||||
};
|
||||
const href = getAppHref(app, {
|
||||
host: "*.apps-host.tld",
|
||||
agent: MockWorkspaceAgent,
|
||||
workspace: MockWorkspace,
|
||||
path: "/path-base",
|
||||
});
|
||||
expect(href).toBe(
|
||||
`/path-base/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/apps/${app.slug}/`,
|
||||
);
|
||||
});
|
||||
|
||||
it("includes the command in the URL when app has a command", () => {
|
||||
const app = {
|
||||
...MockWorkspaceApp,
|
||||
command: "ls -la",
|
||||
};
|
||||
const href = getAppHref(app, {
|
||||
host: "*.apps-host.tld",
|
||||
agent: MockWorkspaceAgent,
|
||||
workspace: MockWorkspace,
|
||||
path: "",
|
||||
});
|
||||
expect(href).toBe(
|
||||
`/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/terminal?command=ls%20-la`,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the subdomain when app has a subdomain", () => {
|
||||
const app = {
|
||||
...MockWorkspaceApp,
|
||||
subdomain: true,
|
||||
subdomain_name: "hellocoder",
|
||||
};
|
||||
const href = getAppHref(app, {
|
||||
host: "*.apps-host.tld",
|
||||
agent: MockWorkspaceAgent,
|
||||
workspace: MockWorkspace,
|
||||
path: "/path-base",
|
||||
});
|
||||
expect(href).toBe("http://hellocoder.apps-host.tld/");
|
||||
});
|
||||
|
||||
it("returns a path when app has a subdomain but no subdomain name", () => {
|
||||
const app = {
|
||||
...MockWorkspaceApp,
|
||||
subdomain: true,
|
||||
subdomain_name: undefined,
|
||||
};
|
||||
const href = getAppHref(app, {
|
||||
host: "*.apps-host.tld",
|
||||
agent: MockWorkspaceAgent,
|
||||
workspace: MockWorkspace,
|
||||
path: "/path-base",
|
||||
});
|
||||
expect(href).toBe(
|
||||
`/path-base/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/apps/${app.slug}/`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,15 @@
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceApp,
|
||||
} from "api/typesGenerated";
|
||||
|
||||
// This is a magic undocumented string that is replaced
|
||||
// with a brand-new session token from the backend.
|
||||
// This only exists for external URLs, and should only
|
||||
// be used internally, and is highly subject to break.
|
||||
export const SESSION_TOKEN_PLACEHOLDER = "$SESSION_TOKEN";
|
||||
|
||||
type GetVSCodeHrefParams = {
|
||||
owner: string;
|
||||
workspace: string;
|
||||
@@ -49,6 +61,73 @@ export const getTerminalHref = ({
|
||||
}/terminal?${params}`;
|
||||
};
|
||||
|
||||
export const openAppInNewWindow = (name: string, href: string) => {
|
||||
export const openAppInNewWindow = (href: string) => {
|
||||
window.open(href, "_blank", "width=900,height=600");
|
||||
};
|
||||
|
||||
export type GetAppHrefParams = {
|
||||
path: string;
|
||||
host: string;
|
||||
workspace: Workspace;
|
||||
agent: WorkspaceAgent;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export const getAppHref = (
|
||||
app: WorkspaceApp,
|
||||
{ path, token, workspace, agent, host }: GetAppHrefParams,
|
||||
): string => {
|
||||
if (isExternalApp(app)) {
|
||||
return needsSessionToken(app)
|
||||
? app.url.replaceAll(SESSION_TOKEN_PLACEHOLDER, token ?? "")
|
||||
: app.url;
|
||||
}
|
||||
|
||||
// The backend redirects if the trailing slash isn't included, so we add it
|
||||
// here to avoid extra roundtrips.
|
||||
let href = `${path}/@${workspace.owner_name}/${workspace.name}.${
|
||||
agent.name
|
||||
}/apps/${encodeURIComponent(app.slug)}/`;
|
||||
|
||||
if (app.command) {
|
||||
// Terminal links are relative. The terminal page knows how
|
||||
// to select the correct workspace proxy for the websocket
|
||||
// connection.
|
||||
href = `/@${workspace.owner_name}/${workspace.name}.${
|
||||
agent.name
|
||||
}/terminal?command=${encodeURIComponent(app.command)}`;
|
||||
}
|
||||
|
||||
if (host && app.subdomain && app.subdomain_name) {
|
||||
const baseUrl = `${window.location.protocol}//${host.replace(/\*/g, app.subdomain_name)}`;
|
||||
const url = new URL(baseUrl);
|
||||
url.pathname = "/";
|
||||
href = url.toString();
|
||||
}
|
||||
|
||||
return href;
|
||||
};
|
||||
|
||||
export const needsSessionToken = (app: WorkspaceApp) => {
|
||||
if (!isExternalApp(app)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// HTTP links should never need the session token, since Cookies
|
||||
// handle sharing it when you access the Coder Dashboard. We should
|
||||
// never be forwarding the bare session token to other domains!
|
||||
const isHttp = app.url.startsWith("http");
|
||||
const requiresSessionToken = app.url.includes(SESSION_TOKEN_PLACEHOLDER);
|
||||
return requiresSessionToken && !isHttp;
|
||||
};
|
||||
|
||||
type ExternalWorkspaceApp = WorkspaceApp & {
|
||||
external: true;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const isExternalApp = (
|
||||
app: WorkspaceApp,
|
||||
): app is ExternalWorkspaceApp => {
|
||||
return app.external && app.url !== undefined;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { apiKey } from "api/queries/users";
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceApp,
|
||||
} from "api/typesGenerated";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
import type React from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import {
|
||||
getAppHref,
|
||||
isExternalApp,
|
||||
needsSessionToken,
|
||||
openAppInNewWindow,
|
||||
} from "./apps";
|
||||
|
||||
type UseAppLinkParams = {
|
||||
workspace: Workspace;
|
||||
agent: WorkspaceAgent;
|
||||
};
|
||||
|
||||
export const useAppLink = (
|
||||
app: WorkspaceApp,
|
||||
{ agent, workspace }: UseAppLinkParams,
|
||||
) => {
|
||||
const label = app.display_name ?? app.slug;
|
||||
const { proxy } = useProxy();
|
||||
const { data: apiKeyResponse } = useQuery({
|
||||
...apiKey(),
|
||||
enabled: isExternalApp(app) && needsSessionToken(app),
|
||||
});
|
||||
|
||||
const href = getAppHref(app, {
|
||||
agent,
|
||||
workspace,
|
||||
token: apiKeyResponse?.key ?? "",
|
||||
path: proxy.preferredPathAppURL,
|
||||
host: proxy.preferredWildcardHostname,
|
||||
});
|
||||
|
||||
const onClick = (e: React.MouseEvent) => {
|
||||
if (!e.currentTarget.getAttribute("href")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (app.external) {
|
||||
// When browser recognizes the protocol and is able to navigate to the app,
|
||||
// it will blur away, and will stop the timer. Otherwise,
|
||||
// an error message will be displayed.
|
||||
const openAppExternallyFailedTimeout = 500;
|
||||
const openAppExternallyFailed = setTimeout(() => {
|
||||
displayError(`${label} must be installed first.`);
|
||||
}, openAppExternallyFailedTimeout);
|
||||
window.addEventListener("blur", () => {
|
||||
clearTimeout(openAppExternallyFailed);
|
||||
});
|
||||
}
|
||||
|
||||
switch (app.open_in) {
|
||||
case "slim-window": {
|
||||
e.preventDefault();
|
||||
openAppInNewWindow(href);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
href,
|
||||
onClick,
|
||||
label,
|
||||
hasToken: !!apiKeyResponse?.key,
|
||||
};
|
||||
};
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||
import { API } from "api/api";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -11,9 +9,9 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
import { needsSessionToken } from "modules/apps/apps";
|
||||
import { useAppLink } from "modules/apps/useAppLink";
|
||||
import { type FC, useState } from "react";
|
||||
import { createAppLinkHref } from "utils/apps";
|
||||
import { generateRandomString } from "utils/random";
|
||||
import { AgentButton } from "../AgentButton";
|
||||
import { BaseIcon } from "./BaseIcon";
|
||||
import { ShareIcon } from "./ShareIcon";
|
||||
@@ -26,11 +24,6 @@ export const DisplayAppNameMap: Record<TypesGen.DisplayApp, string> = {
|
||||
web_terminal: "Terminal",
|
||||
};
|
||||
|
||||
const Language = {
|
||||
appTitle: (appName: string, identifier: string): string =>
|
||||
`${appName} - ${identifier}`,
|
||||
};
|
||||
|
||||
export interface AppLinkProps {
|
||||
workspace: TypesGen.Workspace;
|
||||
app: TypesGen.WorkspaceApp;
|
||||
@@ -39,24 +32,10 @@ export interface AppLinkProps {
|
||||
|
||||
export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
|
||||
const { proxy } = useProxy();
|
||||
const preferredPathBase = proxy.preferredPathAppURL;
|
||||
const appsHost = proxy.preferredWildcardHostname;
|
||||
const [fetchingSessionToken, setFetchingSessionToken] = useState(false);
|
||||
const host = proxy.preferredWildcardHostname;
|
||||
const [iconError, setIconError] = useState(false);
|
||||
const theme = useTheme();
|
||||
const username = workspace.owner_name;
|
||||
const displayName = app.display_name || app.slug;
|
||||
|
||||
const href = createAppLinkHref(
|
||||
window.location.protocol,
|
||||
preferredPathBase,
|
||||
appsHost,
|
||||
app.slug,
|
||||
username,
|
||||
workspace,
|
||||
agent,
|
||||
app,
|
||||
);
|
||||
const link = useAppLink(app, { agent, workspace });
|
||||
|
||||
// canClick is ONLY false when it's a subdomain app and the admin hasn't
|
||||
// enabled wildcard access URL or the session token is being fetched.
|
||||
@@ -64,28 +43,32 @@ export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
|
||||
// To avoid bugs in the healthcheck code locking users out of apps, we no
|
||||
// longer block access to apps if they are unhealthy/initializing.
|
||||
let canClick = true;
|
||||
let primaryTooltip = "";
|
||||
let icon = !iconError && (
|
||||
<BaseIcon app={app} onIconPathError={() => setIconError(true)} />
|
||||
);
|
||||
|
||||
let primaryTooltip = "";
|
||||
if (app.health === "initializing") {
|
||||
icon = <Spinner loading />;
|
||||
primaryTooltip = "Initializing...";
|
||||
}
|
||||
|
||||
if (app.health === "unhealthy") {
|
||||
icon = <ErrorOutlineIcon css={{ color: theme.palette.warning.light }} />;
|
||||
primaryTooltip = "Unhealthy";
|
||||
}
|
||||
if (!appsHost && app.subdomain) {
|
||||
|
||||
if (!host && app.subdomain) {
|
||||
canClick = false;
|
||||
icon = <ErrorOutlineIcon css={{ color: theme.palette.grey[300] }} />;
|
||||
primaryTooltip =
|
||||
"Your admin has not configured subdomain application access";
|
||||
}
|
||||
if (fetchingSessionToken) {
|
||||
|
||||
if (needsSessionToken(app) && !link.hasToken) {
|
||||
canClick = false;
|
||||
}
|
||||
|
||||
if (
|
||||
agent.lifecycle_state === "starting" &&
|
||||
agent.startup_script_behavior === "blocking"
|
||||
@@ -97,67 +80,9 @@ export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
|
||||
|
||||
const button = (
|
||||
<AgentButton asChild>
|
||||
<a
|
||||
href={canClick ? href : undefined}
|
||||
onClick={async (event) => {
|
||||
if (!canClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// HTTP links should never need the session token, since Cookies
|
||||
// handle sharing it when you access the Coder Dashboard. We should
|
||||
// never be forwarding the bare session token to other domains!
|
||||
const isHttp = app.url?.startsWith("http");
|
||||
if (app.external && !isHttp) {
|
||||
// This is a magic undocumented string that is replaced
|
||||
// with a brand-new session token from the backend.
|
||||
// This only exists for external URLs, and should only
|
||||
// be used internally, and is highly subject to break.
|
||||
const magicTokenString = "$SESSION_TOKEN";
|
||||
const hasMagicToken = href.indexOf(magicTokenString);
|
||||
let url = href;
|
||||
if (hasMagicToken !== -1) {
|
||||
setFetchingSessionToken(true);
|
||||
const key = await API.getApiKey();
|
||||
url = href.replaceAll(magicTokenString, key.key);
|
||||
setFetchingSessionToken(false);
|
||||
}
|
||||
|
||||
// When browser recognizes the protocol and is able to navigate to the app,
|
||||
// it will blur away, and will stop the timer. Otherwise,
|
||||
// an error message will be displayed.
|
||||
const openAppExternallyFailedTimeout = 500;
|
||||
const openAppExternallyFailed = setTimeout(() => {
|
||||
displayError(`${displayName} must be installed first.`);
|
||||
}, openAppExternallyFailedTimeout);
|
||||
window.addEventListener("blur", () => {
|
||||
clearTimeout(openAppExternallyFailed);
|
||||
});
|
||||
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (app.open_in) {
|
||||
case "slim-window": {
|
||||
window.open(
|
||||
href,
|
||||
Language.appTitle(displayName, generateRandomString(12)),
|
||||
"width=900,height=600",
|
||||
);
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
window.open(href);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<a href={canClick ? link.href : undefined} onClick={link.onClick}>
|
||||
{icon}
|
||||
{displayName}
|
||||
{link.label}
|
||||
{canShare && <ShareIcon app={app} />}
|
||||
</a>
|
||||
</AgentButton>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const TerminalLink: FC<TerminalLinkProps> = ({
|
||||
href={href}
|
||||
onClick={(event: MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
openAppInNewWindow("Terminal", href);
|
||||
openAppInNewWindow(href);
|
||||
}}
|
||||
>
|
||||
<TerminalIcon />
|
||||
|
||||
@@ -13,8 +13,8 @@ import type {
|
||||
WorkspaceAgent,
|
||||
WorkspaceApp,
|
||||
} from "api/typesGenerated";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
import { createAppLinkHref } from "utils/apps";
|
||||
import { useAppLink } from "modules/apps/useAppLink";
|
||||
import type { FC } from "react";
|
||||
|
||||
const formatURI = (uri: string) => {
|
||||
try {
|
||||
@@ -68,33 +68,7 @@ export const WorkspaceAppStatus = ({
|
||||
agent?: WorkspaceAgent;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { proxy } = useProxy();
|
||||
const preferredPathBase = proxy.preferredPathAppURL;
|
||||
const appsHost = proxy.preferredWildcardHostname;
|
||||
|
||||
const commonStyles = {
|
||||
fontSize: "12px",
|
||||
lineHeight: "15px",
|
||||
color: theme.palette.text.disabled,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "2px 6px",
|
||||
borderRadius: "6px",
|
||||
bgcolor: "transparent",
|
||||
minWidth: 0,
|
||||
maxWidth: "fit-content",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
textDecoration: "none",
|
||||
transition: "all 0.15s ease-in-out",
|
||||
"&:hover": {
|
||||
textDecoration: "none",
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
};
|
||||
const commonStyles = useCommonStyles();
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
@@ -122,20 +96,6 @@ export const WorkspaceAppStatus = ({
|
||||
}
|
||||
const isFileURI = status.uri?.startsWith("file://");
|
||||
|
||||
let appHref: string | undefined;
|
||||
if (app && agent) {
|
||||
appHref = createAppLinkHref(
|
||||
window.location.protocol,
|
||||
preferredPathBase,
|
||||
appsHost,
|
||||
app.slug,
|
||||
workspace.owner_name,
|
||||
workspace,
|
||||
agent,
|
||||
app,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
css={{
|
||||
@@ -187,47 +147,8 @@ export const WorkspaceAppStatus = ({
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{app && appHref && (
|
||||
<a
|
||||
href={appHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
css={{
|
||||
...commonStyles,
|
||||
marginRight: 8,
|
||||
position: "relative",
|
||||
color: theme.palette.text.secondary,
|
||||
"&:hover": {
|
||||
...commonStyles["&:hover"],
|
||||
color: theme.palette.text.primary,
|
||||
"& img": {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{app.icon ? (
|
||||
<img
|
||||
src={app.icon}
|
||||
alt={`${app.display_name} icon`}
|
||||
width={14}
|
||||
height={14}
|
||||
css={{
|
||||
borderRadius: "3px",
|
||||
opacity: 0.8,
|
||||
marginRight: 4,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<AppsIcon
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{app.display_name}</span>
|
||||
</a>
|
||||
{app && agent && (
|
||||
<AppLink app={app} workspace={workspace} agent={agent} />
|
||||
)}
|
||||
{status.uri && (
|
||||
<div
|
||||
@@ -297,3 +218,87 @@ export const WorkspaceAppStatus = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type AppLinkProps = {
|
||||
app: WorkspaceApp;
|
||||
workspace: Workspace;
|
||||
agent: WorkspaceAgent;
|
||||
};
|
||||
|
||||
const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
|
||||
const theme = useTheme();
|
||||
const commonStyles = useCommonStyles();
|
||||
const link = useAppLink(app, { agent, workspace });
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.href}
|
||||
onClick={link.onClick}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
css={{
|
||||
...commonStyles,
|
||||
marginRight: 8,
|
||||
position: "relative",
|
||||
color: theme.palette.text.secondary,
|
||||
"&:hover": {
|
||||
...commonStyles["&:hover"],
|
||||
color: theme.palette.text.primary,
|
||||
"& img": {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{app.icon ? (
|
||||
<img
|
||||
src={app.icon}
|
||||
alt={`${app.display_name} icon`}
|
||||
width={14}
|
||||
height={14}
|
||||
css={{
|
||||
borderRadius: "3px",
|
||||
opacity: 0.8,
|
||||
marginRight: 4,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<AppsIcon
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{app.display_name}</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const useCommonStyles = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return {
|
||||
fontSize: "12px",
|
||||
lineHeight: "15px",
|
||||
color: theme.palette.text.disabled,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "2px 6px",
|
||||
borderRadius: "6px",
|
||||
bgcolor: "transparent",
|
||||
minWidth: 0,
|
||||
maxWidth: "fit-content",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
textDecoration: "none",
|
||||
transition: "all 0.15s ease-in-out",
|
||||
"&:hover": {
|
||||
textDecoration: "none",
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -17,10 +17,9 @@ import type {
|
||||
WorkspaceAgent,
|
||||
WorkspaceApp,
|
||||
} from "api/typesGenerated";
|
||||
import { useProxy } from "contexts/ProxyContext";
|
||||
import { formatDistance, formatDistanceToNow } from "date-fns";
|
||||
import { useAppLink } from "modules/apps/useAppLink";
|
||||
import type { FC } from "react";
|
||||
import { createAppLinkHref } from "utils/apps";
|
||||
|
||||
const getStatusColor = (
|
||||
theme: Theme,
|
||||
@@ -153,9 +152,6 @@ export const AppStatuses: FC<AppStatusesProps> = ({
|
||||
referenceDate,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { proxy } = useProxy();
|
||||
const preferredPathBase = proxy.preferredPathAppURL;
|
||||
const appsHost = proxy.preferredWildcardHostname;
|
||||
|
||||
// 1. Flatten all statuses and include the parent app object
|
||||
const allStatuses: StatusWithAppInfo[] = apps.flatMap((app) =>
|
||||
@@ -194,22 +190,8 @@ export const AppStatuses: FC<AppStatusesProps> = ({
|
||||
|
||||
// Get the associated app for this status
|
||||
const currentApp = status.app;
|
||||
let appHref: string | undefined;
|
||||
const agent = agents.find((agent) => agent.id === status.agent_id);
|
||||
|
||||
if (currentApp && agent) {
|
||||
appHref = createAppLinkHref(
|
||||
window.location.protocol,
|
||||
preferredPathBase,
|
||||
appsHost,
|
||||
currentApp.slug,
|
||||
workspace.owner_name,
|
||||
workspace,
|
||||
agent,
|
||||
currentApp,
|
||||
);
|
||||
}
|
||||
|
||||
// Determine if app link should be shown
|
||||
const showAppLink =
|
||||
isLatest ||
|
||||
@@ -280,63 +262,12 @@ export const AppStatuses: FC<AppStatusesProps> = ({
|
||||
}}
|
||||
>
|
||||
{/* Conditional App Link */}
|
||||
{currentApp && appHref && showAppLink && (
|
||||
<Tooltip
|
||||
title={`Open ${currentApp.display_name}`}
|
||||
placement="top"
|
||||
>
|
||||
<Link
|
||||
href={appHref}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{
|
||||
...commonStyles,
|
||||
position: "relative",
|
||||
"& .MuiSvgIcon-root": {
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
mr: 0.5,
|
||||
},
|
||||
"& img": {
|
||||
opacity: 0.8,
|
||||
marginRight: 0.5,
|
||||
},
|
||||
"&:hover": {
|
||||
...commonStyles["&:hover"],
|
||||
color: theme.palette.text.primary, // Keep consistent hover color
|
||||
"& img": {
|
||||
opacity: 1,
|
||||
},
|
||||
"& .MuiSvgIcon-root": {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{currentApp.icon ? (
|
||||
<img
|
||||
src={currentApp.icon}
|
||||
alt={`${currentApp.display_name} icon`}
|
||||
width={14}
|
||||
height={14}
|
||||
style={{ borderRadius: "3px" }}
|
||||
/>
|
||||
) : (
|
||||
<AppsIcon />
|
||||
)}
|
||||
{/* Keep app name short */}
|
||||
<span
|
||||
css={{
|
||||
lineHeight: 1,
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{currentApp.display_name}
|
||||
</span>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
{currentApp && agent && showAppLink && (
|
||||
<AppLink
|
||||
app={currentApp}
|
||||
agent={agent}
|
||||
workspace={workspace}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Existing URI Link */}
|
||||
@@ -409,3 +340,71 @@ export const AppStatuses: FC<AppStatusesProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type AppLinkProps = {
|
||||
app: WorkspaceApp;
|
||||
agent: WorkspaceAgent;
|
||||
workspace: Workspace;
|
||||
};
|
||||
|
||||
const AppLink: FC<AppLinkProps> = ({ app, agent, workspace }) => {
|
||||
const link = useAppLink(app, { agent, workspace });
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Tooltip title={`Open ${link.label}`} placement="top">
|
||||
<Link
|
||||
href={link.href}
|
||||
onClick={link.onClick}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{
|
||||
...commonStyles,
|
||||
position: "relative",
|
||||
"& .MuiSvgIcon-root": {
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
mr: 0.5,
|
||||
},
|
||||
"& img": {
|
||||
opacity: 0.8,
|
||||
marginRight: 0.5,
|
||||
},
|
||||
"&:hover": {
|
||||
...commonStyles["&:hover"],
|
||||
color: theme.palette.text.primary, // Keep consistent hover color
|
||||
"& img": {
|
||||
opacity: 1,
|
||||
},
|
||||
"& .MuiSvgIcon-root": {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{app.icon ? (
|
||||
<img
|
||||
src={app.icon}
|
||||
alt={`${link.label} icon`}
|
||||
width={14}
|
||||
height={14}
|
||||
style={{ borderRadius: "3px" }}
|
||||
/>
|
||||
) : (
|
||||
<AppsIcon />
|
||||
)}
|
||||
{/* Keep app name short */}
|
||||
<span
|
||||
css={{
|
||||
lineHeight: 1,
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{link.label}
|
||||
</span>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -627,8 +627,8 @@ type WorkspaceAppsProps = {
|
||||
};
|
||||
|
||||
const WorkspaceApps: FC<WorkspaceAppsProps> = ({ workspace }) => {
|
||||
const { data: apiKeyRes } = useQuery(apiKey());
|
||||
const token = apiKeyRes?.key;
|
||||
const { data: apiKeyResponse } = useQuery(apiKey());
|
||||
const token = apiKeyResponse?.key;
|
||||
|
||||
/**
|
||||
* Coder is pretty flexible and allows an enormous variety of use cases, such
|
||||
@@ -658,7 +658,7 @@ const WorkspaceApps: FC<WorkspaceAppsProps> = ({ workspace }) => {
|
||||
owner: workspace.owner_name,
|
||||
workspace: workspace.name,
|
||||
agent: agent.name,
|
||||
token: apiKeyRes?.key ?? "",
|
||||
token: token ?? "",
|
||||
folder: agent.expanded_directory,
|
||||
})}
|
||||
>
|
||||
@@ -676,7 +676,7 @@ const WorkspaceApps: FC<WorkspaceAppsProps> = ({ workspace }) => {
|
||||
owner: workspace.owner_name,
|
||||
workspace: workspace.name,
|
||||
agent: agent.name,
|
||||
token: apiKeyRes?.key ?? "",
|
||||
token: token ?? "",
|
||||
folder: agent.expanded_directory,
|
||||
})}
|
||||
>
|
||||
@@ -696,7 +696,7 @@ const WorkspaceApps: FC<WorkspaceAppsProps> = ({ workspace }) => {
|
||||
href={href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openAppInNewWindow("Terminal", href);
|
||||
openAppInNewWindow(href);
|
||||
}}
|
||||
label="Open Terminal"
|
||||
>
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import {
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
MockWorkspaceApp,
|
||||
} from "testHelpers/entities";
|
||||
import { createAppLinkHref } from "./apps";
|
||||
|
||||
describe("create app link", () => {
|
||||
it("with external URL", () => {
|
||||
const externalURL = "https://external-url.tld";
|
||||
const href = createAppLinkHref(
|
||||
"http:",
|
||||
"/path-base",
|
||||
"*.apps-host.tld",
|
||||
"app-slug",
|
||||
"username",
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
external: true,
|
||||
url: externalURL,
|
||||
},
|
||||
);
|
||||
expect(href).toBe(externalURL);
|
||||
});
|
||||
|
||||
it("without subdomain", () => {
|
||||
const href = createAppLinkHref(
|
||||
"http:",
|
||||
"/path-base",
|
||||
"*.apps-host.tld",
|
||||
"app-slug",
|
||||
"username",
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
subdomain: false,
|
||||
},
|
||||
);
|
||||
expect(href).toBe(
|
||||
"/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/",
|
||||
);
|
||||
});
|
||||
|
||||
it("with command", () => {
|
||||
const href = createAppLinkHref(
|
||||
"https:",
|
||||
"/path-base",
|
||||
"*.apps-host.tld",
|
||||
"app-slug",
|
||||
"username",
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
command: "ls -la",
|
||||
},
|
||||
);
|
||||
expect(href).toBe(
|
||||
"/@username/Test-Workspace.a-workspace-agent/terminal?command=ls%20-la",
|
||||
);
|
||||
});
|
||||
|
||||
it("with subdomain", () => {
|
||||
const href = createAppLinkHref(
|
||||
"ftps:",
|
||||
"/path-base",
|
||||
"*.apps-host.tld",
|
||||
"app-slug",
|
||||
"username",
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
subdomain: true,
|
||||
subdomain_name: "hellocoder",
|
||||
},
|
||||
);
|
||||
expect(href).toBe("ftps://hellocoder.apps-host.tld/");
|
||||
});
|
||||
|
||||
it("with subdomain, but not apps host", () => {
|
||||
const href = createAppLinkHref(
|
||||
"ftps:",
|
||||
"/path-base",
|
||||
"",
|
||||
"app-slug",
|
||||
"username",
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
{
|
||||
...MockWorkspaceApp,
|
||||
subdomain: true,
|
||||
subdomain_name: "hellocoder",
|
||||
},
|
||||
);
|
||||
expect(href).toBe(
|
||||
"/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
|
||||
export const createAppLinkHref = (
|
||||
protocol: string,
|
||||
preferredPathBase: string,
|
||||
appsHost: string,
|
||||
appSlug: string,
|
||||
username: string,
|
||||
workspace: TypesGen.Workspace,
|
||||
agent: TypesGen.WorkspaceAgent,
|
||||
app: TypesGen.WorkspaceApp,
|
||||
): string => {
|
||||
if (app.external) {
|
||||
return app.url as string;
|
||||
}
|
||||
|
||||
// The backend redirects if the trailing slash isn't included, so we add it
|
||||
// here to avoid extra roundtrips.
|
||||
let href = `${preferredPathBase}/@${username}/${workspace.name}.${
|
||||
agent.name
|
||||
}/apps/${encodeURIComponent(appSlug)}/`;
|
||||
if (app.command) {
|
||||
// Terminal links are relative. The terminal page knows how
|
||||
// to select the correct workspace proxy for the websocket
|
||||
// connection.
|
||||
href = `/@${username}/${workspace.name}.${
|
||||
agent.name
|
||||
}/terminal?command=${encodeURIComponent(app.command)}`;
|
||||
}
|
||||
|
||||
if (appsHost && app.subdomain && app.subdomain_name) {
|
||||
const baseUrl = `${protocol}//${appsHost.replace(/\*/g, app.subdomain_name)}`;
|
||||
const url = new URL(baseUrl);
|
||||
url.pathname = "/";
|
||||
|
||||
href = url.toString();
|
||||
}
|
||||
return href;
|
||||
};
|
||||
Reference in New Issue
Block a user