P0 셀프서비스 결정론 리포트 — 적산·물질수지 폐합·cleaning 마스크 (+ P1 온라인 스펙) #1
@@ -5,6 +5,7 @@ using Hc900Crawler.Infrastructure.Control;
|
||||
using Hc900Crawler.Infrastructure.Database;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace Hc900Crawler.Web.Controllers;
|
||||
|
||||
@@ -190,6 +191,9 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref);
|
||||
if (prod is null) tref = tref with { }; // no-op, keep tref for metadata
|
||||
|
||||
var flow = await FetchFlowValues(col);
|
||||
var bases = FlowBases(ToSuffix(col));
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
column = tref.Column,
|
||||
@@ -202,6 +206,22 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
spanRef = prod?.SpanAD,
|
||||
products = tref.Products,
|
||||
timestamp = DateTime.UtcNow,
|
||||
flow = new {
|
||||
feed = flow["feed"],
|
||||
reflux = flow["reflux"],
|
||||
overhead = flow["overhead"],
|
||||
bottom = flow["bottom"],
|
||||
product = flow["product"],
|
||||
steam = flow["steam"],
|
||||
},
|
||||
flowTags = new {
|
||||
feed = $"{bases["feed"]}.PV",
|
||||
reflux = $"{bases["reflux"]}.PV",
|
||||
overhead = $"{bases["overhead"]}.PV",
|
||||
bottom = $"{bases["bottom"]}.PV",
|
||||
product = $"{bases["product"]}.PV",
|
||||
steam = $"FIQ-{ToSuffix(col).Substring(0, ToSuffix(col).Length - 2)}15.PV",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -225,8 +245,13 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
if ((to.Value - from.Value).TotalHours > 24)
|
||||
from = to.Value.AddHours(-24);
|
||||
|
||||
var tagMap = TagsFor(ToSuffix(col));
|
||||
var tagNames = tagMap.Values.ToList();
|
||||
// 태그명 변경 이력 대응: TI-xxxxB(현재) ↔ TI-xxxxB.PV(과거). 두 변형 모두 조회 후 스냅샷별 coalesce.
|
||||
var tagCands = TagCandidatesFor(ToSuffix(col));
|
||||
var tagNames = tagCands.Values.SelectMany(c => c).Distinct().ToList();
|
||||
// 유량 SP/OP도 history 조회에 포함
|
||||
var flowBases = FlowBases(ToSuffix(col));
|
||||
var flowSpOp = flowBases.Values.SelectMany(b => new[] { $"{b}.SP", $"{b}.OP" }).ToList();
|
||||
tagNames.AddRange(flowSpOp);
|
||||
|
||||
var result = await _db.QueryHistoryWithIntervalAsync(
|
||||
new HistoryIntervalQueryRequest(tagNames, from, to, "1 minute", 2000));
|
||||
@@ -234,16 +259,22 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
var snapshots = new List<object>();
|
||||
var products = tref.Products;
|
||||
|
||||
static double? V(IReadOnlyDictionary<string, string?> d, string tag)
|
||||
=> d.TryGetValue(tag, out var s) && double.TryParse(s, out var v) ? (double?)v : null;
|
||||
|
||||
foreach (var row in result.Rows)
|
||||
{
|
||||
var cur = tagMap.ToDictionary(kv => kv.Key, kv =>
|
||||
var cur = tagCands.ToDictionary(kv => kv.Key, kv =>
|
||||
{
|
||||
if (row.Values.TryGetValue(kv.Value, out var s) && double.TryParse(s, out var v))
|
||||
return (double?)v;
|
||||
return null;
|
||||
foreach (var t in kv.Value)
|
||||
if (row.Values.TryGetValue(t, out var s) && double.TryParse(s, out var v))
|
||||
return (double?)v;
|
||||
return (double?)null;
|
||||
});
|
||||
|
||||
var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref);
|
||||
object FlowRole(string baseTag, string role)
|
||||
=> new { pv = cur.GetValueOrDefault(role), sp = V(row.Values, $"{baseTag}.SP"), op = V(row.Values, $"{baseTag}.OP") };
|
||||
snapshots.Add(new
|
||||
{
|
||||
ts = row.TimeBucket,
|
||||
@@ -251,6 +282,22 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
stages,
|
||||
vacuum,
|
||||
spanAD,
|
||||
flow = new {
|
||||
feed = FlowRole(flowBases["feed"], "feed"),
|
||||
reflux = FlowRole(flowBases["reflux"], "reflux"),
|
||||
overhead = FlowRole(flowBases["overhead"], "overhead"),
|
||||
bottom = FlowRole(flowBases["bottom"], "bottom"),
|
||||
product = FlowRole(flowBases["product"], "product"),
|
||||
steam = new { pv = cur.GetValueOrDefault("steam") },
|
||||
},
|
||||
flowTags = new {
|
||||
feed = $"{flowBases["feed"]}.PV",
|
||||
reflux = $"{flowBases["reflux"]}.PV",
|
||||
overhead = $"{flowBases["overhead"]}.PV",
|
||||
bottom = $"{flowBases["bottom"]}.PV",
|
||||
product = $"{flowBases["product"]}.PV",
|
||||
steam = $"FIQ-{ToSuffix(col).Substring(0, ToSuffix(col).Length - 2)}15.PV",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -305,6 +352,51 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
// FICQ 유량 루프의 PV/SP/OP를 realtime_table에서 직접 조회.
|
||||
// v_tag_summary는 base_tag||'.pv'(소문자)로 조인하나 realtime은 '.PV'(대문자) 저장이라
|
||||
// pv/sp/op가 전부 NULL로 나옴 → 뷰 우회하고 정확한 태그명으로 직접 조회.
|
||||
// 익명 객체 { pv, sp, op } 반환 — ValueTuple은 STJ가 필드라 직렬화하지 않아 {}로 나감.
|
||||
private async Task<Dictionary<string, object>> FetchFlowValues(string col)
|
||||
{
|
||||
var ficqBases = FlowBases(ToSuffix(col)); // role → "FICQ-6101" 등
|
||||
var p = ToSuffix(col);
|
||||
var ficqPrefix = p.Substring(0, p.Length - 2);
|
||||
var steamTag = $"FIQ-{ficqPrefix}15.PV";
|
||||
// role별 .PV/.SP/.OP 태그를 한 번에 조회
|
||||
var allTags = ficqBases.Values
|
||||
.SelectMany(b => new[] { $"{b}.PV", $"{b}.SP", $"{b}.OP" })
|
||||
.Append(steamTag)
|
||||
.ToArray();
|
||||
|
||||
var live = await _ctx.RealtimePoints
|
||||
.Where(r => allTags.Contains(r.TagName))
|
||||
.GroupBy(r => r.TagName)
|
||||
.ToDictionaryAsync(g => g.Key, g => g.OrderByDescending(r => r.Timestamp).FirstOrDefault()?.LiveValue);
|
||||
|
||||
static double? P(Dictionary<string, string?> d, string tag)
|
||||
=> d.TryGetValue(tag, out var s) && double.TryParse(s, out var v) ? (double?)v : null;
|
||||
|
||||
var result = new Dictionary<string, object>();
|
||||
foreach (var (role, baseTag) in ficqBases)
|
||||
result[role] = new { pv = P(live, $"{baseTag}.PV"), sp = P(live, $"{baseTag}.SP"), op = P(live, $"{baseTag}.OP") };
|
||||
result["steam"] = new { pv = P(live, steamTag) };
|
||||
return result;
|
||||
}
|
||||
|
||||
// FICQ 유량 루프 base 태그명. ficqPrefix = 컬럼suffix 앞 2자리("6111"→"61", "10111"→"101").
|
||||
private static Dictionary<string, string> FlowBases(string p)
|
||||
{
|
||||
var ficqPrefix = p.Substring(0, p.Length - 2);
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["feed"] = $"FICQ-{ficqPrefix}01",
|
||||
["reflux"] = $"FICQ-{ficqPrefix}13",
|
||||
["overhead"] = $"FICQ-{ficqPrefix}14",
|
||||
["bottom"] = $"FICQ-{ficqPrefix}16",
|
||||
["product"] = $"FICQ-{ficqPrefix}18",
|
||||
};
|
||||
}
|
||||
|
||||
private static (TempProduct? prod, List<object> stages, object vacuum, double? spanAD) ComputeStages(
|
||||
Dictionary<string, double?> cur, TempRef tref)
|
||||
{
|
||||
@@ -347,17 +439,26 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
}, spanAD);
|
||||
}
|
||||
|
||||
// roles_for(C# 미러) — 단별 온도/진공 태그. c6111_extract COLUMN_EXCEPTIONS 대응.
|
||||
// roles_for(C# 미러) — 단별 온도/진공/유량 태그. c6111_extract COLUMN_EXCEPTIONS 대응.
|
||||
// p = numeric suffix ("6111", "6211", "8111", ...)
|
||||
// FICQ 접두사 = p.Substring(0, p.Length-2) → "6111"→"61", "10111"→"101"
|
||||
private static Dictionary<string, string> TagsFor(string p)
|
||||
{
|
||||
var ficqPrefix = p.Substring(0, p.Length - 2);
|
||||
var m = new Dictionary<string, string>
|
||||
{
|
||||
["reb_temp"] = $"TICA-{p}A.PV",
|
||||
// T_B/T_C/T_D 시그널 태그 — register-map 통일로 .PV 접미사 포함
|
||||
["T_B"] = $"TI-{p}B.PV",
|
||||
["T_C"] = $"TI-{p}C.PV",
|
||||
["T_D"] = $"TI-{p}D.PV",
|
||||
["vacuum"] = $"PICA-{p}.PV",
|
||||
["feed"] = $"FICQ-{ficqPrefix}01.PV",
|
||||
["reflux"] = $"FICQ-{ficqPrefix}13.PV",
|
||||
["overhead"] = $"FICQ-{ficqPrefix}14.PV",
|
||||
["bottom"] = $"FICQ-{ficqPrefix}16.PV",
|
||||
["product"] = $"FICQ-{ficqPrefix}18.PV",
|
||||
["steam"] = $"FIQ-{ficqPrefix}15.PV",
|
||||
};
|
||||
switch (p)
|
||||
{
|
||||
@@ -370,6 +471,18 @@ public sealed class SteamAdvisorController : ControllerBase
|
||||
return m;
|
||||
}
|
||||
|
||||
// 과거 이력용 태그 후보(우선순위). T_B/T_C/T_D는 현재 .PV 접미사가 표준,
|
||||
// 과거(무접미사) 폴백으로 두 변형 모두 조회. 나머지 role은 단일 태그.
|
||||
private static Dictionary<string, string[]> TagCandidatesFor(string p)
|
||||
{
|
||||
var result = new Dictionary<string, string[]>();
|
||||
foreach (var (role, tag) in TagsFor(p))
|
||||
result[role] = (role is "T_B" or "T_C" or "T_D")
|
||||
? new[] { tag, tag.Replace(".PV", "") }
|
||||
: new[] { tag };
|
||||
return result;
|
||||
}
|
||||
|
||||
// 컬럼키 "C-6111" → numeric suffix "6111"
|
||||
private static string ToSuffix(string col) => col.StartsWith("C-") ? col[2..] : col;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ let stTempIsLive = true;
|
||||
const ST_TEMP_COLS = [['C-6111','6-1차'],['C-6211','6-2차'],['C-8111','8차'],['C-9111','9-1차'],['C-9211','9-2차'],['C-10111','10-1차'],['C-10211','10-2차']];
|
||||
const ST_STAGE_LABEL = { reb_temp:'reb-A(보텀)', T_B:'T_B', T_C:'T_C(민감단)', T_D:'T_D(탑)' };
|
||||
const stFmt = v => (v === null || v === undefined || Number.isNaN(v)) ? '—' : (+v).toFixed(1);
|
||||
const escHtml = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
const stFmtDT = ts => {
|
||||
const d = new Date(ts);
|
||||
const kst = new Date(d.getTime() + 9 * 3600000);
|
||||
@@ -128,8 +129,6 @@ async function stTempTick() {
|
||||
stTempLive = await api('GET', `/api/steam/tempprofile/${col}`);
|
||||
if (stTempIsLive) {
|
||||
stRenderTemp();
|
||||
} else {
|
||||
stTempUpdateBadges();
|
||||
}
|
||||
st.textContent = '갱신: ' + new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
@@ -201,26 +200,31 @@ function stTempScrub() {
|
||||
stRenderTemp(snap);
|
||||
}
|
||||
|
||||
// 배지만 갱신 (과거 모드에서 현재값 badge update)
|
||||
function stTempUpdateBadges() {
|
||||
if (!stTempLive) return;
|
||||
document.getElementById('st-temp-product').textContent = '제품 ' + (stTempLive.matchedProduct || '—');
|
||||
// 배지만 갱신 (snap 있으면 해당 스냅샷 기준, 없으면 라이브 기준)
|
||||
function stTempUpdateBadges(src) {
|
||||
const s = src || stTempLive;
|
||||
if (!s) return;
|
||||
document.getElementById('st-temp-product').textContent = '제품 ' + (s.matchedProduct || '—');
|
||||
const vb = document.getElementById('st-temp-vac');
|
||||
vb.textContent = `진공 ${stFmt(stTempLive.vacuum.current)} (기준 ${stFmt(stTempLive.vacuum.refMedian)})` + (stTempLive.vacuum.deviated ? ' ⚠이격' : '');
|
||||
vb.style.background = stTempLive.vacuum.deviated ? '#3a1a1a' : '#1a1a3a';
|
||||
vb.style.color = stTempLive.vacuum.deviated ? '#f66' : '#66f';
|
||||
vb.textContent = `진공 ${stFmt(s.vacuum.current)} (기준 ${stFmt(s.vacuum.refMedian)})` + (s.vacuum.deviated ? ' ⚠이격' : '');
|
||||
vb.style.background = s.vacuum.deviated ? '#3a1a1a' : '#1a1a3a';
|
||||
vb.style.color = s.vacuum.deviated ? '#f66' : '#66f';
|
||||
}
|
||||
|
||||
let stRenderTempCol = '';
|
||||
|
||||
function stRenderTemp(snap) {
|
||||
stTempUpdateBadges();
|
||||
if (!stTempLive) {
|
||||
const el = document.getElementById('st-chart-temp');
|
||||
if (el) el.innerHTML = '<div style="padding:40px;text-align:center;color:#555">데이터 없음</div>';
|
||||
const meta = document.getElementById('st-temp-meta');
|
||||
if (meta) { meta.style.display = 'block'; meta.innerHTML = '<div style="padding:8px;color:#f66">데이터 로딩 실패</div>'; }
|
||||
return;
|
||||
}
|
||||
|
||||
const snapSrc = snap || stTempLive;
|
||||
stTempUpdateBadges(snapSrc);
|
||||
|
||||
stRenderTempCol = document.getElementById('st-temp-col').value;
|
||||
const stages = stTempLive.stages;
|
||||
if (!stages || !stages.length) return;
|
||||
@@ -234,29 +238,25 @@ function stRenderTemp(snap) {
|
||||
const el = document.getElementById('st-chart-temp');
|
||||
const chart = echarts.getInstanceByDom(el) || echarts.init(el);
|
||||
|
||||
const snapSrc = snap || stTempLive;
|
||||
|
||||
// 실시간 모드: thick blue one line
|
||||
// 과거 모드: thin dashed live + thick amber 선택시점
|
||||
const leg = ['기준밴드', '기준'];
|
||||
const leg = ['정상범위', '중앙값'];
|
||||
const series = [
|
||||
{ name: '_lo', type: 'line', data: lo, lineStyle: { opacity: 0 }, stack: 'band', symbol: 'none', silent: true },
|
||||
{ name: '기준밴드', type: 'line', data: band, stack: 'band', lineStyle: { opacity: 0 }, areaStyle: { color: 'rgba(80,140,200,0.18)' }, symbol: 'none', silent: true },
|
||||
{ name: '기준', type: 'line', data: med, lineStyle: { type: 'dashed', color: '#688' }, symbol: 'none' },
|
||||
{ name: '정상범위', type: 'line', data: band, stack: 'band', lineStyle: { opacity: 0 }, areaStyle: { color: 'rgba(80,140,200,0.18)' }, symbol: 'none', silent: true },
|
||||
{ name: '중앙값', type: 'line', data: med, lineStyle: { type: 'dashed', color: '#688' }, symbol: 'none' },
|
||||
];
|
||||
|
||||
if (snap) {
|
||||
// 과거 모드: thin dashed live + thick amber 선택시점
|
||||
const curD = stTempLive.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#6cf' } }));
|
||||
const snapD = snapSrc.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#fa3' } }));
|
||||
series.push({ name: '현재', type: 'line', data: curD, lineStyle: { color: '#6cf', width: 1, type: 'dashed' }, symbol: 'none', silent: true });
|
||||
series.push({ name: '선택시점', type: 'line', data: snapD, lineStyle: { color: '#fa3', width: 2.5 }, symbolSize: 9 });
|
||||
leg.push('현재', '선택시점');
|
||||
series.push({ name: '실시간참조', type: 'line', data: curD, lineStyle: { color: '#6cf', width: 1, type: 'dashed' }, symbol: 'none', silent: true });
|
||||
series.push({ name: '선택시점', type: 'line', data: snapD, lineStyle: { color: '#fa3', width: 2.5 }, symbol: 'circle', symbolSize: 9 });
|
||||
leg.push('실시간참조', '선택시점');
|
||||
} else {
|
||||
// 실시간 모드: thick blue
|
||||
const curD = stTempLive.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#6cf' } }));
|
||||
series.push({ name: '현재', type: 'line', data: curD, lineStyle: { color: '#6cf', width: 2.5 }, symbolSize: 9 });
|
||||
leg.push('현재');
|
||||
series.push({ name: '실시간', type: 'line', data: curD, lineStyle: { color: '#6cf', width: 2.5 }, symbol: 'circle', symbolSize: 9 });
|
||||
leg.push('실시간');
|
||||
}
|
||||
|
||||
chart.setOption({
|
||||
@@ -277,14 +277,52 @@ function stRenderTemp(snap) {
|
||||
});
|
||||
chart.resize();
|
||||
|
||||
// 상세 메타 (선택 시점 기준)
|
||||
// 상세 메타 — 두 개의 표 (온도 + 유량)
|
||||
const meta = document.getElementById('st-temp-meta');
|
||||
meta.style.display = 'block';
|
||||
const lines = snapSrc.stages.map(s =>
|
||||
`${(ST_STAGE_LABEL[s.stage] || s.stage).padEnd(12)} ${stFmt(s.current)}℃ 기준 ${stFmt(s.refMedian)}±${stFmt(s.refStd)} z=${s.z != null ? s.z.toFixed(1) : '—'}${s.deviated ? ' ⚠이격' : ''}`);
|
||||
if (snapSrc.spanAD != null) lines.push(`ΔT(A-D) ${stFmt(snapSrc.spanAD)}℃`);
|
||||
if (snapSrc.vacuum) lines.push(`진공 ${stFmt(snapSrc.vacuum.current)} 기준 ${stFmt(snapSrc.vacuum.refMedian)}±${stFmt(snapSrc.vacuum.refStd)} z=${snapSrc.vacuum.z != null ? snapSrc.vacuum.z.toFixed(1) : '—'}${snapSrc.vacuum.deviated ? ' ⚠이격' : ''}`);
|
||||
meta.textContent = lines.join('\n');
|
||||
|
||||
// 왼쪽 표: 온도
|
||||
// 표는 D(탑)→C→B→A(보텀) 순서로 표시 (차트는 A→B→C→D 유지)
|
||||
let stgRows = snapSrc.stages.map(s => {
|
||||
const label = ST_STAGE_LABEL[s.stage] || s.stage;
|
||||
const cur = stFmt(s.current) + '℃';
|
||||
const ref = s.refMedian != null ? `${stFmt(s.refMedian)}±${stFmt(s.refStd)}` : '—';
|
||||
return `<tr><td>${label}</td><td>${cur}</td><td>${ref}</td></tr>`;
|
||||
}).reverse();
|
||||
if (snapSrc.spanAD != null) {
|
||||
stgRows.push(`<tr><td>ΔT(A-D)</td><td>${stFmt(snapSrc.spanAD)}℃</td><td>—</td></tr>`);
|
||||
}
|
||||
if (snapSrc.vacuum) {
|
||||
const v = snapSrc.vacuum;
|
||||
const cur = stFmt(v.current);
|
||||
const ref = v.refMedian != null ? `${stFmt(v.refMedian)}±${stFmt(v.refStd)}` : '—';
|
||||
stgRows.push(`<tr><td>진공</td><td>${cur}</td><td>${ref}</td></tr>`);
|
||||
}
|
||||
|
||||
// 오른쪽 표: 유량
|
||||
let flowHtml = '';
|
||||
if (snapSrc.flow) {
|
||||
const f = snapSrc.flow;
|
||||
const ft = snapSrc.flowTags || {};
|
||||
const flowHeaders = [
|
||||
'<th></th>',
|
||||
`<th>FEED<br><small>${escHtml((ft.feed || 'FICQ-??01.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>REFLUX<br><small>${escHtml((ft.reflux || 'FICQ-??13.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>제품추출<br><small>${escHtml((ft.product || 'FICQ-??18.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>경비물<br><small>${escHtml((ft.overhead || 'FICQ-??14.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>중비물<br><small>${escHtml((ft.bottom || 'FICQ-??16.PV').replace('.PV',''))}</small></th>`,
|
||||
`<th>스팀<br><small>${escHtml((ft.steam || 'FIQ-???15.PV').replace('.PV',''))}</small></th>`,
|
||||
].join('');
|
||||
const stPv = stFmt(f.steam?.pv);
|
||||
const flowRows = [
|
||||
`<tr><td>PV</td><td>${stFmt(f.feed?.pv)}</td><td>${stFmt(f.reflux?.pv)}</td><td>${stFmt(f.product?.pv)}</td><td>${stFmt(f.overhead?.pv)}</td><td>${stFmt(f.bottom?.pv)}</td><td>${stPv}</td></tr>`,
|
||||
`<tr><td>SP</td><td>${stFmt(f.feed?.sp)}</td><td>${stFmt(f.reflux?.sp)}</td><td>${stFmt(f.product?.sp)}</td><td>${stFmt(f.overhead?.sp)}</td><td>${stFmt(f.bottom?.sp)}</td><td>—</td></tr>`,
|
||||
`<tr><td>OP</td><td>${stFmt(f.feed?.op)}</td><td>${stFmt(f.reflux?.op)}</td><td>${stFmt(f.product?.op)}</td><td>${stFmt(f.overhead?.op)}</td><td>${stFmt(f.bottom?.op)}</td><td>—</td></tr>`,
|
||||
].join('');
|
||||
flowHtml = `<table class="st-meta-flow"><thead><tr>${flowHeaders}</tr></thead><tbody>${flowRows}</tbody></table>`;
|
||||
}
|
||||
|
||||
meta.innerHTML = `<div class="st-meta-tables"><table class="st-meta-temp">${stgRows.join('')}</table>${flowHtml}</div>`;
|
||||
}
|
||||
|
||||
/* ── Live panel ── */
|
||||
|
||||
@@ -190,5 +190,13 @@
|
||||
.st-scrub-slider::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:14px; height:14px; border-radius:50%; background:#4af; border:2px solid #0a121a; cursor:pointer; }
|
||||
.st-scrub-slider::-moz-range-thumb { width:14px; height:14px; border-radius:50%; background:#4af; border:2px solid #0a121a; cursor:pointer; }
|
||||
.st-scrub-labels { display:flex; justify-content:space-between; font-size:9px; color:#555; margin-top:2px; }
|
||||
|
||||
.st-meta-tables { display:flex; gap:16px; margin-top:8px; font-size:12px; font-family:monospace; }
|
||||
.st-meta-temp, .st-meta-flow { border-collapse:collapse; }
|
||||
.st-meta-temp td { padding:2px 8px; border-bottom:1px solid #1a2a3a; text-align:left; white-space:nowrap; }
|
||||
.st-meta-flow th { padding:2px 6px; border-bottom:1px solid #2a3a4a; color:#888; font-weight:normal; text-align:right; }
|
||||
.st-meta-flow th small { display:block; font-size:9px; color:#555; margin-top:2px; text-align:left; }
|
||||
.st-meta-flow td { padding:2px 6px; border-bottom:1px solid #1a2a3a; color:#ccc; text-align:right; white-space:nowrap; }
|
||||
.st-meta-flow td:first-child { color:#666; font-weight:bold; text-align:left; }
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user