Files
Academic-Affairs-System/web/src/views/OperationsConsoleView.vue
T
biss 9e0d4062a4 自动编排接口现在立即返回 202 + jobId,不会再等待 15 秒导致 Axios 超时。
任务参数、状态和结果持久化到 MySQL。
接入现有 outbox;配置 BackgroundJobs__Transport=RabbitMq 时使用 RabbitMQ 队列 exam.arrangement,否则使用 InMemory worker。
服务重启后可恢复未完成任务。
前端显示排队/执行/完成/失败状态,刷新页面可恢复正在执行的任务。
编排期间禁止修改、删除或发布对应计划。
补考原有“一键生成”保留,并与编排任务互斥。
运维后台增加“考试与补考编排”失败任务筛选。
2026-07-27 21:40:14 +08:00

1029 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>