Files
onboard/resources/js/Pages/Admin/Replication/Index.vue
brusnitsyn f163b95663 2026.06.8
2026-06-18 17:45:41 +09:00

259 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>