2026.06.8
This commit is contained in:
@@ -3,7 +3,7 @@ import AppLayout from "../../Layouts/AppLayout.vue"
|
||||
import { useAuthStore } from "../../Stores/auth.js"
|
||||
import { NEl, NFlex, NText, NTag, NAvatar } from 'naive-ui'
|
||||
import ActionTile from "../../Components/ActionTile.vue"
|
||||
import { TbUsers, TbChartBar, TbLayoutDashboard } from "vue-icons-plus/tb"
|
||||
import { TbUsers, TbChartBar, TbLayoutDashboard, TbRefresh } from "vue-icons-plus/tb"
|
||||
import { Link } from "@inertiajs/vue3"
|
||||
import { computed } from "vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
@@ -23,7 +23,7 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="flex flex-col justify-center items-center" style="min-height: calc(100vh - 48px);">
|
||||
<NFlex vertical :size="10" class="max-w-xl w-full" style="padding: 0 16px 24px;">
|
||||
<NFlex vertical :size="10" class="max-w-2xl w-full" style="padding: 0 16px 24px;">
|
||||
|
||||
<!-- Шапка -->
|
||||
<NEl class="panel-card rounded-2xl" style="padding: 18px 22px;">
|
||||
@@ -43,7 +43,7 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
|
||||
<NAvatar
|
||||
round :size="40"
|
||||
style="
|
||||
background: color-mix(in srgb, var(--primary-color) 22%, transparent);
|
||||
background: transparent;
|
||||
color: var(--primary-color);
|
||||
font-size: 15px; font-weight: 700;
|
||||
"
|
||||
@@ -78,6 +78,13 @@ const dividerColor = computed(() => themeVars.value.dividerColor)
|
||||
:tag="Link"
|
||||
href="/admin/metrics"
|
||||
/>
|
||||
<ActionTile
|
||||
:icon="TbRefresh"
|
||||
title="Репликация"
|
||||
description="Запуск синхронизации и история вебхуков"
|
||||
:tag="Link"
|
||||
href="/admin/replication"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Назад -->
|
||||
|
||||
258
resources/js/Pages/Admin/Replication/Index.vue
Normal file
258
resources/js/Pages/Admin/Replication/Index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user