Skip to main content
Glama
ApplyChangeSetModal.vue11.2 kB
<template> <div> <Modal ref="modalRef" hideExitButton title="Changes To Be Applied" :size=" changeSet && changeSet.status === ChangeSetStatus.NeedsApproval ? '4xl' : 'md' " > <div class="max-h-[70vh] overflow-hidden flex flex-col"> <div class="text-sm mb-xs pb-sm"> Applying this change set may create, modify, or destroy real resources in the cloud. These actions will be applied to the real world: </div> <div :class=" clsx( 'flex-grow flex flex-col overflow-hidden mb-sm border border-neutral-100 dark:border-neutral-700', approvalsEnabled && 'max-h-[36vh]', ) " > <div class="flex-none flex flex-row py-xs"> <span class="ml-xs text-md">{{ actionsTitle }}</span> <!-- NOTE(nick): these are right-aligned pill counters for each action kind. --> <div class="ml-auto mr-xs flex flex-row"> <PillCounter hideIfZero class="ml-2xs" :count="counts.create"> <Icon name="plus" tone="success" size="xs" /> </PillCounter> <PillCounter hideIfZero class="ml-2xs" :count="counts.destroy"> <Icon name="x" tone="destructive" size="xs" /> </PillCounter> <PillCounter hideIfZero class="ml-2xs" :count="counts.refresh"> <Icon name="refresh" tone="action" size="xs" /> </PillCounter> <PillCounter hideIfZero class="ml-2xs" :count="counts.other"> <Icon name="play" tone="warning" size="xs" /> </PillCounter> </div> </div> <ul class="actions list scrollable flex-1"> <!-- NOTE(nick): we are re-using the action cards, but are disallowing interaction. --> <ActionCard v-for="action in props.actions" :key="action.id" :action="action" :selected="false" noInteraction /> </ul> </div> <ApprovalFlow v-if=" changeSet && changeSet.status === ChangeSetStatus.NeedsApproval && ctx.user " class="flex-1 min-h-0" :changeSet="changeSet" :approvalData="approvalData" :workspaceUsers="workspaceUsers" :user="ctx.user" @closeModal="closeModalHandler" /> <div v-else class="flex flex-row w-full items-center justify-center gap-sm mt-xs" > <NewButton label="Cancel" pill="Esc" @click="closeModalHandler" /> <NewButton v-if="approvalsEnabled" label="Request Approval" tone="action" class="grow" pill="Cmd + Enter" @click="debouncedRequestApproval" /> <NewButton v-else label="Apply Change Set" tone="action" class="grow" loadingText="Applying Changes" :loading="applyInFlight" :disabled="!allowedToApplyWithApprovalsDisabled" disabledWhileLoading pill="Cmd + Enter" @click="debouncedApply" /> </div> </div> </Modal> </div> </template> <script lang="ts" setup> import * as _ from "lodash-es"; import { PillCounter, Icon, NewButton, Modal } from "@si/vue-lib/design-system"; import clsx from "clsx"; import { useRouter, useRoute } from "vue-router"; import { computed, onBeforeUnmount, inject, onMounted, ref, watch } from "vue"; import { debounce } from "lodash-es"; import { useToast, POSITION } from "vue-toastification"; import { useQuery } from "@tanstack/vue-query"; import { ActionKind } from "@/api/sdf/dal/action"; import { ChangeSetStatus } from "@/api/sdf/dal/change_set"; import { ActionProposedView, ApprovalData, WorkspaceUser, Workspaces, } from "./types"; import { keyEmitter } from "./logic_composables/emitters"; import ActionCard from "./ActionCard.vue"; import ApprovalFlow from "./ApprovalFlow.vue"; import ToastApplyFailed from "./nav/ToastApplyFailed.vue"; import { reset } from "./logic_composables/navigation_stack"; import { useApplyChangeSet } from "./logic_composables/change_set"; import ToastApplyingChanges from "./nav/ToastApplyingChanges.vue"; import { useContext } from "./logic_composables/context"; import { useApi, routes } from "./api_composables"; import { useStatus } from "./logic_composables/status"; import * as heimdall from "../store/realtime/heimdall"; const props = defineProps<{ actions: ActionProposedView[]; }>(); const modalRef = ref<InstanceType<typeof Modal> | null>(null); const ctx = useContext(); const changeSet = computed(() => ctx.changeSet.value); // First, check if the workspace has the approvals features enabled at the Auth API level. const workspaces = inject<Workspaces>("WORKSPACES"); const workspace = computed(() => { const maybeWorkspaces = workspaces?.workspaces?.value; if (!maybeWorkspaces) return undefined; return maybeWorkspaces[ctx.workspacePk.value]; }); const approvalsEnabledWithoutSoloUserCheck = computed(() => { if (!workspace.value) return false; return workspace.value.approvalsEnabled; }); // Second, check if we are in a solo user workspace. const usersApi = useApi(ctx); const workspaceUsersQuery = useQuery<Record<string, WorkspaceUser>>({ enabled: () => approvalsEnabledWithoutSoloUserCheck.value, queryKey: ["workspacelistusers"], staleTime: 5000, queryFn: async () => { const call = usersApi.endpoint<{ users: WorkspaceUser[] }>( routes.WorkspaceListUsers, ); const response = await call.get(); if (usersApi.ok(response)) { return _.keyBy(response.data.users, "id"); } return {} as Record<string, WorkspaceUser>; }, }); const workspaceUsers = computed(() => workspaceUsersQuery.data.value ?? {}); const isSoloUserWorkspace = computed( () => Object.keys(workspaceUsers.value).length === 1, ); // Third, combine the two checks to determine if we should allow users to request approval. const approvalsEnabled = computed( () => approvalsEnabledWithoutSoloUserCheck.value && !isSoloUserWorkspace.value, ); const status = useStatus(); const allowedToApplyWithApprovalsDisabled = computed(() => { // Need a change set to apply... if (!changeSet.value) return false; // If we are on HEAD, we cannot apply. if (ctx.onHead.value) return false; // If the change set is churning on work on flight, do not allow the ability to apply. if (status[changeSet.value.id] === "syncing") return false; // The only time you can apply is when all the above is true and the change set is "open". return changeSet.value.status === ChangeSetStatus.Open; }); const router = useRouter(); const route = useRoute(); const actionsTitle = computed(() => props.actions.length === 1 ? `${props.actions.length} Action` : `${props.actions.length} Actions`, ); const counts = computed(() => { const results: Record<string, number> = { create: 0, destroy: 0, refresh: 0, other: 0, // NOTE(nick): "manual" and "other" are grouped together }; for (const action of props.actions) { if (action.kind === ActionKind.Create) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion results.create! += 1; } else if (action.kind === ActionKind.Destroy) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion results.destroy! += 1; } else if (action.kind === ActionKind.Refresh) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion results.refresh! += 1; } else { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion results.other! += 1; } } return results; }); const clearKeyEmitters = () => { keyEmitter.off("Enter"); }; onMounted(() => { clearKeyEmitters(); keyEmitter.on("Enter", (e) => { if (e.metaKey || e.ctrlKey) { if (approvalsEnabled.value) { debouncedRequestApproval(); } else { debouncedApply(); } } }); }); onBeforeUnmount(() => { clearKeyEmitters(); }); // Watch for change set status changes and close modal if it's no longer pending approval watch( () => changeSet.value?.status, (newStatus, oldStatus) => { // If modal is open and status changed from NeedsApproval to something else, close it if ( modalRef.value && oldStatus === ChangeSetStatus.NeedsApproval && newStatus !== ChangeSetStatus.NeedsApproval && newStatus !== undefined ) { closeModalHandler(); } }, ); async function openModalHandler() { if (ctx.onHead.value) return; modalRef.value?.open(); } function closeModalHandler() { modalRef.value?.close(); } const { performApply, applyInFlight } = useApplyChangeSet(ctx); const toast = useToast(); async function applyNotDebounced() { const result = await performApply(); if (result.success) { closeModalHandler(); toast( { component: ToastApplyingChanges, }, { position: POSITION.BOTTOM_CENTER, timeout: 5000, }, ); const name = route.name; heimdall.showInterest(ctx.workspacePk.value, ctx.headChangeSetId.value); router.push({ name, params: { ...route.params, changeSetId: ctx.headChangeSetId.value, }, query: route.query, }); reset(); } else { if (result.req.status === 412) { toast( { component: ToastApplyFailed, props: { precondition: true, }, }, { position: POSITION.TOP_CENTER, timeout: 5000, }, ); } else { toast( { component: ToastApplyFailed, props: { precondition: false, }, }, { position: POSITION.TOP_CENTER, timeout: 5000, }, ); } } } const requestApprovalApi = useApi(ctx); async function requestApprovalNotDebounced() { const requestApprovalCall = requestApprovalApi.endpoint( routes.ChangeSetRequestApproval, ); requestApprovalCall.post({}); } const debouncedApply = debounce(applyNotDebounced, 500); const debouncedRequestApproval = debounce(requestApprovalNotDebounced, 500); onBeforeUnmount(() => { debouncedApply.cancel(); debouncedRequestApproval.cancel(); }); const approvalDataApi = useApi(ctx); const approvalDataQuery = useQuery<ApprovalData | undefined>({ enabled: () => approvalsEnabled.value, queryKey: ["approvalstatus", ctx.changeSetId.value], queryFn: async () => { const call = approvalDataApi.endpoint<ApprovalData>( routes.ChangeSetApprovalStatus, ); const response = await call.get(); if (approvalDataApi.ok(response)) { return response.data; } return undefined; }, }); const approvalData = computed(() => approvalDataQuery.data.value); defineExpose({ open: openModalHandler }); </script>

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/systeminit/si'

If you have feedback or need assistance with the MCP directory API, please join our Discord server