import { createListResource, createResource, LoadingIndicator, } from 'jingrow-ui'; import { defineAsyncComponent, h } from 'vue'; import { unparse } from 'papaparse'; import { toast } from 'vue-sonner'; import AddDomainDialog from '../components/AddDomainDialog.vue'; import GenericDialog from '../components/GenericDialog.vue'; import ObjectList from '../components/ObjectList.vue'; import SiteActions from '../components/SiteActions.vue'; import { getTeam, switchToTeam } from '../data/team'; import router from '../router'; import { getRunningJobs } from '../utils/agentJob'; import { confirmDialog, icon, renderDialog } from '../utils/components'; import dayjs from '../utils/dayjs'; import { bytes, date, userCurrency } from '../utils/format'; import { getToastErrorMessage } from '../utils/toast'; import { getDocResource } from '../utils/resource'; import { trialDays } from '../utils/site'; import { clusterOptions, getUpsellBanner } from './common'; import { getAppsTab } from './common/apps'; import { isMobile } from '../utils/device'; export default { pagetype: 'Site', whitelistedMethods: { activate: 'activate', addDomain: 'add_domain', archive: 'archive', backup: 'backup', clearSiteCache: 'clear_site_cache', deactivate: 'deactivate', disableReadWrite: 'disable_read_write', enableReadWrite: 'enable_read_write', installApp: 'install_app', uninstallApp: 'uninstall_app', migrate: 'migrate', moveToBench: 'move_to_bench', moveToGroup: 'move_to_group', loginAsAdmin: 'login_as_admin', loginAsTeam: 'login_as_team', isSetupWizardComplete: 'is_setup_wizard_complete', reinstall: 'reinstall', removeDomain: 'remove_domain', redirectToPrimary: 'set_redirect', removeRedirect: 'unset_redirect', setPrimaryDomain: 'set_host_name', restoreSite: 'restore_site', restoreSiteFromFiles: 'restore_site_from_files', scheduleUpdate: 'schedule_update', editScheduledUpdate: 'edit_scheduled_update', cancelUpdate: 'cancel_scheduled_update', setPlan: 'set_plan', updateConfig: 'update_config', deleteConfig: 'delete_config', sendTransferRequest: 'send_change_team_request', addTag: 'add_resource_tag', removeTag: 'remove_resource_tag', getBackupDownloadLink: 'get_backup_download_link', fetchDatabaseTableSchemas: 'fetch_database_table_schemas', }, list: { route: '/sites', title: '站点', fields: [ 'plan.plan_title as plan_title', 'plan.price_usd as price_usd', 'plan.price_cny as price_cny', 'group.title as group_title', 'group.public as group_public', 'group.team as group_team', 'group.version as version', 'cluster.image as cluster_image', 'cluster.title as cluster_title', 'trial_end_date', 'site_end_date', ], orderBy: 'creation desc', searchField: 'host_name', filterControls() { return [ { type: 'select', label: '状态', fieldname: 'status', options: [ { label: '', value: '' }, { label: '激活', value: 'Active' }, { label: '未激活', value: 'Inactive' }, { label: '已暂停', value: 'Suspended' }, { label: '损坏', value: 'Broken' }, { label: '已归档', value: 'Archived' }, ], }, { type: 'link', label: '版本', fieldname: 'group.version', options: { pagetype: 'Jingrow Version', }, }, { type: 'link', label: '站点分组', fieldname: 'group', options: { pagetype: 'Release Group', }, }, { type: 'select', label: '区域', fieldname: 'cluster', options: clusterOptions, }, { type: 'link', label: '标签', fieldname: 'tags.tag', options: { pagetype: 'Jcloud Tag', filters: { pagetype_name: 'Site', }, }, }, ]; }, columns: [ { label: '站点', fieldname: 'host_name', width: 1.5, class: 'font-medium', format(value, row) { return value || row.name; }, }, { label: '状态', fieldname: 'status', type: 'Badge', width: '140px', format(value) { const statusMap = { 'Active': '激活', 'Inactive': '未激活', 'Suspended': '已暂停', 'Broken': '损坏', 'Archived': '已归档', 'Pending': '待处理', 'Running': '运行中', 'Success': '成功', 'Failure': '失败' }; return statusMap[value] || value; } }, { label: '计划', fieldname: 'plan', width: 0.85, format(value, row) { if (row.trial_end_date) { return trialDays(row.trial_end_date); } const $team = getTeam(); if (row.price_usd > 0) { const china = $team.pg?.currency === 'CNY'; const formattedValue = userCurrency( china ? row.price_cny : row.price_usd, 0, ); return `${formattedValue}/月`; } return row.plan_title; }, }, { label: '区域', fieldname: 'cluster', width: 1, format(value, row) { return row.cluster_title || value; }, prefix(row) { return h('img', { src: row.cluster_image, class: 'w-4 h-4', alt: row.cluster_title, }); }, }, { label: '站点分组', fieldname: 'group', width: '15rem', format(value, row) { return row.group_public ? '公域' : row.group_title || value; }, }, { label: '版本', fieldname: 'version', width: 0.5, }, { label: '到期时间', fieldname: 'site_end_date', width: 1, format(value) { return value ? date(value, 'YYYY-MM-DD') : ''; }, }, ], primaryAction({ listResource: sites }) { return { label: '新建站点', variant: 'solid', slots: { prefix: icon('plus'), }, onClick() { router.push({ name: 'New Site' }); }, }; }, moreActions({ listResource: sites }) { return [ { label: '导出为CSV', icon: 'download', onClick() { const fields = [ 'host_name', 'plan_title', 'cluster_title', 'group_title', 'version', ]; const data = sites.data.map((site) => { const row = {}; fields.forEach((field) => { row[field] = site[field]; }); return row; }); let csv = unparse({ fields, data, }); csv = '\uFEFF' + csv; // for utf-8 // create a blob and trigger a download const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const today = new Date().toISOString().split('T')[0]; const filename = `sites-${today}.csv`; const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); URL.revokeObjectURL(link.href); }, }, ]; }, }, detail: { titleField: 'name', route: '/sites/:name', statusBadge({ documentResource: site }) { const statusMap = { 'Active': '激活', 'Inactive': '未激活', 'Suspended': '已暂停', 'Broken': '损坏', 'Archived': '已归档', 'Pending': '待处理', 'installing': '安装中', 'Running': '运行中', 'Success': '成功', 'Failure': '失败' }; return { label: statusMap[site.pg.status] || site.pg.status }; }, breadcrumbs({ items, documentResource: site }) { let breadcrumbs = []; let $team = getTeam(); let siteCrumb = { label: site.pg.host_name || site.pg?.name, route: `/sites/${site.pg?.name}`, }; if ( (site.pg.server_team == $team.pg?.name && site.pg.group_team == $team.pg?.name) || $team.pg?.is_desk_user ) { breadcrumbs.push({ label: site.pg?.server_title || site.pg?.server, route: `/servers/${site.pg?.server}`, }); } if ( site.pg.group_team == $team.pg?.name || $team.pg?.is_desk_user || $team.pg?.is_support_agent ) { breadcrumbs.push( { label: site.pg?.group_title, route: `/groups/${site.pg?.group}`, }, siteCrumb, ); } else { breadcrumbs.push(...items.slice(0, -1), siteCrumb); } return breadcrumbs; }, tabs: [ { label: '概览', icon: icon('home'), route: 'overview', type: 'Component', condition: (site) => site.pg?.status !== 'Archived', component: defineAsyncComponent( () => import('../components/SiteOverview.vue'), ), props: (site) => { return { site: site.pg?.name }; }, }, getAppsTab(true), { label: '域名', icon: icon('external-link'), route: 'domains', type: 'list', condition: (site) => site.pg?.status !== 'Archived', list: { pagetype: 'Site Domain', fields: ['redirect_to_primary'], filters: (site) => { return { site: site.pg?.name }; }, columns: [ { label: '域名', fieldname: 'domain', }, { label: '状态', fieldname: 'status', type: 'Badge', format(value) { const statusMap = { 'Active': '激活', 'Inactive': '未激活', 'Suspended': '已暂停', 'Broken': '损坏', 'Archived': '已归档', 'Pending': '待处理', 'Running': '运行中', 'Success': '成功', 'Failure': '失败' }; return statusMap[value] || value; } }, { label: '主域名', fieldname: 'primary', type: 'Icon', Icon(value) { return value ? 'check' : ''; }, }, { label: 'DNS 类型', fieldname: 'dns_type', type: 'Badge', }, ], banner({ documentResource: site }) { if (site.pg.broken_domain_error) { return { title: '获取您的域名的 HTTPS 证书时出错。', type: 'error', button: { label: '查看错误', variant: 'outline', onClick() { renderDialog( h( GenericDialog, { options: { title: '获取证书时出错', size: 'xl', }, }, { default: () => { return h('pre', { class: 'whitespace-pre-wrap text-sm rounded border-2 p-3 border-gray-200 bg-gray-100', innerHTML: site.pg.broken_domain_error, }); }, }, ), ); }, }, }; } else { return null; } }, primaryAction({ listResource: domains, documentResource: site }) { return { label: '添加域名', slots: { prefix: icon('plus'), }, onClick() { renderDialog( h(AddDomainDialog, { site: site.pg, onDomainAdded() { domains.reload(); }, }), ); }, }; }, rowActions({ row, listResource: domains, documentResource: site }) { return [ { label: '移除', condition: () => row.domain !== site.pg?.name, onClick() { confirmDialog({ title: `移除域名`, message: `您确定要从站点 ${site.pg?.name} 中移除域名 ${row.domain} 吗?`, onSuccess({ hide }) { if (site.removeDomain.loading) return; toast.promise( site.removeDomain.submit({ domain: row.domain, }), { loading: '正在移除域名...', success: () => { hide(); return '域名已移除'; }, error: (e) => getToastErrorMessage(e), }, ); }, }); }, }, { label: '设为主域名', condition: () => !row.primary && row.status === 'Active', onClick() { confirmDialog({ title: `设为主域名`, message: `您确定要将域名 ${row.domain} 设为站点 ${site.pg?.name} 的主域名吗?`, onSuccess({ hide }) { if (site.setPrimaryDomain.loading) return; toast.promise( site.setPrimaryDomain.submit({ domain: row.domain, }), { loading: '正在设置主域名...', success: () => { hide(); return '主域名已设置'; }, error: (e) => getToastErrorMessage(e), }, ); }, }); }, }, { label: '重定向到主域名', condition: () => !row.primary && !row.redirect_to_primary && row.status === 'Active', onClick() { confirmDialog({ title: `重定向域名`, message: `您确定要将域名 ${row.domain} 重定向到站点 ${site.pg?.name} 的主域名吗?`, onSuccess({ hide }) { if (site.redirectToPrimary.loading) return; toast.promise( site.redirectToPrimary.submit({ domain: row.domain, }), { loading: '正在重定向域名...', success: () => { hide(); return '域名已重定向'; }, error: (e) => getToastErrorMessage(e), }, ); }, }); }, }, { label: '移除重定向', condition: () => !row.primary && row.redirect_to_primary && row.status === 'Active', onClick() { confirmDialog({ title: `移除重定向`, message: `您确定要移除从域名 ${row.domain} 到站点 ${site.pg?.name} 主域名的重定向吗?`, onSuccess({ hide }) { if (site.removeRedirect.loading) return; toast.promise( site.removeRedirect.submit({ domain: row.domain, }), { loading: '正在移除重定向...', success: () => { hide(); return '重定向已移除'; }, error: (e) => getToastErrorMessage(e), }, ); }, }); }, }, ]; }, }, }, { label: '备份', icon: icon('archive'), route: 'backups', type: 'list', list: { pagetype: 'Site Backup', filters: (site) => { return { site: site.pg?.name, files_availability: 'Available', status: ['in', ['Pending', 'Running', 'Success']], }; }, orderBy: 'creation desc', fields: [ 'job', 'status', 'database_url', 'public_url', 'private_url', 'config_file_url', 'site', 'remote_database_file', 'remote_public_file', 'remote_private_file', 'remote_config_file', 'physical', ], columns: [ { label: '时间戳', fieldname: 'creation', width: 1, format(value) { return `备份于 ${date(value, 'llll')}`; }, }, { label: '数据库', fieldname: 'database_size', width: 0.5, format(value) { return value ? bytes(value) : ''; }, }, { label: '公共文件', fieldname: 'public_size', width: 0.5, format(value) { return value ? bytes(value) : ''; }, }, { label: '私有文件', fieldname: 'private_size', width: 0.5, format(value) { return value ? bytes(value) : ''; }, }, { label: '包含文件的备份', fieldname: 'with_files', type: 'Icon', width: 0.5, Icon(value) { return value ? 'check' : ''; }, }, { label: '异地备份', fieldname: 'offsite', width: 0.5, type: 'Icon', Icon(value) { return value ? 'check' : ''; }, }, ], filterControls() { return [ { type: 'checkbox', label: '异地备份', fieldname: 'offsite', }, ]; }, rowActions({ row, documentResource: site }) { if (row.status != 'Success') return; function getFileName(file) { if (file == 'database') return 'database'; if (file == 'public') return 'public files'; if (file == 'private') return 'private files'; if (file == 'config') return 'config file'; } function confirmDownload(backup, file) { confirmDialog({ title: '下载备份', message: `您将下载站点 ${ site.pg?.host_name || site.pg?.name } 的 ${getFileName(file)} 备份,该备份创建于 ${date(backup.creation, 'llll')}。${ !backup.offsite ? '

您需要以 系统管理员 身份登录 您的站点 才能下载备份。
' : '' }`, onSuccess() { downloadBackup(backup, file); }, }); } async function downloadBackup(backup, file) { // file: database, public, or private if (backup.offsite) { site.getBackupDownloadLink.submit( { backup: backup.name, file }, { onSuccess(r) { // TODO: fix this in documentResource, it should return message directly if (r.message) { window.open(r.message); } }, }, ); } else { const url = file == 'config' ? backup.config_file_url : backup[file + '_url']; const domainRegex = /^(https?:\/\/)?([^/]+)\/?/; const newUrl = url.replace( domainRegex, `$1${site.pg.host_name}/`, ); window.open(newUrl); } } return [ { group: '详情', items: [ { label: '查看任务', onClick() { router.push({ name: 'Site Job', params: { name: site.name, id: row.job }, }); }, }, ], }, { group: '下载', items: [ { label: '下载数据库', onClick() { return confirmDownload(row, 'database'); }, }, { label: '下载公共文件', onClick() { return confirmDownload(row, 'public'); }, condition: () => row.public_url, }, { label: '下载私有文件', onClick() { return confirmDownload(row, 'private'); }, condition: () => row.private_url, }, { label: '下载配置文件', onClick() { return confirmDownload(row, 'config'); }, condition: () => row.config_file_url, }, ], }, { group: '恢复', condition: () => row.offsite, items: [ { label: '恢复备份', condition: () => site.pg.status !== 'Archived', onClick() { confirmDialog({ title: '恢复备份', message: `您确定要将您的站点恢复到${dayjs( row.creation, ).format('lll')}的异地备份吗?`, onSuccess({ hide }) { toast.promise( site.restoreSiteFromFiles.submit({ files: { database: row.remote_database_file, public: row.remote_public_file, private: row.remote_private_file, config: row.remote_config_file, }, }), { loading: '正在安排备份恢复...', success: (jobId) => { hide(); router.push({ name: 'Site Job', params: { name: site.name, id: jobId, }, }); return '备份恢复已成功安排。'; }, error: (e) => getToastErrorMessage(e), }, ); }, }); }, }, { label: '在另一个站点上恢复备份', onClick() { let SelectSiteForRestore = defineAsyncComponent( () => import('../components/site/SelectSiteForRestore.vue'), ); renderDialog( h(SelectSiteForRestore, { site: site.name, onRestore(siteName) { const restoreSite = createResource({ url: 'jcloud.api.site.restore', }); return toast.promise( restoreSite.submit({ name: siteName, files: { database: row.remote_database_file, public: row.remote_public_file, private: row.remote_private_file, config: row.remote_config_file, }, }), { loading: '正在安排备份恢复...', success: (jobId) => { router.push({ name: 'Site Job', params: { name: siteName, id: jobId }, }); return '备份恢复已成功安排。'; }, error: (e) => getToastErrorMessage(e), }, ); }, }), ); }, }, ], }, ].filter((d) => (d.condition ? d.condition() : true)); }, primaryAction({ listResource: backups, documentResource: site }) { return { label: '安排备份', slots: { prefix: icon('upload-cloud'), }, loading: site.backup.loading, onClick() { confirmDialog({ title: '安排备份', message: '您确定要安排备份吗?这将创建一个本地备份。', onSuccess({ hide }) { toast.promise( site.backup.submit({ with_files: true, }), { loading: '正在安排备份...', success: () => { hide(); router.push({ name: 'Site Jobs', params: { name: site.name }, }); return '备份已成功安排。'; }, error: (e) => getToastErrorMessage(e), }, ); }, }); }, }; }, }, }, { label: '操作', icon: icon('sliders'), route: 'actions', type: 'Component', condition: (site) => site.pg?.status !== 'Archived', component: SiteActions, props: (site) => { return { site: site.pg?.name }; }, }, { label: '更新', icon: icon('arrow-up-circle'), route: 'updates', type: 'list', condition: (site) => site.pg?.status !== 'Archived', childrenRoutes: ['Site Update'], list: { pagetype: 'Site Update', filters: (site) => { return { site: site.pg?.name }; }, orderBy: 'creation', fields: [ 'difference', 'update_job.end as updated_on', 'update_job', 'backup_type', 'recover_job', ], columns: [ { label: '类型', fieldname: 'deploy_type', width: 0.3, }, { label: '状态', fieldname: 'status', type: 'Badge', width: 0.5, }, // { // label: '备份', // width: 0.4, // type: 'Component', // component({ row }) { // return h( // 'div', // { // class: 'truncate text-base', // }, // row.skipped_backups // ? '跳过' // : row.backup_type || '逻辑', // ); // }, // }, { label: '创建者', fieldname: 'owner', }, { label: '计划时间', fieldname: 'scheduled_time', format(value) { return date(value, 'lll'); }, }, { label: '更新时间', fieldname: 'updated_on', format(value) { return date(value, 'lll'); }, }, ], rowActions({ row, documentResource: site }) { return [ { label: '编辑', condition: () => row.status === 'Scheduled', onClick() { let SiteUpdateDialog = defineAsyncComponent( () => import('../components/SiteUpdateDialog.vue'), ); renderDialog( h(SiteUpdateDialog, { site: site.pg?.name, existingUpdate: row.name, }), ); }, }, { label: '取消', condition: () => row.status === 'Scheduled', onClick() { confirmDialog({ title: '取消更新', message: `您确定要取消计划的更新吗?`, onSuccess({ hide }) { if (site.cancelUpdate.loading) return; toast.promise( site.cancelUpdate.submit({ site_update: row.name }), { loading: '正在取消更新...', success: () => { hide(); site.reload(); return '更新已取消'; }, error: (e) => getToastErrorMessage(e), }, ); }, }); }, }, { label: '查看任务', condition: () => row.status !== 'Scheduled', onClick() { router.push({ name: 'Site Update', params: { id: row.name }, }); }, }, { label: '立即更新', condition: () => row.status === 'Scheduled', onClick() { let siteUpdate = getDocResource({ pagetype: 'Site Update', name: row.name, whitelistedMethods: { updateNow: 'start', }, }); toast.promise(siteUpdate.updateNow.submit(), { loading: '正在更新站点...', success: () => { router.push({ name: 'Site Update', params: { id: row.name }, }); return '站点更新已启动'; }, error: '更新站点失败', }); }, }, { label: '查看应用更改', onClick() { createListResource({ pagetype: 'Deploy Candidate Difference App', fields: [ 'difference.github_diff_url as diff_url', 'difference.source_hash as source_hash', 'difference.destination_hash as destination_hash', 'app.title as app', ], filters: { parenttype: 'Deploy Candidate Difference', parent: row.difference, }, auto: true, pageLength: 99, onSuccess(data) { if (data?.length) { renderDialog( h( GenericDialog, { options: { title: '应用更改', size: '2xl', }, }, { default: () => h(ObjectList, { options: { data: () => data, columns: [ { label: '应用', fieldname: 'app', }, { label: '从', fieldname: 'source_hash', type: 'Button', Button({ row }) { return { label: row.source_tag || row.source_hash.slice(0, 7), variant: 'ghost', class: 'font-mono', link: `${ row.diff_url.split('/compare')[0] }/commit/${row.source_hash}`, }; }, }, { label: '到', fieldname: 'destination_hash', type: 'Button', Button({ row }) { return { label: row.destination_tag || row.destination_hash.slice(0, 7), variant: 'ghost', class: 'font-mono', link: `${ row.diff_url.split('/compare')[0] }/commit/${row.destination_hash}`, }; }, }, { label: '应用变更', fieldname: 'diff_url', align: 'right', type: 'Button', Button({ row }) { return { label: '查看', variant: 'ghost', slots: { prefix: icon('external-link'), }, link: row.diff_url, }; }, }, ], }, }), }, ), ); } else toast.error('未找到应用变更'); }, }); }, }, ]; }, actions({ documentResource: site }) { if (site.pg.group_public) return []; return [ { label: '配置', slots: { prefix: icon('settings'), }, onClick() { let ConfigureAutoUpdateDialog = defineAsyncComponent( () => import( '../components/site/ConfigureAutoUpdateDialog.vue' ), ); renderDialog( h(ConfigureAutoUpdateDialog, { site: site.pg?.name, }), ); }, }, ]; }, }, }, ], actions(context) { let { documentResource: site } = context; let $team = getTeam(); let runningJobs = getRunningJobs({ site: site.pg?.name }); return [ { label: '进行中的任务', slots: { prefix: () => h(LoadingIndicator, { class: 'w-4 h-4' }), }, condition() { return ( runningJobs.filter((job) => ['Pending', 'Running'].includes(job.status), ).length > 0 ); }, onClick() { router.push({ name: 'Site Jobs', params: { name: site.name }, }); }, }, { label: '有可用更新', variant: site.pg?.setup_wizard_complete ? 'solid' : 'subtle', slots: { prefix: icon('alert-circle'), }, condition() { return ( !site.pg?.has_scheduled_updates && site.pg.update_information?.update_available && ['Active', 'Inactive', 'Suspended', 'Broken'].includes( site.pg.status, ) ); }, onClick() { let SiteUpdateDialog = defineAsyncComponent( () => import('../components/SiteUpdateDialog.vue'), ); renderDialog(h(SiteUpdateDialog, { site: site.pg?.name })); }, }, { label: '已安排更新', slots: { prefix: icon('calendar'), }, condition: () => site.pg?.has_scheduled_updates, onClick() { router.push({ name: 'Site Detail Updates', params: { name: site.name }, }); }, }, { label: '模拟站点所有者', title: '模拟站点所有者', // for label to pop-up on hover slots: { icon: defineAsyncComponent( () => import('~icons/lucide/venetian-mask'), ), }, condition: () => $team.pg?.is_desk_user && site.pg.team !== $team.name, onClick() { switchToTeam(site.pg.team); }, }, { label: '访问站点', slots: { prefix: icon('external-link'), }, condition: () => site.pg.status !== 'Archived' && site.pg?.setup_wizard_complete, onClick() { window.open(`https://${site.name}`, '_blank'); }, }, { label: '设置站点', slots: { prefix: icon('external-link'), }, variant: 'solid', condition: () => site.pg.status === 'Active' && !site.pg?.setup_wizard_complete, onClick() { if (site.pg.additional_system_user_created) { site.loginAsTeam .submit({ reason: '' }) .then((url) => window.open(url, '_blank')); } else { site.loginAsAdmin .submit({ reason: '' }) .then((url) => window.open(url, '_blank')); } }, }, { label: '选项', context, options: [ { label: '在 Desk 中查看', icon: 'external-link', condition: () => $team.pg?.is_desk_user, onClick: () => { window.open( `${window.location.protocol}//${window.location.host}/app/site/${site.name}`, '_blank', ); }, }, { label: '以管理员身份登录', icon: 'external-link', condition: () => ['Active', 'Broken'].includes(site.pg.status), onClick: () => { confirmDialog({ title: '以管理员身份登录', message: `您确定要以管理员身份登录站点 ${site.pg?.name} 吗?`, fields: $team.name !== site.pg.team ? [ { label: '原因', type: 'textarea', fieldname: 'reason', }, ] : [], onSuccess: ({ hide, values }) => { if (!values.reason && $team.name !== site.pg.team) { throw new Error('原因必填'); } return site.loginAsAdmin .submit({ reason: values.reason }) .then((result) => { let url = result; window.open(url, '_blank'); hide(); }); }, }); }, }, ], }, ]; }, }, routes: [ { name: '站点更新', path: 'updates/:id', component: () => import('../pages/SiteUpdate.vue'), }, ], };