259 lines
11 KiB
Vue
259 lines
11 KiB
Vue
<script setup>
|
||
import {
|
||
NFlex, NButton, NDataTable, NTag, NText, NEl, NIcon, NInput, NTooltip, NScrollbar, NGrid, NGi,
|
||
NSwitch, NPopconfirm,
|
||
} from 'naive-ui'
|
||
import {
|
||
TbRefresh, TbPlayerPlay, TbLayoutDashboard, TbAlertTriangle, TbBolt, TbTrash, TbPlus,
|
||
} from 'vue-icons-plus/tb'
|
||
import AppLayout from '../../../Layouts/AppLayout.vue'
|
||
import AppContainer from '../../../Components/AppContainer.vue'
|
||
import SectionCard from '../../../Components/SectionCard.vue'
|
||
import PageBanner from '../../../Components/PageBanner.vue'
|
||
import { Link, router, usePage } from '@inertiajs/vue3'
|
||
import { ref, computed, watch, h } from 'vue'
|
||
import { format } from 'date-fns'
|
||
|
||
const props = defineProps({
|
||
logs: { type: Array, default: () => [] },
|
||
schedules: { type: Array, default: () => [] },
|
||
configured: { type: Boolean, default: false },
|
||
})
|
||
|
||
const page = usePage()
|
||
const running = ref(null)
|
||
const customId = ref('')
|
||
|
||
watch(() => page.props.flash, (f) => {
|
||
if (f?.success) window.$message?.success(f.success)
|
||
if (f?.error) window.$message?.error(f.error)
|
||
}, { deep: true })
|
||
|
||
function run(scheduleId) {
|
||
const id = String(scheduleId ?? '').trim()
|
||
if (!id) return
|
||
running.value = id
|
||
router.post('/admin/replication/run', { schedule_id: id }, {
|
||
preserveScroll: true,
|
||
onFinish: () => { running.value = null },
|
||
})
|
||
}
|
||
|
||
// --- Управление расписаниями (таблица replication_schedules) ---
|
||
const newId = ref('')
|
||
const newName = ref('')
|
||
|
||
function addSchedule() {
|
||
const id = newId.value.trim()
|
||
if (!id) return
|
||
router.post('/admin/replication/schedules', { schedule_id: id, name: newName.value.trim() || null }, {
|
||
preserveScroll: true,
|
||
onSuccess: () => { newId.value = ''; newName.value = '' },
|
||
})
|
||
}
|
||
|
||
function toggleSchedule(s, value) {
|
||
router.put(`/admin/replication/schedules/${s.id}`, { name: s.name, is_active: value }, {
|
||
preserveScroll: true,
|
||
})
|
||
}
|
||
|
||
function removeSchedule(s) {
|
||
router.delete(`/admin/replication/schedules/${s.id}`, { preserveScroll: true })
|
||
}
|
||
|
||
const statusType = (s) => ({ success: 'success', partial_success: 'warning', failed: 'error' }[s] ?? 'default')
|
||
const statusLabel = (s) => ({ success: 'Успешно', partial_success: 'Частично', failed: 'Ошибка' }[s] ?? (s ?? '—'))
|
||
const fmt = (iso) => iso ? format(new Date(iso), 'dd.MM.yyyy HH:mm:ss') : '—'
|
||
|
||
const hasSchedules = computed(() => props.schedules.length > 0)
|
||
const lastLog = computed(() => props.logs[0] ?? null)
|
||
|
||
const columns = computed(() => [
|
||
{
|
||
key: 'received_at',
|
||
title: 'Время',
|
||
width: 170,
|
||
render: (row) => h(NText, { style: 'font-size: 13px;' }, () => fmt(row.received_at)),
|
||
},
|
||
{
|
||
key: 'status',
|
||
title: 'Статус',
|
||
width: 120,
|
||
render: (row) => h(NTag, { type: statusType(row.status), size: 'small', round: true, bordered: false }, () => statusLabel(row.status)),
|
||
},
|
||
{
|
||
key: 'schedule_id',
|
||
title: 'Расписание',
|
||
render: (row) => h(NText, { style: 'font-size: 13px; font-family: monospace;' }, () => row.schedule_id ?? '—'),
|
||
},
|
||
{
|
||
key: 'tables_success',
|
||
title: 'Таблиц ОК',
|
||
width: 110,
|
||
align: 'center',
|
||
render: (row) => h(NTag, { size: 'small', round: true, bordered: false, type: 'success' }, () => `${row.tables_success}`),
|
||
},
|
||
{
|
||
key: 'tables_failed',
|
||
title: 'Ошибок',
|
||
width: 100,
|
||
align: 'center',
|
||
render: (row) => h(NTag, {
|
||
size: 'small', round: true, bordered: false,
|
||
type: row.tables_failed > 0 ? 'error' : 'default',
|
||
}, () => `${row.tables_failed}`),
|
||
},
|
||
{
|
||
key: 'errors',
|
||
title: 'Детали ошибок',
|
||
width: 150,
|
||
render: (row) => {
|
||
if (!row.errors || !row.errors.length) return h(NText, { depth: 3 }, () => '—')
|
||
return h(NTooltip, { style: { maxWidth: '440px' } }, {
|
||
trigger: () => h(NTag, { type: 'error', size: 'small', round: true, bordered: false }, () => `${row.errors.length} ошиб.`),
|
||
default: () => h(NScrollbar, { style: 'max-height: 240px;' },
|
||
() => row.errors.map((e, i) => h('div', { key: i, style: 'font-size: 12px; padding: 2px 0;' },
|
||
typeof e === 'string' ? e : JSON.stringify(e)))),
|
||
})
|
||
},
|
||
},
|
||
])
|
||
</script>
|
||
|
||
<template>
|
||
<AppLayout>
|
||
<AppContainer>
|
||
|
||
<PageBanner
|
||
title="Репликация"
|
||
:icon="TbRefresh"
|
||
:breadcrumbs="[{ label: 'Администратор', href: '/admin', icon: TbLayoutDashboard, tag: Link }]"
|
||
>
|
||
<template #meta>
|
||
<NFlex align="center" :size="8">
|
||
<NText depth="3" style="font-size: 13px;">{{ logs.length }} записей</NText>
|
||
<template v-if="lastLog">
|
||
<NEl style="width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .3;" />
|
||
<NText depth="3" style="font-size: 13px;">последняя:</NText>
|
||
<NTag :type="statusType(lastLog.status)" size="small" round :bordered="false">
|
||
{{ statusLabel(lastLog.status) }}
|
||
</NTag>
|
||
</template>
|
||
</NFlex>
|
||
</template>
|
||
</PageBanner>
|
||
|
||
<!-- Запуск: 2 колонки — ручной запуск и шорткаты -->
|
||
<NGrid cols="2" :x-gap="16">
|
||
<NGi>
|
||
<SectionCard :icon="TbPlayerPlay" title="Запуск репликации">
|
||
<div v-if="!configured" class="hint-error">
|
||
<NIcon :component="TbAlertTriangle" /> Не задан <code>SYNCIO_BASE_URL</code> — запуск недоступен. Добавьте переменные окружения репликатора.
|
||
</div>
|
||
<NFlex v-else align="center" :size="8" :wrap="false">
|
||
<NInput v-model:value="customId" placeholder="schedule_id" clearable />
|
||
<NButton
|
||
type="primary"
|
||
:loading="running !== null && running === customId.trim() && customId.trim() !== ''"
|
||
:disabled="running !== null || !customId.trim()"
|
||
@click="run(customId)"
|
||
>
|
||
Запустить
|
||
</NButton>
|
||
</NFlex>
|
||
</SectionCard>
|
||
</NGi>
|
||
|
||
<NGi>
|
||
<SectionCard :icon="TbBolt" title="Шорткаты (расписания проекта)">
|
||
<NFlex vertical :size="8">
|
||
<NFlex
|
||
v-for="s in schedules" :key="s.id"
|
||
align="center" :size="8" :wrap="false"
|
||
>
|
||
<NButton
|
||
type="primary" secondary
|
||
:loading="running === s.schedule_id"
|
||
:disabled="!configured || running !== null || !s.is_active"
|
||
style="flex: 1; justify-content: flex-start;"
|
||
@click="run(s.schedule_id)"
|
||
>
|
||
<template #icon><NIcon><TbPlayerPlay /></NIcon></template>
|
||
{{ s.name || s.schedule_id }}
|
||
</NButton>
|
||
<NTooltip>
|
||
<template #trigger>
|
||
<NSwitch :value="s.is_active" size="small" @update:value="v => toggleSchedule(s, v)" />
|
||
</template>
|
||
Учитывать вебхуки этого расписания
|
||
</NTooltip>
|
||
<NPopconfirm @positive-click="removeSchedule(s)">
|
||
<template #trigger>
|
||
<NButton text type="error" title="Удалить">
|
||
<template #icon><NIcon><TbTrash /></NIcon></template>
|
||
</NButton>
|
||
</template>
|
||
Удалить расписание «{{ s.name || s.schedule_id }}»?
|
||
</NPopconfirm>
|
||
</NFlex>
|
||
|
||
<NText v-if="!hasSchedules" depth="3" style="font-size: 12px;">
|
||
Расписаний нет. Добавьте ID расписания из репликатора ниже.
|
||
</NText>
|
||
|
||
<!-- Добавление -->
|
||
<NFlex align="center" :size="8" :wrap="false" style="margin-top: 4px;">
|
||
<NInput v-model:value="newId" placeholder="schedule_id" size="small" />
|
||
<NInput v-model:value="newName" placeholder="Имя" size="small" style="max-width: 150px;" />
|
||
<NButton size="small" :disabled="!newId.trim()" @click="addSchedule">
|
||
<template #icon><NIcon><TbPlus /></NIcon></template>
|
||
</NButton>
|
||
</NFlex>
|
||
</NFlex>
|
||
</SectionCard>
|
||
</NGi>
|
||
</NGrid>
|
||
|
||
<!-- История -->
|
||
<SectionCard :icon="TbRefresh" title="История репликаций" no-padding style="margin-top: 12px;">
|
||
<template #header-extra>
|
||
<NButton text size="small" @click="router.reload({ only: ['logs'] })">
|
||
<template #icon><NIcon><TbRefresh /></NIcon></template>
|
||
Обновить
|
||
</NButton>
|
||
</template>
|
||
<NDataTable
|
||
:columns="columns"
|
||
:data="logs"
|
||
:bordered="false"
|
||
size="small"
|
||
:max-height="520"
|
||
/>
|
||
</SectionCard>
|
||
|
||
</AppContainer>
|
||
</AppLayout>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.hint-error {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: var(--error-color);
|
||
font-size: 13px;
|
||
}
|
||
.hint-error code {
|
||
background: color-mix(in srgb, var(--error-color) 12%, transparent);
|
||
padding: 1px 5px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
:deep(.n-data-table-th) { background: transparent !important; }
|
||
:deep(.n-data-table) { background: transparent; }
|
||
:deep(.n-data-table-wrapper) { border-radius: 0; }
|
||
:deep(.n-data-table-th .n-data-table-th__title) { font-size: 12px; }
|
||
:deep(.n-data-table-td) { font-size: 13px; }
|
||
</style>
|