<template>
<Stack
:class="
clsx(
'[&_dd]:m-xs [&_dd]:pl-xs [&_dd]:break-all [&_dd]:font-mono',
'[&_dt]:p-2xs [&_dt]:font-bold',
themeClasses(
'[&_select]:text-neutral-900 [&_select]:bg-neutral-100',
'[&_select]:text-neutral-200 [&_select]:bg-neutral-900',
),
)
"
>
<h2 class="font-bold text-xl">WORKSPACES</h2>
<div class="flex flex-row gap-xs p-xs w-full">
<LoadStatus :requestStatus="searchWorkspacesReqStatus" loadingMessage="Searching workspaces ...">
<template #success>
<select v-if="filteredWorkspaces?.length > 0" v-model="selectedWorkspaceId">
<option v-for="workspace in filteredWorkspaces" :key="workspace.id" :value="workspace.id">
{{ workspace.name }} ({{ workspace.id }})
</option>
</select>
<p v-else>No workspaces found...</p>
</template>
</LoadStatus>
<VormInput
v-model="workspacesFilter"
label="WORKSPACE SEARCH: type here to search for a workspace by id or name, or change set id or name, or user id, name, or email (50 results max). then press ENTER"
placeholder="workspace name/id, or user name/id/email)"
@keydown.enter="searchWorkspaces(workspacesFilter)"
/>
</div>
<template v-if="selectedWorkspaceId && selectedWorkspace">
<LoadStatus :requestStatus="listChangeSetsReqStatus.value" loadingMessage="Loading change sets ...">
<template #success>
<div class="flex flex-row gap-xs p-xs w-full">
<select v-if="filteredChangeSets?.length > 0" v-model="selectedChangeSetId">
<option v-for="changeSet in filteredChangeSets" :key="changeSet.id" :value="changeSet.id">
{{ changeSet.name }} ({{ changeSet.id }}) --
{{ changeSet.status }}
<p v-if="changeSet.id === selectedWorkspace?.defaultChangeSetId">*</p>
</option>
</select>
<p v-else>No change sets found for workspace ...</p>
<VormInput
v-model="changeSetsFilter"
placeholder="change set name, ID, status, e.g. 'open'"
label="CHANGE SET SEARCH: type here to filter the change set list (e.g., type 'open' to see only open change sets)"
/>
</div>
</template>
</LoadStatus>
<div class="flex flex-row flex-wrap gap-xs p-xs w-full">
<Stack v-if="selectedChangeSetId && selectedChangeSet" class="flex-none">
<VButton :loading="isGettingSnapshot" @click="getSnapshot(selectedWorkspaceId, selectedChangeSetId)"
>Save snapshot to disk</VButton
>
<VButton :loading="isSettingSnapshot" @click="setSnapshot(selectedWorkspaceId, selectedChangeSetId)"
>Replace snapshot for this change set</VButton
>
<VButton :loading="isGettingCasData" @click="getCasData(selectedWorkspaceId, selectedChangeSetId)"
>Save cas data to disk</VButton
>
<VButton :loading="isUploadingCasData" @click="uploadCasData(selectedWorkspaceId, selectedChangeSetId)"
>Upload cas data to service</VButton
>
<VButton
:requestStatus="validateSnapshotRequestStatus"
@click="validateSnapshot(selectedWorkspaceId, selectedChangeSetId)"
>Validate snapshot</VButton
>
<VButton
:requestStatus="validateSnapshotRequestStatus"
@click="
validateSnapshot(selectedWorkspaceId, selectedChangeSetId, {
fixIssues: true,
})
"
>Validate AND FIX changeset</VButton
>
</Stack>
<dl class="p-3">
<dt>Workspace Id</dt>
<dd>
{{ selectedWorkspace.id }}
</dd>
<dt>Name</dt>
<dd>
{{ selectedWorkspace.name }}
</dd>
<dt>Snapshot Version</dt>
<dd>
{{ selectedWorkspace.snapshotVersion }}
</dd>
<dt class="font-bold">Component Concurrency Limit</dt>
<dd>
{{ selectedWorkspace.componentConcurrencyLimit ?? "default" }}
<VButton class="m-1" @click="openModal">Set</VButton>
</dd>
</dl>
<Stack v-if="workspaceUsers.length">
<h3 class="font-bold text-sm">USERS</h3>
<div v-for="user in workspaceUsers" :key="user.id" class="m-1 text-sm">
{{ user.name }} <{{ user.email }}>
</div>
</Stack>
<dl v-if="selectedChangeSetId && selectedChangeSet" class="p-3 max-w-xs overflow-hidden">
<dt>Change Set Id</dt>
<dd>
{{ selectedChangeSet.id }}
</dd>
<dt>Name</dt>
<dd>
{{ selectedChangeSet.name }}
</dd>
<dt>Status</dt>
<dd>
{{ selectedChangeSet.status }}
</dd>
<dt>Snapshot Address (blake3 hash of contents)</dt>
<dd>
{{ selectedChangeSet.workspaceSnapshotAddress }}
</dd>
</dl>
</div>
<Modal ref="concurrencyModalRef" :title="concurrencyModalTitle">
<Stack>
<VormInput
v-model="editingConcurrencyLimit"
placeholder="blank is default"
label="Enter a component concurrency limit for this workspace, blank for default"
@keydown.enter="setConcurrencyLimit()"
/>
<VButton :requestStatus="setConcurrencyLimitReqStatus" @click="setConcurrencyLimit()">Set</VButton>
</Stack>
</Modal>
<ValidateSnapshot v-if="showPanel === 'validate-snapshot'" />
</template>
</Stack>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from "vue";
import { Modal, Stack, VormInput, VButton, LoadStatus, useModal, themeClasses } from "@si/vue-lib/design-system";
import clsx from "clsx";
import { WorkspaceUser } from "@/store/auth.store";
import { AdminWorkspace, AdminChangeSet, useAdminStore } from "@/store/admin.store";
import { useWorkspacesStore } from "@/store/workspaces.store";
import { ChangeSetId, ChangeSetStatus } from "@/api/sdf/dal/change_set";
import ValidateSnapshot from "@/components/AdminDashboard/ValidateSnapshot.vue";
import { WorkspacePk } from "@/api/sdf/dal/workspace";
const adminStore = useAdminStore();
const workspacesStore = useWorkspacesStore();
const searchWorkspacesReqStatus = adminStore.getRequestStatus("SEARCH_WORKSPACES");
const setConcurrencyLimitReqStatus = adminStore.getRequestStatus("SET_CONCURRENCY_LIMIT");
const workspaceUsers = ref<WorkspaceUser[]>([]);
const workspaceChangeSets = ref<{ [key: string]: AdminChangeSet }>({});
const editingConcurrencyLimit = ref<string | null>(null);
const isGettingSnapshot = ref<boolean>(false);
const isSettingSnapshot = ref<boolean>(false);
const isGettingCasData = ref<boolean>(false);
const isUploadingCasData = ref<boolean>(false);
const lastUploadedSnapshotAddress = ref<string | null>(null);
const concurrencyModalRef = ref<InstanceType<typeof Modal>>();
const concurrencyModalTitle = computed(() =>
selectedWorkspace.value
? `Set concurrency limit for ${selectedWorkspace.value.name} (${selectedWorkspace.value.id})`
: "No workspace selected",
);
const { open: openModal, close: closeModal } = useModal(concurrencyModalRef);
const applyFilter = <T extends object>(things: { [key: string]: T }, filter?: string | null): T[] => {
const lowerCaseFilter = filter?.toLocaleLowerCase();
return Object.values(things).filter((thing) =>
lowerCaseFilter ? JSON.stringify(Object.values(thing)).toLocaleLowerCase().includes(lowerCaseFilter) : true,
);
};
const workspacesFilter = ref<string | null>(null);
const searchWorkspaces = async (filter?: string | null) => {
const result = await adminStore.SEARCH_WORKSPACES(filter ?? undefined);
if (result?.result.success) {
filteredWorkspaces.value = result.result.data.workspaces;
} else {
filteredWorkspaces.value = [];
}
};
// Start out searching for the current workspace
searchWorkspaces(workspacesStore.selectedWorkspacePk);
const filteredWorkspaces = ref<AdminWorkspace[]>([]);
const selectedWorkspaceId = ref<string | null>(null);
// When filteredWorkspaces changes, make sure selectedWorkspaceId is one of them
watch(filteredWorkspaces, (filteredWorkspaces) => {
if (!(selectedWorkspaceId.value && selectedWorkspaceId.value in filteredWorkspaces)) {
// Select the first workspace, unless the selected workspace is one of the results already
selectedWorkspaceId.value = filteredWorkspaces[0]?.id ?? null;
}
});
const selectedWorkspace = computed(() =>
selectedWorkspaceId.value
? filteredWorkspaces.value.find((workspace) => workspace.id === selectedWorkspaceId.value)
: undefined,
);
watch(selectedWorkspaceId, async (currentWorkspaceId) => {
if (currentWorkspaceId) {
editingConcurrencyLimit.value = selectedWorkspace.value?.componentConcurrencyLimit
? String(selectedWorkspace.value.componentConcurrencyLimit)
: null;
await fetchChangeSets(currentWorkspaceId);
await fetchUsers(currentWorkspaceId);
}
});
const changeSetsFilter = ref<string | null>(null);
// Sort open change sets first
const ACTIVE_CHANGE_SET_STATUS: ChangeSetStatus[] = [
ChangeSetStatus.Open,
ChangeSetStatus.Approved,
ChangeSetStatus.NeedsApproval,
ChangeSetStatus.NeedsAbandonApproval,
ChangeSetStatus.Rejected,
];
const fetchChangeSets = async (workspaceId: string) => {
const result = await adminStore.LIST_CHANGE_SETS(workspaceId);
if (result?.result.success) {
workspaceChangeSets.value = Object.fromEntries(
Object.entries(result.result.data.changeSets).sort((a, b) => {
// Sort HEAD first
if (!a[1].baseChangeSetId !== !b[1].baseChangeSetId) {
return !a[1].baseChangeSetId ? -1 : 1;
}
const aIsActive = ACTIVE_CHANGE_SET_STATUS.includes(a[1].status);
const bIsActive = ACTIVE_CHANGE_SET_STATUS.includes(b[1].status);
if (aIsActive && !bIsActive) {
return -1;
} else if (!aIsActive && bIsActive) {
return 1;
}
return a[1].name.localeCompare(b[1].name);
}),
);
} else {
workspaceChangeSets.value = {};
}
};
const listChangeSetsReqStatus = computed(() =>
adminStore.getRequestStatus("LIST_CHANGE_SETS", selectedWorkspaceId.value),
);
const filteredChangeSets = computed(() =>
selectedWorkspaceId.value ? applyFilter(workspaceChangeSets.value ?? {}, changeSetsFilter.value) : [],
);
const selectedChangeSetId = ref<string | null>(null);
// When filteredChangeSets changes, make sure selectedChangeSetId is one of them
watch(filteredChangeSets, (filteredChangeSets) => {
if (!(selectedChangeSetId.value && selectedChangeSetId.value in filteredChangeSets)) {
// Select the first workspace, unless the selected workspace is one of the results already
selectedChangeSetId.value = filteredChangeSets[0]?.id ?? null;
}
});
const selectedChangeSet = computed(() =>
selectedWorkspaceId.value && selectedChangeSetId.value
? workspaceChangeSets.value?.[selectedChangeSetId.value]
: undefined,
);
if (!(selectedWorkspaceId.value && selectedWorkspaceId.value in filteredWorkspaces.value)) {
// Select the first workspace, unless the selected workspace is one of the results already
selectedWorkspaceId.value = filteredWorkspaces.value[0]?.id ?? null;
}
const fetchUsers = async (workspaceId: string) => {
const result = await adminStore.LIST_WORKSPACE_USERS(workspaceId);
if (result?.result.success) {
workspaceUsers.value = result.result.data.users;
} else {
workspaceUsers.value = [];
}
};
const getSnapshot = async (workspaceId: string, changeSetId: string) => {
isGettingSnapshot.value = true;
try {
const result = await adminStore.GET_SNAPSHOT(workspaceId, changeSetId);
if (result.result.success) {
const bytes = Buffer.from(result.result.data, "base64");
const blob = new Blob([bytes], { type: "application/octet-stream" });
const fileHandle = await window.showSaveFilePicker({
suggestedName: `${changeSetId}.snapshot`,
types: [
{
description: `Workspace Snapshot for change set ${changeSetId}`,
accept: {
"application/octet-stream": [".snapshot"],
},
},
],
});
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
}
} finally {
isGettingSnapshot.value = false;
}
};
const setSnapshot = async (workspaceId: string, changeSetId: string) => {
isSettingSnapshot.value = true;
try {
const [fileHandle] = await window.showOpenFilePicker({
types: [
{
description: `Workspace Snapshot for change set ${changeSetId}`,
accept: {
"application/octet-stream": [".snapshot"],
},
},
],
});
const fileData = await fileHandle.getFile();
const result = await adminStore.SET_SNAPSHOT(workspaceId, changeSetId, fileData);
if (result.result.success) {
lastUploadedSnapshotAddress.value = result.result.data.workspaceSnapshotAddress;
}
if (selectedWorkspaceId.value) {
await fetchChangeSets(selectedWorkspaceId.value);
}
} finally {
isSettingSnapshot.value = false;
}
};
const uploadCasData = async (workspaceId: string, changeSetId: string) => {
isUploadingCasData.value = true;
try {
const [fileHandle] = await window.showOpenFilePicker({
types: [
{
description: `Cas data map for change set ${changeSetId}`,
accept: {
"application/octet-stream": [".cas"],
},
},
],
});
const fileData = await fileHandle.getFile();
await adminStore.UPLOAD_CAS_DATA(workspaceId, changeSetId, fileData);
} finally {
isUploadingCasData.value = false;
}
};
const getCasData = async (workspaceId: string, changeSetId: string) => {
isGettingCasData.value = true;
try {
const result = await adminStore.GET_CAS_DATA(workspaceId, changeSetId);
if (result.result.success) {
const bytes = Buffer.from(result.result.data, "base64");
const blob = new Blob([bytes], { type: "application/octet-stream" });
const fileHandle = await window.showSaveFilePicker({
suggestedName: `${changeSetId}.cas`,
types: [
{
description: `Cas Data map for change set ${changeSetId}`,
accept: {
"application/octet-stream": [".cas"],
},
},
],
});
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
}
} finally {
isGettingCasData.value = false;
}
};
const setConcurrencyLimit = async () => {
const componentConcurrencyLimit = editingConcurrencyLimit.value ? parseInt(editingConcurrencyLimit.value) : undefined;
if (selectedWorkspaceId.value) {
const result = await adminStore.SET_CONCURRENCY_LIMIT(selectedWorkspaceId.value, componentConcurrencyLimit);
if (result.result.success) {
const newLimit = result.result.data.concurrencyLimit;
filteredWorkspaces.value = filteredWorkspaces.value.map((workspace) => {
if (workspace.id === selectedWorkspaceId.value) {
return {
...workspace,
componentConcurrencyLimit: newLimit,
};
}
return workspace;
});
closeModal();
}
}
};
const showPanel = ref<"validate-snapshot" | "migrate-connections" | null>(null);
const validateSnapshotRequestStatus = adminStore.getRequestStatus("VALIDATE_SNAPSHOT");
function validateSnapshot(workspaceId: WorkspacePk, changeSetId: ChangeSetId, options?: { fixIssues?: boolean }) {
adminStore.VALIDATE_SNAPSHOT(workspaceId, changeSetId, options);
showPanel.value = "validate-snapshot";
}
</script>