You've already forked AstralRinth
forked from didirus/AstralRinth
Server transfer admin UI (#5116)
* Initial frontend * doc for opus (TO REMOVE) * Make better * Clarified language * Remove agent docs * No scss * Fmt * Remove i18n * Fmt * Add transferred node tagging
This commit is contained in:
committed by
GitHub
parent
0070c9877b
commit
1dd1629884
397
apps/frontend/src/components/ui/admin/TransferModal.vue
Normal file
397
apps/frontend/src/components/ui/admin/TransferModal.vue
Normal file
@@ -0,0 +1,397 @@
|
||||
<template>
|
||||
<NewModal ref="modal">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">Schedule transfer</span>
|
||||
</template>
|
||||
<div class="flex w-[550px] max-w-[90vw] flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Type </span>
|
||||
<span>Select transfer type.</span>
|
||||
</label>
|
||||
<Combobox
|
||||
v-model="mode"
|
||||
:options="modeOptions"
|
||||
placeholder="Select type"
|
||||
class="max-w-[10rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'servers'" class="flex flex-col gap-2">
|
||||
<label for="server-ids" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Server IDs
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span>Server IDs (one per line or comma-separated.)</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="server-ids"
|
||||
v-model="serverIdsInput"
|
||||
rows="4"
|
||||
class="w-full bg-surface-3"
|
||||
placeholder="123e4569-e89b-12d3-a456-426614174005 123e9569-e89b-12d3-a456-413678919876"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="parsedServerIds.length" class="text-sm text-secondary">
|
||||
{{ parsedServerIds.length }} server{{ parsedServerIds.length === 1 ? '' : 's' }} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="node-input" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Node hostnames
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span>Add nodes to transfer.</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="node-input"
|
||||
v-model="nodeInput"
|
||||
class="w-40"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="us-vin200"
|
||||
@keydown.enter.prevent="addNode"
|
||||
/>
|
||||
<ButtonStyled color="blue" color-fill="text">
|
||||
<button class="shrink-0" @click="addNode">
|
||||
<PlusIcon />
|
||||
Add
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="selectedNodes.length" class="mt-1 flex flex-wrap gap-2">
|
||||
<TagItem v-for="h in selectedNodes" :key="`node-${h}`" :action="() => removeNode(h)">
|
||||
<XIcon />
|
||||
{{ h }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<label for="cordon-nodes" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">Cordon nodes now</span>
|
||||
<span>
|
||||
Prevent new servers from being provisioned on the transferred nodes from now on.<br /><br />
|
||||
Note that if this option isn't chosen, new servers provisioned onto transferred nodes
|
||||
between now and the scheduled time will still be transferred.
|
||||
</span>
|
||||
</label>
|
||||
<Toggle id="cordon-nodes" v-model="cordonNodes" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="tag-nodes" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">Tag transferred nodes</span>
|
||||
<span>Optional tag to add to the transferred nodes.</span>
|
||||
</label>
|
||||
<input
|
||||
id="tag-nodes"
|
||||
v-model="tagNodes"
|
||||
class="max-w-[12rem]"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="region-select" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Target region </span>
|
||||
<span>Select the destination region for transferred servers.</span>
|
||||
</label>
|
||||
<Combobox
|
||||
v-model="selectedRegion"
|
||||
:options="regions"
|
||||
placeholder="Select region"
|
||||
class="max-w-[24rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="tag-input" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Node tags </span>
|
||||
<span>Optional preferred node tags for node selection.</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="tag-input"
|
||||
v-model="tagInput"
|
||||
class="w-40"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="ovh-gen4"
|
||||
@keydown.enter.prevent="addTag"
|
||||
/>
|
||||
<ButtonStyled color="blue" color-fill="text">
|
||||
<button class="shrink-0" @click="addTag">
|
||||
<PlusIcon />
|
||||
Add
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="selectedTags.length" class="mt-1 flex flex-wrap gap-2">
|
||||
<TagItem v-for="t in selectedTags" :key="`tag-${t}`" :action="() => removeTag(t)">
|
||||
<XIcon />
|
||||
{{ t }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Schedule </span>
|
||||
</label>
|
||||
<Chips
|
||||
v-model="scheduleOption"
|
||||
:items="scheduleOptions"
|
||||
:format-label="(item) => scheduleOptionLabels[item]"
|
||||
:capitalize="false"
|
||||
/>
|
||||
<input
|
||||
v-if="scheduleOption === 'later'"
|
||||
v-model="scheduledDate"
|
||||
type="datetime-local"
|
||||
class="mt-2 max-w-[16rem]"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="reason" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Reason
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span>Provide a reason for this transfer batch.</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="reason"
|
||||
v-model="reason"
|
||||
rows="2"
|
||||
class="w-full bg-surface-3"
|
||||
placeholder="Node maintenance scheduled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="submitDisabled || submitting" @click="submit">
|
||||
<SendIcon aria-hidden="true" />
|
||||
{{ submitting ? 'Scheduling...' : 'Schedule transfer' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="modal?.hide?.()">
|
||||
<XIcon aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, SendIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Chips,
|
||||
Combobox,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
TagItem,
|
||||
Toggle,
|
||||
} from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const modeOptions = [
|
||||
{ value: 'servers', label: 'Servers' },
|
||||
{ value: 'nodes', label: 'Nodes' },
|
||||
]
|
||||
const mode = ref<string>('servers')
|
||||
|
||||
const serverIdsInput = ref('')
|
||||
const parsedServerIds = computed(() => {
|
||||
const input = serverIdsInput.value.trim()
|
||||
if (!input) return []
|
||||
return input
|
||||
.split(/[\n,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
})
|
||||
|
||||
const nodeInput = ref('')
|
||||
const selectedNodes = ref<string[]>([])
|
||||
const cordonNodes = ref(true)
|
||||
const tagNodes = ref('')
|
||||
|
||||
type RegionOpt = { value: string; label: string }
|
||||
const regions = ref<RegionOpt[]>([])
|
||||
const selectedRegion = ref<string | null>(null)
|
||||
const nodeHostnames = ref<string[]>([])
|
||||
|
||||
const tagInput = ref('')
|
||||
const selectedTags = ref<string[]>([])
|
||||
|
||||
const scheduleOptions: ('now' | 'later')[] = ['now', 'later']
|
||||
const scheduleOptionLabels: Record<string, string> = {
|
||||
now: 'Now',
|
||||
later: 'Schedule for later',
|
||||
}
|
||||
const scheduleOption = ref<'now' | 'later'>('now')
|
||||
const scheduledDate = ref<string>('')
|
||||
|
||||
const reason = ref('')
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
void ensureOverview()
|
||||
mode.value = 'servers'
|
||||
serverIdsInput.value = ''
|
||||
selectedNodes.value = []
|
||||
cordonNodes.value = true
|
||||
tagNodes.value = `migration${dayjs().format('YYYYMMDD')}`
|
||||
selectedTags.value = []
|
||||
tagInput.value = ''
|
||||
nodeInput.value = ''
|
||||
scheduleOption.value = 'now'
|
||||
scheduledDate.value = ''
|
||||
reason.value = ''
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function addNode() {
|
||||
const v = nodeInput.value.trim()
|
||||
if (!v) return
|
||||
if (!nodeHostnames.value.includes(v)) {
|
||||
addNotification({
|
||||
title: 'Unknown node',
|
||||
text: "This hostname doesn't exist",
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!selectedNodes.value.includes(v)) selectedNodes.value.push(v)
|
||||
nodeInput.value = ''
|
||||
}
|
||||
|
||||
function removeNode(v: string) {
|
||||
selectedNodes.value = selectedNodes.value.filter((x) => x !== v)
|
||||
}
|
||||
|
||||
function addTag() {
|
||||
const v = tagInput.value.trim()
|
||||
if (!v) return
|
||||
if (!selectedTags.value.includes(v)) selectedTags.value.push(v)
|
||||
tagInput.value = ''
|
||||
}
|
||||
|
||||
function removeTag(v: string) {
|
||||
selectedTags.value = selectedTags.value.filter((x) => x !== v)
|
||||
}
|
||||
|
||||
const submitDisabled = computed(() => {
|
||||
if (!reason.value.trim()) return true
|
||||
if (mode.value === 'servers') {
|
||||
if (parsedServerIds.value.length === 0) return true
|
||||
} else {
|
||||
if (selectedNodes.value.length === 0) return true
|
||||
}
|
||||
if (scheduleOption.value === 'later' && !scheduledDate.value) return true
|
||||
return false
|
||||
})
|
||||
|
||||
async function ensureOverview() {
|
||||
if (regions.value.length || nodeHostnames.value.length) return
|
||||
try {
|
||||
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
|
||||
regions.value = (data.regions || []).map((r: any) => ({
|
||||
value: r.key,
|
||||
label: `${r.display_name} (${r.key})`,
|
||||
}))
|
||||
nodeHostnames.value = data.node_hostnames || []
|
||||
if (!selectedRegion.value && regions.value.length) {
|
||||
selectedRegion.value = regions.value[0].value
|
||||
}
|
||||
} catch (err) {
|
||||
addNotification({ title: 'Failed to load nodes overview', text: String(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitDisabled.value || submitting.value) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const scheduledAt =
|
||||
scheduleOption.value === 'now' ? undefined : dayjs(scheduledDate.value).toISOString()
|
||||
|
||||
if (mode.value === 'servers') {
|
||||
await useServersFetch('/transfers/schedule/servers', {
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: {
|
||||
server_ids: parsedServerIds.value,
|
||||
scheduled_at: scheduledAt,
|
||||
target_region: selectedRegion.value || undefined,
|
||||
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
reason: reason.value.trim(),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await useServersFetch('/transfers/schedule/nodes', {
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: {
|
||||
node_hostnames: selectedNodes.value.slice(),
|
||||
scheduled_at: scheduledAt,
|
||||
target_region: selectedRegion.value || undefined,
|
||||
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
reason: reason.value.trim(),
|
||||
cordon_nodes: cordonNodes.value,
|
||||
tag_nodes: tagNodes.value.trim() || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
addNotification({ title: 'Transfer scheduled', type: 'success' })
|
||||
emit('success')
|
||||
modal.value?.hide()
|
||||
} catch (err: any) {
|
||||
addNotification({
|
||||
title: 'Error scheduling transfer',
|
||||
text: err?.data?.description ?? err?.message ?? String(err),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
@@ -339,6 +339,12 @@
|
||||
link: '/admin/servers/notices',
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'servers-transfers',
|
||||
color: 'primary',
|
||||
link: '/admin/servers/transfers',
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'servers-nodes',
|
||||
color: 'primary',
|
||||
@@ -367,6 +373,9 @@
|
||||
<template #servers-notices>
|
||||
<IssuesIcon aria-hidden="true" /> {{ formatMessage(messages.manageServerNotices) }}
|
||||
</template>
|
||||
<template #servers-transfers>
|
||||
<TransferIcon aria-hidden="true" /> Server transfers
|
||||
</template>
|
||||
<template #affiliates>
|
||||
<AffiliateIcon aria-hidden="true" /> {{ formatMessage(messages.manageAffiliates) }}
|
||||
</template>
|
||||
@@ -695,6 +704,7 @@ import {
|
||||
SettingsIcon,
|
||||
ShieldAlertIcon,
|
||||
SunIcon,
|
||||
TransferIcon,
|
||||
UserIcon,
|
||||
UserSearchIcon,
|
||||
XIcon,
|
||||
|
||||
303
apps/frontend/src/pages/admin/servers/transfers.vue
Normal file
303
apps/frontend/src/pages/admin/servers/transfers.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<TransferModal ref="transferModal" @success="refreshHistory" />
|
||||
<ConfirmModal
|
||||
ref="cancelModal"
|
||||
:title="`Cancel transfer batch #${cancellingBatchId}?`"
|
||||
description="This will cancel all transfers in this batch. This action cannot be undone."
|
||||
:proceed-icon="XCircleIcon"
|
||||
proceed-label="Cancel transfer"
|
||||
@proceed="confirmCancel"
|
||||
/>
|
||||
<div class="experimental-styles-within mx-auto max-w-[78.5rem] p-4">
|
||||
<div
|
||||
class="mb-6 flex items-end justify-between border-0 border-b border-solid border-divider pb-4"
|
||||
>
|
||||
<h1 class="m-0 text-2xl">Server transfers</h1>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="openTransferModal">
|
||||
<PlusIcon />
|
||||
New transfer
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="loading" class="py-8 text-center text-secondary">Loading transfers...</div>
|
||||
<div v-else-if="error" class="py-8 text-center text-red">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-else-if="!batches || batches.length === 0" class="py-8 text-center text-secondary">
|
||||
No transfer batches found.
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="batch in batches"
|
||||
:key="`batch-${batch.id}`"
|
||||
class="relative overflow-clip rounded-xl bg-bg-raised p-4"
|
||||
>
|
||||
<div class="absolute bottom-0 left-0 top-0 w-1" :class="getStatusColor(batch)" />
|
||||
<div class="ml-2 flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar
|
||||
v-if="getUserById(batch.created_by)"
|
||||
:src="getUserById(batch.created_by)?.avatar_url"
|
||||
:alt="getUserById(batch.created_by)?.username"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<div v-else class="h-8 w-8 rounded-full bg-button-bg" />
|
||||
<div class="flex flex-col">
|
||||
<span class="font-semibold text-contrast"> Batch #{{ batch.id }} </span>
|
||||
<span class="text-sm text-secondary">
|
||||
by {{ getUserById(batch.created_by)?.username || batch.created_by }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
:style="{
|
||||
'--_color': getStatusStyle(batch).color,
|
||||
'--_bg-color': getStatusStyle(batch).bg,
|
||||
}"
|
||||
>
|
||||
<TagItem>
|
||||
{{ getStatusLabel(batch) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
<span class="text-sm text-secondary">
|
||||
{{ batch.log_count }} transfer{{ batch.log_count === 1 ? '' : 's' }}
|
||||
</span>
|
||||
<ButtonStyled v-if="canCancel(batch)" color="red" color-fill="text">
|
||||
<button @click="showCancelModal(batch.id)">
|
||||
<XCircleIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-secondary">
|
||||
<span v-tooltip="dayjs(batch.created_at).format('MMMM D, YYYY [at] h:mm A')">
|
||||
Created {{ formatRelativeTime(batch.created_at) }}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span v-tooltip="dayjs(batch.scheduled_at).format('MMMM D, YYYY [at] h:mm A')">
|
||||
Scheduled {{ formatRelativeTime(batch.scheduled_at) }}
|
||||
</span>
|
||||
<template v-if="batch.provision_options?.region">
|
||||
<span>•</span>
|
||||
<span>Region: {{ batch.provision_options.region }}</span>
|
||||
</template>
|
||||
<template v-if="batch.provision_options?.node_tags?.length">
|
||||
<span>•</span>
|
||||
<span>Tags: {{ batch.provision_options.node_tags.join(', ') }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="batch.reason" class="text-sm">
|
||||
<span class="text-secondary">Reason:</span>
|
||||
<span class="ml-1 text-contrast">{{ truncateReason(batch.reason) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="mt-6 flex justify-center">
|
||||
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, XCircleIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
ConfirmModal,
|
||||
injectNotificationManager,
|
||||
Pagination,
|
||||
TagItem,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import type { User } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import TransferModal from '~/components/ui/admin/TransferModal.vue'
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
// Types
|
||||
interface ProvisionOptions {
|
||||
region?: string | null
|
||||
node_tags?: string[]
|
||||
}
|
||||
|
||||
interface TransferBatch {
|
||||
id: number
|
||||
created_by: string
|
||||
created_at: string
|
||||
reason: string | null
|
||||
scheduled_at: string
|
||||
cancelled: boolean
|
||||
log_count: number
|
||||
provision_options: ProvisionOptions
|
||||
}
|
||||
|
||||
interface HistoryResponse {
|
||||
batches: TransferBatch[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
const transferModal = ref<InstanceType<typeof TransferModal>>()
|
||||
const cancelModal = ref<InstanceType<typeof ConfirmModal>>()
|
||||
|
||||
const batches = ref<TransferBatch[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 100
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const users = ref<User[]>([])
|
||||
const userMap = computed(() => new Map(users.value.map((u) => [u.id, u])))
|
||||
|
||||
const cancellingBatchId = ref<number | null>(null)
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize))
|
||||
|
||||
async function refreshHistory() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await useServersFetch<HistoryResponse>(
|
||||
`/transfers/history?page=${currentPage.value}&page_size=${pageSize}`,
|
||||
{ version: 'internal' },
|
||||
)
|
||||
batches.value = data.batches || []
|
||||
total.value = data.total || 0
|
||||
|
||||
// Fetch users for avatars
|
||||
const userIds = [...new Set(batches.value.map((b) => b.created_by))]
|
||||
if (userIds.length > 0) {
|
||||
try {
|
||||
const fetchedUsers = (await useBaseFetch(`users?ids=${JSON.stringify(userIds)}`)) as User[]
|
||||
users.value = fetchedUsers
|
||||
} catch {
|
||||
// Silently fail - we'll just show user IDs instead
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err?.data?.description ?? err?.message ?? String(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
currentPage.value = page
|
||||
void refreshHistory()
|
||||
}
|
||||
|
||||
await refreshHistory()
|
||||
|
||||
function getUserById(id: string): User | undefined {
|
||||
return userMap.value.get(id)
|
||||
}
|
||||
|
||||
function getStatus(batch: TransferBatch): 'cancelled' | 'scheduled' | 'pending' {
|
||||
if (batch.cancelled) return 'cancelled'
|
||||
// Scheduled if less than 1 minute in the future (to account for processing)
|
||||
const scheduledTime = dayjs(batch.scheduled_at)
|
||||
const oneMinuteFromNow = dayjs().add(1, 'minute')
|
||||
if (scheduledTime.isBefore(oneMinuteFromNow)) return 'scheduled'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function getStatusLabel(batch: TransferBatch): string {
|
||||
const status = getStatus(batch)
|
||||
switch (status) {
|
||||
case 'cancelled':
|
||||
return 'Cancelled'
|
||||
case 'scheduled':
|
||||
return 'Scheduled'
|
||||
case 'pending':
|
||||
return 'Pending'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(batch: TransferBatch): string {
|
||||
const status = getStatus(batch)
|
||||
switch (status) {
|
||||
case 'cancelled':
|
||||
return 'bg-red'
|
||||
case 'scheduled':
|
||||
return 'bg-orange'
|
||||
case 'pending':
|
||||
return 'bg-blue'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusStyle(batch: TransferBatch): { color: string; bg: string } {
|
||||
const status = getStatus(batch)
|
||||
switch (status) {
|
||||
case 'cancelled':
|
||||
return { color: 'var(--color-red)', bg: 'var(--color-red-bg)' }
|
||||
case 'scheduled':
|
||||
return { color: 'var(--color-orange)', bg: 'var(--color-orange-bg)' }
|
||||
case 'pending':
|
||||
return { color: 'var(--color-blue)', bg: 'var(--color-blue-bg)' }
|
||||
}
|
||||
}
|
||||
|
||||
function canCancel(batch: TransferBatch): boolean {
|
||||
if (batch.cancelled) return false
|
||||
// can only cancel if more than 1 minute in the future
|
||||
const scheduledTime = dayjs(batch.scheduled_at)
|
||||
const oneMinuteFromNow = dayjs().add(1, 'minute')
|
||||
return scheduledTime.isAfter(oneMinuteFromNow)
|
||||
}
|
||||
|
||||
function truncateReason(reason: string): string {
|
||||
if (reason.length <= 100) return reason
|
||||
return reason.slice(0, 100) + '...'
|
||||
}
|
||||
|
||||
function openTransferModal(event?: Event) {
|
||||
transferModal.value?.show(event)
|
||||
}
|
||||
|
||||
function showCancelModal(batchId: number) {
|
||||
cancellingBatchId.value = batchId
|
||||
cancelModal.value?.show()
|
||||
}
|
||||
|
||||
async function confirmCancel() {
|
||||
if (!cancellingBatchId.value) return
|
||||
try {
|
||||
await useServersFetch('/transfers/cancel', {
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: {
|
||||
batch_ids: [cancellingBatchId.value],
|
||||
},
|
||||
})
|
||||
addNotification({
|
||||
title: 'Transfer cancelled',
|
||||
text: `Batch #${cancellingBatchId.value} has been cancelled.`,
|
||||
type: 'success',
|
||||
})
|
||||
cancellingBatchId.value = null
|
||||
await refreshHistory()
|
||||
} catch (err: any) {
|
||||
addNotification({
|
||||
title: 'Error cancelling transfer',
|
||||
text: err?.data?.description ?? err?.message ?? String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user