296 lines
9.7 KiB
Vue
296 lines
9.7 KiB
Vue
|
|
<template>
|
||
|
|
<AppLayout>
|
||
|
|
<TablePageLayout>
|
||
|
|
<template #filters>
|
||
|
|
<MonitorFiltersBar
|
||
|
|
v-model:search="searchQuery"
|
||
|
|
v-model:provider="providerFilter"
|
||
|
|
v-model:enabled="enabledFilter"
|
||
|
|
:loading="loading"
|
||
|
|
@reload="reload"
|
||
|
|
@create="openCreateDialog"
|
||
|
|
@search-input="handleSearch"
|
||
|
|
/>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<template #table>
|
||
|
|
<DataTable :columns="columns" :data="monitors" :loading="loading">
|
||
|
|
<template #cell-name="{ row, value }">
|
||
|
|
<div class="flex items-center gap-1.5">
|
||
|
|
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||
|
|
<HelpTooltip v-if="row.api_key_decrypt_failed" :content="t('admin.channelMonitor.apiKeyDecryptFailed')">
|
||
|
|
<Icon name="exclamationTriangle" size="sm" class="text-red-500" />
|
||
|
|
</HelpTooltip>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<template #cell-provider="{ row }">
|
||
|
|
<span class="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium" :class="providerBadgeClass(row.provider)">
|
||
|
|
{{ providerLabel(row.provider) }}
|
||
|
|
</span>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<template #cell-primary_model="{ row }">
|
||
|
|
<MonitorPrimaryModelCell :row="row" />
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<template #cell-availability_7d="{ row }">
|
||
|
|
<span class="text-sm text-gray-900 dark:text-gray-100">{{ formatAvailability(row) }}</span>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<template #cell-latency="{ row }">
|
||
|
|
<span class="text-sm text-gray-900 dark:text-gray-100">{{ formatLatency(row.primary_latency_ms) }}</span>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<template #cell-enabled="{ row }">
|
||
|
|
<Toggle :modelValue="row.enabled" @update:modelValue="toggleEnabled(row)" />
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<template #cell-actions="{ row }">
|
||
|
|
<MonitorActionsCell
|
||
|
|
:row="row"
|
||
|
|
:running="runningId === row.id"
|
||
|
|
@run="handleRunNow"
|
||
|
|
@edit="openEditDialog"
|
||
|
|
@delete="handleDelete"
|
||
|
|
/>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<template #empty>
|
||
|
|
<EmptyState
|
||
|
|
:title="t('admin.channelMonitor.noMonitorsYet')"
|
||
|
|
:description="t('admin.channelMonitor.createFirstMonitor')"
|
||
|
|
:action-text="t('admin.channelMonitor.createButton')"
|
||
|
|
@action="openCreateDialog"
|
||
|
|
/>
|
||
|
|
</template>
|
||
|
|
</DataTable>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<template #pagination>
|
||
|
|
<Pagination
|
||
|
|
v-if="pagination.total > 0"
|
||
|
|
:page="pagination.page"
|
||
|
|
:total="pagination.total"
|
||
|
|
:page-size="pagination.page_size"
|
||
|
|
@update:page="onPageChange"
|
||
|
|
@update:pageSize="onPageSizeChange"
|
||
|
|
/>
|
||
|
|
</template>
|
||
|
|
</TablePageLayout>
|
||
|
|
|
||
|
|
<MonitorFormDialog
|
||
|
|
:show="showDialog"
|
||
|
|
:monitor="editing"
|
||
|
|
@close="closeDialog"
|
||
|
|
@saved="reload"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<MonitorRunResultDialog
|
||
|
|
:show="showRunResult"
|
||
|
|
:results="runResults"
|
||
|
|
@close="showRunResult = false"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<ConfirmDialog
|
||
|
|
:show="showDeleteDialog"
|
||
|
|
:title="t('common.delete')"
|
||
|
|
:message="deleteConfirmMessage"
|
||
|
|
:confirm-text="t('common.delete')"
|
||
|
|
:cancel-text="t('common.cancel')"
|
||
|
|
:danger="true"
|
||
|
|
@confirm="confirmDelete"
|
||
|
|
@cancel="showDeleteDialog = false"
|
||
|
|
/>
|
||
|
|
</AppLayout>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup lang="ts">
|
||
|
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||
|
|
import { useI18n } from 'vue-i18n'
|
||
|
|
import { useAppStore } from '@/stores/app'
|
||
|
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
||
|
|
import { adminAPI } from '@/api/admin'
|
||
|
|
import type {
|
||
|
|
ChannelMonitor,
|
||
|
|
CheckResult,
|
||
|
|
ListParams,
|
||
|
|
Provider,
|
||
|
|
} from '@/api/admin/channelMonitor'
|
||
|
|
import type { Column } from '@/components/common/types'
|
||
|
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||
|
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||
|
|
import DataTable from '@/components/common/DataTable.vue'
|
||
|
|
import Pagination from '@/components/common/Pagination.vue'
|
||
|
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||
|
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||
|
|
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||
|
|
import Icon from '@/components/icons/Icon.vue'
|
||
|
|
import Toggle from '@/components/common/Toggle.vue'
|
||
|
|
import MonitorFiltersBar from '@/components/admin/monitor/MonitorFiltersBar.vue'
|
||
|
|
import MonitorFormDialog from '@/components/admin/monitor/MonitorFormDialog.vue'
|
||
|
|
import MonitorRunResultDialog from '@/components/admin/monitor/MonitorRunResultDialog.vue'
|
||
|
|
import MonitorPrimaryModelCell from '@/components/admin/monitor/MonitorPrimaryModelCell.vue'
|
||
|
|
import MonitorActionsCell from '@/components/admin/monitor/MonitorActionsCell.vue'
|
||
|
|
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||
|
|
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
||
|
|
|
||
|
|
const { t } = useI18n()
|
||
|
|
const appStore = useAppStore()
|
||
|
|
const {
|
||
|
|
providerLabel,
|
||
|
|
providerBadgeClass,
|
||
|
|
formatLatency,
|
||
|
|
formatAvailability,
|
||
|
|
} = useChannelMonitorFormat()
|
||
|
|
|
||
|
|
const monitors = ref<ChannelMonitor[]>([])
|
||
|
|
const loading = ref(false)
|
||
|
|
const runningId = ref<number | null>(null)
|
||
|
|
const searchQuery = ref('')
|
||
|
|
const providerFilter = ref<Provider | ''>('')
|
||
|
|
const enabledFilter = ref<'' | 'true' | 'false'>('')
|
||
|
|
const pagination = reactive({ page: 1, page_size: getPersistedPageSize(), total: 0 })
|
||
|
|
|
||
|
|
const showDialog = ref(false)
|
||
|
|
const editing = ref<ChannelMonitor | null>(null)
|
||
|
|
const showDeleteDialog = ref(false)
|
||
|
|
const deleting = ref<ChannelMonitor | null>(null)
|
||
|
|
const showRunResult = ref(false)
|
||
|
|
const runResults = ref<CheckResult[]>([])
|
||
|
|
|
||
|
|
let abortController: AbortController | null = null
|
||
|
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||
|
|
|
||
|
|
const columns = computed<Column[]>(() => [
|
||
|
|
{ key: 'name', label: t('admin.channelMonitor.columns.name'), sortable: false },
|
||
|
|
{ key: 'provider', label: t('admin.channelMonitor.columns.provider'), sortable: false },
|
||
|
|
{ key: 'primary_model', label: t('admin.channelMonitor.columns.primaryModel'), sortable: false },
|
||
|
|
{ key: 'availability_7d', label: t('admin.channelMonitor.columns.availability7d'), sortable: false },
|
||
|
|
{ key: 'latency', label: t('admin.channelMonitor.columns.latency'), sortable: false },
|
||
|
|
{ key: 'enabled', label: t('admin.channelMonitor.columns.enabled'), sortable: false },
|
||
|
|
{ key: 'actions', label: t('admin.channelMonitor.columns.actions'), sortable: false },
|
||
|
|
])
|
||
|
|
|
||
|
|
const deleteConfirmMessage = computed(() => {
|
||
|
|
const name = deleting.value?.name || ''
|
||
|
|
return t('admin.channelMonitor.deleteConfirm', { name })
|
||
|
|
})
|
||
|
|
|
||
|
|
async function reload() {
|
||
|
|
if (abortController) abortController.abort()
|
||
|
|
const ctrl = new AbortController()
|
||
|
|
abortController = ctrl
|
||
|
|
loading.value = true
|
||
|
|
try {
|
||
|
|
const params: ListParams = {
|
||
|
|
page: pagination.page,
|
||
|
|
page_size: pagination.page_size,
|
||
|
|
}
|
||
|
|
if (providerFilter.value) params.provider = providerFilter.value
|
||
|
|
if (enabledFilter.value === 'true') params.enabled = true
|
||
|
|
if (enabledFilter.value === 'false') params.enabled = false
|
||
|
|
if (searchQuery.value.trim()) params.search = searchQuery.value.trim()
|
||
|
|
|
||
|
|
const res = await adminAPI.channelMonitor.list(params, { signal: ctrl.signal })
|
||
|
|
if (ctrl.signal.aborted || abortController !== ctrl) return
|
||
|
|
monitors.value = res.items || []
|
||
|
|
pagination.total = res.total
|
||
|
|
} catch (err: unknown) {
|
||
|
|
const e = err as { name?: string; code?: string }
|
||
|
|
if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
|
||
|
|
appStore.showError(extractApiErrorMessage(err, t('admin.channelMonitor.loadError')))
|
||
|
|
} finally {
|
||
|
|
if (abortController === ctrl) {
|
||
|
|
loading.value = false
|
||
|
|
abortController = null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleSearch() {
|
||
|
|
if (searchTimeout) clearTimeout(searchTimeout)
|
||
|
|
searchTimeout = setTimeout(() => {
|
||
|
|
pagination.page = 1
|
||
|
|
reload()
|
||
|
|
}, 300)
|
||
|
|
}
|
||
|
|
|
||
|
|
function onPageChange(page: number) {
|
||
|
|
pagination.page = page
|
||
|
|
reload()
|
||
|
|
}
|
||
|
|
|
||
|
|
function onPageSizeChange(size: number) {
|
||
|
|
pagination.page_size = size
|
||
|
|
pagination.page = 1
|
||
|
|
reload()
|
||
|
|
}
|
||
|
|
|
||
|
|
function openCreateDialog() {
|
||
|
|
editing.value = null
|
||
|
|
showDialog.value = true
|
||
|
|
}
|
||
|
|
|
||
|
|
function openEditDialog(row: ChannelMonitor) {
|
||
|
|
editing.value = row
|
||
|
|
showDialog.value = true
|
||
|
|
}
|
||
|
|
|
||
|
|
function closeDialog() {
|
||
|
|
showDialog.value = false
|
||
|
|
editing.value = null
|
||
|
|
}
|
||
|
|
|
||
|
|
async function toggleEnabled(row: ChannelMonitor) {
|
||
|
|
const next = !row.enabled
|
||
|
|
try {
|
||
|
|
await adminAPI.channelMonitor.update(row.id, { enabled: next })
|
||
|
|
row.enabled = next
|
||
|
|
} catch (err: unknown) {
|
||
|
|
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleRunNow(row: ChannelMonitor) {
|
||
|
|
if (runningId.value != null) return
|
||
|
|
runningId.value = row.id
|
||
|
|
try {
|
||
|
|
const res = await adminAPI.channelMonitor.runNow(row.id)
|
||
|
|
runResults.value = res.results || []
|
||
|
|
showRunResult.value = true
|
||
|
|
appStore.showSuccess(t('admin.channelMonitor.runSuccess'))
|
||
|
|
// Refresh row to get latest status from backend
|
||
|
|
void reload()
|
||
|
|
} catch (err: unknown) {
|
||
|
|
appStore.showError(extractApiErrorMessage(err, t('admin.channelMonitor.runFailed')))
|
||
|
|
} finally {
|
||
|
|
runningId.value = null
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleDelete(row: ChannelMonitor) {
|
||
|
|
deleting.value = row
|
||
|
|
showDeleteDialog.value = true
|
||
|
|
}
|
||
|
|
|
||
|
|
async function confirmDelete() {
|
||
|
|
if (!deleting.value) return
|
||
|
|
try {
|
||
|
|
await adminAPI.channelMonitor.del(deleting.value.id)
|
||
|
|
appStore.showSuccess(t('admin.channelMonitor.deleteSuccess'))
|
||
|
|
showDeleteDialog.value = false
|
||
|
|
deleting.value = null
|
||
|
|
reload()
|
||
|
|
} catch (err: unknown) {
|
||
|
|
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(reload)
|
||
|
|
onUnmounted(() => {
|
||
|
|
if (searchTimeout) clearTimeout(searchTimeout)
|
||
|
|
abortController?.abort()
|
||
|
|
})
|
||
|
|
</script>
|