Compare commits

...

6 Commits

Author SHA1 Message Date
Stephen Kirby
48c4859942 chore(scripts): fix cherry-pick check in check_commit_metadata.sh (#13980) (#13994)
(cherry picked from commit 5a4dbcfc02)

Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
2024-07-23 15:08:32 -05:00
Stephen Kirby
5b120203b2 chore: keep active users active in scim (#13955) (#13973)
* chore: scim should keep active users active
* chore: add a unit test to excercise dormancy bug
* audit log should not be dropped when there is no change
* add ability to cancel audit log

(cherry picked from commit 03c5d42233)

Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
2024-07-22 16:22:01 -05:00
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
19 changed files with 419 additions and 133 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

@@ -257,6 +257,26 @@ func requireOrgID[T Auditable](ctx context.Context, id uuid.UUID, log slog.Logge
return id
}
// InitRequestWithCancel returns a commit function with a boolean arg.
// If the arg is false, future calls to commit() will not create an audit log
// entry.
func InitRequestWithCancel[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request[T], func(commit bool)) {
req, commitF := InitRequest[T](w, p)
cancelled := false
return req, func(commit bool) {
// Once 'commit=false' is called, block
// any future commit attempts.
if !commit {
cancelled = true
return
}
// If it was ever cancelled, block any commits
if !cancelled {
commitF()
}
}
}
// InitRequest initializes an audit log for a request. It returns a function
// that should be deferred, causing the audit log to be committed when the
// handler returns.

View File

@@ -272,13 +272,14 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
}
auditor := *api.AGPL.Auditor.Load()
aReq, commitAudit := audit.InitRequest[database.User](rw, &audit.RequestParams{
aReq, commitAudit := audit.InitRequestWithCancel[database.User](rw, &audit.RequestParams{
Audit: auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
defer commitAudit()
defer commitAudit(true)
id := chi.URLParam(r, "id")
@@ -307,23 +308,39 @@ func (api *API) scimPatchUser(rw http.ResponseWriter, r *http.Request) {
var status database.UserStatus
if sUser.Active {
// The user will get transitioned to Active after logging in.
status = database.UserStatusDormant
switch dbUser.Status {
case database.UserStatusActive:
// Keep the user active
status = database.UserStatusActive
case database.UserStatusDormant, database.UserStatusSuspended:
// Move (or keep) as dormant
status = database.UserStatusDormant
default:
// If the status is unknown, just move them to dormant.
// The user will get transitioned to Active after logging in.
status = database.UserStatusDormant
}
} else {
status = database.UserStatusSuspended
}
//nolint:gocritic // needed for SCIM
userNew, err := api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
ID: dbUser.ID,
Status: status,
UpdatedAt: dbtime.Now(),
})
if err != nil {
_ = handlerutil.WriteError(rw, err)
return
if dbUser.Status != status {
//nolint:gocritic // needed for SCIM
userNew, err := api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(r.Context()), database.UpdateUserStatusParams{
ID: dbUser.ID,
Status: status,
UpdatedAt: dbtime.Now(),
})
if err != nil {
_ = handlerutil.WriteError(rw, err)
return
}
dbUser = userNew
} else {
// Do not push an audit log if there is no change.
commitAudit(false)
}
aReq.New = userNew
aReq.New = dbUser
httpapi.Write(ctx, rw, http.StatusOK, sUser)
}

View File

@@ -8,11 +8,13 @@ import (
"net/http"
"testing"
"github.com/golang-jwt/jwt/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
@@ -364,5 +366,82 @@ func TestScim(t *testing.T) {
require.Len(t, userRes.Users, 1)
assert.Equal(t, codersdk.UserStatusSuspended, userRes.Users[0].Status)
})
// Create a user via SCIM, which starts as dormant.
// Log in as the user, making them active.
// Then patch the user again and the user should still be active.
t.Run("ActiveIsActive", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
scimAPIKey := []byte("hi")
mockAudit := audit.NewMock()
fake := oidctest.NewFakeIDP(t, oidctest.WithServing())
client, _ := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
Auditor: mockAudit,
OIDCConfig: fake.OIDCConfig(t, []string{}),
},
SCIMAPIKey: scimAPIKey,
AuditLogging: true,
LicenseOptions: &coderdenttest.LicenseOptions{
AccountID: "coolin",
Features: license.Features{
codersdk.FeatureSCIM: 1,
codersdk.FeatureAuditLog: 1,
},
},
})
mockAudit.ResetLogs()
// User is dormant on create
sUser := makeScimUser(t)
res, err := client.Request(ctx, "POST", "/scim/v2/Users", sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
err = json.NewDecoder(res.Body).Decode(&sUser)
require.NoError(t, err)
// Check the audit log
aLogs := mockAudit.AuditLogs()
require.Len(t, aLogs, 1)
assert.Equal(t, database.AuditActionCreate, aLogs[0].Action)
// Verify the user is dormant
scimUser, err := client.User(ctx, sUser.UserName)
require.NoError(t, err)
require.Equal(t, codersdk.UserStatusDormant, scimUser.Status, "user starts as dormant")
// Log in as the user, making them active
//nolint:bodyclose
scimUserClient, _ := fake.Login(t, client, jwt.MapClaims{
"email": sUser.Emails[0].Value,
})
scimUser, err = scimUserClient.User(ctx, codersdk.Me)
require.NoError(t, err)
require.Equal(t, codersdk.UserStatusActive, scimUser.Status, "user should now be active")
// Patch the user
mockAudit.ResetLogs()
res, err = client.Request(ctx, "PATCH", "/scim/v2/Users/"+sUser.ID, sUser, setScimAuth(scimAPIKey))
require.NoError(t, err)
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
// Should be no audit logs since there is no diff
aLogs = mockAudit.AuditLogs()
require.Len(t, aLogs, 0)
// Verify the user is still active.
scimUser, err = client.User(ctx, sUser.UserName)
require.NoError(t, err)
require.Equal(t, codersdk.UserStatusActive, scimUser.Status, "user is still active")
})
})
}

View File

@@ -126,6 +126,7 @@ main() {
log "Found renamed cherry-pick commit ${commit1} -> ${renamed}"
renamed_cherry_pick_commits[${commit1}]=${renamed}
renamed_cherry_pick_commits[${renamed}]=${commit1}
i=$((i - 1))
continue
fi
@@ -147,6 +148,11 @@ main() {
log "Found matching cherry-pick commit ${commit} -> ${renamed_cherry_pick_commits[${commit}]}"
done
# Merge the two maps.
for commit in "${!renamed_cherry_pick_commits[@]}"; do
cherry_pick_commits[${commit}]=${renamed_cherry_pick_commits[${commit}]}
done
# Get abbreviated and full commit hashes and titles for each commit.
git_log_out="$(git log --no-merges --left-right --pretty=format:"%m %h %H %s" "${range}")"
if [[ -z ${git_log_out} ]]; then

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