Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48c4859942 | ||
|
|
5b120203b2 | ||
|
|
5edfccf30d | ||
|
|
56bf386b15 | ||
|
|
c620bffbb3 | ||
|
|
84d992062b |
6
.github/workflows/release.yaml
vendored
6
.github/workflows/release.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -95,7 +95,6 @@ describe("useAuthenticated", () => {
|
||||
wrapper: createAuthWrapper({
|
||||
user: MockUser,
|
||||
permissions: MockPermissions,
|
||||
organizationIds: [],
|
||||
}),
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -140,7 +140,7 @@ const OrganizationSettingsPage: FC = () => {
|
||||
css={styles.dangerButton}
|
||||
variant="contained"
|
||||
onClick={() =>
|
||||
deleteOrganizationMutation.mutate(currentOrganizationId)
|
||||
deleteOrganizationMutation.mutate(currentOrganizationId!)
|
||||
}
|
||||
>
|
||||
Delete this organization
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -48,7 +48,7 @@ export const abilitiesByWorkspaceStatus = (
|
||||
return {
|
||||
actions: ["activate"],
|
||||
canCancel: false,
|
||||
canAcceptJobs: false,
|
||||
canAcceptJobs: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ export const withDashboardProvider = (
|
||||
<DashboardContext.Provider
|
||||
value={{
|
||||
organizationId: "",
|
||||
setOrganizationId: () => {},
|
||||
entitlements,
|
||||
experiments,
|
||||
appearance: MockAppearanceConfig,
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"datagrip.svg",
|
||||
"dataspell.svg",
|
||||
"debian.svg",
|
||||
"desktop.svg",
|
||||
"discord.svg",
|
||||
"do.png",
|
||||
"docker-white.svg",
|
||||
|
||||
7
site/static/icon/desktop.svg
Normal file
7
site/static/icon/desktop.svg
Normal 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 |
Reference in New Issue
Block a user