Compare commits

...

4 Commits

Author SHA1 Message Date
Stephen Kirby
5edfccf30d chore: patch 2.13.1 (#13927)
* fix: let workspace pages download partial logs for unhealthy workspaces (#13761)

* fix: get basic fix in for  preventing download logs from blowing up UI

* fix: make sure blob units can't go out of bounds

* fix: make sure timeout is cleared on component unmount

* fix: reduce risk of shared cache state breaking useAgentLogs

* fix: allow partial downloading of logs

* fix: make sure useMemo cache is used properly

* wip: commit current progress on updated logs functionality

* docs: rewrite comment for clarity

* refactor: clean up current code

* fix: update styles for unavailable logs

* fix: resolve linter violations

* fix: update type signature of getErrorDetail

* fix: revert log/enabled logic for useAgentLogs

* fix: remove memoization from DownloadLogsDialog

* fix: update name of timeout state

* refactor: make log web sockets logic more clear

* docs: reword comment for clarity

* fix: commit current style update progress

* fix: finish style updates

(cherry picked from commit 940afa1ab1)

* fix(site): enable dormant workspace to be deleted (#13850)

(cherry picked from commit 01b30eaa32)

* chore: remove `organizationIds` from `AuthProvider` (#13917)

(cherry picked from commit 80cbffe843)

---------

Co-authored-by: Michael Smith <throwawayclover@gmail.com>
Co-authored-by: Bruno Quaresma <bruno@coder.com>
Co-authored-by: Kayla Washburn-Love <mckayla@hey.com>
2024-07-18 16:18:09 -05:00
Mathias Fredriksson
56bf386b15 ci: remove release make concurrency to fix docker image race (#13769)
(cherry picked from commit a114288ef2)
2024-07-02 17:54:06 +00:00
Cian Johnston
c620bffbb3 ci: use postgres version 13 to test migrations (#13767)
(cherry picked from commit 5ea5db29e9)
2024-07-02 17:04:33 +00:00
Michael Smith
84d992062b chore: add SVG desktop icon (#13765)
* chore: add SVG desktop icon

* fix: add desktop icon to to icons.json

(cherry picked from commit 21a923a7a0)
2024-07-02 16:02:26 +00:00
15 changed files with 283 additions and 119 deletions

View File

@@ -180,7 +180,7 @@ jobs:
- name: Test migrations from current ref to main
run: |
make test-migrations
POSTGRES_VERSION=13 make test-migrations
# Setup GCloud for signing Windows binaries.
- name: Authenticate to Google Cloud
@@ -297,7 +297,7 @@ jobs:
# build Docker images for each architecture
version="$(./scripts/version.sh)"
make -j build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
make build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
# we can't build multi-arch if the images aren't pushed, so quit now
# if dry-running
@@ -308,7 +308,7 @@ jobs:
# build and push multi-arch manifest, this depends on the other images
# being pushed so will automatically push them.
make -j push/build/coder_"$version"_linux.tag
make push/build/coder_"$version"_linux.tag
# if the current version is equal to the highest (according to semver)
# version in the repo, also create a multi-arch image as ":latest" and

View File

@@ -110,15 +110,18 @@ export const getValidationErrorMessage = (error: unknown): string => {
return validationErrors.map((error) => error.detail).join("\n");
};
export const getErrorDetail = (error: unknown): string | undefined | null => {
export const getErrorDetail = (error: unknown): string | undefined => {
if (error instanceof Error) {
return "Please check the developer console for more details.";
}
if (isApiError(error)) {
return error.response.data.detail;
}
if (isApiErrorResponse(error)) {
return error.detail;
}
return null;
return undefined;
};

View File

@@ -30,7 +30,6 @@ export type AuthContextValue = {
isUpdatingProfile: boolean;
user: User | undefined;
permissions: Permissions | undefined;
organizationIds: readonly string[] | undefined;
signInError: unknown;
updateProfileError: unknown;
signOut: () => void;
@@ -119,7 +118,6 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
permissions: permissionsQuery.data as Permissions | undefined,
signInError: loginMutation.error,
updateProfileError: updateProfileMutation.error,
organizationIds: userQuery.data?.organization_ids,
}}
>
{children}

View File

@@ -95,7 +95,6 @@ describe("useAuthenticated", () => {
wrapper: createAuthWrapper({
user: MockUser,
permissions: MockPermissions,
organizationIds: [],
}),
});
}).not.toThrow();

View File

@@ -74,7 +74,7 @@ type RequireKeys<T, R extends keyof T> = Omit<T, R> & {
// values are not undefined when authenticated
type AuthenticatedAuthContextValue = RequireKeys<
AuthContextValue,
"user" | "permissions" | "organizationIds"
"user" | "permissions"
>;
export const useAuthenticated = (): AuthenticatedAuthContextValue => {
@@ -88,9 +88,5 @@ export const useAuthenticated = (): AuthenticatedAuthContextValue => {
throw new Error("Permissions are not available.");
}
if (!auth.organizationIds) {
throw new Error("Organization ID is not available.");
}
return auth as AuthenticatedAuthContextValue;
};

View File

@@ -1,9 +1,4 @@
import {
createContext,
type FC,
type PropsWithChildren,
useState,
} from "react";
import { createContext, type FC, type PropsWithChildren } from "react";
import { useQuery } from "react-query";
import { appearance } from "api/queries/appearance";
import { entitlements } from "api/queries/entitlements";
@@ -15,12 +10,14 @@ import type {
} from "api/typesGenerated";
import { Loader } from "components/Loader/Loader";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { useEffectEvent } from "hooks/hookPolyfills";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
export interface DashboardValue {
/**
* @deprecated Do not add new usage of this value. It is being removed as part
* of the multi-org work.
*/
organizationId: string;
setOrganizationId: (id: string) => void;
entitlements: Entitlements;
experiments: Experiments;
appearance: AppearanceConfig;
@@ -32,7 +29,7 @@ export const DashboardContext = createContext<DashboardValue | undefined>(
export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
const { metadata } = useEmbeddedMetadata();
const { user, organizationIds } = useAuthenticated();
const { user } = useAuthenticated();
const entitlementsQuery = useQuery(entitlements(metadata.entitlements));
const experimentsQuery = useQuery(experiments(metadata.experiments));
const appearanceQuery = useQuery(appearance(metadata.appearance));
@@ -40,23 +37,6 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
const isLoading =
!entitlementsQuery.data || !appearanceQuery.data || !experimentsQuery.data;
const lastUsedOrganizationId = localStorage.getItem(
`user:${user.id}.lastUsedOrganizationId`,
);
const [activeOrganizationId, setActiveOrganizationId] = useState(() =>
lastUsedOrganizationId && organizationIds.includes(lastUsedOrganizationId)
? lastUsedOrganizationId
: organizationIds[0],
);
const setOrganizationId = useEffectEvent((id: string) => {
if (!organizationIds.includes(id)) {
throw new ReferenceError("Invalid organization ID");
}
localStorage.setItem(`user:${user.id}.lastUsedOrganizationId`, id);
setActiveOrganizationId(id);
});
if (isLoading) {
return <Loader fullscreen />;
}
@@ -64,8 +44,7 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
return (
<DashboardContext.Provider
value={{
organizationId: activeOrganizationId,
setOrganizationId: setOrganizationId,
organizationId: user.organization_ids[0] ?? "default",
entitlements: entitlementsQuery.data,
experiments: experimentsQuery.data,
appearance: appearanceQuery.data,

View File

@@ -15,22 +15,35 @@ export type UseAgentLogsOptions = Readonly<{
enabled?: boolean;
}>;
/**
* Defines a custom hook that gives you all workspace agent logs for a given
* workspace.
*
* Depending on the status of the workspace, all logs may or may not be
* available.
*/
export function useAgentLogs(
options: UseAgentLogsOptions,
): readonly WorkspaceAgentLog[] | undefined {
const { workspaceId, agentId, agentLifeCycleState, enabled = true } = options;
const queryClient = useQueryClient();
const queryOptions = agentLogs(workspaceId, agentId);
const query = useQuery({
...queryOptions,
enabled,
});
const logs = query.data;
const { data: logs, isFetched } = useQuery({ ...queryOptions, enabled });
// Track the ID of the last log received when the initial logs response comes
// back. If the logs are not complete, the ID will mark the start point of the
// Web sockets response so that the remaining logs can be received over time
const lastQueriedLogId = useRef(0);
useEffect(() => {
if (logs && lastQueriedLogId.current === 0) {
lastQueriedLogId.current = logs[logs.length - 1].id;
const isAlreadyTracking = lastQueriedLogId.current !== 0;
if (isAlreadyTracking) {
return;
}
const lastLog = logs?.at(-1);
if (lastLog !== undefined) {
lastQueriedLogId.current = lastLog.id;
}
}, [logs]);
@@ -42,7 +55,7 @@ export function useAgentLogs(
});
useEffect(() => {
if (agentLifeCycleState !== "starting" || !query.isFetched) {
if (agentLifeCycleState !== "starting" || !isFetched) {
return;
}
@@ -69,7 +82,7 @@ export function useAgentLogs(
return () => {
socket.close();
};
}, [addLogs, agentId, agentLifeCycleState, query.isFetched]);
}, [addLogs, agentId, agentLifeCycleState, isFetched]);
return logs;
}

View File

@@ -1,6 +1,6 @@
import { createContext, type FC, Suspense, useContext } from "react";
import { useQuery } from "react-query";
import { Outlet, useParams } from "react-router-dom";
import { Outlet, useLocation, useParams } from "react-router-dom";
import { myOrganizations } from "api/queries/users";
import type { Organization } from "api/typesGenerated";
import { Loader } from "components/Loader/Loader";
@@ -13,7 +13,7 @@ import NotFoundPage from "pages/404Page/404Page";
import { Sidebar } from "./Sidebar";
type OrganizationSettingsContextValue = {
currentOrganizationId: string;
currentOrganizationId?: string;
organizations: Organization[];
};
@@ -32,13 +32,18 @@ export const useOrganizationSettings = (): OrganizationSettingsContextValue => {
};
export const OrganizationSettingsLayout: FC = () => {
const { permissions, organizationIds } = useAuthenticated();
const location = useLocation();
const { permissions } = useAuthenticated();
const { experiments } = useDashboard();
const { organization } = useParams() as { organization: string };
const organizationsQuery = useQuery(myOrganizations());
const multiOrgExperimentEnabled = experiments.includes("multi-organization");
const inOrganizationSettings =
location.pathname.startsWith("/organizations") &&
location.pathname !== "/organizations/new";
if (!multiOrgExperimentEnabled) {
return <NotFoundPage />;
}
@@ -50,10 +55,13 @@ export const OrganizationSettingsLayout: FC = () => {
{organizationsQuery.data ? (
<OrganizationSettingsContext.Provider
value={{
currentOrganizationId:
organizationsQuery.data.find(
(org) => org.name === organization,
)?.id ?? organizationIds[0],
currentOrganizationId: !inOrganizationSettings
? undefined
: !organization
? organizationsQuery.data[0]?.id
: organizationsQuery.data.find(
(org) => org.name === organization,
)?.id,
organizations: organizationsQuery.data,
}}
>

View File

@@ -140,7 +140,7 @@ const OrganizationSettingsPage: FC = () => {
css={styles.dangerButton}
variant="contained"
onClick={() =>
deleteOrganizationMutation.mutate(currentOrganizationId)
deleteOrganizationMutation.mutate(currentOrganizationId!)
}
>
Delete this organization

View File

@@ -2,10 +2,11 @@ import { useTheme, type Interpolation, type Theme } from "@emotion/react";
import Skeleton from "@mui/material/Skeleton";
import { saveAs } from "file-saver";
import JSZip from "jszip";
import { useMemo, useState, type FC } from "react";
import { type FC, useMemo, useState, useRef, useEffect } from "react";
import { useQueries, useQuery } from "react-query";
import { agentLogs, buildLogs } from "api/queries/workspaces";
import type { Workspace, WorkspaceAgent } from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import {
ConfirmDialog,
type ConfirmDialogProps,
@@ -28,70 +29,107 @@ type DownloadableFile = {
export const DownloadLogsDialog: FC<DownloadLogsDialogProps> = ({
workspace,
open,
onClose,
download = saveAs,
...dialogProps
}) => {
const theme = useTheme();
const agents = selectAgents(workspace);
const agentLogResults = useQueries({
queries: agents.map((a) => ({
...agentLogs(workspace.id, a.id),
enabled: dialogProps.open,
})),
});
const buildLogsQuery = useQuery({
...buildLogs(workspace),
enabled: dialogProps.open,
enabled: open,
});
const downloadableFiles: DownloadableFile[] = useMemo(() => {
const files: DownloadableFile[] = [
{
name: `${workspace.name}-build-logs.txt`,
blob: buildLogsQuery.data
? new Blob([buildLogsQuery.data.map((l) => l.output).join("\n")], {
type: "text/plain",
})
: undefined,
},
];
agents.forEach((a, i) => {
const allUniqueAgents = useMemo<readonly WorkspaceAgent[]>(() => {
const allAgents = workspace.latest_build.resources.flatMap(
(resource) => resource.agents ?? [],
);
// Can't use the "new Set()" trick because we're not dealing with primitives
const uniqueAgents = new Map(allAgents.map((agent) => [agent.id, agent]));
const iterable = [...uniqueAgents.values()];
return iterable;
}, [workspace.latest_build.resources]);
const agentLogQueries = useQueries({
queries: allUniqueAgents.map((agent) => ({
...agentLogs(workspace.id, agent.id),
enabled: open,
})),
});
// Note: trying to memoize this via useMemo got really clunky. Removing all
// memoization for now, but if we get to a point where performance matters,
// we should make it so that this state doesn't even begin to mount until the
// user decides to open the Logs dropdown
const allFiles: readonly DownloadableFile[] = (() => {
const files = allUniqueAgents.map<DownloadableFile>((a, i) => {
const name = `${a.name}-logs.txt`;
const logs = agentLogResults[i].data;
const txt = logs?.map((l) => l.output).join("\n");
const txt = agentLogQueries[i]?.data?.map((l) => l.output).join("\n");
let blob: Blob | undefined;
if (txt) {
blob = new Blob([txt], { type: "text/plain" });
}
files.push({ name, blob });
return { name, blob };
});
const buildLogsFile = {
name: `${workspace.name}-build-logs.txt`,
blob: buildLogsQuery.data
? new Blob([buildLogsQuery.data.map((l) => l.output).join("\n")], {
type: "text/plain",
})
: undefined,
};
files.unshift(buildLogsFile);
return files;
}, [agentLogResults, agents, buildLogsQuery.data, workspace.name]);
const isLoadingFiles = downloadableFiles.some((f) => f.blob === undefined);
})();
const [isDownloading, setIsDownloading] = useState(false);
const isWorkspaceHealthy = workspace.health.healthy;
const isLoadingFiles = allFiles.some((f) => f.blob === undefined);
const downloadTimeoutIdRef = useRef<number | undefined>(undefined);
useEffect(() => {
const clearTimeoutOnUnmount = () => {
window.clearTimeout(downloadTimeoutIdRef.current);
};
return clearTimeoutOnUnmount;
}, []);
return (
<ConfirmDialog
{...dialogProps}
open={open}
onClose={onClose}
hideCancel={false}
title="Download logs"
confirmText="Download"
disabled={isLoadingFiles}
confirmLoading={isDownloading}
confirmText="Download"
disabled={
isDownloading ||
// If a workspace isn't healthy, let the user download as many logs as
// they can. Otherwise, wait for everything to come in
(isWorkspaceHealthy && isLoadingFiles)
}
onConfirm={async () => {
setIsDownloading(true);
const zip = new JSZip();
allFiles.forEach((f) => {
if (f.blob) {
zip.file(f.name, f.blob);
}
});
try {
setIsDownloading(true);
const zip = new JSZip();
downloadableFiles.forEach((f) => {
if (f.blob) {
zip.file(f.name, f.blob);
}
});
const content = await zip.generateAsync({ type: "blob" });
download(content, `${workspace.name}-logs.zip`);
dialogProps.onClose();
setTimeout(() => {
onClose();
downloadTimeoutIdRef.current = window.setTimeout(() => {
setIsDownloading(false);
}, theme.transitions.duration.leavingScreen);
} catch (error) {
@@ -106,18 +144,21 @@ export const DownloadLogsDialog: FC<DownloadLogsDialogProps> = ({
Downloading logs will create a zip file containing all logs from all
jobs in this workspace. This may take a while.
</p>
{!isWorkspaceHealthy && isLoadingFiles && (
<Alert severity="warning">
Your workspace is unhealthy. Some logs may be unavailable for
download.
</Alert>
)}
<ul css={styles.list}>
{downloadableFiles.map((f) => (
<li key={f.name} css={styles.listItem}>
<span css={styles.listItemPrimary}>{f.name}</span>
<span css={styles.listItemSecondary}>
{f.blob ? (
humanBlobSize(f.blob.size)
) : (
<Skeleton variant="text" width={48} height={12} />
)}
</span>
</li>
{allFiles.map((f) => (
<DownloadingItem
key={f.name}
file={f}
giveUpTimeMs={isWorkspaceHealthy ? undefined : 5_000}
/>
))}
</ul>
</Stack>
@@ -126,20 +167,98 @@ export const DownloadLogsDialog: FC<DownloadLogsDialogProps> = ({
);
};
type DownloadingItemProps = Readonly<{
// A value of undefined indicates that the component will wait forever
giveUpTimeMs?: number;
file: DownloadableFile;
}>;
const DownloadingItem: FC<DownloadingItemProps> = ({ file, giveUpTimeMs }) => {
const theme = useTheme();
const [isWaiting, setIsWaiting] = useState(true);
useEffect(() => {
if (giveUpTimeMs === undefined || file.blob !== undefined) {
setIsWaiting(true);
return;
}
const timeoutId = window.setTimeout(
() => setIsWaiting(false),
giveUpTimeMs,
);
return () => window.clearTimeout(timeoutId);
}, [giveUpTimeMs, file]);
const { baseName, fileExtension } = extractFileNameInfo(file.name);
return (
<li css={styles.listItem}>
<span
css={[
styles.listItemPrimary,
!isWaiting && { color: theme.palette.text.disabled },
]}
>
<span css={styles.listItemPrimaryBaseName}>{baseName}</span>
<span css={styles.listItemPrimaryFileExtension}>.{fileExtension}</span>
</span>
<span css={styles.listItemSecondary}>
{file.blob ? (
humanBlobSize(file.blob.size)
) : isWaiting ? (
<Skeleton variant="text" width={48} height={12} />
) : (
<p css={styles.notAvailableText}>Not available</p>
)}
</span>
</li>
);
};
function humanBlobSize(size: number) {
const units = ["B", "KB", "MB", "GB", "TB"];
const BLOB_SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
let i = 0;
while (size > 1024 && i < units.length) {
while (size > 1024 && i < BLOB_SIZE_UNITS.length) {
size /= 1024;
i++;
}
return `${size.toFixed(2)} ${units[i]}`;
// The condition for the while loop above means that over time, we could break
// out of the loop because we accidentally shot past the array bounds and i
// is at index (BLOB_SIZE_UNITS.length). Adding a lot of redundant checks to
// make sure we always have a usable unit
const finalUnit = BLOB_SIZE_UNITS[i] ?? BLOB_SIZE_UNITS.at(-1) ?? "TB";
return `${size.toFixed(2)} ${finalUnit}`;
}
function selectAgents(workspace: Workspace): WorkspaceAgent[] {
return workspace.latest_build.resources
.flatMap((r) => r.agents)
.filter((a) => a !== undefined) as WorkspaceAgent[];
type FileNameInfo = Readonly<{
baseName: string;
fileExtension: string | undefined;
}>;
function extractFileNameInfo(filename: string): FileNameInfo {
if (filename.length === 0) {
return {
baseName: "",
fileExtension: undefined,
};
}
const periodIndex = filename.lastIndexOf(".");
if (periodIndex === -1) {
return {
baseName: filename,
fileExtension: undefined,
};
}
return {
baseName: filename.slice(0, periodIndex),
fileExtension: filename.slice(periodIndex + 1),
};
}
const styles = {
@@ -151,16 +270,46 @@ const styles = {
flexDirection: "column",
gap: 8,
},
listItem: {
width: "100%",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
columnGap: "32px",
},
listItemPrimary: (theme) => ({
fontWeight: 500,
color: theme.palette.text.primary,
display: "flex",
flexFlow: "row nowrap",
columnGap: 0,
overflow: "hidden",
}),
listItemSecondary: {
fontSize: 14,
listItemPrimaryBaseName: {
minWidth: 0,
flexShrink: 1,
overflow: "hidden",
textOverflow: "ellipsis",
},
listItemPrimaryFileExtension: {
flexShrink: 0,
},
listItemSecondary: {
flexShrink: 0,
fontSize: 14,
whiteSpace: "nowrap",
},
notAvailableText: (theme) => ({
display: "flex",
flexFlow: "row nowrap",
alignItems: "center",
columnGap: "4px",
color: theme.palette.text.disabled,
}),
} satisfies Record<string, Interpolation<Theme>>;

View File

@@ -206,6 +206,18 @@ export const OpenDownloadLogs: Story = {
},
};
export const CanDeleteDormantWorkspace: Story = {
args: {
workspace: Mocks.MockDormantWorkspace,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "More options" }));
const deleteButton = canvas.getByText("Delete…");
await expect(deleteButton).toBeEnabled();
},
};
function generateLogs(count: number) {
return Array.from({ length: count }, (_, i) => ({
output: `log ${i + 1}`,

View File

@@ -48,7 +48,7 @@ export const abilitiesByWorkspaceStatus = (
return {
actions: ["activate"],
canCancel: false,
canAcceptJobs: false,
canAcceptJobs: true,
};
}

View File

@@ -27,7 +27,6 @@ export const withDashboardProvider = (
<DashboardContext.Provider
value={{
organizationId: "",
setOrganizationId: () => {},
entitlements,
experiments,
appearance: MockAppearanceConfig,

View File

@@ -25,6 +25,7 @@
"datagrip.svg",
"dataspell.svg",
"debian.svg",
"desktop.svg",
"discord.svg",
"do.png",
"docker-white.svg",

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31 6V22C31 23.65 29.65 25 28 25H4C2.35 25 1 23.65 1 22V6C1 4.35 2.35 3 4 3H28C29.65 3 31 4.35 31 6Z" fill="#2197F3"/>
<path d="M21 27H17V24C17 23.4478 16.5522 23 16 23C15.4478 23 15 23.4478 15 24V27H11C10.4478 27 10 27.4478 10 28C10 28.5522 10.4478 29 11 29H21C21.5522 29 22 28.5522 22 28C22 27.4478 21.5522 27 21 27Z" fill="#FFC10A"/>
<path d="M31 17V22C31 23.65 29.65 25 28 25H4C2.35 25 1 23.65 1 22V17H31Z" fill="#3F51B5"/>
</svg>

After

Width:  |  Height:  |  Size: 684 B