Skip to main content
Glama
Workspace.vue34.6 kB
<template> <div id="app-layout" :class=" clsx('h-screen flex flex-col', themeClasses('bg-white', 'bg-neutral-900')) " > <!-- nav itself is fixed at 60 px--> <nav v-if="!showOnboarding" :class=" clsx( 'navbar relative shadow-[0_4px_4px_0_rgba(0,0,0,0.15)] border-b', 'z-[1001] h-[60px] overflow-hidden shrink-0 flex flex-row justify-between select-none', 'bg-neutral-800 border-neutral-600 text-white', windowWidth > 740 && 'gap-sm', ) " > <!-- Left side --> <NavbarPanelLeft v-if="!changeSetRetrievalError" ref="navbarPanelLeftRef" :workspaceId="workspacePk" :changeSetId="changeSetId" :invalidWorkspace="tokenFail" /> <!-- Center --> <NavbarPanelCenter v-if="!lobby && !tokenFail && !showOnboarding" :workspaceId="workspacePk" :changeSetId="changeSetId" :componentId="componentId" :viewId="viewId" :connected="haveWSConn" /> <!-- Right --> <NavbarPanelRight :changeSetId="changeSetId" :workspaceId="workspacePk" :changeSetsNeedingApproval="changeSetsNeedingApproval" :invalidWorkspace="tokenFail" /> </nav> <main v-if="changeSetRetrievalError" :class=" clsx( 'grow min-h-0 m-sm border flex flex-row items-center justify-center', themeClasses('border-neutral-400', 'border-neutral-600'), ) " > <EmptyState icon="logo-si" text="Initialization Error" secondaryText="We were unable to fetch the change sets on this workspace. This is likely an authorization problem, please try again." finalLinkText="Click here to return to your workspaces and re-enter this workspace." @final="bumpToWorkspaces" /> </main> <!-- Since lobby hides away the navbar, it's more of an overlay and stays apart from all else --> <Transition enterActiveClass="duration-300 ease-out" enterFromClass="transform opacity-0" enterToClass="opacity-100" leaveActiveClass="delay-1000 duration-200 ease-in" leaveFromClass="opacity-100" leaveToClass="transform opacity-0" > <Lobby v-if="!tokenFail && !changeSetRetrievalError && lobby" :loadingSteps="_.values(muspelheimStatuses)" /> </Transition> <main v-if="tokenFail" :class=" clsx( 'grow min-h-0 m-sm border flex flex-row items-center justify-center', themeClasses('border-neutral-400', 'border-neutral-600'), ) " :data-error-code="tokenFailStatus" > <EmptyState v-if="tokenFailStatus === 401" icon="logo-si" text="Unauthorized" secondaryText="Your account is not authorized to access this workspace" finalLinkText="Click here to see your workspaces" @final="bumpToWorkspaces" /> <EmptyState v-else-if="tokenFailStatus === 400" icon="logo-si" text="Invalid workspace id" :secondaryText="`A workspace with the id &quot;${workspacePk}&quot; does not exist`" finalLinkText="Click here to see your workspaces" @final="bumpToWorkspaces" /> <EmptyState v-else icon="logo-si" text="Something went wrong" secondaryText="Click here to see your workspaces" secondaryClickable @secondary="bumpToWorkspaces" /> </main> <template v-else-if="!lobby"> <Onboarding v-if="showOnboarding" @completed="onboardingCompleted = true" /> <main v-else-if="indexFailedToLoad" :class=" clsx( 'grow min-h-0 m-sm border flex flex-row items-center justify-center', themeClasses('border-neutral-400', 'border-neutral-600'), ) " > <EmptyState icon="logo-si" text="Unable to Load Change Set Data" secondaryText="We failed to load the data associated with this change set. This is our problem, and we have been alerted. You may select another change set." finalLinkText="Click here to see your other workspaces" @final="bumpToWorkspaces" /> </main> <!-- grow the main body to fit all the space in between the nav and the bottom of the browser window min-h-0 prevents the main container from being *larger* than the max it can grow, no matter its contents --> <main v-else class="grow min-h-0"> <ComponentPage v-if="componentId" :componentId="componentId" /> <FuncRunDetails v-else-if="funcRunId" :funcRunId="funcRunId" /> <LatestFuncRunDetails v-else-if="actionId" :functionKind="FunctionKind.Action" :actionId="actionId" /> <Review v-else-if="onReviewPage" /> <Explore v-else @openChangesetModal="openChangesetModal" /> </main> </template> </div> </template> <script lang="ts" setup> import { useRoute, useRouter } from "vue-router"; import clsx from "clsx"; import { computed, onMounted, onBeforeUnmount, onBeforeMount, ref, provide, watch, Ref, inject, } from "vue"; import * as _ from "lodash-es"; import { useQuery, useQueryClient } from "@tanstack/vue-query"; import { Span, trace } from "@opentelemetry/api"; import { themeClasses } from "@si/vue-lib/design-system"; import { storeToRefs } from "pinia"; import * as heimdall from "@/store/realtime/heimdall"; import { useAuthStore } from "@/store/auth.store"; import { useRealtimeStore } from "@/store/realtime/realtime.store"; import { ComponentDetails, EntityKind, OutgoingCounts, SchemaMembers, } from "@/workers/types/entity_kind_types"; import { SchemaId } from "@/api/sdf/dal/schema"; import { ChangeSet, ChangeSetStatus } from "@/api/sdf/dal/change_set"; import { muspelheimStatuses } from "@/store/realtime/heimdall"; import { trackEvent } from "@/utils/tracking"; import Onboarding, { DEBUG_MODE } from "@/newhotness/Onboarding.vue"; import NavbarPanelRight from "./nav/NavbarPanelRight.vue"; import Lobby from "./Lobby.vue"; import Explore, { GroupByUrlQuery, SortByUrlQuery } from "./Explore.vue"; import FuncRunDetails from "./FuncRunDetails.vue"; import LatestFuncRunDetails from "./LatestFuncRunDetails.vue"; import { Context, FunctionKind, AuthApiWorkspace } from "./types"; import { startMouseEmitters, startKeyEmitter, startWindowResizeEmitter, windowResizeEmitter, } from "./logic_composables/emitters"; import { getUserPkFromToken, tokensByWorkspacePk, } from "./logic_composables/tokens"; import ComponentPage from "./ComponentDetails.vue"; import NavbarPanelLeft from "./nav/NavbarPanelLeft.vue"; import { useChangeSets } from "./logic_composables/change_set"; import { routes, useApi } from "./api_composables"; import Review from "./Review.vue"; import EmptyState from "./EmptyState.vue"; import NavbarPanelCenter from "./nav/NavbarPanelCenter.vue"; const AUTH_PORTAL_URL = import.meta.env.VITE_AUTH_PORTAL_URL; const tracer = trace.getTracer("si-vue"); const navbarPanelLeftRef = ref<InstanceType<typeof NavbarPanelLeft>>(); const span = ref<Span | undefined>(); const route = useRoute(); const router = useRouter(); const props = defineProps<{ workspacePk: string; changeSetId: string; componentId?: string; viewId?: string; secretId?: string; funcRunId?: string; actionId?: string; }>(); const authStore = useAuthStore(); const realtimeStore = useRealtimeStore(); const workspacePk = computed(() => props.workspacePk); const changeSetId = computed(() => props.changeSetId); const coldStartInProgress = computed(() => heimdall.muspelheimInProgress.value); const haveWSConn = computed<boolean>(() => { return !!heimdall.wsConnections.value[props.workspacePk]; }); // no tan stack queries hitting sqlite until after the cold start has finished const queriesEnabled = computed( () => heimdall.initCompleted.value && !coldStartInProgress.value, ); const countsQueryKey = computed(() => { return [ workspacePk.value, changeSetId.value, EntityKind.OutgoingCounts, workspacePk.value, ]; }); const args = computed(() => { return { workspaceId: workspacePk.value, changeSetId: changeSetId.value, }; }); const countsQuery = useQuery<OutgoingCounts>({ queryKey: countsQueryKey, enabled: queriesEnabled, queryFn: async () => await heimdall.getOutgoingConnectionsCounts(args.value), }); const namesQueryKey = computed(() => { return [ workspacePk.value, changeSetId.value, EntityKind.ComponentDetails, workspacePk.value, ]; }); const namesQuery = useQuery<ComponentDetails>({ queryKey: namesQueryKey, enabled: queriesEnabled, queryFn: async () => { return await heimdall.getComponentDetails(args.value); }, }); const schemaQueryKey = computed(() => { return [ workspacePk.value, changeSetId.value, EntityKind.SchemaMembers, workspacePk.value, ]; }); const schemaQuery = useQuery<Record<SchemaId, SchemaMembers>>({ queryKey: schemaQueryKey, enabled: queriesEnabled, queryFn: async () => { const data = await heimdall.getSchemaMembers(args.value); return data.reduce((obj, s) => { obj[s.id] = s; return obj; }, {} as Record<SchemaId, SchemaMembers>); }, }); const outgoingCounts = computed(() => { return countsQuery.data.value ?? {}; }); const componentDetails = computed(() => { return namesQuery.data.value ?? {}; }); const schemaMembers = computed(() => { return schemaQuery.data.value ?? {}; }); const changeSet = ref<ChangeSet | undefined>(); const _headChangeSetId = ref<string>(""); const approvers = ref<string[]>([]); const onboardingCompleted = ref(false); const onboardingIsReopened = ref(false); const reopenOnboarding = () => { onboardingIsReopened.value = true; onboardingCompleted.value = false; }; const { user, userWorkspaceFlags } = storeToRefs(authStore); const ctx = computed<Context>(() => { return { workspacePk, changeSetId, changeSet, approvers, user: user.value, userWorkspaceFlags, onHead: computed(() => { return changeSetId.value === _headChangeSetId.value; }), headChangeSetId: _headChangeSetId, outgoingCounts, componentDetails, schemaMembers, queriesEnabled, reopenOnboarding, }; }); const workspaceApi = useApi(ctx.value); const workspaceQuery = useQuery<Record<string, AuthApiWorkspace>>({ queryKey: ["workspaces"], staleTime: 5000, queryFn: async () => { const call = workspaceApi.endpoint<AuthApiWorkspace[]>(routes.Workspaces); const response = await call.get(); if (workspaceApi.ok(response)) { const renameIdList = _.map(response.data, (w) => ({ ...w, pk: w.id, })); const workspacesByPk = _.keyBy(renameIdList, "pk"); return workspacesByPk; } return {} as Record<string, AuthApiWorkspace>; }, }); const workspaces = computed(() => { return { workspaces: computed(() => workspaceQuery.data.value), }; }); provide("WORKSPACES", workspaces.value); const { openChangeSets, changeSet: activeChangeSet, headChangeSetId, defaultApprovers, } = useChangeSets(ctx, queriesEnabled); const changeSetsNeedingApproval = computed(() => openChangeSets.value.filter( (cs) => cs.status === ChangeSetStatus.NeedsApproval, ), ); watch( defaultApprovers, () => { approvers.value = defaultApprovers.value; }, { immediate: true }, ); watch( activeChangeSet, () => { changeSet.value = activeChangeSet.value; }, { immediate: true }, ); watch( headChangeSetId, () => { // never assign a blank value over a truth-y value if (_headChangeSetId.value && !headChangeSetId.value) return; _headChangeSetId.value = headChangeSetId.value; }, { immediate: true }, ); watch( () => openChangeSets, () => { const ids = openChangeSets.value.map((c) => c.id); if (ids.length > 0 && !ids.includes(props.changeSetId)) { if (ctx.value.headChangeSetId.value) { router.push({ name: "new-hotness", params: { workspacePk: props.workspacePk, changeSetId: headChangeSetId.value, }, }); } else { router.push({ name: "new-hotness-workspace", params: { workspacePk: props.workspacePk, }, }); } } }, { immediate: true, deep: true }, ); startKeyEmitter(document); startMouseEmitters(window); startWindowResizeEmitter(window); provide("CONTEXT", ctx.value); // LOBBY AND ONBOARDING FLAGS const userPk = computed(() => authStore.user?.pk); const checkOnboardingCompleteApi = useApi(ctx.value); const loadedOnboardingStateFromApi = ref(false); const checkOnboardingCompleteData = async () => { if (!userPk.value) return; const call = checkOnboardingCompleteApi.endpoint<{ firstTimeModal: boolean }>( routes.CheckDismissedOnboarding, { userPk: userPk.value }, ); const { data, status } = await call.get(); if (status !== 200) { // If we don't get onboarding status back successfully, default to skipping onboarding. // Because we do not want to end up in a state where a user is locked out of a workspace! onboardingCompleted.value = true; loadedOnboardingStateFromApi.value = true; return; } loadedOnboardingStateFromApi.value = true; // firstTimeModal === true means that we should SHOW it, so we need to invert it to see if it's been dismissed. onboardingCompleted.value = !data.firstTimeModal; }; const cohEnabled = ref(true); const componentsOnHeadApi = useApi(ctx.value); const componentsOnHeadQuery = useQuery<boolean | null>({ queryKey: ["componentsOnHead", workspacePk.value], enabled: cohEnabled, queryFn: async () => { const call = componentsOnHeadApi.endpoint<{ componentsFound: boolean }>( routes.ComponentsOnHead, ); const response = await call.get(); // Check if the request was successful (200/201) if (componentsOnHeadApi.ok(response)) { cohEnabled.value = false; return response.data.componentsFound ?? null; } // Return null on error to indicate we tried but failed // (token failure, SDF down/deploying, network issues, etc.) return null; }, staleTime: 5000, retry: false, // Don't retry on failure - we want to handle the null case }); const componentsOnHead = computed(() => componentsOnHeadQuery.data.value); const lobby = computed( () => coldStartInProgress.value || // not looking for feature flag values in here (if for any reason, network connectivity, posthog being down, we didn't get feature flags everyone would be stuck in the lobby) loadedOnboardingStateFromApi.value === false || // Don't continue before we got the response from the auth API componentsOnHeadQuery.isFetching.value, // Wait for componentsOnHead check to complete ); const showOnboarding = computed(() => { // Force Onboarding if one of the two debug options is set to true if (DEBUG_MODE) return true; // If null (endpoint failed), skip onboarding to avoid blocking the user if (componentsOnHead.value === null) return false; // If components exist on HEAD and onboarding has not been reopened, skip onboarding if (componentsOnHead.value === true && onboardingIsReopened.value === false) return false; return !onboardingCompleted.value; }); watch( lobby, () => { if (!span.value && lobby.value) { span.value = tracer.startSpan("lobby"); span.value.setAttributes({ ...props, user: authStore.user?.pk, "si.workspace.id": props.workspacePk, "si.changeset.id": props.changeSetId, }); } if (span.value && !lobby.value) { span.value.setAttribute( "numOpenChangeSets", openChangeSets.value.length || -1, ); span.value.end(); span.value = undefined; } }, { immediate: true }, ); export type SelectionsInQueryString = Partial<{ map: string; grid: string; c: string; s: string; // represents the selected component indexes b: string; // 0 or 1, represents "in bulk editing" for ^ selected groupBy: GroupByUrlQuery; sortBy: SortByUrlQuery; pinned: string; defaultSubscriptions: string; viewId: string; searchQuery: string; retainSessionState: string; // If set, the component should load up with the last state it had on this tab. Used by Explore.vue hideSubscriptions: string; // Flag to hide unconnected components when navigating to map showDiff: string; // Flag to show only components with diff on the map }>; const tokenFailStatus = ref(); const tokenFail = ref(false); const queryClient = useQueryClient(); queryClient.setDefaultOptions({ queries: { staleTime: Infinity } }); const container = inject<{ loadingGuard: Ref<boolean> }>("LOADINGGUARD"); const getTokenForWorkspace = (workspaceId: string) => { return tokensByWorkspacePk[workspaceId]; }; onMounted(async () => { await checkOnboardingCompleteData(); }); const changeSetRetrievalError = ref(false); onBeforeMount(async () => { if (container && container.loadingGuard.value) { return; } if (container) { container.loadingGuard.value = true; } const thisWorkspacePk = workspacePk.value; const workspaceAuthToken = getTokenForWorkspace(thisWorkspacePk); if (!workspaceAuthToken) { tokenFail.value = true; tokenFailStatus.value = 500; // we don't have a token for this workspace, this is a dead end // push the user to the auth flow for this workspace. const url = `${ import.meta.env.VITE_AUTH_API_URL }/workspaces/${thisWorkspacePk}/go?redirect=${encodeURIComponent( window.location.pathname, )}`; window.location.href = url; return; } const userPk = getUserPkFromToken(workspaceAuthToken); await heimdall.init(thisWorkspacePk, workspaceAuthToken, queryClient, userPk); watch( [connectionShouldBeEnabled, heimdall.initCompleted], async () => { if (connectionShouldBeEnabled.value && heimdall.initCompleted.value) { // NOTE(nick,wendy): this says "reconnect", but it must run once on startup. await heimdall.bifrostReconnect(); } }, { immediate: true }, ); heimdall.showInterest(thisWorkspacePk, changeSetId.value); // NOTE: onBeforeMount doesn't wait on promises // the page will load before execution finishes try { changeSetRetrievalError.value = false; await heimdall.muspelheim(thisWorkspacePk, true); } catch (err) { if (err instanceof heimdall.ChangeSetRetrievalError) { changeSetRetrievalError.value = true; } } }); watch( () => [props.workspacePk, props.changeSetId], async ([newWorkspacePk, newChangeSetId], _) => { if (newWorkspacePk && newChangeSetId) { const workspaceToken = getTokenForWorkspace(newWorkspacePk); if (!workspaceToken) { tokenFail.value = true; tokenFailStatus.value = 401; return; } await heimdall.registerBearerToken(newWorkspacePk, workspaceToken); await heimdall.niflheim(newWorkspacePk, newChangeSetId, true, false); } }, ); const EVENTUALLY_CONSISTENT_TIME = 30 * 1000; // 30 seconds let indexInterval: NodeJS.Timeout; watch( () => [props.changeSetId], () => { // clear any interval for other change set if (indexInterval) clearInterval(indexInterval); // set a new interval (note: above watcher fires first cold start from the user selection change) indexInterval = setInterval(() => { heimdall.syncAtoms(props.workspacePk, props.changeSetId); }, EVENTUALLY_CONSISTENT_TIME); }, { immediate: true }, ); watch( () => heimdall.indexTouches.get(props.changeSetId), () => { if (lobby.value) return; // dont do anything when in the lobby // each time we get index updates, clear the interval if (indexInterval) clearInterval(indexInterval); // and restart it indexInterval = setInterval(() => { heimdall.syncAtoms(props.workspacePk, props.changeSetId); }, EVENTUALLY_CONSISTENT_TIME); }, { immediate: true }, ); const indexFailedToLoad = computed(() => { // NOTE: this represents a 5XX error that was retried 5 times // if we get here its likely because the index could not be retrieved, or even built // don't trap people into the lobby, but give them an error page for only one index! return heimdall.indexFailures.has(props.changeSetId); }); const hiddenAt = ref<Date | undefined>(undefined); // Force muspelheim if the window has been hidden for 12 hours const FORCE_MUSPELHEIM_AFTER_MS = 12 * 60 * 60 * 1000; // We have put this in a watch, instead of in the event // listener, in order to ensure we are reactive to the prop watch(hiddenAt, (newValue, oldValue) => { if (!newValue && oldValue) { const now = new Date(); const timeDiff = now.getTime() - oldValue.getTime(); if (timeDiff >= FORCE_MUSPELHEIM_AFTER_MS) { heimdall.muspelheim(props.workspacePk, true); } } }); document.addEventListener("visibilitychange", () => { if (document.hidden) { hiddenAt.value = new Date(); } else { hiddenAt.value = undefined; } }); realtimeStore.subscribe( "TOP_LEVEL_WORKSPACE", `workspace/${props.workspacePk}`, [ { eventType: "ChangeSetCreated", callback: async (data) => { queryClient.invalidateQueries({ queryKey: ["changesets"] }); if (ctx.value.headChangeSetId.value) { await heimdall.linkNewChangeset( props.workspacePk, data.changeSetId, ctx.value.headChangeSetId.value, ); } }, }, { eventType: "ChangeSetStatusChanged", callback: async (data) => { queryClient.invalidateQueries({ queryKey: ["changesets"] }); queryClient.invalidateQueries({ queryKey: ["approvalstatus", data.changeSet.id], }); queryClient.invalidateQueries({ queryKey: ["approvalstatusbychangesetid", data.changeSet.id], }); /* TURN THIS ON WHEN WE REMOVE CHANGE SET STORE if ( [ ChangeSetStatus.Abandoned, ChangeSetStatus.Applied, ChangeSetStatus.Closed, ].includes(data.changeSet.status) && data.changeSet.id !== ctx.value.headChangeSetId.value ) { heimdall.prune(props.workspacePk, data.changeSet.id); } // If I'm the one who requested this change set - toast that it's been approved/rejected/etc. if (data.changeSet.mergeRequestedByUserId === authStore.user?.pk) { if (data.changeSet.status === ChangeSetStatus.Rejected) { toast({ component: ChangeSetStatusChanged, props: { user: data.changeSet.reviewedByUser, command: "rejected the request to apply", changeSetName: data.changeSet.name, }, }); } else if (data.changeSet.status === ChangeSetStatus.Approved) { toast({ component: ChangeSetStatusChanged, props: { user: data.changeSet.reviewedByUser, command: "approved the request to apply", changeSetName: data.changeSet.name, }, }); } } else if (data.changeSet.status === ChangeSetStatus.NeedsApproval) { // this.FETCH_APPROVAL_STATUS(data.changeSet.id); // APPROVALS TODO, all the other change set store listeners } */ }, }, { eventType: "ChangeSetAbandoned", callback: async (_data) => { queryClient.invalidateQueries({ queryKey: ["changesets"] }); /* TURN THIS ON WHEN WE REMOVE CHANGE SET STORE if ( if (data.changeSetId !== ctx.value.headChangeSetId.value) { heimdall.prune(props.workspacePk, data.changeSetId); } if (data.changeSetId === props.changeSetId) { if (headChangeSetId.value) { const params = { ...route.params }; delete params.componentId; await router.push({ name: "new-hotness-head", params: { ...params, changeSetId: ctx.value.headChangeSetId.value, }, }); toast({ component: MovedToHead, props: { icon: "trash", changeSetName: activeChangeSet.value?.name, action: "abandoned", }, }); } } */ }, }, { eventType: "ChangeSetCancelled", callback: () => { queryClient.invalidateQueries({ queryKey: ["changesets"] }); }, }, { eventType: "ChangeSetApplied", callback: (_data) => { queryClient.invalidateQueries({ queryKey: ["changesets"] }); /* TURN THIS ON WHEN WE REMOVE CHANGE SET STORE if ( const { changeSetId: appliedId, toRebaseChangeSetId } = data; if (activeChangeSet) { if (appliedId !== ctx.value.headChangeSetId.value) { // never set HEAD to Applied heimdall.prune(props.workspacePk, appliedId); } } if ( props.changeSetId === toRebaseChangeSetId && props.changeSetId !== headChangeSetId.value ) { toast({ component: RebaseOnBase, }); } // `list_open_change_sets` gets called prior on voters // which means the change set is gone, so always move if (!props.changeSetId || props.changeSetId === appliedId) { if (route?.name) { if ((route.name as string).startsWith("new-hotness")) { let name = route.name as string; // if you're on a single-item page in the UI while just bring them to explore if ( ![ "new-hotness", "new-hotness-workspace-auto", "new-hotness-head", ].includes(route.name as string) ) name = "new-hotness"; router.push({ name, params: { ...route.query, // this will keep map/grid, search, if its there changeSetId: headChangeSetId.value, }, }); } else { router.push({ name: route.name, params: { ...route.params, changeSetId: headChangeSetId.value, }, }); } if (!ctx.value.onHead.value) // FIXME(nick): use a new design in the new UI, but keep the toast. toast({ component: MovedToHead, props: { icon: "tools", changeSetName: activeChangeSet.value?.name, action: "merged", }, }); } } */ }, }, { eventType: "ChangeSetRename", callback: () => { queryClient.invalidateQueries({ queryKey: ["changesets"] }); }, }, { eventType: "ChangeSetApprovalStatusChanged", callback: (changeSetId) => { queryClient.invalidateQueries({ queryKey: ["approvalstatus", changeSetId], }); queryClient.invalidateQueries({ queryKey: ["approvalstatusbychangesetid", changeSetId], }); }, }, ], ); const connectionShouldBeEnabled = computed(() => { try { const authStore = useAuthStore(); return ( authStore.userIsLoggedInAndInitialized && authStore.selectedWorkspaceToken ); } catch (_err) { return false; } }); const windowWidth = ref(window.innerWidth); const windowResizeHandler = () => { windowWidth.value = window.innerWidth; }; const openChangesetModal = () => { navbarPanelLeftRef.value?.openCreateModal(); }; const funcRunKey = "paginatedFuncRuns"; // Invalidates the paginatedFuncRuns query so it can update. // // We debounce because we frequently get a bunch of FuncRunLogUpdated events in a row, and we // don't want to query the server over and over again. // // NOTE: now the WsEvent fires after the write completes, we lowered the debounce for a snappier experience // but still enough so that repeated writes & events dont over-fire the paginated query endpoint const invalidatePaginatedFuncRuns = _.debounce(() => { const queryKey = [ctx.value.changeSetId, "paginatedFuncRuns"]; // If the query took longer than 500ms, invalidating would cancel it, and we might never // actually finish! We'll just requeue later when it's done. if (queryClient.isFetching({ queryKey }) > 0) { invalidatePaginatedFuncRuns(); return; } queryClient.invalidateQueries({ queryKey }); }, 250); onMounted(() => { windowResizeHandler(); windowResizeEmitter.on("resize", windowResizeHandler); }); const invalidateOneFuncRun = _.debounce((funcRunId: string) => { const queryKey = [ctx.value.changeSetId, "funcRunLogs", funcRunId]; if (queryClient.isFetching({ queryKey }) > 0) { invalidateOneFuncRun(funcRunId); return; } queryClient.invalidateQueries({ queryKey }); }, 500); watch( ctx.value.changeSetId, () => { // stop listening to old change set realtimeStore.unsubscribe(funcRunKey); // listen to new change set // Invalidate the paginatedFuncRuns query when FuncRunLogUpdated events are received. realtimeStore.subscribe( funcRunKey, `changeset/${ctx.value.changeSetId.value}`, [ { eventType: "FuncRunLogUpdated", callback: async (payload) => { if (payload.funcRunId) { invalidatePaginatedFuncRuns(); invalidateOneFuncRun(payload.funcRunId); } }, }, ], ); }, { immediate: true }, ); const bumpToWorkspaces = () => { window.open(`${AUTH_PORTAL_URL}/workspaces/`, "_self"); }; const onReviewPage = computed(() => route.name === "new-hotness-review"); // POSTHOG TRACKING watch(heimdall.initCompleted, () => { if (heimdall.initCompleted) { trackEvent("finished_cold_start"); } }); watch( [onboardingIsReopened, showOnboarding], () => { if (!showOnboarding.value) { return; } if (onboardingIsReopened.value) { trackEvent("started_onboarding_manual"); } else { trackEvent("started_onboarding_auto"); } }, { immediate: true }, ); onBeforeUnmount(() => { windowResizeEmitter.off("resize", windowResizeHandler); realtimeStore.unsubscribe(funcRunKey); }); </script> <style lang="less"> * { box-sizing: border-box; /* other global styles */ } /* use on any div in a grid that you want to have scrollable content */ .scrollable { overflow-y: auto; scrollbar-width: thin; } .scrollable-horizontal { overflow-x: auto; scrollbar-width: thin; } body.dark .scrollable, body.dark .scrollable-horizontal { scrollbar-color: @colors-neutral-800 @colors-black; } body.light .scrollable, body.light .scrollable-horizontal { scrollbar-color: @colors-neutral-200 @colors-white; } .grid > .scrollable { transition: max-height 1s; } /* any grid that has scrollable elements needs min-height 0 (just like main, above) otherwise the contents of the scrollable can blow out the container (putting it here globally means a human doesnt need to remember to do it every time) */ .grid:has(> .scrollable) { min-height: 0; } .tilegrid { display: grid; grid-gap: 1rem; > .tile { &.pinned { grid-column: 1 / -1; } } } /* This rules determines * - the min card width * - fit as many on a row * - and since its a grid, every card will have the same height (as tall as the tallest card) */ @supports (width: min(250px, 100%)) { .tilegrid { grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr)); grid-auto-rows: min-content; } } // this is a LESS mixin, right now we've got 2 different logical elements with the same grid // let's re-use the definition only once, until we don't want these elements to follow the same grid .MixinGridIconsFlankingHeaderAndSubheader { display: grid; grid-row-gap: 2px; grid-column-gap: 0.5rem; // 32px is the icon size grid-template-columns: 20px minmax(0, 1fr) 20px; grid-template-rows: 16px 16px; grid-template-areas: "logo h2 spinner" "logo h3 spinner"; // the icons are divs > div:first-child { grid-area: "logo"; } > div:last-child { grid-area: spinner; } > h2 { grid-area: h2; font-weight: bold; } > h3 { grid-area: h3; font-size: 0.8rem; } } .actions.list { .item { // actions list items also follow the grid .MixinGridIconsFlankingHeaderAndSubheader(); } } // component cards look like this, everywhere in the app .tile.component { display: flex; flex-direction: column; > header { // this header follows the grid from the mixin .MixinGridIconsFlankingHeaderAndSubheader(); } > footer { // always place a gap between buttons button + button { margin-left: 0.5rem; } } } /* ================================================================================================ * Shimmer for skeleton divs * ================================================================================================ */ .skeleton-shimmer { position: relative; overflow: hidden; } .skeleton-shimmer::before { content: ""; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; animation: shimmer 1.5s infinite; } /* Light theme shimmer */ body.light .skeleton-shimmer::before { background: linear-gradient( 90deg, transparent, rgba(255, 255, 255, 0.8), transparent ); } /* Dark theme shimmer */ body.dark .skeleton-shimmer::before { background: linear-gradient( 90deg, transparent, rgba(255, 255, 255, 0.1), transparent ); } @keyframes shimmer { 0% { left: -100%; } 100% { left: 100%; } } /* ================================================================================================ * END Shimmer for skeleton divs * ================================================================================================ */ </style>

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