2026.06.8

This commit is contained in:
brusnitsyn
2026-06-18 17:45:41 +09:00
parent 698422e0ba
commit f163b95663
15 changed files with 695 additions and 20 deletions

View File

@@ -0,0 +1,258 @@
<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>