platform/plugins/login-resources/src/components/AdminWorkspaces.svelte
Andrey Sobolev 931ed82403
QFIX: Admin panel (#7953)
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
2025-02-06 22:51:31 +07:00

520 lines
19 KiB
Svelte

<script lang="ts">
import {
groupByArray,
isActiveMode,
isArchivingMode,
isDeletingMode,
isMigrationMode,
isRestoringMode,
isUpgradingMode,
reduceCalls,
versionToString,
type WorkspaceInfoWithStatus
} from '@hcengineering/core'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { isAdminUser, MessageBox } from '@hcengineering/presentation'
import {
Button,
ButtonMenu,
CheckBox,
Expandable,
IconArrowRight,
IconOpen,
IconStart,
IconStop,
locationToUrl,
Popup,
Scroller,
SearchEdit,
showPopup,
ticker
} from '@hcengineering/ui'
import { workbenchId } from '@hcengineering/workbench'
import { getAllWorkspaces, getRegionInfo, performWorkspaceOperation } from '../utils'
import { RegionInfo } from '@hcengineering/account-client'
$: now = $ticker
$: isAdmin = isAdminUser()
let search: string = ''
async function select (workspace: string): Promise<void> {
const url = locationToUrl({ path: [workbenchId, workspace] })
window.open(url, '_blank')
}
type WorkspaceInfo = WorkspaceInfoWithStatus & { attempts: number }
let workspaces: WorkspaceInfo[] = []
enum SortingRule {
Name = '1',
BackupDate = '2',
BackupSize = '3',
LastVisit = '4'
}
let sortingRule = SortingRule.BackupDate
const sortRules = {
[SortingRule.Name]: 'Name',
[SortingRule.BackupDate]: 'Backup date',
[SortingRule.BackupSize]: 'Backup size',
[SortingRule.LastVisit]: 'Last visit'
}
const updateWorkspaces = reduceCalls(async (_: number) => {
const res = await getAllWorkspaces()
workspaces = res as WorkspaceInfo[]
})
$: void updateWorkspaces($ticker)
$: sortedWorkspaces = workspaces
.filter(
(it) =>
((it.name?.includes(search) ?? false) ||
(it.url?.includes(search) ?? false) ||
it.uuid?.includes(search) ||
it.createdBy?.includes(search)) &&
((showActive && isActiveMode(it.mode)) ||
(showArchived && isArchivingMode(it.mode)) ||
(showDeleted && isDeletingMode(it.mode)) ||
(showOther && (isMigrationMode(it.mode) || isRestoringMode(it.mode) || isUpgradingMode(it.mode))))
)
.sort((a, b) => {
switch (sortingRule) {
case SortingRule.BackupDate: {
return (a.backupInfo?.lastBackup ?? 0) - (b.backupInfo?.lastBackup ?? 0)
}
case SortingRule.BackupSize:
return (b.backupInfo?.backupSize ?? 0) - (a.backupInfo?.backupSize ?? 0)
case SortingRule.LastVisit:
return (b.lastVisit ?? 0) - (a.lastVisit ?? 0)
}
return (b.url ?? b.uuid).localeCompare(a.url ?? a.uuid)
})
let backupIdx = new Map<string, number>()
const backupInterval: number = 43200
let backupable: WorkspaceInfo[] = []
$: {
// Assign backup idx
const backupSorting = [...workspaces].filter((it) => {
if (!isActiveMode(it.mode)) {
return false
}
const lastBackup = it.backupInfo?.lastBackup ?? 0
if ((now - lastBackup) / 1000 < backupInterval) {
// No backup required, interval not elapsed
return false
}
const createdOn = Math.floor((now - it.createdOn) / 1000)
if (createdOn <= 2) {
// Skip if we created is less 2 days
return false
}
if (it.lastVisit == null) {
return false
}
const lastVisitSec = Math.floor((now - it.lastVisit) / 1000)
if (lastVisitSec > backupInterval) {
// No backup required, interval not elapsed
return false
}
return true
})
const newBackupIdx = new Map<string, number>()
backupSorting.sort((a, b) => {
return (a.backupInfo?.lastBackup ?? 0) - (b.backupInfo?.lastBackup ?? 0)
})
// Shift new with existing ones.
const existingNew = groupByArray(backupSorting, (it) => it.backupInfo != null)
const existing = existingNew.get(true) ?? []
const newOnes = existingNew.get(false) ?? []
const mixedBackupSorting: WorkspaceInfo[] = []
while (existing.length > 0 || newOnes.length > 0) {
const e = existing.shift()
const n = newOnes.shift()
if (e != null) {
mixedBackupSorting.push(e)
}
if (n != null) {
mixedBackupSorting.push(n)
}
}
backupable = mixedBackupSorting
for (const [idx, it] of mixedBackupSorting.entries()) {
newBackupIdx.set(it.uuid, idx)
}
backupIdx = newBackupIdx
}
const dayRanges = {
Today: 1,
'Tree Days': 3,
Week: 7,
Month: 30,
'Two Months': 60,
'Tree Months': 90,
'Six Month': 182,
'Nine Months': 270,
Year: 365,
FewOrMoreYears: 10000000
}
let limit = 50
// Individual filters
let showActive: boolean = true
let showArchived: boolean = false
let showDeleted: boolean = false
let showOther: boolean = true
$: groupped = groupByArray(sortedWorkspaces, (it) => {
const lastUsageDays = Math.round((now - (it.lastVisit ?? 0)) / (1000 * 3600 * 24))
return Object.entries(dayRanges).find(([_k, v]) => lastUsageDays <= v)?.[0] ?? 'Other'
})
let regionInfo: RegionInfo[] = []
let selectedRegionId: string = ''
void getRegionInfo().then((_regionInfo) => {
regionInfo = _regionInfo?.filter((it) => it.name !== '') ?? []
if (selectedRegionId === '' && regionInfo.length > 0) {
selectedRegionId = regionInfo[0].region
}
})
$: selectedRegionName = regionInfo.find((it) => it.region === selectedRegionId)?.name
$: byVersion = groupByArray(
workspaces.filter((it) => {
const lastUsed = Math.round((now - (it.lastVisit ?? 0)) / (1000 * 3600 * 24))
return isActiveMode(it.mode) && lastUsed < 1
}),
(it) => versionToString({ major: it.versionMajor, minor: it.versionMinor, patch: it.versionPatch })
)
let superAdminMode = false
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if isAdmin}
<div class="anticrm-panel flex-row flex-grow p-5" style:overflow-y={'auto'}>
<div class="flex-between">
<div class="fs-title p-3">Workspaces administration panel</div>
<div>
<CheckBox bind:checked={superAdminMode} />
</div>
</div>
<div class="fs-title p-3">
Workspaces: {workspaces.length} active: {workspaces.filter((it) => isActiveMode(it.mode)).length}
upgrading: {workspaces.filter((it) => isUpgradingMode(it.mode)).length}
Backupable: {backupable.length} new: {backupable.reduce((p, it) => p + (it.backupInfo == null ? 1 : 0), 0)}
<div class="flex-row-center">
{#each byVersion.entries() as [k, v]}
<div class="p-1">
{k}: {v.length}
</div>
{/each}
</div>
</div>
<div class="fs-title p-3 flex-no-shrink">
<SearchEdit bind:value={search} width={'100%'} />
</div>
<div class="p-3 flex-col">
<span class="fs-title mr-2">Filters: </span>
<div class="flex-row-center">
Show active workspaces:
<CheckBox bind:checked={showActive} />
</div>
<div class="flex-row-center">
<span class="mr-2">Show archived workspaces:</span>
<CheckBox bind:checked={showArchived} />
</div>
<div class="flex-row-center">
<span class="mr-2">Show deleted workspaces:</span>
<CheckBox bind:checked={showDeleted} />
</div>
<div class="flex-row-center">
<span class="mr-2">Show other workspaces:</span>
<CheckBox bind:checked={showOther} />
</div>
</div>
<div class="fs-title p-3 flex-row-center">
<span class="mr-2"> Sorting order: {sortingRule} </span>
<ButtonMenu
selected={sortingRule}
autoSelectionIfOne
title={sortRules[sortingRule]}
items={Object.entries(sortRules).map((it) => ({ id: it[0], label: getEmbeddedLabel(it[1]) }))}
on:selected={(it) => {
sortingRule = it.detail
}}
/>
</div>
<div class="fs-title p-3 flex-row-center">
<span class="mr-2"> Migration region selector: </span>
<ButtonMenu
selected={selectedRegionId}
autoSelectionIfOne
title={regionInfo.find((it) => it.region === selectedRegionId)?.name}
items={regionInfo.map((it) => ({ id: it.region === '' ? '#' : it.region, label: getEmbeddedLabel(it.name) }))}
on:selected={(it) => {
selectedRegionId = it.detail === '#' ? '' : it.detail
}}
/>
</div>
<div class="fs-title p-1">
<Scroller maxHeight={40} noStretch={true}>
<div class="mr-4">
{#each Object.keys(dayRanges) as k}
{@const v = groupped.get(k) ?? []}
{@const hasMore = (groupped.get(k) ?? []).length > limit}
{@const activeV = v.filter((it) => it.mode === 'active' && (it.region ?? '') !== selectedRegionId)}
{@const archivedV = v.filter((it) => it.mode === 'archived')}
{@const deletedV = v.filter((it) => it.mode === 'deleted')}
{@const av = v.length - archivedV.length - deletedV.length}
{#if v.length > 0}
<Expandable expandable={true} bordered={true}>
<svelte:fragment slot="title">
<span class="fs-title focused-button flex-row-center">
{k} -
{#if hasMore}
{limit} of {v.length}
{:else}
{v.length}
{/if}
{#if av > 0}
- maitenance: {av}
{/if}
</span>
</svelte:fragment>
<svelte:fragment slot="title-tools">
{#if hasMore}
<div class="ml-4">
<Button
label={getEmbeddedLabel('More items')}
kind={'link'}
on:click={() => {
limit += 50
}}
/>
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="tools">
{#if archivedV.length > 0}
<Button
icon={IconStop}
label={getEmbeddedLabel(`Mass Archive ${archivedV.length}`)}
kind={'ghost'}
on:click={() => {
showPopup(MessageBox, {
label: getEmbeddedLabel(`Mass Archive ${archivedV.length}`),
message: getEmbeddedLabel(`Please confirm archive ${archivedV.length} workspaces`),
action: async () => {
void performWorkspaceOperation(
archivedV.map((it) => it.uuid),
'archive'
)
}
})
}}
/>
{/if}
{#if regionInfo.length > 0 && activeV.length > 0}
<Button
icon={IconArrowRight}
kind={'positive'}
label={getEmbeddedLabel(`Mass Migrate ${activeV.length} to ${selectedRegionName ?? ''}`)}
on:click={() => {
showPopup(MessageBox, {
label: getEmbeddedLabel(`Mass Migrate ${archivedV.length}`),
message: getEmbeddedLabel(`Please confirm migrate ${archivedV.length} workspaces`),
action: async () => {
await performWorkspaceOperation(
activeV.map((it) => it.uuid),
'migrate-to',
selectedRegionId
)
}
})
}}
/>
{/if}
</svelte:fragment>
{#each v.slice(0, limit) as workspace}
{@const wsName = workspace.name}
{@const lastUsageDays = Math.round((now - (workspace.lastVisit ?? 0)) / (1000 * 3600 * 24))}
{@const bIdx = backupIdx.get(workspace.uuid)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="flex fs-title cursor-pointer focused-button bordered">
<div class="flex p-2">
<span class="label overflow-label flex-row-center" style:width={'12rem'}>
{wsName}
<div class="ml-1">
<Button icon={IconOpen} size={'small'} on:click={() => select(workspace.url)} />
</div>
</span>
<div class="ml-1" style:width={'12rem'}>
{workspace.createdBy}
</div>
<span class="label overflow-label" style:width={'8rem'}>
{workspace.region ?? ''}
</span>
<span class="label overflow-label" style:width={'5rem'}>
{lastUsageDays} days
</span>
<span class="label overflow-label" style:width={'10rem'}>
{workspace.mode ?? '-'}
</span>
<span class="label overflow-label" style:width={'2rem'}>
{workspace.attempts}
</span>
<span class="flex flex-between" style:width={'5rem'}>
{#if workspace.processingProgress !== 100 && workspace.processingProgress !== 0}
({workspace.processingProgress}%)
{/if}
</span>
<span class="flex flex-between" style:width={'5rem'}>
{#if workspace.backupInfo != null}
{@const sz = Math.max(
workspace.backupInfo.backupSize,
workspace.backupInfo.dataSize + workspace.backupInfo.blobsSize
)}
{@const szGb = Math.round((sz * 100) / 1024) / 100}
{#if szGb > 0}
{Math.round((sz * 100) / 1024) / 100}Gb
{:else}
{Math.round(sz * 100) / 100}Mb
{/if}
{/if}
{#if bIdx != null}
[#{bIdx}]
{/if}
</span>
<span class="flex flex-between" style:width={'5rem'}>
{#if workspace.backupInfo != null}
{@const hours = Math.round((now - workspace.backupInfo.lastBackup) / (1000 * 3600))}
{#if hours > 24}
{Math.round(hours / 24)} days
{:else}
{hours} hours
{/if}
{/if}
</span>
</div>
<div class="flex flex-grow gap-1-5 flex-between">
<div class="flex flex-row-center gap-1-5">
{#if workspace.mode === 'active'}
<Button
icon={IconStop}
size={'small'}
label={getEmbeddedLabel('Archive')}
kind={'ghost'}
on:click={() => {
showPopup(MessageBox, {
label: getEmbeddedLabel(`Archive ${workspace.url}`),
message: getEmbeddedLabel('Please confirm'),
action: async () => {
await performWorkspaceOperation(workspace.uuid, 'archive')
}
})
}}
/>
{/if}
{#if workspace.mode === 'archived'}
<Button
icon={IconStart}
size={'small'}
kind={'ghost'}
label={getEmbeddedLabel('Unarchive')}
on:click={() => {
showPopup(MessageBox, {
label: getEmbeddedLabel(`Unarchive ${workspace.url}`),
message: getEmbeddedLabel('Please confirm'),
action: async () => {
await performWorkspaceOperation(workspace.uuid, 'unarchive')
}
})
}}
/>
{/if}
{#if regionInfo.length > 0 && workspace.mode === 'active' && (workspace.region ?? '') !== selectedRegionId}
<Button
icon={IconArrowRight}
size={'small'}
kind={'positive'}
label={getEmbeddedLabel('Migrate ' + (selectedRegionName ?? ''))}
on:click={() => {
showPopup(MessageBox, {
label: getEmbeddedLabel(`Migrate ${workspace.url}`),
message: getEmbeddedLabel('Please confirm'),
action: async () => {
await performWorkspaceOperation(workspace.uuid, 'migrate-to', selectedRegionId)
}
})
}}
/>
{/if}
{#if superAdminMode && !isDeletingMode(workspace.mode) && !isArchivingMode(workspace.mode)}
<Button
icon={IconStop}
size={'small'}
kind={'dangerous'}
label={getEmbeddedLabel('Delete')}
on:click={() => {
showPopup(MessageBox, {
label: getEmbeddedLabel(`Delete ${workspace.url}`),
message: getEmbeddedLabel('Please confirm'),
action: async () => {
await performWorkspaceOperation(workspace.uuid, 'delete')
}
})
}}
/>
{/if}
</div>
</div>
</div>
{/each}
</Expandable>
{/if}
{/each}
</div>
</Scroller>
</div>
</div>
<Popup />
{/if}