diff --git a/src/Hc900Crawler/Controllers/SteamAdvisorController.cs b/src/Hc900Crawler/Controllers/SteamAdvisorController.cs index efce057..77abca9 100644 --- a/src/Hc900Crawler/Controllers/SteamAdvisorController.cs +++ b/src/Hc900Crawler/Controllers/SteamAdvisorController.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Hc900Crawler.Core.Application.Interfaces; using Hc900Crawler.Infrastructure.Control; using Hc900Crawler.Infrastructure.Database; using Microsoft.AspNetCore.Mvc; @@ -16,12 +17,14 @@ public sealed class SteamAdvisorController : ControllerBase private readonly SteamAdvisor _advisor; private readonly Hc900DbContext _ctx; private readonly IConfiguration _config; + private readonly IExperionDbService _db; - public SteamAdvisorController(SteamAdvisor advisor, Hc900DbContext ctx, IConfiguration config) + public SteamAdvisorController(SteamAdvisor advisor, Hc900DbContext ctx, IConfiguration config, IExperionDbService db) { _advisor = advisor; _ctx = ctx; _config = config; + _db = db; EnsureTagDescs(); } @@ -179,31 +182,134 @@ public sealed class SteamAdvisorController : ControllerBase // col 규약 = 컬럼키("C-6111","C-6211","C-8111",...). [HttpGet("tempprofile/{col}")] public async Task TempProfile(string col) + { + var tref = await LoadTempRef(col); + if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" }); + + var cur = await FetchRealtimeValues(col, tref); + var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref); + if (prod is null) tref = tref with { }; // no-op, keep tref for metadata + + return Ok(new + { + column = tref.Column, + period = tref.Period, + matchedProduct = prod?.Label, + nProducts = tref.NProducts, + stages, + vacuum, + spanAD, + spanRef = prod?.SpanAD, + products = tref.Products, + timestamp = DateTime.UtcNow, + }); + } + + // ── 레이어③: 온도 프로파일 과거 이력 (z-score 추세) ────────────── + // history_table에서 time_bucket 집계 → 각 스냅샷 z-score 계산. + // 기본 1시간, max 4시간. UI는 z-score 추세 미니차트로 표시. + [HttpGet("tempprofile/{col}/history")] + public async Task TempProfileHistory( + string col, + [FromQuery] DateTime? from = null, + [FromQuery] DateTime? to = null) + { + try + { + var tref = await LoadTempRef(col); + if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" }); + + to ??= DateTime.UtcNow; + from ??= to.Value.AddHours(-1); + // 24시간 초과 방지 (전일 조회 대응) + if ((to.Value - from.Value).TotalHours > 24) + from = to.Value.AddHours(-24); + + var tagMap = TagsFor(ToSuffix(col)); + var tagNames = tagMap.Values.ToList(); + + var result = await _db.QueryHistoryWithIntervalAsync( + new HistoryIntervalQueryRequest(tagNames, from, to, "1 minute", 2000)); + + var snapshots = new List(); + var products = tref.Products; + + foreach (var row in result.Rows) + { + var cur = tagMap.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; + }); + + var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref); + snapshots.Add(new + { + ts = row.TimeBucket, + matchedProduct = prod?.Label, + stages, + vacuum, + spanAD, + }); + } + + return Ok(new + { + column = tref.Column, + period = tref.Period, + from, + to, + interval = "1 minute", + n = snapshots.Count, + snapshots, + timestamp = DateTime.UtcNow, + }); + } + catch (Exception ex) + { + return StatusCode(500, new { error = ex.Message, stack = ex.ToString() }); + } + } + + // ── 헬퍼 ────────────────────────────────────────────────────────── + + private async Task LoadTempRef(string col) { var dir = _config.GetValue("SteamAdvisor:ModelDir") ?? "/home/windpacer/projects/hc900_ax/scripts/analysis"; var path = Path.Combine(dir, $"{col}_tempref.json"); - if (!System.IO.File.Exists(path)) - return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" }); - - var tref = JsonSerializer.Deserialize( - await System.IO.File.ReadAllTextAsync(path), - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); - if (tref is null) return StatusCode(500, new { error = "tempref 파싱 실패" }); + if (!System.IO.File.Exists(path)) return null; + try + { + return JsonSerializer.Deserialize( + await System.IO.File.ReadAllTextAsync(path), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch { return null; } + } + private async Task> FetchRealtimeValues(string col, TempRef tref) + { var tagMap = TagsFor(ToSuffix(col)); var tagNames = tagMap.Values.ToArray(); var live = await _ctx.RealtimePoints .Where(r => tagNames.Contains(r.TagName)) .GroupBy(r => r.TagName) .ToDictionaryAsync(g => g.Key, g => g.OrderByDescending(r => r.Timestamp).FirstOrDefault()?.LiveValue); - double? Val(string tag) - => live.TryGetValue(tag, out var s) && double.TryParse(s, out var v) ? v : null; - var cur = tagMap.ToDictionary(kv => kv.Key, kv => Val(kv.Value)); + return tagMap.ToDictionary(kv => kv.Key, kv => + { + if (live.TryGetValue(kv.Value, out var s) && double.TryParse(s, out var v)) + return (double?)v; + return null; + }); + } - // 제품 매칭: 현재 reb_temp에 가장 가까운 기준 제품(온도프로파일=제품 식별자) + private static (TempProduct? prod, List stages, object vacuum, double? spanAD) ComputeStages( + Dictionary cur, TempRef tref) + { TempProduct? prod = null; - if (cur["reb_temp"] is double reb && tref.Products.Count > 0) + if (cur.GetValueOrDefault("reb_temp") is double reb && tref.Products.Count > 0) prod = tref.Products .OrderBy(pr => Math.Abs((pr.Stages.GetValueOrDefault("reb_temp")?.Median ?? 1e9) - reb)) .First(); @@ -218,32 +324,27 @@ public sealed class SteamAdvisorController : ControllerBase var z = Z(c, rs); return (object)new { - stage = s, current = c, refMedian = rs?.Median, refStd = rs?.Std, - z, deviated = z is double zz && Math.Abs(zz) > 2 + stage = s, + current = c, + refMedian = rs?.Median, + refStd = rs?.Std, + z, + deviated = z is double zz && Math.Abs(zz) > 2 }; }).ToList(); var vac = cur.GetValueOrDefault("vacuum"); var vz = Z(vac, prod?.Vacuum); - double? spanAD = (cur["reb_temp"] is double r2 && cur["T_D"] is double td) ? r2 - td : null; + double? spanAD = (cur.GetValueOrDefault("reb_temp") is double r2 && cur.GetValueOrDefault("T_D") is double td) ? r2 - td : null; - return Ok(new + return (prod, stages, new { - column = tref.Column, - period = tref.Period, - matchedProduct = prod?.Label, - nProducts = tref.NProducts, - stages, - vacuum = new - { - current = vac, refMedian = prod?.Vacuum.Median, refStd = prod?.Vacuum.Std, - z = vz, deviated = vz is double v && Math.Abs(v) > 2 - }, - spanAD, - spanRef = prod?.SpanAD, - products = tref.Products, - timestamp = DateTime.UtcNow, - }); + current = vac, + refMedian = prod?.Vacuum.Median, + refStd = prod?.Vacuum.Std, + z = vz, + deviated = vz is double v && Math.Abs(v) > 2 + }, spanAD); } // roles_for(C# 미러) — 단별 온도/진공 태그. c6111_extract COLUMN_EXCEPTIONS 대응. diff --git a/src/Hc900Crawler/wwwroot/js/steam.js b/src/Hc900Crawler/wwwroot/js/steam.js index 5663a5d..c4d0d7e 100644 --- a/src/Hc900Crawler/wwwroot/js/steam.js +++ b/src/Hc900Crawler/wwwroot/js/steam.js @@ -76,20 +76,48 @@ async function stLoadColumns() { /* ── 온도 프로파일 이격 모니터 ── */ let stTempTimer = null; +let stTempSnapshots = []; +let stTempLive = null; +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 stFmtDT = ts => { + const d = new Date(ts); + const kst = new Date(d.getTime() + 9 * 3600000); + return kst.toISOString().slice(0,19).replace('T',' '); +}; // KST (UTC+9) function stTempInit() { const sel = document.getElementById('st-temp-col'); if (sel && !sel.options.length) sel.innerHTML = ST_TEMP_COLS.map(([v, l]) => ``).join(''); + // default date = today + document.getElementById('st-temp-date').value = new Date().toISOString().split('T')[0]; + document.getElementById('st-temp-date').onchange = stTempLoad; document.getElementById('st-temp-load').onclick = stTempLoad; + document.querySelectorAll('.st-temp-range').forEach(btn => { + btn.onclick = () => { + document.querySelectorAll('.st-temp-range').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + stTempLoad(); + }; + }); + const slider = document.getElementById('st-scrub-slider'); + slider.addEventListener('input', stTempScrub); + slider.addEventListener('change', stTempScrub); + document.getElementById('st-scrub-live').onclick = stTempGoLive; } async function stTempLoad() { if (stTempTimer) { clearInterval(stTempTimer); stTempTimer = null; } - await stTempTick(); + stTempSnapshots = []; + stTempLive = null; + stTempIsLive = true; + try { + stTempLive = await api('GET', `/api/steam/tempprofile/${document.getElementById('st-temp-col').value}`); + } catch (_) {} + await stTempHistLoad(); stTempTimer = setInterval(stTempTick, 5000); } @@ -97,57 +125,166 @@ async function stTempTick() { const col = document.getElementById('st-temp-col').value; const st = document.getElementById('st-temp-status'); try { - const d = await api('GET', `/api/steam/tempprofile/${col}`); - stRenderTemp(d); + stTempLive = await api('GET', `/api/steam/tempprofile/${col}`); + if (stTempIsLive) { + stRenderTemp(); + } else { + stTempUpdateBadges(); + } st.textContent = '갱신: ' + new Date().toLocaleTimeString(); } catch (e) { st.textContent = '오류: ' + e.message; } } -function stRenderTemp(d) { - // 제품/진공 배지 - const pb = document.getElementById('st-temp-product'); - pb.textContent = '제품 ' + (d.matchedProduct || '—'); +async function stTempHistLoad() { + const col = document.getElementById('st-temp-col').value; + const dateStr = document.getElementById('st-temp-date').value; // "2026-06-07" + const todayStr = new Date().toISOString().split('T')[0]; + const isToday = (dateStr === todayStr); + const activeBtn = document.querySelector('.st-temp-range.active'); + const range = activeBtn ? activeBtn.dataset.range : '1h'; + const to = new Date(); + let from; + + if (isToday) { + if (range === '30m') from = new Date(to - 30 * 60000); + else if (range === '1h') from = new Date(to - 3600000); + else if (range === '4h') from = new Date(to - 4 * 3600000); + else if (range === 'today') { from = new Date(to); from.setHours(0, 0, 0, 0); } + else from = new Date(to - 3600000); + } else { + // 과거 날짜: 00:00~23:59 UTC → KST 기준 + from = new Date(dateStr + 'T00:00:00+09:00'); + to.setTime(from.getTime() + 86400000 - 1000); // 23:59:59 + } + const url = `/api/steam/tempprofile/${col}/history?from=${from.toISOString()}&to=${to.toISOString()}`; + console.log('[steam] hist URL:', url); + try { + const h = await api('GET', url); + console.log('[steam] hist response:', JSON.stringify(h).slice(0, 300)); + stTempSnapshots = h && h.snapshots || []; + } catch (e) { + console.warn('[steam] hist load fail:', e); + stTempSnapshots = []; + } + const slider = document.getElementById('st-scrub-slider'); + const n = stTempSnapshots.length; + if (n >= 2) { + slider.max = (n - 1).toString(); + slider.value = (n - 1).toString(); + document.getElementById('st-scrub-from').textContent = stFmtDT(stTempSnapshots[0].ts); + document.getElementById('st-scrub-to').textContent = stFmtDT(stTempSnapshots[n - 1].ts); + } else { + slider.max = '0'; slider.value = '0'; + document.getElementById('st-scrub-ts').textContent = n === 0 ? '히스토리 없음' : stFmtDT(stTempSnapshots[0].ts); + document.getElementById('st-scrub-from').textContent = '—'; + document.getElementById('st-scrub-to').textContent = '—'; + } + stRenderTemp(); +} + +function stTempGoLive() { + document.getElementById('st-temp-date').value = new Date().toISOString().split('T')[0]; + stTempLoad(); +} + +function stTempScrub() { + const idx = parseInt(document.getElementById('st-scrub-slider').value); + const snap = stTempSnapshots[idx]; + if (!snap) { + console.log('[steam] scrub: no snapshot at idx', idx, 'total', stTempSnapshots.length); + return; + } + stTempIsLive = false; + document.getElementById('st-scrub-ts').textContent = stFmtDT(snap.ts); + stRenderTemp(snap); +} + +// 배지만 갱신 (과거 모드에서 현재값 badge update) +function stTempUpdateBadges() { + if (!stTempLive) return; + document.getElementById('st-temp-product').textContent = '제품 ' + (stTempLive.matchedProduct || '—'); const vb = document.getElementById('st-temp-vac'); - vb.textContent = `진공 ${stFmt(d.vacuum.current)} (기준 ${stFmt(d.vacuum.refMedian)})` + (d.vacuum.deviated ? ' ⚠이격' : ''); - vb.style.background = d.vacuum.deviated ? '#3a1a1a' : '#1a1a3a'; - vb.style.color = d.vacuum.deviated ? '#f66' : '#66f'; + 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'; +} - const cats = d.stages.map(s => ST_STAGE_LABEL[s.stage] || s.stage); - const lo = d.stages.map(s => (s.refMedian != null && s.refStd != null) ? +(s.refMedian - 2 * s.refStd).toFixed(2) : null); - const band = d.stages.map(s => s.refStd != null ? +(4 * s.refStd).toFixed(2) : null); - const med = d.stages.map(s => s.refMedian); - const cur = d.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#6cf' } })); +let stRenderTempCol = ''; +function stRenderTemp(snap) { + stTempUpdateBadges(); + if (!stTempLive) { + const el = document.getElementById('st-chart-temp'); + if (el) el.innerHTML = '
데이터 없음
'; + return; + } + + stRenderTempCol = document.getElementById('st-temp-col').value; + const stages = stTempLive.stages; + if (!stages || !stages.length) return; + + const cats = stages.map(s => ST_STAGE_LABEL[s.stage] || s.stage); + const lo = stages.map(s => (s.refMedian != null && s.refStd != null) ? +(s.refMedian - 2 * s.refStd).toFixed(2) : null); + const band = stages.map(s => s.refStd != null ? +(4 * s.refStd).toFixed(2) : null); + const med = stages.map(s => s.refMedian); + + const col = stRenderTempCol; 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 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' }, + ]; + + 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('현재', '선택시점'); + } 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('현재'); + } + chart.setOption({ backgroundColor: 'transparent', - title: { text: `${d.column} 단면 온도 프로파일`, subtext: `매칭제품 ${d.matchedProduct || '—'} · 진공 ${stFmt(d.vacuum.current)} (기준 ${stFmt(d.vacuum.refMedian)})${d.vacuum.deviated ? ' ⚠' : ''}`, - textStyle: { color: '#ccc', fontSize: 13 }, subtextStyle: { color: d.vacuum.deviated ? '#f66' : '#888', fontSize: 11 } }, + title: { + text: snap ? `${col} · ${stFmtDT(snap.ts)}` : `${col} 단면 온도 프로파일`, + subtext: snap + ? `매칭제품 ${snap.matchedProduct || '—'} · 진공 ${stFmt(snap.vacuum?.current)} (기준 ${stFmt(snap.vacuum?.refMedian)})${snap.vacuum?.deviated ? ' ⚠' : ''}` + : `매칭제품 ${stTempLive.matchedProduct || '—'} · 진공 ${stFmt(stTempLive.vacuum.current)} (기준 ${stFmt(stTempLive.vacuum.refMedian)})${stTempLive.vacuum.deviated ? ' ⚠' : ''}`, + textStyle: { color: '#ccc', fontSize: 13 }, + subtextStyle: { color: snap ? (snap.vacuum?.deviated ? '#f66' : '#888') : (stTempLive.vacuum.deviated ? '#f66' : '#888'), fontSize: 11 } }, tooltip: { trigger: 'axis' }, - legend: { data: ['기준밴드', '기준', '현재'], textStyle: { color: '#888' }, top: 6, right: 10 }, + legend: { data: leg, textStyle: { color: '#888' }, top: 6, right: 10 }, grid: { top: 64, left: 48, right: 20, bottom: 28 }, xAxis: { type: 'category', data: cats, axisLabel: { color: '#aaa' } }, yAxis: { type: 'value', name: '℃', scale: true, axisLabel: { color: '#aaa' }, splitLine: { lineStyle: { color: '#1a2a3a' } } }, - 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: cur, lineStyle: { color: '#6cf', width: 2 }, symbolSize: 9 }, - ], + series, }); chart.resize(); - // 상세 메타 + // 상세 메타 (선택 시점 기준) const meta = document.getElementById('st-temp-meta'); meta.style.display = 'block'; - const lines = d.stages.map(s => + 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 ? ' ⚠이격' : ''}`); - lines.push(`ΔT(A-D) ${stFmt(d.spanAD)}℃ 기준 ${stFmt(d.spanRef)}`); - lines.push(`진공 ${stFmt(d.vacuum.current)} 기준 ${stFmt(d.vacuum.refMedian)}±${stFmt(d.vacuum.refStd)} z=${d.vacuum.z != null ? d.vacuum.z.toFixed(1) : '—'}${d.vacuum.deviated ? ' ⚠이격' : ''}`); - meta.textContent = lines.join('\n') + `\n\n기간 ${d.period} · 제품 ${d.nProducts}종`; + 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'); } /* ── Live panel ── */ diff --git a/src/Hc900Crawler/wwwroot/panes/steam.html b/src/Hc900Crawler/wwwroot/panes/steam.html index 0ce6ee5..bc13fe1 100644 --- a/src/Hc900Crawler/wwwroot/panes/steam.html +++ b/src/Hc900Crawler/wwwroot/panes/steam.html @@ -65,10 +65,31 @@ 제품 — 진공 — + 일자: + + 범위: + + + +
단별 온도(reb-A>T_B>T_C>T_D)를 기준밴드(±2σ)와 대조 · 진공 종속이라 진공 동시표시 · 밴드 이탈 단계=빨강(조성/제품전환 의심)
+ + +
+
+ + +
+ +
+ + +
+
+ @@ -158,5 +179,16 @@ .st-chart-panel { min-height:200px; } .st-bt-meta { margin-top:6px; font-size:11px; color:#666; background:#0d1520; border:1px solid #1a2a3a; border-radius:4px; padding:8px; white-space:pre-wrap; } .st-temp-note { font-size:11px; color:#789; margin-bottom:6px; } +.st-temp-range { font-size:10px; padding:2px 8px; } +.st-temp-range.active { background:#2a4a6a; color:#4af; border-color:#4af; } +.st-scrubber { margin-top:8px; padding:6px 10px; background:#0d1520; border:1px solid #1a2a3a; border-radius:4px; } +.st-scrub-top { display:flex; align-items:center; justify-content:space-between; margin-bottom:2px; } +.st-scrub-ts { font-size:12px; color:#8cf; } +.st-scrub-live { font-size:10px; padding:1px 10px; border:1px solid #2a4a6a; background:#0d1520; color:#4af; border-radius:3px; cursor:pointer; } +.st-scrub-live:hover { background:#1a2a4a; } +.st-scrub-slider { width:100%; height:4px; -webkit-appearance:none; appearance:none; background:#1a2a3a; border-radius:2px; outline:none; cursor:pointer; } +.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; } diff --git a/src/Infrastructure/Database/Hc900DbContext.cs b/src/Infrastructure/Database/Hc900DbContext.cs index df3f1ca..83bf897 100644 --- a/src/Infrastructure/Database/Hc900DbContext.cs +++ b/src/Infrastructure/Database/Hc900DbContext.cs @@ -453,7 +453,7 @@ public class Hc900DbService : IExperionDbService await _ctx.Database.ExecuteSqlRawAsync( "ALTER TABLE realtime_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'"); await _ctx.Database.ExecuteSqlRawAsync( - "ALTER TABLE history_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'"); + "ALTER TABLE history_table ADD COLUMN IF NOT EXISTS controller_id TEXT DEFAULT 'HC1'"); await _ctx.Database.ExecuteSqlRawAsync( "ALTER TABLE event_history_table ADD COLUMN IF NOT EXISTS controller_id TEXT NOT NULL DEFAULT 'HC1'"); await _ctx.Database.ExecuteSqlRawAsync( @@ -1721,7 +1721,8 @@ public class Hc900DbService : IExperionDbService List tags, DateTime? from, DateTime? to, string intervalStr, int limit) { var selectParts = new List(); - selectParts.Add($"time_bucket('{intervalStr}', recorded_at) AS time_bucket"); + // time_bucket은 TimescaleDB 전용 함수 — PostgreSQL 내장 date_trunc로 대체 + selectParts.Add($"date_trunc('second', recorded_at) AS time_bucket"); // 태그명별로 동적으로 PIVOT 컬럼 생성 (MAX + CASE) foreach (var tag in tags)