任务参数、状态和结果持久化到 MySQL。 接入现有 outbox;配置 BackgroundJobs__Transport=RabbitMq 时使用 RabbitMQ 队列 exam.arrangement,否则使用 InMemory worker。 服务重启后可恢复未完成任务。 前端显示排队/执行/完成/失败状态,刷新页面可恢复正在执行的任务。 编排期间禁止修改、删除或发布对应计划。 补考原有“一键生成”保留,并与编排任务互斥。 运维后台增加“考试与补考编排”失败任务筛选。
1029 lines
28 KiB
Vue
1029 lines
28 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import {
|
||
CircleCheckFilled,
|
||
Refresh,
|
||
WarningFilled,
|
||
} from '@element-plus/icons-vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import http, { apiErrorMessage } from '../api/http'
|
||
|
||
type HealthStatus = 'healthy' | 'warning' | 'unhealthy'
|
||
|
||
interface HealthComponent {
|
||
key: string
|
||
label: string
|
||
status: HealthStatus
|
||
backend: string
|
||
latencyMilliseconds?: number
|
||
detail: string
|
||
}
|
||
|
||
interface OperationalAlert {
|
||
id: string
|
||
severity: 'critical' | 'warning'
|
||
source: string
|
||
title: string
|
||
detail: string
|
||
occurredAt: string
|
||
}
|
||
|
||
interface BackupArtifact {
|
||
id: string
|
||
fileName: string
|
||
provider: string
|
||
createdAt: string
|
||
sizeBytes: number
|
||
sha256: string
|
||
note?: string
|
||
lastDrillAt?: string
|
||
lastDrillSucceeded?: boolean
|
||
lastDrillDetail?: string
|
||
lastDrillDurationMilliseconds?: number
|
||
}
|
||
|
||
interface Summary {
|
||
generatedAt: string
|
||
health: {
|
||
checkedAt: string
|
||
overallStatus: HealthStatus
|
||
components: HealthComponent[]
|
||
backlog?: {
|
||
pending: number
|
||
publishing: number
|
||
published: number
|
||
processing: number
|
||
expiredLeases: number
|
||
oldestUnfinishedAgeSeconds?: number
|
||
}
|
||
}
|
||
counters: {
|
||
auditEvents24Hours: number
|
||
serverErrors24Hours: number
|
||
failedJobs7Days: number
|
||
criticalAlerts: number
|
||
}
|
||
alerts: OperationalAlert[]
|
||
latestBackup?: BackupArtifact
|
||
}
|
||
|
||
interface AuditLog {
|
||
id: string
|
||
userName?: string
|
||
method: string
|
||
path: string
|
||
statusCode: number
|
||
ipAddress?: string
|
||
createdAt: string
|
||
}
|
||
|
||
interface FailedJob {
|
||
id: string
|
||
kind: string
|
||
kindLabel: string
|
||
context: string
|
||
errorMessage: string
|
||
createdAt: string
|
||
startedAt?: string
|
||
completedAt?: string
|
||
processingAttempts: number
|
||
}
|
||
|
||
interface PageResult<T> {
|
||
items: T[]
|
||
total: number
|
||
page: number
|
||
pageSize: number
|
||
}
|
||
|
||
const loading = ref(true)
|
||
const refreshing = ref(false)
|
||
const backupBusy = ref(false)
|
||
const summary = ref<Summary>()
|
||
const backups = ref<BackupArtifact[]>([])
|
||
const activeLedger = ref<'audit' | 'jobs'>('audit')
|
||
|
||
const auditLoading = ref(false)
|
||
const auditRows = ref<AuditLog[]>([])
|
||
const auditTotal = ref(0)
|
||
const auditFilter = reactive({
|
||
page: 1,
|
||
pageSize: 20,
|
||
method: '',
|
||
statusCode: '',
|
||
userName: '',
|
||
path: '',
|
||
range: [] as Date[],
|
||
})
|
||
|
||
const jobsLoading = ref(false)
|
||
const jobRows = ref<FailedJob[]>([])
|
||
const jobTotal = ref(0)
|
||
const jobFilter = reactive({
|
||
page: 1,
|
||
pageSize: 20,
|
||
kind: '',
|
||
range: [] as Date[],
|
||
})
|
||
|
||
const healthTrack = computed(() => {
|
||
const components = summary.value?.health.components ?? []
|
||
const backup = summary.value?.latestBackup
|
||
return [
|
||
...components,
|
||
{
|
||
key: 'backup',
|
||
label: '可恢复备份',
|
||
backend: backup?.provider?.toLowerCase() ?? 'none',
|
||
status: !backup
|
||
? 'unhealthy'
|
||
: backup.lastDrillSucceeded === true
|
||
? 'healthy'
|
||
: backup.lastDrillSucceeded === false
|
||
? 'unhealthy'
|
||
: 'warning',
|
||
detail: !backup
|
||
? '尚未创建备份。'
|
||
: backup.lastDrillSucceeded === true
|
||
? '最近备份已通过隔离恢复演练。'
|
||
: backup.lastDrillSucceeded === false
|
||
? backup.lastDrillDetail ?? '恢复演练失败。'
|
||
: '备份存在,但尚未执行恢复演练。',
|
||
} as HealthComponent,
|
||
]
|
||
})
|
||
|
||
const overallStatus = computed<HealthStatus>(() => {
|
||
if (!summary.value) return 'warning'
|
||
if (
|
||
summary.value.health.overallStatus === 'unhealthy' ||
|
||
summary.value.alerts.some((alert) => alert.severity === 'critical')
|
||
) return 'unhealthy'
|
||
if (
|
||
summary.value.health.overallStatus === 'warning' ||
|
||
summary.value.alerts.length > 0
|
||
) return 'warning'
|
||
return 'healthy'
|
||
})
|
||
|
||
function formatTime(value?: string) {
|
||
if (!value) return '—'
|
||
return new Date(value).toLocaleString('zh-CN', {
|
||
hour12: false,
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
})
|
||
}
|
||
|
||
function formatBytes(value: number) {
|
||
if (value < 1024) return `${value} B`
|
||
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`
|
||
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MB`
|
||
return `${(value / 1024 ** 3).toFixed(2)} GB`
|
||
}
|
||
|
||
function healthLabel(status: HealthStatus) {
|
||
return status === 'healthy' ? '正常' : status === 'warning' ? '注意' : '异常'
|
||
}
|
||
|
||
function statusClass(status: HealthStatus) {
|
||
return `is-${status}`
|
||
}
|
||
|
||
function httpStatusType(status: number) {
|
||
if (status >= 500) return 'danger'
|
||
if (status >= 400) return 'warning'
|
||
if (status >= 300) return 'info'
|
||
return 'success'
|
||
}
|
||
|
||
async function loadSummary() {
|
||
const [{ data: summaryData }, { data: backupData }] = await Promise.all([
|
||
http.get<Summary>('/operations/summary'),
|
||
http.get<BackupArtifact[]>('/operations/backups'),
|
||
])
|
||
summary.value = summaryData
|
||
backups.value = backupData
|
||
}
|
||
|
||
async function loadAudit() {
|
||
auditLoading.value = true
|
||
try {
|
||
const params: Record<string, string | number> = {
|
||
page: auditFilter.page,
|
||
pageSize: auditFilter.pageSize,
|
||
}
|
||
if (auditFilter.method) params.method = auditFilter.method
|
||
if (auditFilter.statusCode) params.statusCode = Number(auditFilter.statusCode)
|
||
if (auditFilter.userName.trim()) params.userName = auditFilter.userName.trim()
|
||
if (auditFilter.path.trim()) params.path = auditFilter.path.trim()
|
||
if (auditFilter.range.length === 2) {
|
||
params.from = auditFilter.range[0].toISOString()
|
||
params.to = auditFilter.range[1].toISOString()
|
||
}
|
||
const { data } = await http.get<PageResult<AuditLog>>(
|
||
'/operations/audit-logs',
|
||
{ params },
|
||
)
|
||
auditRows.value = data.items
|
||
auditTotal.value = data.total
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
} finally {
|
||
auditLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadJobs() {
|
||
jobsLoading.value = true
|
||
try {
|
||
const params: Record<string, string | number> = {
|
||
page: jobFilter.page,
|
||
pageSize: jobFilter.pageSize,
|
||
}
|
||
if (jobFilter.kind) params.kind = jobFilter.kind
|
||
if (jobFilter.range.length === 2) {
|
||
params.from = jobFilter.range[0].toISOString()
|
||
params.to = jobFilter.range[1].toISOString()
|
||
}
|
||
const { data } = await http.get<PageResult<FailedJob>>(
|
||
'/operations/failed-jobs',
|
||
{ params },
|
||
)
|
||
jobRows.value = data.items
|
||
jobTotal.value = data.total
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
} finally {
|
||
jobsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function refreshAll() {
|
||
refreshing.value = true
|
||
try {
|
||
await Promise.all([loadSummary(), loadAudit(), loadJobs()])
|
||
} catch (error) {
|
||
ElMessage.error(apiErrorMessage(error))
|
||
} finally {
|
||
refreshing.value = false
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function searchAudit() {
|
||
auditFilter.page = 1
|
||
loadAudit()
|
||
}
|
||
|
||
function resetAudit() {
|
||
Object.assign(auditFilter, {
|
||
page: 1,
|
||
method: '',
|
||
statusCode: '',
|
||
userName: '',
|
||
path: '',
|
||
range: [],
|
||
})
|
||
loadAudit()
|
||
}
|
||
|
||
function searchJobs() {
|
||
jobFilter.page = 1
|
||
loadJobs()
|
||
}
|
||
|
||
async function createBackup() {
|
||
try {
|
||
const { value } = await ElMessageBox.prompt(
|
||
'可填写本次备份的变更窗口、发布版本或值班说明。',
|
||
'创建数据库备份',
|
||
{
|
||
confirmButtonText: '开始备份',
|
||
cancelButtonText: '取消',
|
||
inputPlaceholder: '选填,最多 200 字',
|
||
inputValidator: (text) => text.length <= 200 || '说明不能超过 200 字。',
|
||
},
|
||
)
|
||
backupBusy.value = true
|
||
await http.post(
|
||
'/operations/backups',
|
||
{ note: value?.trim() || null },
|
||
{ timeout: 30 * 60 * 1000 },
|
||
)
|
||
ElMessage.success('数据库备份已创建。')
|
||
await loadSummary()
|
||
} catch (error) {
|
||
if (error === 'cancel' || error === 'close') return
|
||
ElMessage.error(apiErrorMessage(error))
|
||
} finally {
|
||
backupBusy.value = false
|
||
}
|
||
}
|
||
|
||
async function runDrill(backup: BackupArtifact) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`将把“${backup.fileName}”恢复到隔离数据库并执行完整性检查。当前业务数据库不会被覆盖。`,
|
||
'运行恢复演练',
|
||
{
|
||
type: 'warning',
|
||
confirmButtonText: '开始隔离演练',
|
||
cancelButtonText: '取消',
|
||
},
|
||
)
|
||
backupBusy.value = true
|
||
const { data } = await http.post(
|
||
`/operations/backups/${backup.id}/restore-drill`,
|
||
{ confirmation: 'RESTORE_DRILL' },
|
||
{ timeout: 30 * 60 * 1000 },
|
||
)
|
||
if (data.succeeded) ElMessage.success(data.detail)
|
||
else ElMessage.error(data.detail)
|
||
await loadSummary()
|
||
} catch (error) {
|
||
if (error === 'cancel' || error === 'close') return
|
||
ElMessage.error(apiErrorMessage(error))
|
||
} finally {
|
||
backupBusy.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(refreshAll)
|
||
</script>
|
||
|
||
<template>
|
||
<main v-loading="loading" class="operations-console">
|
||
<header class="console-heading">
|
||
<div>
|
||
<span class="console-kicker">SYSTEM WATCH / {{ formatTime(summary?.generatedAt) }}</span>
|
||
<h2>运维与审计控制台</h2>
|
||
<p>检查系统是否可用、任务为何失败,以及备份能否真正恢复。</p>
|
||
</div>
|
||
<el-button :icon="Refresh" :loading="refreshing" @click="refreshAll">
|
||
刷新状态
|
||
</el-button>
|
||
</header>
|
||
|
||
<section class="signal-board" aria-label="系统健康链路">
|
||
<div class="signal-summary">
|
||
<span>当前值守结论</span>
|
||
<strong :class="statusClass(overallStatus)">
|
||
{{ healthLabel(overallStatus) }}
|
||
</strong>
|
||
<p>
|
||
{{ summary?.counters.criticalAlerts ?? 0 }} 个严重告警 ·
|
||
{{ summary?.counters.auditEvents24Hours ?? 0 }} 次写操作 / 24h
|
||
</p>
|
||
</div>
|
||
<div class="signal-track">
|
||
<article
|
||
v-for="(component, index) in healthTrack"
|
||
:key="component.key"
|
||
:class="statusClass(component.status)"
|
||
class="signal-node"
|
||
>
|
||
<div class="signal-mark">
|
||
<span>{{ index + 1 }}</span>
|
||
</div>
|
||
<div>
|
||
<small>{{ component.backend }}</small>
|
||
<h3>{{ component.label }}</h3>
|
||
<p>{{ component.detail }}</p>
|
||
</div>
|
||
<b>
|
||
{{ healthLabel(component.status) }}
|
||
<template v-if="component.latencyMilliseconds != null">
|
||
· {{ component.latencyMilliseconds }} ms
|
||
</template>
|
||
</b>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="console-split">
|
||
<article class="alerts-panel">
|
||
<div class="panel-heading">
|
||
<div>
|
||
<span>EXCEPTION QUEUE</span>
|
||
<h3>异常告警</h3>
|
||
</div>
|
||
<em>{{ summary?.alerts.length ?? 0 }}</em>
|
||
</div>
|
||
<div v-if="summary?.alerts.length" class="alert-list">
|
||
<div
|
||
v-for="alert in summary.alerts"
|
||
:key="alert.id"
|
||
:class="`is-${alert.severity}`"
|
||
class="alert-row"
|
||
>
|
||
<el-icon>
|
||
<WarningFilled v-if="alert.severity === 'critical'" />
|
||
<CircleCheckFilled v-else />
|
||
</el-icon>
|
||
<div>
|
||
<strong>{{ alert.title }}</strong>
|
||
<p>{{ alert.detail }}</p>
|
||
<small>{{ alert.source.toUpperCase() }} · {{ formatTime(alert.occurredAt) }}</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="clear-state">
|
||
<el-icon><CircleCheckFilled /></el-icon>
|
||
<div>
|
||
<strong>当前没有异常告警</strong>
|
||
<p>健康探针、失败任务与备份时效均在允许范围内。</p>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<article class="backup-panel">
|
||
<div class="panel-heading">
|
||
<div>
|
||
<span>RECOVERY READINESS</span>
|
||
<h3>备份与恢复演练</h3>
|
||
</div>
|
||
<el-button
|
||
type="primary"
|
||
:loading="backupBusy"
|
||
@click="createBackup"
|
||
>
|
||
创建备份
|
||
</el-button>
|
||
</div>
|
||
<p class="backup-rule">
|
||
恢复演练只使用隔离数据库;校验失败的备份不会进入业务库。
|
||
</p>
|
||
<div v-if="backups.length" class="backup-list">
|
||
<div v-for="backup in backups.slice(0, 5)" :key="backup.id" class="backup-row">
|
||
<div class="backup-stamp">
|
||
<span>{{ backup.provider }}</span>
|
||
<b>{{ formatBytes(backup.sizeBytes) }}</b>
|
||
</div>
|
||
<div class="backup-copy">
|
||
<strong>{{ backup.fileName }}</strong>
|
||
<p>{{ backup.note || '无备份说明' }}</p>
|
||
<small>
|
||
{{ formatTime(backup.createdAt) }} · SHA-256
|
||
{{ backup.sha256.slice(0, 10) }}…
|
||
</small>
|
||
</div>
|
||
<div class="drill-state">
|
||
<span
|
||
:class="backup.lastDrillSucceeded === true
|
||
? 'passed'
|
||
: backup.lastDrillSucceeded === false
|
||
? 'failed'
|
||
: 'pending'"
|
||
>
|
||
{{
|
||
backup.lastDrillSucceeded === true
|
||
? '演练通过'
|
||
: backup.lastDrillSucceeded === false
|
||
? '演练失败'
|
||
: '待演练'
|
||
}}
|
||
</span>
|
||
<el-button
|
||
link
|
||
type="primary"
|
||
:disabled="backupBusy"
|
||
@click="runDrill(backup)"
|
||
>
|
||
运行恢复演练
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="empty-backup">
|
||
尚无备份。创建首个备份后,立即运行一次恢复演练。
|
||
</div>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="ledger-panel">
|
||
<div class="ledger-tabs" role="tablist" aria-label="审计查询类型">
|
||
<button
|
||
type="button"
|
||
:class="{ active: activeLedger === 'audit' }"
|
||
@click="activeLedger = 'audit'"
|
||
>
|
||
操作日志
|
||
<span>{{ auditTotal }}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
:class="{ active: activeLedger === 'jobs' }"
|
||
@click="activeLedger = 'jobs'"
|
||
>
|
||
失败后台任务
|
||
<span>{{ jobTotal }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="activeLedger === 'audit'" class="ledger-content">
|
||
<div class="query-strip">
|
||
<el-select v-model="auditFilter.method" clearable placeholder="请求方法">
|
||
<el-option v-for="method in ['POST', 'PUT', 'PATCH', 'DELETE']" :key="method" :value="method" />
|
||
</el-select>
|
||
<el-input v-model="auditFilter.statusCode" clearable placeholder="状态码,如 500" />
|
||
<el-input v-model="auditFilter.userName" clearable placeholder="操作账号" />
|
||
<el-input v-model="auditFilter.path" clearable placeholder="接口路径" />
|
||
<el-date-picker
|
||
v-model="auditFilter.range"
|
||
type="datetimerange"
|
||
start-placeholder="开始时间"
|
||
end-placeholder="结束时间"
|
||
range-separator="至"
|
||
/>
|
||
<el-button type="primary" @click="searchAudit">查询</el-button>
|
||
<el-button @click="resetAudit">重置</el-button>
|
||
</div>
|
||
<el-table v-loading="auditLoading" :data="auditRows" stripe>
|
||
<el-table-column label="时间" width="172">
|
||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="userName" label="账号" min-width="120">
|
||
<template #default="{ row }">{{ row.userName || '未认证' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="96">
|
||
<template #default="{ row }">
|
||
<span class="method-pill">{{ row.method }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="path" label="接口路径" min-width="260" show-overflow-tooltip />
|
||
<el-table-column label="结果" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag :type="httpStatusType(row.statusCode)" effect="plain">
|
||
{{ row.statusCode }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="ipAddress" label="来源 IP" width="142">
|
||
<template #default="{ row }">{{ row.ipAddress || '—' }}</template>
|
||
</el-table-column>
|
||
<template #empty>
|
||
<span>该时间范围内没有匹配的写操作。</span>
|
||
</template>
|
||
</el-table>
|
||
<el-pagination
|
||
v-model:current-page="auditFilter.page"
|
||
v-model:page-size="auditFilter.pageSize"
|
||
background
|
||
layout="total, prev, pager, next"
|
||
:total="auditTotal"
|
||
@current-change="loadAudit"
|
||
/>
|
||
</div>
|
||
|
||
<div v-else class="ledger-content">
|
||
<div class="query-strip jobs-query">
|
||
<el-select v-model="jobFilter.kind" clearable placeholder="任务类型">
|
||
<el-option label="自动排课" value="AutomaticSchedule" />
|
||
<el-option label="课表发布" value="SchedulePublish" />
|
||
<el-option label="补考自动安排" value="MakeupExamAuto" />
|
||
<el-option label="考试与补考编排" value="ExamArrangement" />
|
||
</el-select>
|
||
<el-date-picker
|
||
v-model="jobFilter.range"
|
||
type="datetimerange"
|
||
start-placeholder="开始时间"
|
||
end-placeholder="结束时间"
|
||
range-separator="至"
|
||
/>
|
||
<el-button type="primary" @click="searchJobs">查询</el-button>
|
||
</div>
|
||
<div v-loading="jobsLoading" class="job-list">
|
||
<article v-for="job in jobRows" :key="job.id" class="job-row">
|
||
<div>
|
||
<span>{{ job.kindLabel }}</span>
|
||
<strong>{{ job.context }}</strong>
|
||
<small>任务 {{ job.id }} · 已尝试 {{ job.processingAttempts }} 次</small>
|
||
</div>
|
||
<p>{{ job.errorMessage }}</p>
|
||
<time>{{ formatTime(job.completedAt || job.createdAt) }}</time>
|
||
</article>
|
||
<div v-if="!jobsLoading && !jobRows.length" class="jobs-empty">
|
||
该时间范围内没有失败的后台任务。
|
||
</div>
|
||
</div>
|
||
<el-pagination
|
||
v-model:current-page="jobFilter.page"
|
||
v-model:page-size="jobFilter.pageSize"
|
||
background
|
||
layout="total, prev, pager, next"
|
||
:total="jobTotal"
|
||
@current-change="loadJobs"
|
||
/>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.operations-console {
|
||
--ink: #1e2932;
|
||
--muted: #66747e;
|
||
--line: #d8dee1;
|
||
--paper: #f6f8f8;
|
||
--panel: #ffffff;
|
||
--signal: #1f7468;
|
||
--warning: #b47722;
|
||
--danger: #b1463e;
|
||
color: var(--ink);
|
||
display: grid;
|
||
gap: 18px;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
min-width: 0;
|
||
padding-bottom: 28px;
|
||
}
|
||
|
||
.operations-console > * {
|
||
box-sizing: border-box;
|
||
min-width: 0;
|
||
width: 100%;
|
||
}
|
||
|
||
.console-heading,
|
||
.panel-heading,
|
||
.signal-board,
|
||
.console-split,
|
||
.backup-row,
|
||
.job-row {
|
||
display: flex;
|
||
}
|
||
|
||
.console-heading {
|
||
align-items: flex-end;
|
||
justify-content: space-between;
|
||
border-bottom: 1px solid var(--line);
|
||
padding: 4px 2px 16px;
|
||
}
|
||
|
||
.console-kicker,
|
||
.panel-heading span,
|
||
.signal-summary > span,
|
||
.signal-node small,
|
||
.alert-row small,
|
||
.backup-row small,
|
||
.job-row small {
|
||
color: #74818a;
|
||
font-family: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
|
||
font-size: 10px;
|
||
letter-spacing: .09em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.console-heading h2 {
|
||
font-size: clamp(25px, 3vw, 38px);
|
||
font-weight: 720;
|
||
letter-spacing: -.04em;
|
||
line-height: 1;
|
||
margin: 8px 0 9px;
|
||
}
|
||
|
||
.console-heading p,
|
||
.signal-summary p,
|
||
.signal-node p,
|
||
.alert-row p,
|
||
.backup-rule,
|
||
.backup-copy p,
|
||
.job-row p,
|
||
.clear-state p {
|
||
color: var(--muted);
|
||
margin: 0;
|
||
}
|
||
|
||
.signal-board {
|
||
background:
|
||
linear-gradient(90deg, rgba(31, 116, 104, .06) 1px, transparent 1px) 0 0 / 42px 100%,
|
||
var(--panel);
|
||
border: 1px solid #cfd8d8;
|
||
box-shadow: 0 12px 30px rgba(33, 51, 59, .06);
|
||
min-height: 184px;
|
||
}
|
||
|
||
.signal-summary {
|
||
background: #25323a;
|
||
color: white;
|
||
flex: 0 0 210px;
|
||
padding: 25px;
|
||
}
|
||
|
||
.signal-summary > span { color: #aebdc2; }
|
||
|
||
.signal-summary strong {
|
||
display: block;
|
||
font-size: 34px;
|
||
letter-spacing: -.05em;
|
||
margin: 28px 0 9px;
|
||
}
|
||
|
||
.signal-summary p {
|
||
color: #c2cdd0;
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.signal-track {
|
||
display: grid;
|
||
flex: 1;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
padding: 22px 18px;
|
||
}
|
||
|
||
.signal-node {
|
||
min-width: 0;
|
||
padding: 4px 16px 2px;
|
||
position: relative;
|
||
}
|
||
|
||
.signal-node:not(:last-child)::after {
|
||
background: var(--line);
|
||
content: "";
|
||
height: 1px;
|
||
left: 43px;
|
||
position: absolute;
|
||
right: -16px;
|
||
top: 16px;
|
||
}
|
||
|
||
.signal-mark {
|
||
align-items: center;
|
||
background: var(--panel);
|
||
display: flex;
|
||
height: 28px;
|
||
margin-bottom: 19px;
|
||
position: relative;
|
||
width: 28px;
|
||
z-index: 1;
|
||
}
|
||
|
||
.signal-mark span {
|
||
align-items: center;
|
||
background: #e7eeed;
|
||
border: 1px solid #b8c8c5;
|
||
border-radius: 50%;
|
||
color: var(--signal);
|
||
display: flex;
|
||
font-family: "Cascadia Mono", Consolas, monospace;
|
||
font-size: 10px;
|
||
height: 24px;
|
||
justify-content: center;
|
||
width: 24px;
|
||
}
|
||
|
||
.signal-node.is-warning .signal-mark span { background: #fbf0dd; border-color: #dfc092; color: var(--warning); }
|
||
.signal-node.is-unhealthy .signal-mark span { background: #fae7e5; border-color: #d7aaa6; color: var(--danger); }
|
||
|
||
.signal-node h3 {
|
||
font-size: 15px;
|
||
margin: 4px 0 8px;
|
||
}
|
||
|
||
.signal-node p {
|
||
font-size: 11px;
|
||
line-height: 1.5;
|
||
min-height: 48px;
|
||
}
|
||
|
||
.signal-node > b {
|
||
color: var(--signal);
|
||
display: block;
|
||
font-size: 11px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.is-warning { color: var(--warning) !important; }
|
||
.is-unhealthy { color: var(--danger) !important; }
|
||
.is-healthy { color: var(--signal) !important; }
|
||
|
||
.console-split {
|
||
align-items: stretch;
|
||
gap: 18px;
|
||
}
|
||
|
||
.alerts-panel,
|
||
.backup-panel,
|
||
.ledger-panel {
|
||
background: var(--panel);
|
||
border: 1px solid var(--line);
|
||
}
|
||
|
||
.alerts-panel { flex: 0 0 min(38%, 460px); }
|
||
.backup-panel { flex: 1; min-width: 0; }
|
||
|
||
.panel-heading {
|
||
align-items: center;
|
||
border-bottom: 1px solid var(--line);
|
||
justify-content: space-between;
|
||
min-height: 72px;
|
||
padding: 14px 18px;
|
||
}
|
||
|
||
.panel-heading h3 {
|
||
font-size: 17px;
|
||
margin: 3px 0 0;
|
||
}
|
||
|
||
.panel-heading em {
|
||
align-items: center;
|
||
background: #f3e3e1;
|
||
border-radius: 50%;
|
||
color: var(--danger);
|
||
display: flex;
|
||
font-size: 12px;
|
||
font-style: normal;
|
||
height: 30px;
|
||
justify-content: center;
|
||
width: 30px;
|
||
}
|
||
|
||
.alert-list { max-height: 365px; overflow: auto; }
|
||
|
||
.alert-row {
|
||
align-items: flex-start;
|
||
border-bottom: 1px solid #e7ebed;
|
||
gap: 12px;
|
||
padding: 15px 18px;
|
||
}
|
||
|
||
.alert-row:last-child { border-bottom: 0; }
|
||
.alert-row > .el-icon { color: var(--warning); font-size: 17px; margin-top: 2px; }
|
||
.alert-row.is-critical > .el-icon { color: var(--danger); }
|
||
.alert-row strong { display: block; font-size: 13px; line-height: 1.45; }
|
||
.alert-row p { font-size: 11px; line-height: 1.55; margin: 4px 0 7px; }
|
||
|
||
.clear-state {
|
||
align-items: flex-start;
|
||
display: flex;
|
||
gap: 13px;
|
||
padding: 30px 20px;
|
||
}
|
||
|
||
.clear-state .el-icon { color: var(--signal); font-size: 22px; }
|
||
.clear-state strong { font-size: 14px; }
|
||
.clear-state p { font-size: 12px; margin-top: 5px; }
|
||
|
||
.backup-rule {
|
||
background: #f5f8f7;
|
||
border-bottom: 1px solid #e0e6e5;
|
||
font-size: 11px;
|
||
padding: 10px 18px;
|
||
}
|
||
|
||
.backup-list { max-height: 324px; overflow: auto; }
|
||
|
||
.backup-row {
|
||
align-items: center;
|
||
border-bottom: 1px solid #e7ebed;
|
||
gap: 14px;
|
||
padding: 13px 18px;
|
||
}
|
||
|
||
.backup-row:last-child { border-bottom: 0; }
|
||
|
||
.backup-stamp {
|
||
border-right: 1px solid var(--line);
|
||
flex: 0 0 78px;
|
||
padding-right: 13px;
|
||
}
|
||
|
||
.backup-stamp span {
|
||
color: var(--signal);
|
||
display: block;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.backup-stamp b { display: block; font-size: 12px; margin-top: 5px; }
|
||
.backup-copy { flex: 1; min-width: 0; }
|
||
.backup-copy strong { display: block; font-family: "Cascadia Mono", Consolas, monospace; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.backup-copy p { font-size: 11px; margin: 5px 0; }
|
||
.backup-copy small { font-size: 9px; }
|
||
.drill-state { flex: 0 0 112px; text-align: right; }
|
||
.drill-state > span { display: block; font-size: 10px; font-weight: 700; margin-bottom: 3px; }
|
||
.drill-state .passed { color: var(--signal); }
|
||
.drill-state .failed { color: var(--danger); }
|
||
.drill-state .pending { color: var(--warning); }
|
||
.empty-backup { color: var(--muted); font-size: 12px; padding: 30px 18px; }
|
||
|
||
.ledger-tabs {
|
||
border-bottom: 1px solid var(--line);
|
||
display: flex;
|
||
gap: 26px;
|
||
padding: 0 20px;
|
||
}
|
||
|
||
.ledger-tabs button {
|
||
background: transparent;
|
||
border: 0;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
font-weight: 650;
|
||
padding: 18px 0 14px;
|
||
position: relative;
|
||
}
|
||
|
||
.ledger-tabs button::after {
|
||
background: var(--signal);
|
||
bottom: -1px;
|
||
content: "";
|
||
height: 2px;
|
||
left: 0;
|
||
position: absolute;
|
||
transform: scaleX(0);
|
||
transform-origin: left;
|
||
transition: transform .18s ease;
|
||
width: 100%;
|
||
}
|
||
|
||
.ledger-tabs button.active { color: var(--ink); }
|
||
.ledger-tabs button.active::after { transform: scaleX(1); }
|
||
.ledger-tabs span { background: #edf1f1; border-radius: 10px; font-size: 10px; margin-left: 6px; padding: 2px 6px; }
|
||
.ledger-content { padding: 16px 18px 18px; }
|
||
|
||
.query-strip {
|
||
display: grid;
|
||
gap: 9px;
|
||
grid-template-columns: 120px 130px 150px minmax(170px, 1fr) 360px auto auto;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.jobs-query { grid-template-columns: 180px 360px auto; }
|
||
.method-pill { color: #355b65; font-family: "Cascadia Mono", Consolas, monospace; font-size: 11px; font-weight: 700; }
|
||
.el-pagination { justify-content: flex-end; margin-top: 16px; }
|
||
|
||
.job-list { min-height: 180px; }
|
||
|
||
.job-row {
|
||
align-items: flex-start;
|
||
border-bottom: 1px solid #e3e8e9;
|
||
gap: 22px;
|
||
padding: 15px 4px;
|
||
}
|
||
|
||
.job-row > div { flex: 0 0 260px; }
|
||
.job-row span { color: var(--danger); display: block; font-size: 10px; font-weight: 750; letter-spacing: .06em; }
|
||
.job-row strong { display: block; font-size: 13px; margin: 5px 0; }
|
||
.job-row small { display: block; font-size: 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.job-row p { flex: 1; font-size: 12px; line-height: 1.6; }
|
||
.job-row time { color: var(--muted); flex: 0 0 150px; font-family: "Cascadia Mono", Consolas, monospace; font-size: 10px; text-align: right; }
|
||
.jobs-empty { color: var(--muted); font-size: 12px; padding: 34px 4px; text-align: center; }
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.ledger-tabs button::after { transition: none; }
|
||
}
|
||
|
||
@media (max-width: 1180px) {
|
||
.signal-track { grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 18px; }
|
||
.signal-node:nth-child(2)::after { display: none; }
|
||
.query-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||
.query-strip .el-date-editor { grid-column: span 2; width: 100%; }
|
||
.jobs-query { grid-template-columns: 180px minmax(300px, 1fr) auto; }
|
||
}
|
||
|
||
@media (max-width: 820px) {
|
||
.signal-board,
|
||
.console-split { display: block; }
|
||
.signal-summary { padding: 20px; }
|
||
.signal-summary strong { margin-top: 16px; }
|
||
.signal-track { padding: 18px 8px; }
|
||
.alerts-panel { margin-bottom: 18px; }
|
||
.job-row { display: grid; gap: 8px; grid-template-columns: 1fr auto; }
|
||
.job-row > div,
|
||
.job-row p,
|
||
.job-row time { grid-column: 1 / -1; text-align: left; }
|
||
}
|
||
|
||
@media (max-width: 560px) {
|
||
.operations-console { gap: 12px; }
|
||
.console-heading { align-items: flex-start; gap: 14px; }
|
||
.console-heading p { font-size: 12px; line-height: 1.5; }
|
||
.signal-track { display: block; }
|
||
.signal-node { border-bottom: 1px solid #e4e9ea; padding: 13px 12px; }
|
||
.signal-node:last-child { border-bottom: 0; }
|
||
.signal-node::after { display: none !important; }
|
||
.signal-mark { float: left; margin: 0 12px 26px 0; }
|
||
.signal-node p { min-height: 0; }
|
||
.panel-heading { align-items: flex-start; gap: 12px; }
|
||
.backup-row { align-items: flex-start; flex-wrap: wrap; }
|
||
.backup-copy { min-width: calc(100% - 100px); }
|
||
.drill-state { display: flex; flex: 1 0 100%; justify-content: space-between; text-align: left; }
|
||
.query-strip,
|
||
.jobs-query { display: flex; flex-wrap: wrap; }
|
||
.query-strip > * { flex: 1 1 140px; }
|
||
.query-strip .el-date-editor { flex-basis: 100%; max-width: 100%; width: 100%; }
|
||
.ledger-panel,
|
||
.ledger-content { min-width: 0; overflow: hidden; }
|
||
.ledger-content { padding: 12px; }
|
||
.el-pagination { justify-content: center; overflow: auto; }
|
||
}
|
||
</style>
|