Compare commits

...

8 Commits

Author SHA1 Message Date
Stephen Kirby
bddf0bf85a chore: update emoji-mart data (#13746) (#14189)
(cherry picked from commit 4a0fd7466c)

Co-authored-by: Kayla Washburn-Love <mckayla@hey.com>
2024-08-06 11:58:37 -05:00
Stephen Kirby
d4f8a6481a fix: change time format string from 15:40 to 15:04 (#14033) (#14072)
* Change string format to constant value

(cherry picked from commit eacdfb9f9c)

Co-authored-by: Charlie Voiselle <464492+angrycub@users.noreply.github.com>
2024-08-01 12:34:10 -05:00
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
24 changed files with 832 additions and 146 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

@@ -14,6 +14,8 @@ import (
"github.com/coder/coder/v2/codersdk"
)
const TimeFormatHHMM = "15:04"
func (api *API) autostopRequirementEnabledMW(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// Entitlement must be enabled.
@@ -66,7 +68,7 @@ func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request)
RawSchedule: opts.Schedule.String(),
UserSet: opts.UserSet,
UserCanSet: opts.UserCanSet,
Time: opts.Schedule.TimeParsed().Format("15:40"),
Time: opts.Schedule.TimeParsed().Format(TimeFormatHHMM),
Timezone: opts.Schedule.Location().String(),
Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())),
})
@@ -118,7 +120,7 @@ func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Reques
RawSchedule: opts.Schedule.String(),
UserSet: opts.UserSet,
UserCanSet: opts.UserCanSet,
Time: opts.Schedule.TimeParsed().Format("15:40"),
Time: opts.Schedule.TimeParsed().Format(TimeFormatHHMM),
Timezone: opts.Schedule.Location().String(),
Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())),
})

View File

@@ -12,11 +12,14 @@ import (
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/schedule/cron"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
)
const TimeFormatHHMM = coderd.TimeFormatHHMM
func TestUserQuietHours(t *testing.T) {
t.Parallel()
@@ -42,15 +45,17 @@ func TestUserQuietHours(t *testing.T) {
t.Run("OK", func(t *testing.T) {
t.Parallel()
defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 0 1 * * *"
// Using 10 for minutes lets us test a format bug in which values greater
// than 5 were causing the API to explode because the time was returned
// incorrectly
defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 10 1 * * *"
defaultScheduleParsed, err := cron.Daily(defaultQuietHoursSchedule)
require.NoError(t, err)
nextTime := defaultScheduleParsed.Next(time.Now().In(defaultScheduleParsed.Location()))
if time.Until(nextTime) < time.Hour {
// Use a different default schedule instead, because we want to avoid
// the schedule "ticking over" during this test run.
defaultQuietHoursSchedule = "CRON_TZ=America/Chicago 0 13 * * *"
defaultQuietHoursSchedule = "CRON_TZ=America/Chicago 10 13 * * *"
defaultScheduleParsed, err = cron.Daily(defaultQuietHoursSchedule)
require.NoError(t, err)
}
@@ -79,7 +84,7 @@ func TestUserQuietHours(t *testing.T) {
require.NoError(t, err)
require.Equal(t, defaultScheduleParsed.String(), sched1.RawSchedule)
require.False(t, sched1.UserSet)
require.Equal(t, defaultScheduleParsed.TimeParsed().Format("15:40"), sched1.Time)
require.Equal(t, defaultScheduleParsed.TimeParsed().Format(TimeFormatHHMM), sched1.Time)
require.Equal(t, defaultScheduleParsed.Location().String(), sched1.Timezone)
require.WithinDuration(t, defaultScheduleParsed.Next(time.Now()), sched1.Next, 15*time.Second)
@@ -102,7 +107,7 @@ func TestUserQuietHours(t *testing.T) {
require.NoError(t, err)
require.Equal(t, customScheduleParsed.String(), sched2.RawSchedule)
require.True(t, sched2.UserSet)
require.Equal(t, customScheduleParsed.TimeParsed().Format("15:40"), sched2.Time)
require.Equal(t, customScheduleParsed.TimeParsed().Format(TimeFormatHHMM), sched2.Time)
require.Equal(t, customScheduleParsed.Location().String(), sched2.Timezone)
require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched2.Next, 15*time.Second)
@@ -111,7 +116,7 @@ func TestUserQuietHours(t *testing.T) {
require.NoError(t, err)
require.Equal(t, customScheduleParsed.String(), sched3.RawSchedule)
require.True(t, sched3.UserSet)
require.Equal(t, customScheduleParsed.TimeParsed().Format("15:40"), sched3.Time)
require.Equal(t, customScheduleParsed.TimeParsed().Format(TimeFormatHHMM), sched3.Time)
require.Equal(t, customScheduleParsed.Location().String(), sched3.Timezone)
require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched3.Next, 15*time.Second)

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

@@ -30,7 +30,7 @@
"deadcode": "ts-prune | grep -v \".stories\\|.config\\|e2e\\|__mocks__\\|used in module\\|testHelpers\\|typesGenerated\" || echo \"No deadcode found.\""
},
"dependencies": {
"@emoji-mart/data": "1.1.2",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@emotion/css": "11.11.2",
"@emotion/react": "11.11.1",

8
site/pnpm-lock.yaml generated
View File

@@ -10,8 +10,8 @@ overrides:
dependencies:
'@emoji-mart/data':
specifier: 1.1.2
version: 1.1.2
specifier: 1.2.1
version: 1.2.1
'@emoji-mart/react':
specifier: 1.1.1
version: 1.1.1(emoji-mart@5.6.0)(react@18.2.0)
@@ -2130,8 +2130,8 @@ packages:
engines: {node: '>=10.0.0'}
dev: true
/@emoji-mart/data@1.1.2:
resolution: {integrity: sha512-1HP8BxD2azjqWJvxIaWAMyTySeZY0Osr83ukYjltPVkNXeJvTz7yDrPLBtnrD5uqJ3tg4CcLuuBW09wahqL/fg==}
/@emoji-mart/data@1.2.1:
resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==}
dev: false
/@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@18.2.0):

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,393 @@
Attribution 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More_considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution 4.0 International Public License ("Public License"). To the
extent this Public License may be interpreted as a contract, You are
granted the Licensed Rights in consideration of Your acceptance of
these terms and conditions, and the Licensor grants You such rights in
consideration of benefits the Licensor receives from making the
Licensed Material available under these terms and conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
d. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
e. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
f. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
g. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
h. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
i. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
j. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
k. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part; and
b. produce, reproduce, and Share Adapted Material.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
4. If You Share Adapted Material You produce, the Adapter's
License You apply must not prevent recipients of the Adapted
Material from complying with this Public License.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public licenses.
Notwithstanding, Creative Commons may elect to apply one of its public
licenses to material it publishes and in those instances will be
considered the "Licensor." Except for the limited purpose of indicating
that material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the public
licenses.
Creative Commons may be contacted at creativecommons.org.

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