You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
244 lines
10 KiB
244 lines
10 KiB
<template>
|
|
<div>
|
|
<!-- 顶部:汇总 + 操作 -->
|
|
<div class="panel p-4 mb-5">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<div class="flex items-center gap-2 mr-2">
|
|
<h2 class="text-base font-bold text-slate-800">服务巡检</h2>
|
|
<span class="text-xs text-slate-400">
|
|
定时校验第三方服务配置并探测可用性 · 最近巡检 {{ lastRunText }}
|
|
</span>
|
|
</div>
|
|
<div class="ml-auto flex items-center gap-2">
|
|
<select v-model="filterStatus" class="select select-bordered select-sm w-32">
|
|
<option value="">全部状态</option>
|
|
<option value="error">仅不可用</option>
|
|
<option value="warn">仅有隐患</option>
|
|
<option value="ok">仅正常</option>
|
|
<option value="skip">仅已停用</option>
|
|
</select>
|
|
<input v-model="keyword" class="input input-bordered input-sm w-44" placeholder="搜索服务名/ID/服务商" />
|
|
<button class="btn btn-sm" :disabled="busy" @click="load">刷新</button>
|
|
<button class="btn btn-primary btn-sm text-white" :disabled="busy" @click="runAll">
|
|
{{ busy ? '巡检中…' : '立即巡检' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-3 mt-4">
|
|
<div v-for="s in summaryCards" :key="s.key" class="sum-card" :class="{ on: filterStatus === s.key }"
|
|
@click="filterStatus = filterStatus === s.key ? '' : s.key">
|
|
<span class="sum-ico" :style="{ background: s.bg, color: s.color }">{{ s.ico }}</span>
|
|
<div>
|
|
<div class="sum-lbl">{{ s.label }}</div>
|
|
<div class="sum-val" :style="{ color: s.color }">{{ s.value }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 巡检明细 -->
|
|
<div class="panel">
|
|
<div class="panel-head">
|
|
<span class="font-semibold text-slate-700 text-sm">巡检明细</span>
|
|
<span class="text-xs text-slate-400">共 {{ filtered.length }} 份配置</span>
|
|
<span class="ml-auto text-xs text-slate-400">
|
|
一个服务在不同区域可能配着不同凭据,故按「服务 × 区域」分别巡检
|
|
</span>
|
|
</div>
|
|
|
|
<div class="overflow-x-auto">
|
|
<table class="table w-full">
|
|
<thead>
|
|
<tr>
|
|
<th>状态</th>
|
|
<th>服务</th>
|
|
<th>作用域 / 区域</th>
|
|
<th>类别</th>
|
|
<th>探测方式</th>
|
|
<th>巡检结论</th>
|
|
<th class="text-right">耗时</th>
|
|
<th>检查时间</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="h in filtered" :key="rowKey(h)">
|
|
<td>
|
|
<span class="st-badge" :style="{ background: meta(h.status).bg, color: meta(h.status).color }">
|
|
{{ meta(h.status).label }}
|
|
</span>
|
|
<div v-if="h.fail_streak > 1" class="streak">连续 {{ h.fail_streak }} 次</div>
|
|
</td>
|
|
<td>
|
|
<div class="font-semibold text-slate-700">{{ h.name || h.svc_id }}</div>
|
|
<div class="text-xs text-slate-400 font-mono">
|
|
{{ h.svc_id }}<span v-if="h.provider"> · {{ h.provider }}</span>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span class="chip chip-scope">{{ h.app_name || '全局默认' }}</span>
|
|
<span v-if="h.region" class="chip chip-region">{{ regionLabel(h.region) }}</span>
|
|
<span v-else class="chip">默认配置</span>
|
|
</td>
|
|
<td>
|
|
<span v-for="c in cats(h.categories)" :key="c" class="chip">{{ catLabel(c) }}</span>
|
|
<span v-if="!cats(h.categories).length" class="text-xs text-slate-300">—</span>
|
|
</td>
|
|
<td>
|
|
<span class="chip" :class="'pk-' + h.probe_kind" :title="probeMeta(h.probe_kind).hint">
|
|
{{ probeMeta(h.probe_kind).label }}
|
|
</span>
|
|
</td>
|
|
<td class="reason-cell">
|
|
<div :class="h.status === 'error' ? 'text-rose-600' : 'text-slate-500'">{{ reason(h) || '—' }}</div>
|
|
<div v-if="issuesOf(h).length" class="issue-list">
|
|
<div v-for="(i, idx) in issuesOf(h)" :key="idx" class="issue">· {{ i }}</div>
|
|
</div>
|
|
</td>
|
|
<td class="text-right text-xs text-slate-400">
|
|
{{ h.probe_kind === 'none' ? '—' : h.latency_ms + ' ms' }}
|
|
</td>
|
|
<td class="text-xs text-slate-400">{{ timeText(h.checked_at) }}</td>
|
|
<td>
|
|
<button class="btn btn-xs" :disabled="busy" @click="runOne(h)">重测</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="!filtered.length">
|
|
<td colspan="9" class="text-center text-slate-400 py-8">
|
|
{{ rows.length ? '当前筛选下没有匹配的记录' : '暂无巡检记录,点击右上角「立即巡检」执行一次' }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
import {
|
|
REGION_LABEL, CAT_LABEL, STATUS_META, PROBE_META,
|
|
healthReason, healthLastRunText, parseCats, type SvcHealth,
|
|
} from '~/utils/svchealth'
|
|
|
|
definePageMeta({ title: '服务巡检' })
|
|
|
|
const { webApi } = useApi()
|
|
const toast = useToast()
|
|
|
|
const rows = ref<SvcHealth[]>([])
|
|
const summary = ref({ total: 0, ok: 0, warn: 0, error: 0, skip: 0 })
|
|
const lastRun = ref(0)
|
|
const busy = ref(false)
|
|
const filterStatus = ref('')
|
|
const keyword = ref('')
|
|
|
|
const lastRunText = computed(() => healthLastRunText(lastRun.value))
|
|
|
|
const summaryCards = computed(() => [
|
|
{ key: 'error', label: '不可用', value: summary.value.error, ico: '🚨', color: '#be123c', bg: '#ffe4e6' },
|
|
{ key: 'warn', label: '有隐患', value: summary.value.warn, ico: '⚠️', color: '#b45309', bg: '#fef3c7' },
|
|
{ key: 'ok', label: '正常', value: summary.value.ok, ico: '✅', color: '#047857', bg: '#d1fae5' },
|
|
{ key: 'skip', label: '已停用', value: summary.value.skip, ico: '⏸️', color: '#64748b', bg: '#f1f5f9' },
|
|
{ key: '', label: '巡检配置总数', value: summary.value.total, ico: '📋', color: '#334155', bg: '#e2e8f0' },
|
|
])
|
|
|
|
const filtered = computed(() => {
|
|
const kw = keyword.value.trim().toLowerCase()
|
|
return rows.value.filter((h) => {
|
|
if (filterStatus.value && h.status !== filterStatus.value) return false
|
|
if (!kw) return true
|
|
return `${h.name} ${h.svc_id} ${h.provider} ${h.app_name}`.toLowerCase().includes(kw)
|
|
})
|
|
})
|
|
|
|
function rowKey(h: SvcHealth) { return `${h.app_name}/${h.svc_id}/${h.region}` }
|
|
function meta(s: string) { return STATUS_META[s] || STATUS_META.skip }
|
|
function probeMeta(k: string) { return PROBE_META[k] || PROBE_META.none }
|
|
function regionLabel(r: number) { return REGION_LABEL[r] || `区域 ${r}` }
|
|
function catLabel(c: number) { return CAT_LABEL[c] || `类别 ${c}` }
|
|
function cats(s: string) { return parseCats(s) }
|
|
function reason(h: SvcHealth) { return healthReason(h) }
|
|
function timeText(ms: number) { return healthLastRunText(ms) }
|
|
|
|
// 摘要行已经展示了最要紧的一条原因,这里补充列出其余配置问题,避免重复刷屏。
|
|
function issuesOf(h: SvcHealth): string[] {
|
|
const all = (h.config_issues || []).map((s) => s.replace('[致命] ', ''))
|
|
const shown = reason(h)
|
|
return all.filter((s) => s !== shown && !shown.includes(s))
|
|
}
|
|
|
|
async function load() {
|
|
try {
|
|
const res = await webApi<{
|
|
items: SvcHealth[]
|
|
summary: typeof summary.value
|
|
last_run: number
|
|
running: boolean
|
|
}>('getsvchealth', {})
|
|
rows.value = res.items || []
|
|
summary.value = res.summary || { total: 0, ok: 0, warn: 0, error: 0, skip: 0 }
|
|
lastRun.value = res.last_run || 0
|
|
} catch (e: any) {
|
|
toast.error(e?.message || '加载巡检结果失败')
|
|
}
|
|
}
|
|
|
|
async function runAll() {
|
|
busy.value = true
|
|
try {
|
|
const res = await webApi<{ count: number }>('runsvcinspect', {})
|
|
toast.success(`巡检完成,已检查 ${res.count} 份配置`)
|
|
await load()
|
|
} catch (e: any) {
|
|
toast.error(e?.message || '巡检失败')
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
async function runOne(h: SvcHealth) {
|
|
busy.value = true
|
|
try {
|
|
await webApi('runsvcinspect', { app_name: h.app_name, svc_id: h.svc_id })
|
|
toast.success(`已重新检测:${h.name || h.svc_id}`)
|
|
await load()
|
|
} catch (e: any) {
|
|
toast.error(e?.message || '检测失败')
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.panel { background:#fff; border:1px solid #f1f5f9; border-radius:16px; box-shadow:0 1px 2px rgba(0,0,0,0.04); overflow:hidden; }
|
|
.panel-head { padding:12px 18px; display:flex; align-items:center; gap:10px; border-bottom:1px solid #f8fafc; flex-wrap:wrap; }
|
|
|
|
.sum-card { display:flex; align-items:center; gap:11px; background:#f8fafc; border:1px solid #eef2f7; border-radius:12px; padding:10px 13px; cursor:pointer; transition:all .15s; }
|
|
.sum-card:hover { border-color:#cbd5e1; }
|
|
.sum-card.on { border-color:#0f172a; background:#fff; box-shadow:0 0 0 1px #0f172a inset; }
|
|
.sum-ico { width:30px; height:30px; border-radius:9px; display:flex; align-items:center; justify-content:center; flex-shrink:0; font-size:14px; }
|
|
.sum-lbl { font-size:11.5px; color:#94a3b8; line-height:1.2; }
|
|
.sum-val { font-size:19px; font-weight:700; line-height:1.3; letter-spacing:-0.01em; }
|
|
|
|
.st-badge { font-size:11px; font-weight:700; padding:2px 9px; border-radius:999px; white-space:nowrap; }
|
|
.streak { font-size:10.5px; color:#be123c; margin-top:3px; font-weight:600; }
|
|
|
|
.chip { display:inline-block; font-size:11px; padding:1px 7px; border-radius:999px; background:#f1f5f9; color:#64748b; margin-right:4px; white-space:nowrap; }
|
|
.chip-scope { background:#e0e7ff; color:#4338ca; }
|
|
.chip-region { background:#cffafe; color:#0e7490; }
|
|
.pk-credential { background:#d1fae5; color:#047857; }
|
|
.pk-reachable { background:#fef3c7; color:#b45309; }
|
|
.pk-none { background:#f1f5f9; color:#94a3b8; }
|
|
|
|
.reason-cell { max-width:420px; font-size:12px; line-height:1.5; }
|
|
.issue-list { margin-top:3px; }
|
|
.issue { font-size:11px; color:#94a3b8; }
|
|
</style>
|
|
|