添加控制台

This commit is contained in:
zlt 2026-07-29 13:51:10 +08:00
parent 691c375002
commit 1c184db5c2
1 changed files with 148 additions and 2 deletions

View File

@ -404,6 +404,96 @@
</el-table> </el-table>
</div> </div>
</el-tab-pane> </el-tab-pane>
<!-- ==================== ④ ws 通道 (只读监控) ====================
只读: 任何会产生新委托的动作都不放在页面上。联调发单仍走
scripts/ws_smoke.py place --yes —— 那条路径有显式确认、有参数校验、
有"这张单会真成交"的警告, 且不受 PMS_DISPATCH_MODE 影响。页面上加个
发单按钮等于在生产界面开了第二条绕过开关的下单入口, 不值当。 -->
<el-tab-pane label="ws 通道" name="ws">
<div class="row" style="margin-bottom:10px">
<el-tag :type="wsc.process_alive ? 'success':'danger'" effect="dark" size="small">
进程 {{ wsc.process_alive ? '在线':'不在线' }}</el-tag>
<el-tag :type="wsc.conn_state==='ONLINE' ? 'success':'warning'" effect="dark" size="small">
{{ wsc.conn_state || '-' }}</el-tag>
<span class="muted">心跳 {{ wsc.heartbeat_age_sec==null ? '从未' : wsc.heartbeat_age_sec+'s 前' }}</span>
<div class="spacer"></div>
<el-checkbox v-model="wsAuto" size="small">每 3 秒自动刷新</el-checkbox>
<el-button size="small" @click="loadWs">刷新</el-button>
</div>
<!-- 异常自检: 与 scripts/ws_smoke.py 用同一套判据, 页面和命令行的结论必须一致,
否则联调时两边打架比没有告警更糟 -->
<el-alert v-for="(w,i) in wsWarnings" :key="i" class="banner" type="warning" effect="dark"
show-icon :closable="false" :title="w"></el-alert>
<div class="metrics">
<div class="m"><div class="k">seq 已落库</div><div class="v">{{ wsc.last_seq }}</div></div>
<div class="m"><div class="k">seq 已确认</div><div class="v">{{ wsc.acked_seq }}</div></div>
<div class="m"><div class="k">待入账上行</div><div class="v">{{ wsc.inbox_pending }}</div></div>
<div class="m"><div class="k">收 / 发</div>
<div class="v">{{ wst.rx||0 }} / {{ wst.tx||0 }}</div></div>
<div class="m"><div class="k">成交 / 拒绝</div>
<div class="v">{{ wst.trades||0 }} / {{ wst.rejects||0 }}</div></div>
<div class="m"><div class="k">重连</div><div class="v">{{ wst.reconnects||0 }}</div></div>
<div class="m"><div class="k">对端时钟偏差</div>
<div class="v" :class="Math.abs(wst.peer_skew_ms||0)>20000?'up':''">
{{ wst.peer_skew_ms==null ? '—' : (wst.peer_skew_ms>0?'+':'') + wst.peer_skew_ms + 'ms' }}</div></div>
</div>
<div class="panel">
<h3>通道明细</h3>
<div class="row" style="gap:24px">
<span class="muted">下发模式 <span class="mono">{{ wsMode || '-' }}</span></span>
<span class="muted">上次连上 {{ wsc.connected_at || '—' }}</span>
<span class="muted">出口队列 <span class="mono">{{ JSON.stringify(wsc.queue||{}) }}</span></span>
</div>
<div v-if="wsc.last_error" class="muted" style="margin-top:8px;color:#e6a23c">
最后错误: {{ wsc.last_error }}</div>
<div v-if="wst.last_reject" class="muted" style="margin-top:6px">
最后一条拒绝: <span class="mono">{{ wst.last_reject }}</span></div>
<div v-if="wst.last_close" class="muted" style="margin-top:6px">
最后断开原因: <span class="mono">{{ wst.last_close }}</span></div>
</div>
<div class="panel">
<h3>出口委托 (pms_qmt_order)</h3>
<el-table :data="wsOrders" size="small" border max-height="300" empty-text="暂无委托">
<el-table-column prop="instruction_id" label="指令号" width="230" class-name="mono"></el-table-column>
<el-table-column prop="ts_code" label="股票" width="110" class-name="mono"></el-table-column>
<el-table-column prop="side" label="方向" width="70"></el-table-column>
<el-table-column prop="qty" label="数量" width="80"></el-table-column>
<el-table-column prop="limit_price" label="限价" width="90"></el-table-column>
<el-table-column prop="status" label="状态" width="120"></el-table-column>
<el-table-column prop="cum_qty" label="已成" width="80"></el-table-column>
<el-table-column prop="cancel_state" label="撤单" width="90"></el-table-column>
<el-table-column prop="reject_code" label="拒绝码" min-width="140"></el-table-column>
</el-table>
</div>
<div class="panel">
<h3>上行消息 (pms_qmt_inbox)
<span class="muted" style="font-weight:400">· 新→旧</span></h3>
<div class="row" style="margin-bottom:8px">
<el-checkbox v-model="wsHidePong" size="small">隐藏 pong</el-checkbox>
<span class="muted">pong 每 5 秒一条且按协议 §5 带 seq, 不隐藏会把成交冲没</span>
</div>
<el-table :data="wsInbox" size="small" border max-height="380" empty-text="还没收到上行消息">
<el-table-column prop="seq" label="seq" width="90" class-name="mono"></el-table-column>
<el-table-column prop="msg_type" label="类型" width="130"></el-table-column>
<el-table-column label="入账" width="80">
<template #default="s">
<span class="muted">{{ ({0:'待入账',1:'已入账',2:'已消化'})[s.row.processed] || '?' }}</span>
</template>
</el-table-column>
<el-table-column label="内容" min-width="380">
<template #default="s">
<span class="mono muted">{{ JSON.stringify(s.row.payload || {}) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs> </el-tabs>
<!-- 运维抽屉 --> <!-- 运维抽屉 -->
@ -455,7 +545,7 @@
</div> </div>
<script> <script>
const { createApp, ref, reactive, computed, onMounted } = Vue; const { createApp, ref, reactive, computed, onMounted, onUnmounted, watch } = Vue;
const api = axios.create({ timeout: 60000 }); const api = axios.create({ timeout: 60000 });
createApp({ createApp({
@ -470,6 +560,46 @@ createApp({
const issuing = ref(false); const issuing = ref(false);
const form = reactive({ cmd_type: '', params: {}, note: '' }); const form = reactive({ cmd_type: '', params: {}, note: '' });
// ---- ws 通道监控 (只读)
const wsRaw = ref({}), wsAuto = ref(true), wsHidePong = ref(true);
let wsTimer = null;
const wsc = computed(() => wsRaw.value.channel || {});
const wst = computed(() => wsc.value.stat || {});
const wsMode = computed(() => wsRaw.value.mode || '');
const wsOrders = computed(() => wsRaw.value.orders || []);
const wsInbox = computed(() => (wsRaw.value.inbox || [])
.filter(r => !(wsHidePong.value && r.msg_type === 'pong')));
// 判据与 scripts/ws_smoke.py 的自检逐条对应。改一边就要改另一边 ——
// 页面说"正常"而命令行说"异常"(或反过来), 比两边都没有告警更能误导人。
const wsWarnings = computed(() => {
const c = wsc.value, s = wst.value, out = [];
if (!c.process_alive)
out.push('pms-ws 进程不在线 (心跳陈旧) —— dispatcher 会一律拒发。'
+ '起进程: docker compose --profile ws up -d pms-ws');
const hsSrv = +(s.hs_server_seq || 0), hsLast = +(s.hs_last_seq || 0);
if (hsSrv && hsSrv < hsLast)
out.push(`序号倒挂: 握手时对端自报 ${hsSrv}, 本端水位已到 ${hsLast}。对端此后发的每一条`
+ `(含成交)都会因「不高于水位」被静默丢弃。不要自行下调水位 —— 会把 `
+ `${hsSrv}..${hsLast} 重复入账。须先与 QMT 侧对齐 seq 语义`);
if (+(s.dropped || 0))
out.push(`已丢弃 ${s.dropped} 条上行 (seq 不高于水位)。补发时命中是正常的, `
+ `持续增长说明序号语义对不上`);
if (s.ack_seq_degraded)
out.push('ack_seq 已降级停发 (对端不认这个类型, 协议 §4.5)。水位照常落库、重连补发靠 '
+ 'hello.last_seq, 不丢成交; 代价是 QMT 侧 Redis 清不掉');
if (+(s.proto_rejects || 0))
out.push(`协议级 reject ${s.proto_rejects} 条 (不带 instruction_id, 拒的不是委托而是`
+ `我们发的协议消息)。若收发计数同步增长, 基本是「ack→reject→再 ack」的自激循环`);
if (s.peer_skew_ms != null && Math.abs(+s.peer_skew_ms) > 20000)
out.push(`对端时钟偏差 ${s.peer_skew_ms} ms, 已逼近 QMT 侧 ±30 秒时间窗。再漂下去连 `
+ `place_order 都会被 TS_SKEW 拒, 现象是「下单没反应」。两台机器都对一下 NTP`);
if (c.resync_required)
out.push('resync_flag=1: 对端补发不全, 须走全量对账后经运维抽屉清除标记');
if (c.error) out.push(c.error);
return out;
});
const curSpec = computed(() => catalog.value.find(c => c.cmd_type === form.cmd_type)); const curSpec = computed(() => catalog.value.find(c => c.cmd_type === form.cmd_type));
const dirtyCount = computed(() => const dirtyCount = computed(() =>
params.value.filter(p => String(p.value) !== String(paramsRaw.value[p.key])).length); params.value.filter(p => String(p.value) !== String(paramsRaw.value[p.key])).length);
@ -559,12 +689,27 @@ createApp({
async function loadDispatchMode() { async function loadDispatchMode() {
const d = await call('get', '/api/dispatch-mode'); dm.value = d.data || d || {}; const d = await call('get', '/api/dispatch-mode'); dm.value = d.data || d || {};
} }
async function loadWs() {
const d = await call('get', '/api/ws-channel?limit=60');
wsRaw.value = d.data || d || {};
}
// 只在 ws 页签可见时轮询。3 秒一次对一个只读接口不算什么, 但让它在后台一直跑
// 会把日志和连接数刷得很难看, 排查问题时反而碍事。
function wsPollSync() {
const want = tab.value === 'ws' && wsAuto.value;
if (want && !wsTimer) { loadWs(); wsTimer = setInterval(loadWs, 3000); }
if (!want && wsTimer) { clearInterval(wsTimer); wsTimer = null; }
}
watch([tab, wsAuto], wsPollSync);
onUnmounted(() => { if (wsTimer) clearInterval(wsTimer); });
async function loadAll() { async function loadAll() {
loading.value = true; err.value = ''; loading.value = true; err.value = '';
await Promise.all([loadOverview(), loadParams(), loadCatalog(), loadCommands(), await Promise.all([loadOverview(), loadParams(), loadCatalog(), loadCommands(),
loadPositions(), loadInstructions(), loadLedger(), loadProposals(), loadPositions(), loadInstructions(), loadLedger(), loadProposals(),
loadDispatchMode()]); loadDispatchMode(), loadWs()]);
loading.value = false; loading.value = false;
wsPollSync();
} }
async function cancelIns(iid) { async function cancelIns(iid) {
try { try {
@ -665,6 +810,7 @@ createApp({
positions, lots, lotsOf, instructions, ledger, proposals, report, reportDrawer, positions, lots, lotsOf, instructions, ledger, proposals, report, reportDrawer,
opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount, dm, opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount, dm,
money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct, insPct, money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct, insPct,
wsRaw, wsAuto, wsHidePong, wsc, wst, wsMode, wsOrders, wsInbox, wsWarnings, loadWs,
loadAll, loadParams, saveParams, loadPlans, loadLots, onCmdChange, issue, loadAll, loadParams, saveParams, loadPlans, loadLots, onCmdChange, issue,
cancelCmd, replan, decide, ops, loadSchema, openReport, cancelIns }; cancelCmd, replan, decide, ops, loadSchema, openReport, cancelIns };
} }