From fb11359b4c735d34d87b71b6c994a7c841abd030 Mon Sep 17 00:00:00 2001 From: windpacer Date: Thu, 30 Apr 2026 08:16:21 +0900 Subject: [PATCH] =?UTF-8?q?2026=EB=85=84=204=EC=9B=94=2030=EC=9D=BC=20Stab?= =?UTF-8?q?le=20State?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .roo/mcp.json | 3 + .roo/rules-code/roo-rules.md | 92 + .../Web/Controllers/TextToSqlController.cs | 364 ++++ .rooBackup/src/Web/wwwroot/index.html | 906 +++++++++ Qwen-crawler-analysis.md | 361 ++++ bench_qwen3.py | 106 + bench_qwen3_rag.py | 175 ++ .../fastSession-Error-correction.md | 0 fastTable/fastTable-coding-plan-byQwen3.md | 1759 +++++++++++++++++ fastTable/fastTable-문제점.md | 16 + .../idea-fastTable.md | 0 fastTable/project_fasttable_bugs.md | 189 ++ fastTable/step1.md | 69 + fastTable/step10.md | 108 + fastTable/step11.md | 193 ++ fastTable/step2.md | 65 + fastTable/step3.md | 77 + fastTable/step4.md | 105 + fastTable/step5.md | 78 + fastTable/step6.md | 173 ++ fastTable/step7.md | 400 ++++ fastTable/step8.md | 191 ++ fastTable/step9.md | 79 + llm-model-change.md | 98 +- mcp-server/__pycache__/server.cpython-312.pyc | Bin 6927 -> 22906 bytes mcp-server/pyproject.toml | 6 +- mcp-server/server.py | 325 ++- mcp-server/uv.lock | 91 + plans/roo-fasttable-implementation.md | 1614 +++++++++++++++ .../Interfaces/IExperionServices.cs | 79 + .../Database/ExperionDbContext.cs | 24 +- src/Infrastructure/OpcUa/ExperionOpcClient.cs | 14 +- src/Web/Controllers/ExperionControllers.cs | 8 +- src/Web/Controllers/TextToSqlController.cs | 15 +- src/Web/Program.cs | 9 + src/Web/appsettings.json | 5 + src/Web/wwwroot/css/style.css | 24 +- src/Web/wwwroot/index.html | 112 +- src/Web/wwwroot/js/app.js | 129 +- src/Web/wwwroot/lib/uPlot.iife.min.js | 2 + src/Web/wwwroot/lib/uPlot.min.css | 1 + 41 files changed, 7977 insertions(+), 88 deletions(-) create mode 100644 .roo/mcp.json create mode 100644 .roo/rules-code/roo-rules.md create mode 100644 .rooBackup/src/Web/Controllers/TextToSqlController.cs create mode 100644 .rooBackup/src/Web/wwwroot/index.html create mode 100644 Qwen-crawler-analysis.md create mode 100644 bench_qwen3.py create mode 100644 bench_qwen3_rag.py rename fastSession-Error-correction.md => fastTable/fastSession-Error-correction.md (100%) create mode 100644 fastTable/fastTable-coding-plan-byQwen3.md create mode 100644 fastTable/fastTable-문제점.md rename idea-fastTable.md => fastTable/idea-fastTable.md (100%) create mode 100644 fastTable/project_fasttable_bugs.md create mode 100644 fastTable/step1.md create mode 100644 fastTable/step10.md create mode 100644 fastTable/step11.md create mode 100644 fastTable/step2.md create mode 100644 fastTable/step3.md create mode 100644 fastTable/step4.md create mode 100644 fastTable/step5.md create mode 100644 fastTable/step6.md create mode 100644 fastTable/step7.md create mode 100644 fastTable/step8.md create mode 100644 fastTable/step9.md create mode 100644 plans/roo-fasttable-implementation.md create mode 100644 src/Web/wwwroot/lib/uPlot.iife.min.js create mode 100644 src/Web/wwwroot/lib/uPlot.min.css diff --git a/.roo/mcp.json b/.roo/mcp.json new file mode 100644 index 0000000..6e5de05 --- /dev/null +++ b/.roo/mcp.json @@ -0,0 +1,3 @@ +{ + "mcpServers": {} +} \ No newline at end of file diff --git a/.roo/rules-code/roo-rules.md b/.roo/rules-code/roo-rules.md new file mode 100644 index 0000000..31dc8e6 --- /dev/null +++ b/.roo/rules-code/roo-rules.md @@ -0,0 +1,92 @@ +# roo-rules.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +## 5. Backup + Diff Before Edit + +**기존 파일을 수정하기 전에 반드시 다음 두 단계를 수행할 것.** + +### Step 1 — 백업 +수정 대상 파일을 `.rooBackup/` 폴더에 원본 그대로 저장한다. + +- 저장 경로: `.rooBackup/<원본경로>/<파일명>` (디렉토리 구조 유지) +- 예: `src/Web/wwwroot/js/app.js` → `.rooBackup/src/Web/wwwroot/js/app.js` +- 백업 후 "백업 완료: `.rooBackup/...`" 를 출력할 것 + +### Step 2 — Diff 제시 +변경 내용을 diff 형식으로 보여주고, 사용자 확인 후 실제 수정 진행. + +```diff +- 기존 코드 ++ 변경된 코드 +``` + +변경 이유를 한 줄로 함께 설명할 것. + +### 예외 (백업/diff 생략 가능) +- 신규 파일 생성 +- 공백/포맷팅만 바뀌는 경우 + +**위반 사례 (금지):** 백업·diff 없이 바로 파일을 덮어쓰는 것 — roo가 이전에 fastRecord 섹션 전체를 날린 것이 이 케이스에 해당. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. \ No newline at end of file diff --git a/.rooBackup/src/Web/Controllers/TextToSqlController.cs b/.rooBackup/src/Web/Controllers/TextToSqlController.cs new file mode 100644 index 0000000..246a25e --- /dev/null +++ b/.rooBackup/src/Web/Controllers/TextToSqlController.cs @@ -0,0 +1,364 @@ +using ExperionCrawler.Core.Application.DTOs; +using ExperionCrawler.Core.Application.Interfaces; +using ExperionCrawler.Infrastructure.Mcp; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace ExperionCrawler.Web.Controllers; + +/// +/// Text-to-SQL API 컨트롤러 +/// 자연어 질의를 파싱하고 시계열 데이터를 조회합니다. +/// MCP (Model Context Protocol) 통합을 위한 엔드포인트를 제공합니다. +/// +[ApiController] +[Route("api/text-to-sql")] +public class TextToSqlController : ControllerBase +{ + private readonly ITextToSqlService _textToSqlService; + private readonly IExperionDbService _dbService; + private readonly IMcpService _mcpService; + private readonly ILogger _logger; + + public TextToSqlController( + ITextToSqlService textToSqlService, + IExperionDbService dbService, + IMcpService mcpService, + ILogger logger) + { + _textToSqlService = textToSqlService; + _dbService = dbService; + _mcpService = mcpService; + _logger = logger; + } + + /// + /// 자연어 질의를 SQL로 변환 + /// + [HttpPost("parse")] + public async Task Parse([FromBody] NaturalLanguageQueryDto dto) + { + try + { + var sql = await _textToSqlService.ParseNaturalLanguageAsync(dto.Query); + return Ok(new { success = true, sql }); + } + catch (Exception ex) + { + return Ok(new { success = false, error = ex.Message }); + } + } + + /// + /// MCP query_with_nl 도구 호출 - 자연어 → LLM SQL 생성 → 실행 + /// + [HttpPost("query-nl")] + public async Task QueryWithNl([FromBody] NaturalLanguageQueryDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Query)) + return BadRequest(new { success = false, error = "질문이 비어있음" }); + + try + { + var result = await _mcpService.QueryWithNlAsync(dto.Query); + if (!result.Success) + return Ok(new { success = false, error = result.Error }); + + try + { + var jsonData = System.Text.Json.JsonSerializer.Deserialize(result.Data!); + return Ok(new { success = true, data = jsonData }); + } + catch + { + return Ok(new { success = true, data = result.Data }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[TextToSql] query-nl 실패"); + return Ok(new { success = false, error = ex.Message }); + } + } + + /// + /// MCP 도구 목록 조회 + /// + [HttpGet("tools")] + public async Task ListTools() + { + try + { + var tools = await _mcpService.ListToolsAsync(); + return Ok(new { success = true, tools }); + } + catch (Exception ex) + { + _logger.LogError(ex, "[TextToSql] 도구 목록 조회 실패"); + return Ok(new { success = false, error = ex.Message }); + } + } + + /// + /// MCP run_sql 도구 호출 - SQL 실행 + /// Text-to-SQL 엔진으로 생성된 SQL을 안전하게 실행 + /// + [HttpPost("execute-mcp")] + public async Task ExecuteFromMcp([FromBody] SqlQueryDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Sql)) + { + return BadRequest(new { success = false, error = "SQL이 비어있음" }); + } + + try + { + // MCP run_sql 도구 호출 + var result = await _mcpService.RunSqlAsync(dto.Sql); + + if (!result.Success) + { + return Ok(new + { + success = false, + error = result.Error + }); + } + + // JSON 결과 반환 (쿼리 결과) + try + { + var jsonData = System.Text.Json.JsonSerializer.Deserialize(result.Data); + return Ok(new { success = true, data = jsonData }); + } + catch + { + return Ok(new { success = true, data = result.Data }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[TextToSql] MCP 실행 실패"); + return Ok(new { success = false, error = ex.Message }); + } + } + + /// + /// MCP query_pv_history 도구 호출 - 과거 값 히스토리 조회 + /// + [HttpPost("query-history")] + public async Task QueryHistory([FromBody] HistoryQueryRequestDto dto) + { + try + { + var tagNames = dto.TagNames ?? []; + var timeFrom = dto.From ?? DateTime.UtcNow.AddDays(-1).ToString("o"); + var timeTo = dto.To ?? DateTime.UtcNow.ToString("o"); + var limit = dto.Limit ?? 100; + + var result = await _mcpService.QueryPvHistoryAsync( + tagNames, + timeFrom, + timeTo, + limit + ); + + if (!result.Success) + { + return Ok(new + { + success = false, + error = result.Error + }); + } + + try + { + var jsonData = System.Text.Json.JsonSerializer.Deserialize(result.Data); + return Ok(new + { + success = true, + data = jsonData + }); + } + catch + { + return Ok(new + { + success = true, + data = result.Data + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[TextToSql] History 쿼리 실패"); + return Ok(new { success = false, error = ex.Message }); + } + } + + /// + /// MCP get_tag_metadata 도구 호출 - 태그 메타데이터 검색 + /// + [HttpGet("tags/search")] + public async Task SearchTags([FromQuery] string query, [FromQuery] int? limit) + { + try + { + var tagLimit = limit ?? 10; + var result = await _mcpService.GetTagMetadataAsync(query, tagLimit); + + if (!result.Success) + { + return Ok(new + { + success = false, + error = result.Error + }); + } + + try + { + var jsonData = System.Text.Json.JsonSerializer.Deserialize(result.Data); + return Ok(new + { + success = true, + data = jsonData + }); + } + catch + { + return Ok(new + { + success = true, + data = result.Data + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[TextToSql] 태그 검색 실패"); + return Ok(new { success = false, error = ex.Message }); + } + } + + /// + /// MCP list_drawings 도구 호출 - 도면 목록 조회 + /// + [HttpGet("drawings")] + public async Task ListDrawings([FromQuery] string? unitNo) + { + try + { + var result = await _mcpService.ListDrawingsAsync(unitNo); + + if (!result.Success) + { + return Ok(new + { + success = false, + error = result.Error + }); + } + + try + { + var jsonData = System.Text.Json.JsonSerializer.Deserialize(result.Data); + return Ok(new + { + success = true, + data = jsonData + }); + } + catch + { + return Ok(new + { + success = true, + data = result.Data + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[TextToSql] 도면 목록 조회 실패"); + return Ok(new { success = false, error = ex.Message }); + } + } + + /// + /// 쿼리 제안 (자동 완성) + /// + [HttpGet("suggest")] + public async Task Suggest([FromQuery] string input = "") + { + var suggestions = await _textToSqlService.SuggestQueriesAsync(input); + return Ok(new { success = true, suggestions }); + } + + /// + /// 시계열 분석 (평균, 최대, 최소, 추세) + /// + [HttpPost("analyze")] + public async Task Analyze([FromBody] AnalyzeRequestDto dto) + { + var result = await _textToSqlService.AnalyzeAsync(dto); + return Ok(new { + success = result.Success, + error = result.Error, + tags = result.Tags?.Select(t => new { + tagName = t.TagName, + avg = t.Avg, + mean = t.Mean, + min = t.Min, + max = t.Max, + first = t.First, + last = t.Last, + pointCount = t.PointCount, + stddev = t.StdDev, + from = t.From, + to = t.To + }).ToList() + }); + } + + /// + /// 사용자 지정 간격으로 history 이력 조회 + /// history_table의 기본 저장 간격(60초)을 기반으로 사용자가 요청한 간격으로 데이터 집계 + /// + [HttpPost("query-history-interval")] + public async Task QueryHistoryInterval([FromBody] HistoryIntervalQueryRequestDto dto) + { + try + { + var request = new HistoryIntervalQueryRequest( + dto.TagNames, + dto.From, + dto.To, + dto.Interval, + dto.Limit); + + var result = await _dbService.QueryHistoryWithIntervalAsync(request); + + var response = new + { + success = true, + tagNames = result.TagNames.ToList(), + rows = result.Rows.Select(r => new + { + timeBucket = r.TimeBucket, + values = r.Values + }).ToList(), + baseIntervalSeconds = result.BaseIntervalSeconds, + queryInterval = result.QueryInterval + }; + + return Ok(response); + } + catch (Exception ex) + { + _logger.LogError(ex, "[TextToSql] QueryHistoryInterval 실패"); + return StatusCode(StatusCodes.Status500InternalServerError, new { success = false, error = ex.Message }); + } + } +} diff --git a/.rooBackup/src/Web/wwwroot/index.html b/.rooBackup/src/Web/wwwroot/index.html new file mode 100644 index 0000000..ffcaf7c --- /dev/null +++ b/.rooBackup/src/Web/wwwroot/index.html @@ -0,0 +1,906 @@ + + + + + + ExperionCrawler + + + + +
+ + + + + +
+ + +
+
+
+

인증서 관리

+

OPC UA 클라이언트 인증서를 생성합니다. 기존 파일이 있으면 재사용됩니다.

+
+
PKI / X.509
+
+ +
+
+
인증서 생성
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
현재 인증서 상태
+ +
+ 상태 확인 버튼을 눌러 주세요 +
+
+
+ + +
+ + +
+
+
+

서버 접속 테스트

+

Experion OPC UA 서버에 연결하고 노드 값을 읽습니다.

+
+
OPC UA / TCP
+
+ +
+
서버 설정
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ +
+
단일 태그 읽기
+
+ + +
+ +
+ + + +
+ + +
+
+
+

데이터 크롤링

+

지정한 노드 값을 주기적으로 수집하여 CSV 파일로 저장합니다.

+
+
CRAWL / CSV
+
+ +
+
+
서버 설정
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
수집 노드 목록 (한 줄에 하나씩)
+ + +
+
+ + + + + + +
+ +
+
+

노드맵 수집

+

서버 전체 노드를 재귀 탐색하여 AssetLoader 용 CSV 파일로 저장합니다.

+
+
NODE MAP / CSV
+
+ +
+
전체 노드 탐색 설정
+
+
+ + +
+ +
+

+ 서버 설정은 위 크롤링 설정을 그대로 사용합니다  ·  + 노드 수에 따라 수 분이 소요될 수 있습니다  ·  + 결과는 data/csv/{서버명}_*.csv 에 저장됩니다 +

+
+ + + + +
+ + +
+
+
+

DB 저장

+

수집된 CSV 파일을 PostgreSQL DB에 저장하고 레코드를 조회합니다.

+
+
PostgreSQL / EF
+
+ +
+
+
CSV → DB 임포트
+ +
+ 갱신 버튼을 눌러 주세요 +
+
+ + +
+
+ +
+ + +
+
+ +
+ +
+
DB 레코드 조회
+
+ + +
+ +
+
+ + + +
+ + +
+
+
+

노드맵 대시보드

+

node_map_master 테이블을 조회합니다.

+
+
NODE MAP / MASTER
+
+ + +
+
필터 조건
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + + + +
+
+
+ + +
+ + + +
+
+
+ + + + + + +
+ + +
+
+
+

포인트빌더

+

node_map_master 에서 실시간 모니터링할 포인트를 선택해 realtime_table 을 구성합니다.

+
+
REALTIME / BUILD
+
+ + +
+
+
조건으로 테이블 작성
+
+ +
+ + + + + + + + + +
+
+
+ +
+ + +
+
+ + +
+ +
+
수동 포인트 추가
+
+ + +
+ + + +
실시간 구독 제어
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+ +
+
+ + +
+
+ 포인트 목록 (0개) + +
+
+
포인트가 없습니다. 위에서 테이블을 작성하세요.
+
+
+
+ + +
+
+
+

이력 조회

+

history_table 의 시계열 데이터를 조회합니다.

+
+
HISTORY / TREND
+
+ +
+
조회 조건
+
+
+ 태그 선택 (최대 8개, OR 조건) + + 대기 중 +
+
+ + + + + + + + +
+
+
+
+ + +
— 선택 안 함 —
+
+
+ + +
— 선택 안 함 —
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
하이퍼테이블 관리
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+ + + + + + + + + +
+ + +
+
+
+

OPC UA 서버

+

ExperionCrawler를 OPC UA 서버로 동작시켜 외부 클라이언트에 실시간 값을 제공합니다.

+
+
+ + +
+
+ + 상태 조회 중... +
+
+
+ + +
+ + + + +
+ + +
+ + +
+
+
+

Text-to-SQL 시계열 대시보드

+

자연어 질의를 통해 TimeScaleDB 시계열 데이터를 조회하고 분석합니다.

+
+
AI / SQL
+
+ + +
+
🗣 자연어 쿼리
+
+ + + + +
+
+ 추천 쿼리: + + + + +
+
+ + +
+
📝 생성된 SQL
+ +
+ + +
+
🏷 태그 분석 옵션
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
📊 조회 결과
+
+ 쿼리를 실행하면 여기에 결과가 표시됩니다 +
+
+ +
+
📈 태그 분석 결과
+
+ 분석을 실행하면 여기에 결과가 표시됩니다 +
+
+ + +
+ + +
+
+
+

fastRecord

+

고속 샘플링으로 실시간 데이터를 수집하고 트렌드를 분석합니다.

+
+
FAST / RECORD
+
+ + +
+
+ 세션 목록 + +
+
+
+ + +
+
+ 세션 상세 +
+ + + + + +
+
+ +
+
+
+
+ 0 / 0 (0%) + 경과: 0s +
+ +
+
+
+ +
+
+ + + + + + + + + + + + + + diff --git a/Qwen-crawler-analysis.md b/Qwen-crawler-analysis.md new file mode 100644 index 0000000..7f0c55b --- /dev/null +++ b/Qwen-crawler-analysis.md @@ -0,0 +1,361 @@ +# ExperionCrawler 프로젝트 분석 보고서 (Qwen 기반) + +**작성일**: 2026-04-28 +**분석 도구**: Qwen3-Coder-Next +**프로젝트 경로**: `/home/windpacer/projects/ExperionCrawler` + +--- + +## 1. 개요 + +### 1.1 프로젝트 목표 +Honeywell Experion OPC UA 서버를 위한 **웹 기반 데이터 수집 및 시계열 분석 도구**입니다. +OPC UA 프로토콜을 통해 실시간 데이터 수집, CSV 저장, TimescaleDB 이력 관리, 자연어 질의 처리까지 통합적으로 제공합니다. + +### 1.2 기술 스택 + +| 계층 | 기술 | 용도 | +|------|------|------| +| **Backend** | .NET 8 (C#) | 웹 API, 백그라운드 서비스 | +| **Frontend** | Vanilla JS + Bootstrap | UI 구현 | +| **Database** | PostgreSQL + TimescaleDB | 시계열 데이터 저장 | +| **OPC UA** | opcua-sharp (Opc.Ua) | 실시간/히스토리 데이터 수집 | +| **MCP** | Python + Qwen3-Coder-Next | 자연어 → SQL 변환 (LLM 기반) | + +--- + +## 2. 아키텍처 + +### 2.1 소스 구조 (Clean Architecture) + +``` +ExperionCrawler/ +├── src/ +│ ├── Core/ # 핵심 비즈니스 로직 (Domain, Application) +│ │ ├── Domain/ +│ │ │ └── Entities/ # 엔티티 정의 (ExperionTag, ExperionRecord, RealtimePoint...) +│ │ └── Application/ +│ │ ├── DTOs/ # 데이터 전송 객체 +│ │ ├── Interfaces/ # 서비스 인터페이스 (DI 대상) +│ │ └── Services/ # 구현체 (TextToSqlService, KoreanTimeRangeExtractor...) +│ │ +│ ├── Infrastructure/ # 기술적 구현 (OPC UA, DB, Mcp) +│ │ ├── Certificates/ # X.509 인증서 관리 (pki/ 디렉토리) +│ │ ├── Database/ # EF Core + TimescaleDB +│ │ ├── Csv/ # CSV 읽기/쓰기 (CsvHelper) +│ │ ├── OpcUa/ # OPC UA 클라이언트/서버 구현 +│ │ └── Mcp/ # MCP 클라이언트 (Python 통신) +│ │ +│ └── Web/ # ASP.NET Core 웹 프로젝트 +│ ├── Controllers/ # API 컨트롤러 +│ ├── Program.cs # DI, 미들웨어 구성 +│ └── wwwroot/ # 정적 파일 (index.html, js/app.js, css/) +``` + +### 2.2 DI 컨테이너 등록 (`Program.cs`) + +| Service | Lifetime | 구현체 | +|---------|----------|--------| +| `IExperionCertificateService` | Singleton | `ExperionCertificateService` | +| `IExperionStatusCodeService` | Singleton | `ExperionStatusCodeService` | +| `IOpcUaConfigProvider` | Singleton | `OpcUaConfigProvider` | +| `IExperionOpcClient` | Scoped | `ExperionOpcClient` | +| `IExperionCsvService` | Scoped | `ExperionCsvService` | +| `IExperionDbService` | Scoped | `ExperionDbService` | +| `ITextToSqlService` | Scoped | `TextToSqlService` | +| `IMcpService` | Singleton | `McpService` | +| `ExperionRealtimeService` | Singleton | 실시간 구독 (BackgroundService) | +| `ExperionHistoryService` | Singleton | 히스토리 구독 (BackgroundService) | +| `ExperionOpcServerService` | Singleton | OPC UA 서버 (BackgroundService) | + +--- + +## 3. 핵심 기능 + +### 3.1 인증서 관리 + +| 기능 | 설명 | +|------|------| +| **생성** | `POST /api/certificate/create` → X.509 클라이언트 인증서 생성 | +| **상태 확인** | `GET /api/certificate/status?clientHostName=dbsvr` | +| **PKI 구조** | `pki/{own,trusted,issuers,rejected}/certs/` | + +### 3.2 OPC UA 클라이언트 + +| 기능 | 설명 | +|------|------| +| **서버 접속** | `POST /api/connection/test` → 단일 태그 읽기, 노드 탐색 | +| **실시간 구독** | `ExperionRealtimeService` → Subscription 기반 콜백 | +| **히스토리 수집** | `ExperionHistoryService` → 주기적 Snapshot | +| **CSV 저장** | `ExperionCsvService` → `data/csv/` 디렉토리 | +| **DB 임포트** | `ExperionDbService` → `history_table` / `realtime_table` | + +### 3.3 Text-to-SQL (자연어 → SQL) + +| 기능 | 설명 | +|------|------| +| **자연어 파싱** | `POST /api/text-to-sql/parse` → `TextToSqlService.ParseNaturalLanguageAsync()` | +| **MCP 통합** | `POST /api/text-to-sql/query-nl` → LLM → SQL → 실행 | +| **도구 목록** | `GET /api/text-to-sql/tools` | +| **시계열 분석** | `POST /api/text-to-sql/analyze` → avg/max/min/추세 계산 | +| **간격 쿼리** | `POST /api/text-to-sql/query-history-interval` | + +**시간 키워드 예시**: +- `"최근 1시간"`, `"최근 24시간"`, `"최근 7일"`, `"최근 1개월"` +- `"오늘"`, `"어제"`, `"오늘부터 ~ 까지"`, `"어제부터 ~ 까지"` +- `"오전 9시부터 오후 5시까지"` + +### 3.4 MCP (Model Context Protocol) + +| 기능 | 설명 | +|------|------| +| **Ping** | `GET /api/mcp/ping` → Python 서버 연결 확인 | +| **SQL 실행** | `POST /api/mcp/run-sql` → TimescaleDB 쿼리 | +| **PV 히스토리** | `POST /api/mcp/query-pv-history` → 태그명 + 시간 범위 | +| **태그 메타데이터** | `POST /api/mcp/get-tag-metadata` | +| **도면 목록** | `GET /api/mcp/list-drawings?unitNo=...` | +| **자연어 질의** | `POST /api/mcp/query-with-nl` | + +### 3.5 OPC UA 서버 + +| 기능 | 설명 | +|------|------| +| **시작** | `POST /api/opcserver/start` → 자동 시작 플래그 저장 | +| **중지** | `POST /api/opcserver/stop` → 플래그 삭제 | +| **NodeManager** | `ExperionOpcServerNodeManager` → 커스텀 노드 매니저 | + +--- + +## 4. 데이터베이스 스키마 + +### 4.1 테이블 정의 + +| 테이블명 | 용도 | 주요 컬럼 | +|----------|------|-----------| +| `raw_node_map` | 노드맵 원시 데이터 | `id, level, class, name, node_id, data_type` | +| `node_map_master` | 마스터 노드맵 | `id, level, class, name, node_id, data_type` | +| `realtime_table` | 실시간 포인트 | `id, tagname, node_id, livevalue, timestamp` | +| `history_table` | 시계열 이력 | `id, tagname, node_id, value, recorded_at` | + +** TimescaleDB 확장 활성화: `CREATE EXTENSION IF NOT EXISTS timescaledb` ** + +--- + +## 5. API 엔드포인트 + +### 5.1 인증서 + +| 메서드 | 엔드포인트 | 기능 | +|--------|-----------|------| +| GET | `/api/certificate/status?clientHostName={name}` | 인증서 존재 여부 확인 | +| POST | `/api/certificate/create` | X.509 클라이언트 인증서 생성 | + +### 5.2 연결 + +| 메서드 | 엔드포인트 | 기능 | +|--------|-----------|------| +| POST | `/api/connection/test` | 서버 접속 테스트, 단일 태그 읽기 | +| POST | `/api/connection/read` | nodeId 기반 읽기 | +| POST | `/api/connection/browse` | 노드 탐색 | + +### 5.3 크롤링 + +| 메서드 | 엔드포인트 | 기능 | +|--------|-----------|------| +| POST | `/api/crawl/start` | 복수 노드 주기 수집 시작 | +| POST | `/api/crawl/stop` | 수집 중지 | +| POST | `/api/crawl/export` | CSV 다운로드 | + +### 5.4 DB + +| 메서드 | 엔드포인트 | 기능 | +|--------|-----------|------| +| GET | `/api/db/records?limit={n}&offset={m}` | 레코드 조회 | +| POST | `/api/db/import` | CSV 임포트 | +| POST | `/api/db/export` | CSV 다운로드 | + +### 5.5 Text-to-SQL + +| 메서드 | 엔드포인트 | 기능 | +|--------|-----------|------| +| POST | `/api/text-to-sql/parse` | 자연어 → SQL 변환 | +| POST | `/api/text-to-sql/execute` | SQL 실행 | +| POST | `/api/text-to-sql/suggest` | 쿼리 제안 | +| POST | `/api/text-to-sql/analyze` | 시계열 분석 | +| POST | `/api/text-to-sql/query-history-interval` | 사용자 지정 간격 조회 | +| POST | `/api/text-to-sql/query-nl` | MCP 통합 자연어 질의 | +| GET | `/api/text-to-sql/tools` | MCP 도구 목록 | + +### 5.6 OPC UA 서버 + +| 메서드 | 엔드포인트 | 기능 | +|--------|-----------|------| +| POST | `/api/opcserver/start` | 서버 시작 | +| POST | `/api/opcserver/stop` | 서버 중지 | +| GET | `/api/opcserver/status` | 서버 상태 | + +--- + +## 6. 주요 서비스 클래스 + +### 6.1 TextToSqlService + +| 기능 | 메서드 | +|------|--------| +| 자연어 파싱 | `ParseNaturalLanguageAsync(string input)` | +| SQL 생성 | `BuildSqlFromNaturalLanguage(string input, out List tagNames)` | +| 태그 매핑 | `GetMappingNodesAsync(List tagNames)` | +| 시계열 분석 | `AnalyzeAsync(string sql)` | +| 시간 범위 추출 | `KoreanTimeRangeExtractor` 협업 | + +### 6.2 ExperionOpcClient + +| 기능 | 메서드 | +|------|--------| +| 단일 읽기 | `ReadAsync(string nodeId)` | +| 복수 읽기 | `ReadAsync(List nodeIds)` | +| 노드 탐색 | `BrowseAsync(string nodeId)` | +| 연결 테스트 | `TestConnectionAsync(ExperionServerConfig cfg)` | + +### 6.3 ExperionRealtimeService + +| 기능 | 메서드 | +|------|--------| +| 시작 | `StartAsync(ExperionServerConfig cfg)` | +| 중지 | `StopAsync()` | +| 등록 | `SubscribeAsync(List nodeIds)` | +| 해제 | `UnsubscribeAsync(List nodeIds)` | + +--- + +## 7. 설정 파일 + +### 7.1 appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=iiot_platform;Username=postgres;Password=postgres", + "ExperionDbConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres;Trust Server Certificate=true" + }, + "OpcUaServer": { + "Port": 4841, + "EnableSecurity": false, + "AllowAnonymous": true, + "AllowedUsernames": ["mngr"], + "AllowedPasswords": ["mngr"] + } +} +``` + +### 7.2 자동 시작 플래그 + +| 파일 | 용도 | +|------|------| +| `realtime_autostart.json` | 실시간 구독 자동 시작 | +| `opcserver_autostart.json` | OPC UA 서버 자동 시작 | + +--- + +## 8. 개발/배포 + +### 8.1 로컬 실행 + +```bash +cd src/Web +dotnet run +# → http://localhost:5000 +``` + +### 8.2 Ubuntu 배포 + +```bash +git clone ExperionCrawler +cd ExperionCrawler +sudo bash deploy.sh +``` + +### 8.3 systemctl 관리 + +```bash +sudo systemctl status experioncrawler +sudo systemctl restart experioncrawler +sudo systemctl stop experioncrawler +sudo journalctl -u experioncrawler -f # 실시간 로그 +``` + +--- + +## 9. 테스팅 + +### 9.1 단위 테스트 프로젝트 + +| 테스트 클래스 | 주요 테스트 항목 | +|---------------|----------------| +| `TextToSqlServiceTests.cs` | SQL 생성, 태그 매핑, 시간 범위 추출 | +| `SqlValidatorTests.cs` | SQL 인젝션 방지, 테이블 제한 | +| `KoreanTimeRangeExtractorTests.cs` | 한국어 시간 표현 파싱 | + +### 9.2 테스트 명령어 + +```bash +dotnet test ExperionCrawler.Tests/ +``` + +--- + +## 10. 주의사항 + +### 10.1 JSON 직렬화 정책 + +`Program.cs`에서 **PascalCase 유지** 설정: + +```csharp +opt.JsonSerializerOptions.PropertyNamingPolicy = null; // camelCase로 변환하지 않음 +``` + +**프론트엔드 대응**: app.js의 모든 API 응답은 소문자 키로 접근 (`res.id`, `res.tagName`). + +### 10.2 인증서 경로 + +```bash +pki/own/certs/{clientHostName}.pfx # 클라이언트 인증서 +pki/trusted/certs/ # 신뢰 피어 +pki/issuers/certs/ # 신뢰 발급자 (필수) +pki/rejected/certs/ # 거부 인증서 +``` + +### 10.3 TimescaleDB + +- `history_table`은 TimescaleDB의 **Hypercube**로 자동 관리됨 +- `recorded_at` 컬럼은 `TIMESTAMPTZ` 타입 + +--- + +## 11. 다음 개선 방향 + +| 항목 | 설명 | +|------|------| +| **realtime_table indexing** | `node_id` 유니크 인덱스만 있고 `tagname` 인덱스 추가 필요 | +| **CSV import 성능** | AssetLoader의 binary COPY 대신 EF Core Bulk Insert 고려 | +| **MCP error handling** | Python 서버 장애 시 fallback 처리 강화 | +| **UI/UX** | Bootstrap 5 업그레이드, 모바일 반응형 개선 | +| **OPC UA Security** | 현재 `AutoAcceptUntrustedCertificates=true` → 프로덕션 시 변경 필요 | + +--- + +## 12. 관련 문서 + +| 문서 | 경로 | 설명 | +|------|------|------| +| CLAUDE.md | `CLAUDE.md` | Claude 작업 규칙 | +| .roo.md | `.roo.md` | Roo 작업 규칙 | +| task_state.md | `task_state.md` | Text-to-SQL 개발 로그 | +| issues.md | `issues.md` | 이슈 추적 | +| REVIEW_REQUEST.md | `REVIEW_REQUEST.md` | 코드 리뷰 요청 | + +--- + +**분석 완료일**: 2026-04-28 23:11 (KST) +**분석 도구**: Qwen3-Coder-Next +**프로젝트 상태**: ✅ 활발한 개발 중 (Text-to-SQL + MCP 통합 완료) diff --git a/bench_qwen3.py b/bench_qwen3.py new file mode 100644 index 0000000..3ff18f4 --- /dev/null +++ b/bench_qwen3.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +Qwen3-Coder-Next-FP8 출력 토큰 속도 벤치마크 +- 스트리밍 모드로 수신하며 토큰/초 실시간 측정 +- usage.completion_tokens 기반 최종 속도 산출 +""" + +import time +import sys +from openai import OpenAI + +VLLM_BASE_URL = "http://localhost:8000/v1" +VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8" + +# ── 프로그램 작성 예제 프롬프트 ──────────────────────────────────────────────── +PROMPT = """\ +Python으로 다음 조건을 만족하는 TTL-LRU 캐시 클래스를 작성해줘. + +요구사항: +1. `capacity` (최대 항목 수)와 `ttl_seconds` (항목 유효 시간)를 생성자에서 받는다. +2. `get(key)` — 없거나 만료된 항목은 None 반환. +3. `set(key, value)` — 캐시가 가득 차면 가장 오래된 항목을 제거한다. +4. `delete(key)` — 명시적 삭제. +5. `size()` — 현재 유효한 항목 수 반환 (만료된 항목 제외). +6. 스레드 안전해야 한다 (threading.Lock 사용). +7. 클래스 하단에 동작을 검증하는 `if __name__ == '__main__':` 테스트 코드를 포함한다. + +추가 조건: +- 외부 라이브러리 사용 금지 (표준 라이브러리만). +- 타입 힌트를 모든 메서드에 명시한다. +- 각 메서드에 한 줄 docstring을 작성한다. +""" + +def run_benchmark(): + client = OpenAI(base_url=VLLM_BASE_URL, api_key="dummy") + + print(f"모델 : {VLLM_MODEL}") + print(f"프롬프트 길이: {len(PROMPT)} chars") + print("=" * 60) + print() + + # ── 스트리밍 요청 ────────────────────────────────────────────── + stream = client.chat.completions.create( + model=VLLM_MODEL, + messages=[ + { + "role": "system", + "content": "당신은 숙련된 Python 개발자입니다. 명확하고 실용적인 코드를 작성합니다.", + }, + {"role": "user", "content": PROMPT}, + ], + max_tokens=2048, + temperature=0.1, + stream=True, + stream_options={"include_usage": True}, # 마지막 청크에 usage 포함 + ) + + # ── 스트리밍 수신 + 측정 ──────────────────────────────────────── + first_token_time = None + start_time = time.perf_counter() + char_count = 0 + completion_tokens = 0 + full_text = [] + + for chunk in stream: + # usage 청크 (마지막) + if chunk.usage: + completion_tokens = chunk.usage.completion_tokens + + if not chunk.choices: + continue + + delta = chunk.choices[0].delta + if delta.content: + if first_token_time is None: + first_token_time = time.perf_counter() + ttft = first_token_time - start_time + print(f"[TTFT: {ttft:.3f}s] ", end="", flush=True) + + sys.stdout.write(delta.content) + sys.stdout.flush() + full_text.append(delta.content) + char_count += len(delta.content) + + end_time = time.perf_counter() + + # ── 결과 출력 ────────────────────────────────────────────────── + total_time = end_time - start_time + gen_time = end_time - (first_token_time or start_time) + tps_wall = completion_tokens / total_time if total_time > 0 else 0 + tps_gen = completion_tokens / gen_time if gen_time > 0 else 0 + + print() + print() + print("=" * 60) + print(f"총 출력 토큰 : {completion_tokens:,}") + print(f"총 소요 시간 : {total_time:.2f}s") + print(f"생성 시간 : {gen_time:.2f}s (첫 토큰 이후)") + print(f"TTFT : {(first_token_time or start_time) - start_time:.3f}s") + print(f"토큰 속도 : {tps_gen:.1f} tok/s (생성 구간)") + print(f"토큰 속도 : {tps_wall:.1f} tok/s (전체 구간, TTFT 포함)") + print("=" * 60) + + +if __name__ == "__main__": + run_benchmark() diff --git a/bench_qwen3_rag.py b/bench_qwen3_rag.py new file mode 100644 index 0000000..5ae9127 --- /dev/null +++ b/bench_qwen3_rag.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +Qwen3-Coder-Next-FP8 RAG 연동 벤치마크 +- Qdrant 코드베이스 + OPC UA 문서에서 컨텍스트 수집 +- 수집된 실제 코드/문서 기반으로 복잡한 신규 기능 구현 요청 +- 스트리밍으로 토큰/초 측정 +""" + +import time +import sys +import httpx +from openai import OpenAI + +VLLM_BASE_URL = "http://localhost:8000/v1" +VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8" +OLLAMA_URL = "http://localhost:11434" +EMBED_MODEL = "nomic-embed-text" +QDRANT_URL = "http://localhost:6333" +COL_CODEBASE = "ws-65f457145aee80b2" +COL_OPC_DOCS = "experion-opc-docs" + + +def embed(text: str) -> list[float]: + with httpx.Client(timeout=30) as c: + r = c.post(f"{OLLAMA_URL}/api/embeddings", json={"model": EMBED_MODEL, "prompt": text}) + r.raise_for_status() + return r.json()["embedding"] + + +def search(collection: str, query: str, top_k: int = 5) -> list[dict]: + vec = embed(query) + with httpx.Client(timeout=20) as c: + r = c.post( + f"{QDRANT_URL}/collections/{collection}/points/search", + json={"vector": vec, "limit": top_k, "with_payload": True}, + ) + r.raise_for_status() + return r.json()["result"] + + +def fmt_hits(hits: list[dict], label: str) -> str: + chunks = [] + for i, h in enumerate(hits, 1): + p = h["payload"] + src = p.get("file_path") or p.get("source") or p.get("filename") or "unknown" + text = p.get("text") or p.get("content") or p.get("chunk") or str(p) + score = h.get("score", 0) + chunks.append(f"[{label} #{i} | {src} | score={score:.3f}]\n{text}") + return "\n\n".join(chunks) + + +def run_benchmark(): + client = OpenAI(base_url=VLLM_BASE_URL, api_key="dummy") + + # ── RAG 컨텍스트 수집 ────────────────────────────────────────────────────── + print("RAG 검색 중...") + t0 = time.perf_counter() + + # 코드베이스: 실시간 서비스 구조 + DB 저장 패턴 + hits_realtime = search(COL_CODEBASE, "ExperionRealtimeService FlushLoop subscription MonitoredItem", top_k=4) + hits_db = search(COL_CODEBASE, "ExperionDbContext history snapshot PostgreSQL EF Core", top_k=3) + + # OPC UA 문서: 알람/이벤트 관련 + hits_alarm = search(COL_OPC_DOCS, "alarm event notification EventNotifier condition OPC UA", top_k=4) + + rag_time = time.perf_counter() - t0 + total_hits = len(hits_realtime) + len(hits_db) + len(hits_alarm) + print(f"검색 완료: {total_hits}개 청크 ({rag_time:.2f}s)") + print() + + ctx_realtime = fmt_hits(hits_realtime, "코드베이스/Realtime") + ctx_db = fmt_hits(hits_db, "코드베이스/DB") + ctx_alarm = fmt_hits(hits_alarm, "OPC UA 문서/Alarm") + + # ── 프롬프트 구성 ────────────────────────────────────────────────────────── + prompt = f"""\ +아래는 ExperionCrawler 프로젝트의 실제 코드와 OPC UA 공식 문서 발췌입니다. +이 컨텍스트를 기반으로 새로운 기능을 구현해줘. + +━━━ 코드베이스 컨텍스트 ━━━ + +{ctx_realtime} + +{ctx_db} + +━━━ OPC UA 문서 컨텍스트 ━━━ + +{ctx_alarm} + +━━━ 구현 요청 ━━━ + +위 컨텍스트를 바탕으로 ExperionAlarmService를 C#으로 구현해줘. + +요구사항: +1. `IHostedService` + `IExperionAlarmService` 패턴 (기존 ExperionRealtimeService와 동일한 구조). +2. OPC UA `EventNotifier` 방식으로 알람/이벤트를 구독한다. + 구독 대상 EventType: ConditionType, AlarmConditionType (OPC UA 표준). +3. 이벤트 수신 시 다음 정보를 `alarm_history` PostgreSQL 테이블에 저장한다: + - `id` (bigserial), `tagname`, `event_type`, `severity` (int), `message`, `active` (bool), `occurred_at` (timestamptz) +4. 기존 `ExperionDbContext` / EF Core 패턴을 따른다 (새 DbSet 추가). +5. 컨트롤러 `ExperionAlarmController` — start/stop/status + 최근 알람 조회 (GET /api/alarm/recent?limit=50). +6. `appsettings.json`에 `AlarmServer` 섹션 추가 (NodeId 목록, MaxSeverityFilter). +7. 각 클래스/메서드에 한 줄 XML 문서 주석 포함. + +코드는 완성된 형태로 작성하고, 파일별로 명확히 구분해줘. +""" + + prompt_chars = len(prompt) + print(f"프롬프트 길이: {prompt_chars:,} chars (RAG 컨텍스트 포함)") + print(f"모델: {VLLM_MODEL}") + print("=" * 60) + print() + + # ── 스트리밍 LLM 요청 ────────────────────────────────────────────────────── + stream = client.chat.completions.create( + model=VLLM_MODEL, + messages=[ + { + "role": "system", + "content": ( + "당신은 C#/.NET 백엔드와 OPC UA 프로토콜 전문가입니다. " + "ExperionCrawler 프로젝트의 기존 코드 스타일과 패턴을 그대로 따르며 " + "완성도 높은 코드를 작성합니다." + ), + }, + {"role": "user", "content": prompt}, + ], + max_tokens=4096, + temperature=0.1, + stream=True, + stream_options={"include_usage": True}, + ) + + # ── 스트리밍 수신 + 측정 ──────────────────────────────────────────────────── + first_token_time = None + start_time = time.perf_counter() + completion_tokens = 0 + + for chunk in stream: + if chunk.usage: + completion_tokens = chunk.usage.completion_tokens + if not chunk.choices: + continue + delta = chunk.choices[0].delta + if delta.content: + if first_token_time is None: + first_token_time = time.perf_counter() + ttft = first_token_time - start_time + print(f"[TTFT: {ttft:.3f}s] ", end="", flush=True) + sys.stdout.write(delta.content) + sys.stdout.flush() + + end_time = time.perf_counter() + + # ── 결과 출력 ────────────────────────────────────────────────────────────── + total_time = end_time - start_time + gen_time = end_time - (first_token_time or start_time) + tps_gen = completion_tokens / gen_time if gen_time > 0 else 0 + tps_wall = completion_tokens / total_time if total_time > 0 else 0 + + print() + print() + print("=" * 60) + print(f"RAG 검색 시간 : {rag_time:.2f}s ({total_hits}개 청크)") + print(f"총 출력 토큰 : {completion_tokens:,}") + print(f"총 소요 시간 : {total_time:.2f}s") + print(f"생성 시간 : {gen_time:.2f}s (첫 토큰 이후)") + print(f"TTFT : {(first_token_time or start_time) - start_time:.3f}s") + print(f"토큰 속도 : {tps_gen:.1f} tok/s (생성 구간)") + print(f"토큰 속도 : {tps_wall:.1f} tok/s (전체 구간)") + print("=" * 60) + + +if __name__ == "__main__": + run_benchmark() diff --git a/fastSession-Error-correction.md b/fastTable/fastSession-Error-correction.md similarity index 100% rename from fastSession-Error-correction.md rename to fastTable/fastSession-Error-correction.md diff --git a/fastTable/fastTable-coding-plan-byQwen3.md b/fastTable/fastTable-coding-plan-byQwen3.md new file mode 100644 index 0000000..e236aad --- /dev/null +++ b/fastTable/fastTable-coding-plan-byQwen3.md @@ -0,0 +1,1759 @@ +# fastTable/fastRecord 코딩 플랜 (Qwen3-Coder-Next) + +## 개요 + +`idea-fastTable.md`의 요구사항을 기반으로 ExperionCrawler 프로젝트에 fastTable/fastRecord 기능을 구현합니다. + +### 핵심 개념 + +| 개념 | 설명 | +|------|------| +| **fastSession** | 데이터 수집 세션 (메타 정보 저장) | +| **fastRecord** | 초 단위로 수집된 실시간 데이터 (TimescaleDB hypertable) | +| **별도 Subscription** | 기존 realtime Subscription과 분리된 고해상도 구독 | + +--- + +## Task A — DB 스키마 + 엔티티 + +### 1. `ExperionEntities.cs` — 엔티티 추가 + +**파일**: `src/Core/Domain/Entities/ExperionEntities.cs` + +```csharp +/// fastSession — 데이터 수집 세션 메타 +[Table("fast_session")] +public class FastSession +{ + [Column("id")] public int Id { get; set; } + [Column("name")] public string Name { get; set; } = string.Empty; + [Column("started_at")] public DateTime StartedAt { get; set; } + [Column("ended_at")] public DateTime? EndedAt { get; set; } + [Column("status")] public string Status { get; set; } = "Pending"; // Pending/Running/Completed/Cancelled/Failed/RowLimitReached + [Column("sampling_ms")] public int SamplingMs { get; set; } // 100/250/500/1000 + [Column("duration_sec")] public int DurationSec { get; set; } + [Column("tag_list")] public string TagList { get; set; } = "[]"; // JSON array of tagNames + [Column("row_count")] public int RowCount { get; set; } + [Column("retention_days")] public int? RetentionDays { get; set; } // null = 무한 보관 + [Column("pinned")] public bool Pinned { get; set; } +} + +/// fastRecord — 시계열 데이터 (Long 포맷: 태그 1행/시점) +[Table("fast_record")] +public class FastRecord +{ + [Column("id")] public int Id { get; set; } + [Column("session_id")] public int SessionId { get; set; } + [Column("recorded_at")] public DateTime RecordedAt { get; set; } + [Column("tagname")] public string TagName { get; set; } = string.Empty; + [Column("value")] public string? Value { get; set; } +} +``` + +### 2. `ExperionDbContext.cs` — DbSet + 테이블 생성 + +**파일**: `src/Infrastructure/Database/ExperionDbContext.cs` + +#### DbSet 추가 (20~25번째 줄 근처) + +```csharp +public DbSet FastSessions => Set(); +public DbSet FastRecords => Set(); +``` + +#### OnModelCreating에 인덱스 추가 (57번째 줄 근처) + +```csharp +modelBuilder.Entity(e => +{ + e.HasKey(x => x.Id); + e.HasIndex(x => x.Status); + e.HasIndex(x => x.StartedAt); +}); + +modelBuilder.Entity(e => +{ + e.HasKey(x => x.Id); + e.HasIndex(x => x.SessionId); + e.HasIndex(x => new { x.SessionId, x.TagName, x.RecordedAt }); +}); +``` + +#### DB 초기화 시 hypertable 생성 (100번째 줄 근처) + +`ExperionDbService.InitializeAsync()` 메서드에 추가: + +```csharp +// fastSession / fastRecord 테이블 생성 +await _ctx.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS fast_session ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + started_at TIMESTAMPTZ NOT NULL, + ended_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'Pending', + sampling_ms INTEGER NOT NULL, + duration_sec INTEGER NOT NULL, + tag_list JSONB NOT NULL DEFAULT '[]', + row_count INTEGER NOT NULL DEFAULT 0, + retention_days INTEGER, + pinned BOOLEAN NOT NULL DEFAULT FALSE + ) + """); + +await _ctx.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS fast_record ( + id SERIAL PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES fast_session(id) ON DELETE CASCADE, + recorded_at TIMESTAMPTZ NOT NULL, + tagname TEXT NOT NULL, + value TEXT + ) + """); + +// TimescaleDB hypertable 생성 (recorded_at 기준, chunk_interval = 1 day) +await _ctx.Database.ExecuteSqlRawAsync(""" + SELECT create_hypertable('fast_record', 'recorded_at', if_not_exists => TRUE) + """); + +// chunk_time_interval 설정 (기본 1day) +await _ctx.Database.ExecuteSqlRawAsync(""" + SELECT set_chunk_time_interval('fast_record', INTERVAL '1 day') + """); +``` + +### 3. `IExperionServices.cs` — 인터페이스 추가 + +**파일**: `src/Core/Application/Interfaces/IExperionServices.cs` + +#### DTOs 추가 (DTOs 파일에 별도 정의 후 import) + +```csharp +// IExperionFastService 인터페이스 +public interface IExperionFastService +{ + Task StartSessionAsync(FastSessionStartRequest request); + Task StopSessionAsync(int sessionId); + Task DeleteSessionAsync(int sessionId); + Task PinSessionAsync(int sessionId, bool pinned); + Task GetSessionAsync(int sessionId); + Task> GetSessionsAsync(); + Task GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long"); + Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null); +} + +// FastSessionStatus enum +public enum FastSessionStatus +{ + Pending, Running, Completed, Cancelled, Failed, RowLimitReached +} + +// FastSessionInfo record +public record FastSessionInfo( + int Id, + string Name, + DateTime StartedAt, + DateTime? EndedAt, + string Status, + int SamplingMs, + int DurationSec, + string[] TagList, + int RowCount, + int? RetentionDays, + bool Pinned +); + +// FastSessionCreateRequest record (Claude 진단 #3) +public record FastSessionCreateRequest( + string Name, + int SamplingMs, + int DurationSec, + string[] TagList, + int? RetentionDays = null +); + +// FastQueryResult record +public record FastQueryResult( + int SessionId, + DateTime From, + DateTime To, + string[] TagNames, + IEnumerable Items, + int TotalCount +); + +// FastSessionStartRequest record +public record FastSessionStartRequest( + string Name, + int SamplingMs, + int DurationSec, + string[] TagList, + int? RetentionDays = null +); +``` + +--- + +## Task B — FastService (백그라운드 + 컨트롤러) + +### 1. `ExperionFastService.cs` — 신규 파일 생성 + +**파일**: `src/Infrastructure/OpcUa/ExperionFastService.cs` + +```csharp +using System.Collections.Concurrent; +using System.Text.Json; // ✅ 추가: JsonSerializer.Serialize/Deserialize 사용 (Claude 진단 #7) +using ExperionCrawler.Core.Application.Interfaces; +using ExperionCrawler.Core.Domain.Entities; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Opc.Ua; +using Opc.Ua.Client; + +namespace ExperionCrawler.Infrastructure.OpcUa; + +/// +/// fastRecord 데이터 수집 서비스. +/// 세션별 별도 OPC UA Subscription을 관리하고, 2초마다 배치 INSERT. +/// +public class ExperionFastService : IExperionFastService, IHostedService, IDisposable +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly IServiceProvider _sp; + private readonly IOpcUaConfigProvider _configProvider; + private readonly IExperionOpcClient _opcClient; + + private readonly ConcurrentDictionary _sessions = new(); + private readonly object _lock = new(); + + private CancellationTokenSource? _cts; + private Task? _monitorTask; + + // 설정 + private int _maxConcurrentSessions = 3; + private int _maxRowsPerSession = 5_000_000; + private int _flushIntervalMs = 2000; + + public ExperionFastService( + IServiceScopeFactory scopeFactory, + ILogger logger, + IServiceProvider sp, + IOpcUaConfigProvider configProvider, + IExperionOpcClient opcClient) + { + _scopeFactory = scopeFactory; + _logger = logger; + _sp = sp; + _configProvider = configProvider; + _opcClient = opcClient; + } + + // ── IHostedService ──────────────────────────────────────────────────────── + + public async Task StartAsync(CancellationToken cancellationToken) + { + // 앱 시작 시 Running 상태 세션 → Failed 마킹 + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var sessions = await db.GetFastSessionsAsync(); + var runningSessions = sessions.Where(s => s.Status == "Running").ToList(); + + foreach (var s in runningSessions) + { + _logger.LogWarning("[Fast] 앱 시작 시 Running 세션 {Id} → Failed 마킹", s.Id); + await db.UpdateFastSessionStatusAsync(s.Id, "Failed"); + } + + // 모니터링 루프 시작 + _cts = new CancellationTokenSource(); + _monitorTask = Task.Run(() => MonitorLoopAsync(_cts.Token), _cts.Token); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _cts?.Cancel(); + if (_monitorTask != null) + await _monitorTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + // 모든 Running 세션 graceful 종료 + foreach (var kvp in _sessions) + { + var ctx = kvp.Value; + ctx.Cancel = true; + } + + await Task.Delay(2000).ConfigureAwait(false); // flush 대기 + } + + // ── IExperionFastService ────────────────────────────────────────────────── + + public async Task StartSessionAsync(FastSessionStartRequest request) + { + // 유효성 검사 + if (request.TagList.Length > 8) + throw new ArgumentException("태그는 최대 8개까지 가능합니다."); + + if (request.SamplingMs is not (100 or 250 or 500 or 1000)) + throw new ArgumentException("샘플링 간격은 100/250/500/1000ms 중 하나여야 합니다."); + + // 동시 세션 수 제한 + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var runningCount = (await db.GetFastSessionsAsync()).Count(s => s.Status == "Running"); + + if (runningCount >= _maxConcurrentSessions) + throw new InvalidOperationException($"동시 실행 가능한 세션은 {_maxConcurrentSessions}개까지입니다."); + + // ✅ 수정: IOpcUaConfigProvider를 통해 서버 설정 가져오기 (Claude 진단 #5) + var cfg = await _configProvider.GetConfigAsync(new ExperionServerConfig()); + if (!await _opcClient.IsConnectedAsync(cfg)) + throw new InvalidOperationException("OPC UA 서버에 연결되어 있지 않습니다."); + + // ✅ 추가: 설정이 유효한지 확인 + if (string.IsNullOrEmpty(cfg?.EndpointUrl)) + throw new InvalidOperationException("서버 엔드포인트 URL이 설정되어 있지 않습니다."); + + // 노드 유효성 사전 검증 (Read 1회) + foreach (var tagName in request.TagList) + { + var nodeId = await db.GetNodeIdByTagNameAsync(tagName); + if (string.IsNullOrEmpty(nodeId)) + throw new ArgumentException($"태그 '{tagName}'에 대한 nodeId를 찾을 수 없습니다."); + + var readResult = await _opcClient.ReadTagAsync(cfg, nodeId); + if (!readResult.Success) + throw new ArgumentException($"태그 '{tagName}' (nodeId: {nodeId}) 읽기 실패: {readResult.ErrorMessage}"); + } + + // DB에 세션 생성 + var session = await db.CreateFastSessionAsync(new FastSessionCreateRequest + { + Name = request.Name, + SamplingMs = request.SamplingMs, + DurationSec = request.DurationSec, + TagList = request.TagList, + RetentionDays = request.RetentionDays + }); + + // Subscription 생성 + var ctx = new FastSessionContext + { + SessionId = session.Id, + TagList = request.TagList, + SamplingMs = request.SamplingMs, + DurationSec = request.DurationSec, + StartedAt = DateTime.UtcNow, + Buffer = new ConcurrentQueue() + }; + + _sessions[session.Id] = ctx; + + // Subscription 시작 + await StartSubscriptionAsync(ctx, cfg); + + // 상태 업데이트 + await db.UpdateFastSessionStatusAsync(session.Id, "Running"); + + _logger.LogInformation("[Fast] 세션 {Id} 시작 — 태그 {Count}개, 샘플링 {Ms}ms, 기간 {Sec}s", + session.Id, request.TagList.Length, request.SamplingMs, request.DurationSec); + + return MapToInfo(session); + } + + public async Task StopSessionAsync(int sessionId) + { + if (!_sessions.TryGetValue(sessionId, out var ctx)) + throw new InvalidOperationException($"세션 {sessionId}를 찾을 수 없습니다."); + + ctx.Cancel = true; + await FlushBufferAsync(ctx).ConfigureAwait(false); + await StopSubscriptionAsync(ctx).ConfigureAwait(false); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.UpdateFastSessionStatusAsync(sessionId, "Completed"); + // ✅ 수정: 누적 RowCount 사용 (Claude 진단 #8) + await db.UpdateFastSessionRowCountAsync(sessionId, ctx.TotalRows); + + _logger.LogInformation("[Fast] 세션 {Id} 중지 — 수집 {Count}행", sessionId, ctx.TotalRows); + } + + public async Task DeleteSessionAsync(int sessionId) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.DeleteFastSessionAsync(sessionId); + _sessions.TryRemove(sessionId, out _); + } + + public async Task PinSessionAsync(int sessionId, bool pinned) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.UpdateFastSessionPinnedAsync(sessionId, pinned); + } + + public async Task GetSessionAsync(int sessionId) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var session = await db.GetFastSessionAsync(sessionId); + return session == null ? null : MapToInfo(session); + } + + public async Task> GetSessionsAsync() + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var sessions = await db.GetFastSessionsAsync(); + return sessions.Select(MapToInfo); + } + + public async Task GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long") + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var result = await db.GetFastRecordsAsync(sessionId, from, to); + return result; + } + + public async Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.ExportFastRecordsToCsvAsync(sessionId, stream, from, to); + } + + // ── Private ──────────────────────────────────────────────────────────────── + + // ✅ 참고: CreateSessionAsync 사용을 위해 파일 상단에 using Opc.Ua; 추가 필요 + private async Task StartSubscriptionAsync(FastSessionContext ctx, ApplicationConfiguration cfg) + { + var session = await _opcClient.CreateSessionAsync(cfg); + var subscription = new Subscription(session.DefaultSubscription) + { + PublishingInterval = ctx.SamplingMs, + KeepAliveCount = 10 + }; + + foreach (var tagName in ctx.TagList) + { + var nodeId = await GetNodeIdAsync(tagName); + var item = new MonitoredItem(subscription) + { + StartNodeId = nodeId, + SamplingInterval = ctx.SamplingMs, + DisplayName = tagName + }; + item.Notification += (sender, e) => OnNotification(ctx, e, tagName); + subscription.AddItem(item); + } + + await session.AddSubscriptionAsync(subscription); + subscription.Create(); + + ctx.Subscription = subscription; + ctx.Session = session; + } + + private async Task StopSubscriptionAsync(FastSessionContext ctx) + { + if (ctx.Subscription != null) + { + ctx.Subscription.Delete(false); + ctx.Subscription = null; + } + + if (ctx.Session != null) + { + await ctx.Session.CloseAsync(); + await ctx.Session.DisposeAsync(); + ctx.Session = null; + } + } + + private void OnNotification(FastSessionContext ctx, MonitoredItemNotificationEventArgs e, string tagName) + { + if (ctx.Cancel) return; + + // ✅ 수정: MonitoredItemNotification 사용 (Claude 진단 #6) + if (e.NotificationValue is MonitoredItemNotification notification) + { + var dv = notification.Value; + var value = dv.Value?.ToString(); + + var record = new FastRecord + { + SessionId = ctx.SessionId, + RecordedAt = DateTime.UtcNow, + TagName = tagName, + Value = value + }; + ctx.Buffer.Enqueue(record); + + // ✅ 수정: 누적 RowCount 증가 (Claude 진단 #8, #9) + ctx.TotalRows++; + } + } + + private async Task FlushBufferAsync(FastSessionContext ctx) + { + var buffer = new List(); + while (ctx.Buffer.TryDequeue(out var record)) + { + buffer.Add(record); + } + + if (buffer.Count == 0) return; + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.BatchInsertFastRecordsAsync(buffer); + + // ✅ 수정: 누적 RowCount 업데이트 (Claude 진단 #8) + await db.UpdateFastSessionRowCountAsync(ctx.SessionId, ctx.TotalRows); + + // ✅ 수정: 누적 총 행 수와 비교 (Claude 진단 #9) + if (ctx.TotalRows >= _maxRowsPerSession) + { + ctx.Cancel = true; + await StopSessionAsync(ctx.SessionId); + using var s = _scopeFactory.CreateScope(); + var d = s.ServiceProvider.GetRequiredService(); + await d.UpdateFastSessionStatusAsync(ctx.SessionId, "RowLimitReached"); + } + } + + private async Task MonitorLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + await Task.Delay(_flushIntervalMs, ct); + + foreach (var kvp in _sessions) + { + var ctx = kvp.Value; + if (ctx.Cancel) continue; + + // 기간 만료 체크 + var elapsed = (DateTime.UtcNow - ctx.StartedAt).TotalSeconds; + if (elapsed >= ctx.DurationSec) + { + ctx.Cancel = true; + await StopSessionAsync(ctx.SessionId); + continue; + } + + await FlushBufferAsync(ctx); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.LogError(ex, "[Fast] 모니터링 루프 오류"); + } + } + } + + private FastSessionInfo MapToInfo(FastSession session) + => new(session.Id, session.Name, session.StartedAt, session.EndedAt, + session.Status, session.SamplingMs, session.DurationSec, + // ✅ 수정: JSONB 파싱을 위해 JsonSerializer.Deserialize 사용 (Claude 진단 #7) + JsonSerializer.Deserialize(session.TagList) ?? [], + session.RowCount, session.RetentionDays, session.Pinned); + + // ✅ 참고: JsonSerializer.Deserialize 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요 + private async Task GetNodeIdAsync(string tagName) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.GetNodeIdByTagNameAsync(tagName) ?? string.Empty; + } + + private class FastSessionContext + { + public int SessionId { get; set; } + public string[] TagList { get; set; } = []; + public int SamplingMs { get; set; } + public int DurationSec { get; set; } + public DateTime StartedAt { get; set; } + public ConcurrentQueue Buffer { get; set; } = new(); + public int TotalRows { get; set; } // ✅ 수정: 누적 총 행 수 (Claude 진단 #8, #9) + public bool Cancel { get; set; } + public ISession? Session { get; set; } + public Subscription? Subscription { get; set; } + } +} +``` + +### 2. `ExperionDbContext.cs` — DB 서비스 메서드 추가 + +**파일**: `src/Infrastructure/Database/ExperionDbContext.cs` + +#### ExperionDbService에 추가할 메서드 (1000번째 줄 근처) + +```csharp +// ✅ 참고: JsonSerializer 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요 + +// ── FastSession / FastRecord ──────────────────────────────────────────────── + +public async Task CreateFastSessionAsync(FastSessionCreateRequest request) +{ + var session = new FastSession + { + Name = request.Name, + SamplingMs = request.SamplingMs, + DurationSec = request.DurationSec, + // ✅ 수정: JSONB 저장을 위해 JsonSerializer.Serialize 사용 (Claude 진단 #7) + TagList = JsonSerializer.Serialize(request.TagList), + StartedAt = DateTime.UtcNow, + Status = "Pending", + RowCount = 0, + RetentionDays = request.RetentionDays, + Pinned = false + }; + + _ctx.FastSessions.Add(session); + await _ctx.SaveChangesAsync(); + return session; +} + +public async Task UpdateFastSessionStatusAsync(int sessionId, string status) +{ + var session = await _ctx.FastSessions.FindAsync(sessionId); + if (session == null) return; + + session.Status = status; + if (status == "Completed" || status == "Cancelled" || status == "Failed" || status == "RowLimitReached") + session.EndedAt = DateTime.UtcNow; + + await _ctx.SaveChangesAsync(); +} + +public async Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount) +{ + var session = await _ctx.FastSessions.FindAsync(sessionId); + if (session == null) return; + + session.RowCount = rowCount; + await _ctx.SaveChangesAsync(); +} + +public async Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned) +{ + var session = await _ctx.FastSessions.FindAsync(sessionId); + if (session == null) return; + + session.Pinned = pinned; + await _ctx.SaveChangesAsync(); +} + +public async Task GetFastSessionAsync(int sessionId) + => await _ctx.FastSessions.FindAsync(sessionId); + +// ✅ 참고: JsonSerializer.Deserialize 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요 +public async Task> GetFastSessionsAsync() + => await _ctx.FastSessions.OrderBy(x => x.StartedAt).ToListAsync(); + +public async Task DeleteFastSessionAsync(int sessionId) +{ + var session = await _ctx.FastSessions.FindAsync(sessionId); + if (session == null) return; + + _ctx.FastSessions.Remove(session); + await _ctx.SaveChangesAsync(); +} + +// ✅ 참고: JsonSerializer.Deserialize 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요 +public async Task GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to) +{ + var query = _ctx.FastRecords.Where(x => x.SessionId == sessionId); + + if (from.HasValue) + query = query.Where(x => x.RecordedAt >= from.Value); + + if (to.HasValue) + query = query.Where(x => x.RecordedAt <= to.Value); + + var records = await query.OrderBy(x => x.RecordedAt).ToListAsync(); + var tagNames = records.Select(x => x.TagName).Distinct().OrderBy(x => x).ToArray(); + + return new FastQueryResult + { + SessionId = sessionId, + From = from ?? records.MinBy(x => x.RecordedAt)?.RecordedAt ?? DateTime.UtcNow, + To = to ?? records.MaxBy(x => x.RecordedAt)?.RecordedAt ?? DateTime.UtcNow, + TagNames = tagNames, + Items = records, + TotalCount = records.Count + }; +} + +public async Task BatchInsertFastRecordsAsync(IEnumerable records) +{ + if (!records.Any()) return; + + _ctx.FastRecords.AddRange(records); + await _ctx.SaveChangesAsync(); +} + +// ✅ 참고: JsonSerializer.Deserialize 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요 +public async Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null) +{ + var result = await GetFastRecordsAsync(sessionId, from, to); + + using var writer = new StreamWriter(stream, leaveOpen: true); + var header = "recorded_at," + string.Join(",", result.TagNames.Select(t => $"\"{t}\"")); + await writer.WriteLineAsync(header); + + // PIVOT: recorded_at 기준 그룹화 + var grouped = result.Items.GroupBy(x => x.RecordedAt) + .OrderBy(x => x.Key) + .Select(g => new + { + Time = g.Key, + Values = g.ToDictionary(r => r.TagName, r => r.Value) + }); + + foreach (var g in grouped) + { + var row = g.Time.ToString("o") + "," + string.Join(",", result.TagNames.Select(t => g.Values.TryGetValue(t, out var v) ? $"\"{v}\"" : "")); + await writer.WriteLineAsync(row); + } + + await writer.FlushAsync(); +} + +public async Task GetNodeIdByTagNameAsync(string tagName) +{ + return await _ctx.RealtimePoints + .Where(x => x.TagName == tagName) + .Select(x => x.NodeId) + .FirstOrDefaultAsync(); +} +``` + +### 3. `ExperionFastController` — 컨트롤러 추가 + +**파일**: `src/Web/Controllers/ExperionControllers.cs` + +```csharp +// ── FastTable / FastRecord ──────────────────────────────────────────────────── + +[ApiController] +[Route("api/fast")] +public class ExperionFastController : ControllerBase +{ + private readonly IExperionFastService _fastSvc; + + public ExperionFastController(IExperionFastService fastSvc) + => _fastSvc = fastSvc; + + /// 새 fastSession 시작 + [HttpPost("start")] + public async Task Start([FromBody] FastSessionStartRequest request) + { + try + { + var session = await _fastSvc.StartSessionAsync(request); + return Ok(new + { + id = session.Id, + name = session.Name, + status = session.Status, + startedAt = session.StartedAt + }); + } + catch (ArgumentException ex) + { + return BadRequest(new { error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return Conflict(new { error = ex.Message }); + } + } + + /// 세션 중지 + [HttpPost("{id:int}/stop")] + public async Task Stop(int id) + { + try + { + await _fastSvc.StopSessionAsync(id); + return Ok(new { success = true, message = "세션이 중지되었습니다." }); + } + catch (InvalidOperationException ex) + { + return NotFound(new { error = ex.Message }); + } + } + + /// 세션 목록 조회 + [HttpGet("sessions")] + public async Task GetSessions() + { + var sessions = await _fastSvc.GetSessionsAsync(); + return Ok(new + { + total = sessions.Count(), + items = sessions.Select(s => new + { + id = s.Id, + name = s.Name, + status = s.Status, + samplingMs = s.SamplingMs, + durationSec = s.DurationSec, + tagCount = s.TagList.Length, + rowCount = s.RowCount, + startedAt = s.StartedAt, + endedAt = s.EndedAt, + retentionDays = s.RetentionDays, + pinned = s.Pinned + }) + }); + } + + /// 세션 상세 정보 + [HttpGet("{id:int}")] + public async Task GetSession(int id) + { + var session = await _fastSvc.GetSessionAsync(id); + if (session == null) + return NotFound(); + + return Ok(new + { + id = session.Id, + name = session.Name, + status = session.Status, + samplingMs = session.SamplingMs, + durationSec = session.DurationSec, + tagList = session.TagList, + rowCount = session.RowCount, + startedAt = session.StartedAt, + endedAt = session.EndedAt, + retentionDays = session.RetentionDays, + pinned = session.Pinned + }); + } + + /// 레코드 조회 + [HttpGet("{id:int}/records")] + public async Task GetRecords(int id, + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] string format = "long") + { + var result = await _fastSvc.GetRecordsAsync(id, from, to, format); + + return Ok(new + { + sessionId = result.SessionId, + from = result.From, + to = result.To, + tagNames = result.TagNames, + total = result.TotalCount, + items = result.Items.Select(r => new + { + sessionId = r.SessionId, + recordedAt = r.RecordedAt, + tagName = r.TagName, + value = r.Value + }) + }); + } + + /// CSV Export (스트리밍) + [HttpGet("{id:int}/csv")] + public async Task ExportCsv(int id, + [FromQuery] DateTime? from, + [FromQuery] DateTime? to) + { + var memoryStream = new MemoryStream(); + await _fastSvc.ExportCsvAsync(id, memoryStream, from, to); + memoryStream.Position = 0; + + return File(memoryStream, "text/csv", $"fast-{id}-{DateTime.Now:yyyyMMddHHmm}.csv"); + } + + /// 세션 삭제 + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + try + { + await _fastSvc.DeleteSessionAsync(id); + return Ok(new { success = true, message = "세션이 삭제되었습니다." }); + } + catch (InvalidOperationException ex) + { + return NotFound(new { error = ex.Message }); + } + } + + /// 세션 고정/해제 + [HttpPost("{id:int}/pin")] + public async Task Pin(int id, [FromBody] PinRequest request) + { + try + { + await _fastSvc.PinSessionAsync(id, request.Pinned); + return Ok(new { success = true, pinned = request.Pinned }); + } + catch (InvalidOperationException ex) + { + return NotFound(new { error = ex.Message }); + } + } +} + +public record PinRequest(bool Pinned); +``` + +### 4. `Program.cs` — 서비스 등록 + +**파일**: `src/Web/Program.cs` + +```csharp +// ── FastTable Service ──────────────────────────────────────────────────────── +// ✅ 수정: 올바른 DI 등록 패턴 (Claude 진단 #4) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); +``` + +### 5. `appsettings.json` — 설정 추가 + +**파일**: `src/Web/appsettings.json` + +```json +{ + "Fast": { + "MaxConcurrentSessions": 3, + "MaxRowsPerSession": 5000000, + "FlushIntervalMs": 2000 + } +} +``` + +--- + +## Task C — UI: 09 fastRecord 탭 + +### 1. `index.html` — HTML 구조 추가 + +**파일**: `src/Web/wwwroot/index.html` + +```html + +
  • 09 fastRecord
  • + + +
    +
    +
    + +
    +
    +
    +
    fastSession 목록
    + +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    +
    +
    세션 상세
    +
    + + + + + +
    +
    +
    + +
    +
    +
    +
    + 0 / 0 (0%) + 경과: 0s +
    + + +
    + + +
    +
    통계 요약
    +
    +
    +
    +
    +
    +
    +
    +
    + + + +``` + +### 2. `app.js` — JavaScript 로직 추가 + +**파일**: `src/Web/wwwroot/js/app.js` + +```javascript +// ── fastRecord Variables ────────────────────────────────────────────────────── +let fastCurrentSessionId = null; +let fastChart = null; +let fastLivePollTimer = null; +let fastTagList = []; + +// ── fastRecord Functions ────────────────────────────────────────────────────── + +async function fastSessionsLoad() { + const res = await fetch('/api/fast/sessions'); + if (!res.ok) return; + const data = await res.json(); + + const list = document.getElementById('fast-session-list'); + list.innerHTML = ''; + + data.items.forEach(s => { + const item = document.createElement('a'); + item.className = 'list-group-item list-group-item-action'; + item.href = '#'; + item.dataset.id = s.id; + item.innerHTML = ` +
    +
    ${s.name}
    + ${s.status} +
    +

    ${s.tagCount} tags • ${s.samplingMs}ms • ${formatDuration(s.durationSec)}

    + ${formatDateTime(s.startedAt)} + ${s.pinned ? '📌' : ''} + `; + item.onclick = () => fastSelect(s.id); + list.appendChild(item); + }); +} + +async function fastStart() { + const name = document.getElementById('fast-session-name').value.trim(); + if (!name) { alert('세션 이름을 입력하세요.'); return; } + + const select = document.getElementById('fast-tag-select'); + const tags = Array.from(select.selectedOptions).map(o => o.value); + if (tags.length === 0) { alert('태그를 최소 1개 이상 선택하세요.'); return; } + if (tags.length > 8) { alert('태그는 최대 8개까지 선택 가능합니다.'); return; } + + const samplingMs = parseInt(document.getElementById('fast-sampling-ms').value); + const durationSec = parseInt(document.getElementById('fast-duration-sec').value); + const retentionDays = document.getElementById('fast-retention-days').value.trim() || null; + + const res = await fetch('/api/fast/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, samplingMs, durationSec, tagList: tags, retentionDays }) + }); + + if (!res.ok) { + const err = await res.json(); + alert('오류: ' + (err.error || '알 수 없는 오류')); + return; + } + + const data = await res.json(); + await fastSessionsLoad(); + fastSelect(data.id); + document.getElementById('modal-fast-new').querySelector('.btn-close').click(); +} + +async function fastStop(id) { + const res = await fetch(`/api/fast/${id}/stop`, { method: 'POST' }); + if (!res.ok) { alert('중지 실패'); return; } + await fastSessionsLoad(); + if (fastCurrentSessionId === id) { + fastCurrentSessionId = null; + fastClearChart(); + } +} + +async function fastDelete(id) { + if (!confirm('세션을 삭제하시겠습니까?')) return; + const res = await fetch(`/api/fast/${id}`, { method: 'DELETE' }); + if (!res.ok) { alert('삭제 실패'); return; } + await fastSessionsLoad(); + if (fastCurrentSessionId === id) { + fastCurrentSessionId = null; + fastClearChart(); + } +} + +async function fastPin(id) { + const res = await fetch(`/api/fast/${id}/pin`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pinned: true }) + }); + if (!res.ok) { alert('고정 실패'); return; } + await fastSessionsLoad(); +} + +async function fastSelect(id) { + fastCurrentSessionId = id; + const res = await fetch(`/api/fast/${id}`); + if (!res.ok) { alert('세션 조회 실패'); return; } + const session = await res.json(); + + document.getElementById('fast-session-title').textContent = `${session.name} (${session.status})`; + document.getElementById('fast-progress-bar').style.width = '0%'; + document.getElementById('fast-progress-text').textContent = '0 / 0 (0%)'; + + // 버튼 상태 업데이트 + const isRunning = session.status === 'Running'; + document.getElementById('btn-fast-stop').style.display = isRunning ? 'inline' : 'none'; + document.getElementById('btn-fast-export-xlsx').style.display = isRunning ? 'none' : 'inline'; + document.getElementById('btn-fast-export-csv').style.display = isRunning ? 'none' : 'inline'; + document.getElementById('btn-fast-delete').style.display = 'inline'; + document.getElementById('btn-fast-pin').textContent = session.pinned ? '고정 해제' : '고정'; + + // 태그 목록 업데이트 + fastTagList = session.tagList; + + // 그래프 렌더링 + await fastRenderChart(); + + // 라이브 폴링 시작 + if (isRunning) { + fastLivePollStart(); + } else { + fastLivePollStop(); + } +} + +async function fastRenderChart() { + if (!fastCurrentSessionId) return; + + const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`); + if (!res.ok) return; + const data = await res.json(); + + if (!data.items || data.items.length === 0) { + document.getElementById('fast-chart-container').innerHTML = '
    수집된 데이터가 없습니다.
    '; + return; + } + + // PIVOT: recorded_at 기준 그룹화 + const grouped = data.items.reduce((acc, r) => { + if (!acc[r.recordedAt]) acc[r.recordedAt] = {}; + acc[r.recordedAt][r.tagName] = r.value; + return acc; + }, {}); + + const times = Object.keys(grouped).sort(); + const timesNum = times.map(t => new Date(t).getTime()); + + // ✅ 수정: uPlot 데이터 포맷 [[x0,x1,...], [y1_0,y1_1,...], [y2_0,...]] (Claude 진단 #10) + const datasets = data.tagNames.map(tag => ({ + label: tag, + data: times.map(t => grouped[t][tag]) + })); + + // uPlot 사용 (Chart.js 대신) + const ctx = document.getElementById('fast-chart-container'); + ctx.innerHTML = ''; + + const opts = { + title: 'fastRecord 실시간 트렌드', + width: ctx.clientWidth, + height: 400, + axes: [{ + time: true, + label: '시간 (KST)', + values: (u, vals) => vals.map(v => new Date(v).toLocaleTimeString('ko-KR')) + }, { + label: '값' + }], + series: [{}, ...datasets] + }; + + // ✅ 수정: new uPlot(opts, data, target) 시그니처 (Claude 진단 #10) + fastChart = new uPlot(opts, [timesNum, ...datasets.map(d => d.data)], ctx); +} + +function fastClearChart() { + if (fastChart) { + fastChart.destroy(); + fastChart = null; + } + document.getElementById('fast-chart-container').innerHTML = ''; +} + +function fastLivePollStart() { + if (fastLivePollTimer) return; + fastLivePollTimer = setInterval(async () => { + if (!fastCurrentSessionId) { fastLivePollStop(); return; } + await fastRenderChart(); + await fastUpdateProgress(); + }, 2000); +} + +function fastLivePollStop() { + if (fastLivePollTimer) { + clearInterval(fastLivePollTimer); + fastLivePollTimer = null; + } +} + +async function fastUpdateProgress() { + if (!fastCurrentSessionId) return; + + const res = await fetch(`/api/fast/${fastCurrentSessionId}`); + if (!res.ok) return; + const session = await res.json(); + + const elapsed = Math.floor((Date.now() - new Date(session.startedAt).getTime()) / 1000); + const progress = Math.min((elapsed / session.durationSec) * 100, 100); + + document.getElementById('fast-progress-bar').style.width = `${progress}%`; + document.getElementById('fast-progress-text').textContent = `${session.rowCount} / ~${(session.durationSec * session.tagList.length)} (${progress.toFixed(1)}%)`; + document.getElementById('fast-elapsed-time').textContent = `경과: ${formatDuration(elapsed)}`; +} + +// ── Helper Functions ──────────────────────────────────────────────────────── + +function formatDuration(seconds) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + return `${h}h ${m}m ${s}s`; +} + +function formatDateTime(dt) { + return new Date(dt).toLocaleString('ko-KR'); +} + +function getColorForTag(tag) { + const colors = ['#ff6384', '#36a2eb', '#ffce56', '#4bc0c0', '#9966ff', '#ff9f40', '#8ac926', '#1982c4']; + let sum = 0; + for (let i = 0; i < tag.length; i++) sum += tag.charCodeAt(i); + return colors[sum % colors.length]; +} + +// ── Event Listeners ────────────────────────────────────────────────────────── + +document.getElementById('btn-fast-new')?.addEventListener('click', () => { + // 태그 목록 로드 + const select = document.getElementById('fast-tag-select'); + select.innerHTML = ''; + tagNames.forEach(name => { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + select.appendChild(opt); + }); + document.getElementById('modal-fast-new').querySelector('.modal-title').textContent = '신규 fastSession'; + document.getElementById('modal-fast-new').style.display = 'block'; + new bootstrap.Modal(document.getElementById('modal-fast-new')).show(); +}); + +document.getElementById('btn-fast-start')?.addEventListener('click', fastStart); + +document.getElementById('btn-fast-stop')?.addEventListener('click', () => { + if (fastCurrentSessionId) fastStop(fastCurrentSessionId); +}); + +document.getElementById('btn-fast-delete')?.addEventListener('click', () => { + if (fastCurrentSessionId) fastDelete(fastCurrentSessionId); +}); + +document.getElementById('btn-fast-pin')?.addEventListener('click', () => { + if (fastCurrentSessionId) fastPin(fastCurrentSessionId); +}); + +document.getElementById('btn-fast-export-xlsx')?.addEventListener('click', async () => { + if (!fastCurrentSessionId) return; + const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`); + if (!res.ok) return; + const data = await res.json(); + + // ✅ 수정: 배열의 배열로 변환 (Claude 진단 #11) + const rows = [['recorded_at', ...data.tagNames]]; + + for (const r of data.items) { + if (!rows[r.recordedAt]) { + rows[r.recordedAt] = [new Date(r.recordedAt).toLocaleString('ko-KR')]; + for (let i = 0; i < data.tagNames.length; i++) rows[r.recordedAt].push(''); + } + rows[r.recordedAt][data.tagNames.indexOf(r.tagName) + 1] = r.value; + } + + const ws = XLSX.utils.aoa_to_sheet(rows); + + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'fastRecord'); + XLSX.writeFile(wb, `fast-${fastCurrentSessionId}-${new Date().toISOString().slice(0,10)}.xlsx`); +}); + +document.getElementById('btn-fast-export-csv')?.addEventListener('click', async () => { + if (!fastCurrentSessionId) return; + const res = await fetch(`/api/fast/${fastCurrentSessionId}/csv`); + if (!res.ok) return; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `fast-${fastCurrentSessionId}-${new Date().toISOString().slice(0,10)}.csv`; + a.click(); + URL.revokeObjectURL(url); +}); + +// 탭 전환 시 로드 +document.querySelectorAll('[href="#pane-fast"]').forEach(a => { + a.addEventListener('show.bs.tab', () => { + fastSessionsLoad(); + }); +}); +``` + +### 3. `style.css` — 스타일 추가 + +**파일**: `src/Web/wwwroot/css/style.css` + +```css +/* fastRecord Styles */ +#pane-fast .list-group-item { + cursor: pointer; +} + +#pane-fast .list-group-item:hover { + background-color: #f8f9fa; +} + +#pane-fast .progress-bar-animated { + background-color: #0d6efd; +} + +#pane-fast .fast-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px; +} + +#pane-fast .fast-stat-card { + background: #f8f9fa; + border-radius: 4px; + padding: 10px; + text-align: center; +} + +#pane-fast .fast-stat-value { + font-size: 1.2rem; + font-weight: bold; + color: #0d6efd; +} + +#pane-fast .fast-stat-label { + font-size: 0.8rem; + color: #6c757d; +} + +#pane-fast .fast-outlier { + background-color: rgba(255, 0, 0, 0.2); + border-radius: 2px; +} +``` + +### 4. uPlot 라이브러리 추가 + +**파일**: `src/Web/wwwroot/lib/uPlot.iife.min.js` (CDN에서 다운로드) + +```html + + + +``` + +--- + +## Task D — 정리/보관 백그라운드 + +### `ExperionFastCleanupService.cs` — 신규 파일 생성 + +**파일**: `src/Infrastructure/OpcUa/ExperionFastCleanupService.cs` + +```csharp +using System.Text.Json; // ✅ 추가: JsonSerializer.Deserialize 사용 (Claude 진단 #7) +using ExperionCrawler.Core.Application.Interfaces; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ExperionCrawler.Infrastructure.OpcUa; + +/// +/// fastSession 정리 서비스. +/// 매일 03:00에 만료된 세션 + 데이터 삭제. +/// pinned=true 제외. +/// +public class ExperionFastCleanupService : BackgroundService +{ + private readonly IServiceProvider _sp; + private readonly ILogger _logger; + + public ExperionFastCleanupService(IServiceProvider sp, ILogger logger) + { + _sp = sp; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + // 매일 03:00에 실행 + var now = DateTime.UtcNow; + var nextRun = new DateTime(now.Year, now.Month, now.Day, 3, 0, 0, DateTimeKind.Utc); + if (now > nextRun) nextRun = nextRun.AddDays(1); + var delay = nextRun - now; + + await Task.Delay(delay, stoppingToken); + + using var scope = _sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // 만료된 세션 조회 (ended_at이 있고, pinned=false, retention_days 지남) + var expired = await db.GetExpiredFastSessionsAsync(); + + foreach (var s in expired) + { + _logger.LogInformation("[FastCleanup] 세션 {Id} ({Name}) 삭제 — 만료됨", s.Id, s.Name); + await db.DeleteFastSessionAsync(s.Id); + } + + _logger.LogInformation("[FastCleanup] 정리 완료 — {Count}개 세션 삭제", expired.Count); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.LogError(ex, "[FastCleanup] 오류"); + } + } + } +} +``` + +// ✅ 참고: JsonSerializer.Deserialize 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요 +// ✅ 참고: FastSession.TagList는 JSONB이므로 Deserialize 필요 (Claude 진단 #7) +public async Task> GetExpiredFastSessionsAsync() +{ + var now = DateTime.UtcNow; + return await _ctx.FastSessions + .Where(x => x.EndedAt != null + && !x.Pinned + && x.RetentionDays.HasValue + && x.EndedAt.Value.AddDays(x.RetentionDays.Value) < now) + .OrderBy(x => x.EndedAt) + .ToListAsync(); +} +``` + +### `Program.cs`에 등록 + +```csharp +// ── FastTable Cleanup Service ──────────────────────────────────────────────── +builder.Services.AddHostedService(); +``` + +--- + +## Task E — 안정성 / QA + +### 구현 시 주의사항 + +1. **노드 유효성 사전 검증**: `StartSessionAsync`에서 각 태그에 대해 `Read` 1회 수행 +2. **동시 세션 수 제한**: `MaxConcurrentSessions` 설정 (기본 3) +3. **OPC 연결 상태 확인**: 시작 전 `IsConnectedAsync` 체크 +4. **앱 종료 시 graceful 마무리**: `StopAsync`에서 버퍼 flush 후 종료 +5. **앱 시작 시 Running 세션 처리**: `Failed` 마킹 + +### 테스트 시나리오 + +| 시나리오 | 검증 방법 | +|----------|-----------| +| 세션 시작 | 태그 8개 선택 → 시작 → OPC UA 구독 생성 확인 | +| 데이터 수집 | 1분간 수집 → DB에 60×8=480행 기록 확인 | +| 중지 | 중지 버튼 → 세션 상태 `Completed` 확인 | +| CSV Export | Export → CSV 파일 다운로드 확인 | +| 고정 | Pin → 재기동 후 보존 확인 | +| RowLimit | 500만행 초과 → 자동 종료 `RowLimitReached` 확인 | + +--- + +## 구현 우선순위 + +1. **MVP (1~2일)**: Task A + B(start/stop/sessions/records) + C(목록/시작/중지/단순 그래프) +2. **분석 (0.5일)**: 통계 패널 + 이상치 강조 +3. **Export (0.5일)**: xlsx + csv 스트리밍 +4. **운영 (0.5일)**: Task D 정리, retention/pinned +5. **고급 (1일)**: 템플릿, 다중 Y축, LTTB 다운샘플링 + +--- + +## 참고 사항 + +- **JSON 직렬화**: `PropertyNamingPolicy = null`이므로 C# PascalCase가 JSON 키로 그대로 사용됨 → 클라이언트에서 camelCase로 접근하려면 익명 객체로 명시적 매핑 필요 +- **TimescaleDB**: hypertable 생성 후 `set_chunk_time_interval('1 day')` 설정 권장 +- **uPlot**: Chart.js 대신 시계열 특화 라이브러리 → 100만점도 부드러움 + +--- + +## 코드 진단 (Claude Sonnet 4.6, 2026-04-29) — 수정 완료 + +전체적인 방향성(TimescaleDB hypertable, 별도 Subscription, 세션 관리)은 올바르며, **11개 이슈가 모두 수정 완료**되었습니다. + +--- + +### 수정 완료된 이슈 + +| # | 이슈 | 수정 내용 | 상태 | +|---|------|-----------|------| +| 1 | `IExperionOpcClient` 메서드 누락 | `IsConnectedAsync`, `CreateSessionAsync` 메서드 추가 | ✅ | +| 2 | `IExperionDbService` 인터페이스 누락 | Fast 관련 메서드 12개 추가 | ✅ | +| 3 | `FastSessionCreateRequest` 미선언 | DTO 추가 | ✅ | +| 4 | `Program.cs` DI 등록 오류 | 올바른 등록 패턴 적용 | ✅ | +| 5 | `ExperionServerConfig` 주입 경로 | `IOpcUaConfigProvider` 사용 | ✅ | +| 6 | `OnNotification` 타입 오류 | `MonitoredItemNotification` 사용 | ✅ | +| 7 | `TagList` JSONB vs CSV 불일치 | `JsonSerializer.Serialize/Deserialize` 사용 | ✅ | +| 8 | `RowCount` 누적 추적 | `FastSessionContext.TotalRows` 추가 | ✅ | +| 9 | `RowLimit` 체크 오류 | `ctx.TotalRows`와 비교 | ✅ | +| 10 | `uPlot` API 오류 | `new uPlot(opts, data, target)` 올바른 시그니처 | ✅ | +| 11 | `XLSX.utils.aoa_to_sheet` 오류 | 배열의 배열로 변환 로직 추가 | ✅ | + +--- + +### 수정된 핵심 코드 + +#### 1. `IExperionOpcClient` 인터페이스 확장 + +```csharp +// src/Core/Application/Interfaces/IExperionOpcClient.cs +public interface IExperionOpcClient +{ + // ✅ 추가: fastTable용 메서드 (Claude 진단 #1) + Task IsConnectedAsync(ApplicationConfiguration cfg); + Task CreateSessionAsync(ApplicationConfiguration cfg); + + // 기존 메서드들 + Task TestConnectionAsync(ApplicationConfiguration cfg); + Task ReadTagAsync(ApplicationConfiguration cfg, string nodeId); + Task ReadTagsAsync(ApplicationConfiguration cfg, string[] nodeIds); + Task BrowseNodesAsync(ApplicationConfiguration cfg, string parentId); + Task BrowseAllNodesAsync(ApplicationConfiguration cfg); +} +``` + +#### 2. `IExperionDbService` 인터페이스 확장 + +```csharp +// src/Core/Application/Interfaces/IExperionDbService.cs +public interface IExperionDbService +{ + // ... 기존 메서드들 + + // FastSession + Task CreateFastSessionAsync(FastSessionCreateRequest request); + Task UpdateFastSessionStatusAsync(int sessionId, string status); + Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount); + Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned); + Task GetFastSessionAsync(int sessionId); + Task> GetFastSessionsAsync(); + Task DeleteFastSessionAsync(int sessionId); + Task> GetExpiredFastSessionsAsync(); + + // FastRecord + Task GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to); + Task BatchInsertFastRecordsAsync(IEnumerable records); + Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to); +} +``` + +#### 3. `OnNotification` 올바른 패턴 + +```csharp +private void OnNotification(FastSessionContext ctx, MonitoredItemNotificationEventArgs e, string tagName) +{ + if (ctx.Cancel) return; + + if (e.NotificationValue is MonitoredItemNotification notification) + { + var dv = notification.Value; + var value = dv.Value?.ToString(); + + var record = new FastRecord + { + SessionId = ctx.SessionId, + RecordedAt = DateTime.UtcNow, + TagName = tagName, + Value = value + }; + ctx.Buffer.Enqueue(record); + + // 누적 RowCount 증가 + ctx.TotalRows++; + } +} +``` + +#### 4. `TagList` JSONB 저장 + +```csharp +// 저장 시 +session.TagList = JsonSerializer.Serialize(request.TagList); + +// 조회 시 +session.TagList = JsonSerializer.Deserialize(session.TagList) ?? []; +``` + +#### 5. `FastSessionContext`에 누적 RowCount 추가 + +```csharp +private class FastSessionContext +{ + public int SessionId { get; set; } + public string[] TagList { get; set; } = []; + public int SamplingMs { get; set; } + public int DurationSec { get; set; } + public DateTime StartedAt { get; set; } + public ConcurrentQueue Buffer { get; set; } = new(); + public int TotalRows { get; set; } // 누적 총 행 수 + public bool Cancel { get; set; } + public ISession? Session { get; set; } + public Subscription? Subscription { get; set; } +} +``` + +#### 6. `RowLimit` 체크 수정 + +```csharp +// 올바른 체크 +if (ctx.TotalRows + buffer.Count >= _maxRowsPerSession) +{ + ctx.Cancel = true; + await StopSessionAsync(ctx.SessionId); + // ... +} +``` + +#### 7. uPlot API 올바른 사용 + +```javascript +// data: [[x0,x1,...], [y1_0,y1_1,...], [y2_0,...]] +const times = Object.keys(grouped).sort(); +const timesNum = times.map(t => new Date(t).getTime()); + +const datasets = data.tagNames.map(tag => ({ + label: tag, + data: times.map(t => grouped[t][tag]) +})); + +// target: DOM element (세 번째 인자) +fastChart = new uPlot(opts, [timesNum, ...datasets.map(d => d.data)], ctx); +``` + +#### 8. XLSX export 올바른 데이터 포맷 + +```javascript +const rows = [['recorded_at', ...data.tagNames]]; + +for (const r of data.items) { + if (!rows[r.recordedAt]) { + rows[r.recordedAt] = [new Date(r.recordedAt).toLocaleString('ko-KR')]; + for (let i = 0; i < data.tagNames.length; i++) rows[r.recordedAt].push(''); + } + rows[r.recordedAt][data.tagNames.indexOf(r.tagName) + 1] = r.value; +} + +const ws = XLSX.utils.aoa_to_sheet(rows); +``` + +--- + +### 요약 + +| 구분 | 건수 | 상태 | +|------|------|------| +| 컴파일 에러 | 5건 | ✅ 수정 완료 | +| 런타임 버그 | 4건 | ✅ 수정 완료 | +| JS 오류 | 2건 | ✅ 수정 완료 | + +**가장 치명적인 #6 OnNotification 타입 오류**(데이터가 아예 수집 안 됨)와 **#7 JSONB 저장 오류**(DB INSERT 에러)가 모두 수정되었습니다. diff --git a/fastTable/fastTable-문제점.md b/fastTable/fastTable-문제점.md new file mode 100644 index 0000000..6589325 --- /dev/null +++ b/fastTable/fastTable-문제점.md @@ -0,0 +1,16 @@ +# fastRecord 섹션 공통 문제점 + - 1. 처음 진입시 FASTSESSION 목록 표시 안됨, 신규 세션 생성한 이후에나 기존 목록 보임--> 최초 진입시 부터 목록 보이게 개선. + - 2. 목록이 세로로 보이면서 그래프 아래로 밀려남, --> 목록 가로 표시 요망 + - 3. 목록 색상 시인성 개선 청색-->적색 반전을 + +## Trend uPlot 부분 + - 1. pen 색상 바뀜 -> 고정 + - 2. 실시간 그래프 상태 일때는 ZOOM IN 해도 새로운 데이터 갱신 되면서 1초만에 ZOOM IN 풀림-> ZOOM IN 되면 실시간 그래프 갱신 정지 후 스타트 버튼을 만들어서 다시 원래 PAN 상태로 복귀하게 하는것 추천됨. 중단된 그래프 도 동일 줌인, 복귀 가능하게 할것. + - 3. 밑의 PEN LEGEND 글자도 그래프 진행 상태에 따라서 폰트 크기가 변함, -> 고정 + - + + +### 의문점 + - 1. 신규 생성 하면 테이블이 또 생기는것인가? + - 2. 삭제하면 테이블이 삭제되는 것인가? + - 3. 신규 생성 몇개 까지 가능한가? \ No newline at end of file diff --git a/idea-fastTable.md b/fastTable/idea-fastTable.md similarity index 100% rename from idea-fastTable.md rename to fastTable/idea-fastTable.md diff --git a/fastTable/project_fasttable_bugs.md b/fastTable/project_fasttable_bugs.md new file mode 100644 index 0000000..941ebf3 --- /dev/null +++ b/fastTable/project_fasttable_bugs.md @@ -0,0 +1,189 @@ +--- +name: fastTable/fastRecord 구현 검증 결과 +description: roo-fasttable-implementation.md 계획 대비 실제 구현 차이 및 버그 목록 +type: project +originSessionId: ec4d397a-b394-4d23-b041-03b70a7d0136 +--- +## 검증 대상 +- 계획서: `plans/roo-fasttable-implementation.md` +- 검증 시점: 2026-04-29 +- 빌드 결과: 경고 0건, 에러 0건 (빌드는 통과) + +## Step별 구현 상태 + +| Step | 파일 | 상태 | 비고 | +|------|------|------|------| +| 1 | ExperionEntities.cs | ✅ 완료 | | +| 2 | IExperionServices.cs | ✅ 완료 | | +| 3 | IExperionServices.cs | ✅ 완료 | | +| 4 | ExperionDbContext.cs | ✅ 완료 | | +| 5 | ExperionDbContext.cs (DDL) | ⚠️ 차이 | `tag_list JSONB` (계획: TEXT). EnsureCreatedAsync가 먼저 실행되어 실제로는 text 타입이 됨 → 런타임 문제 없음 | +| 6 | ExperionDbContext.cs (메서드) | ⚠️ 차이 | GetFastSessionsAsync 정렬 역전, UpdateFastSessionRowCountAsync 구현 방식 다름 | +| 7 | ExperionDbContext.cs (메서드) | ❌ 버그 | CSV Export 헤더 오류 | +| 8~11 | ExperionFastService.cs | ❌ 치명적 | StartSessionAsync 항상 실패 | +| 12 | ExperionFastCleanupService | ✅ 완료 | ExperionFastService.cs 파일에 같이 위치 (문제없음) | +| 13 | ExperionControllers.cs | ✅ 완료 | FastPinRequest → PinRequest로 이름 다름 (동작 동일) | +| 14 | Program.cs | ✅ 완료 | DI 패턴 3줄 패턴 정확히 구현 | +| 15 | appsettings.json | ✅ 완료 | Fast 섹션 추가됨 | +| 16 | index.html | ✅ 완료 | Bootstrap 방식으로 구현 (계획과 스타일 다르나 기능 동일) | +| 17 | app.js | ⚠️ 차이 | 태그 목록 로딩 방식 다름 (전역변수 의존) | +| 18 | style.css | ⚠️ 생략 | Bootstrap 사용하여 별도 CSS 불필요 | + +--- + +## 버그 목록 + +### Bug 1 — StartSessionAsync 항상 실패 (치명적) +**파일**: `src/Infrastructure/OpcUa/ExperionFastService.cs:99-116` + +현재 코드: +```csharp +var cfg = await _configProvider.GetConfigAsync(new ExperionServerConfig()); // 빈 설정! +if (string.IsNullOrEmpty(cfg?.ServerConfiguration?.BaseAddresses?.Count > 0 ? cfg.ServerConfiguration.BaseAddresses[0] : null)) + throw new InvalidOperationException("서버 엔드포인트 URL이 설정되어 있지 않습니다."); + +if (!await _opcClient.IsConnectedAsync(cfg)) + throw new InvalidOperationException("OPC UA 서버에 연결되어 있지 않습니다."); + +// 노드 유효성 사전 검증 +foreach (var tagName in request.TagList) +{ + var nodeId = await db.GetNodeIdByTagNameAsync(tagName); + if (string.IsNullOrEmpty(nodeId)) + throw new ArgumentException($"태그 '{tagName}'의 nodeId를 찾을 수 없습니다."); + + var readResult = await _opcClient.ReadTagAsync(new ExperionServerConfig(), nodeId); // 빈 설정! + if (!readResult.Success) + throw new ArgumentException($"태그 '{tagName}' 읽기 실패: {readResult.ErrorMessage}"); +} +``` + +문제: `new ExperionServerConfig()`는 ServerHostName이 빈 문자열이라 EndpointUrl = `opc.tcp://:4840`. ApplicationConfiguration의 BaseAddresses가 비어 있어 "서버 엔드포인트 URL이 설정되어 있지 않습니다." 항상 throw. + +또한 `StartSubscriptionAsync(ctx, cfg)` 내부: +```csharp +var endpoint = await SelectEndpointAsync(cfg, cfg.ServerConfiguration?.BaseAddresses?[0] ?? string.Empty); +var session = await CreateSessionAsync(cfg, endpoint, new ExperionServerConfig()); // 빈 UserName/Password +``` + +수정 방법 (계획서 Step 9 참조): `realtime_autostart.json`에서 ExperionServerConfig를 읽어야 함. +```csharp +private static readonly string RealtimeFlagPath = Path.GetFullPath("realtime_autostart.json"); + +private static async Task ReadServerConfigAsync() +{ + if (!File.Exists(RealtimeFlagPath)) return null; + try + { + var json = await File.ReadAllTextAsync(RealtimeFlagPath); + return JsonSerializer.Deserialize(json); + } + catch { return null; } +} +``` + +그리고 `StartSessionAsync` 시작 부분을: +```csharp +var serverCfg = await ReadServerConfigAsync(); +if (serverCfg == null) + throw new InvalidOperationException("OPC UA 서버 설정을 찾을 수 없습니다. 실시간 구독을 먼저 시작하세요."); + +var appConfig = await _configProvider.GetConfigAsync(serverCfg); +// IsConnectedAsync 체크 제거 or appConfig 사용 +``` + +`StartSubscriptionAsync` 시그니처도 변경: +```csharp +private async Task StartSubscriptionAsync(FastSessionContext ctx, ExperionServerConfig serverCfg) +{ + var appConfig = await _configProvider.GetConfigAsync(serverCfg); + var endpoint = await SelectEndpointAsync(appConfig, serverCfg.EndpointUrl); + var identity = new UserIdentity(serverCfg.UserName, System.Text.Encoding.UTF8.GetBytes(serverCfg.Password)); + // ... +} +``` + +`IExperionOpcClient` 생성자 주입도 제거 가능 (사전 검증 로직 삭제). Program.cs의 DI 등록은 이미 정상. + +--- + +### Bug 2 — CSV Export 헤더 오류 +**파일**: `src/Infrastructure/Database/ExperionDbContext.cs:828-829` + +현재 코드 (잘못됨): +```csharp +csv.WriteRecord(new { RecordedAt = "recorded_at", TagNames = tagNames.Select((t, i) => $"tag{i+1}") }); +await writer.WriteLineAsync(); +``` +→ CSV 헤더가 `RecordedAt,TagNames` 또는 `recorded_at,tag1` 형태로 출력됨. 태그명이 아님. + +수정 방법 (계획서 Step 7 참조): +```csharp +using var writer = new StreamWriter(stream, leaveOpen: true); +await writer.WriteLineAsync("recorded_at," + string.Join(",", tagNames)); + +foreach (var g in records.GroupBy(x => x.RecordedAt).OrderBy(g => g.Key)) +{ + var values = g.ToDictionary(r => r.TagName, r => r.Value); + var row = g.Key.ToString("o") + "," + + string.Join(",", tagNames.Select(t => values.TryGetValue(t, out var v) ? $"\"{v}\"" : "")); + await writer.WriteLineAsync(row); +} +await writer.FlushAsync(); +``` +CsvHelper 의존성 제거. `CsvHelper` using도 제거 필요. + +--- + +### Bug 3 — 세션 목록 정렬 역전 (경미) +**파일**: `src/Infrastructure/Database/ExperionDbContext.cs:760` + +현재: +```csharp +.OrderBy(x => x.StartedAt) // 오래된 것이 위 +``` +계획: +```csharp +.OrderByDescending(x => x.StartedAt) // 최신이 위 +``` + +--- + +### Bug 4 — 신규 세션 모달에서 태그 목록이 비어 있을 수 있음 (경미) +**파일**: `src/Web/wwwroot/js/app.js`, `btn-fast-new` 이벤트 핸들러 + +현재 코드: +```javascript +(typeof tagNames !== 'undefined' ? tagNames : []).forEach(name => { ... }); +``` +`tagNames` 전역 변수가 실시간 탭 방문 전에는 비어 있음 → 태그 선택 불가. + +계획서 `fastNewModal()` 참조 → `/api/realtime/points` 직접 fetch: +```javascript +async function fastNewModal() { + const res = await fetch('/api/realtime/points'); + const select = document.getElementById('fast-tag-select'); + select.innerHTML = ''; + if (res.ok) { + const data = await res.json(); + (data.items || []).forEach(p => { + const opt = document.createElement('option'); + opt.value = p.tagName || p.TagName; + opt.textContent = p.tagName || p.TagName; + select.appendChild(opt); + }); + } + // ... +} +``` + +--- + +## 수정 우선순위 +1. **Bug 1** (치명적): StartSessionAsync — realtime_autostart.json 기반 서버 설정 읽기로 전면 수정 +2. **Bug 2** (중요): ExportFastRecordsToCsvAsync — CsvHelper 제거, 수동 CSV 작성으로 교체 +3. **Bug 3** (경미): GetFastSessionsAsync 정렬 방향 수정 +4. **Bug 4** (경미): fastNewModal 태그 목록 직접 fetch로 수정 + +**Why:** Bug 1은 fastRecord 기능이 전혀 동작하지 않게 만드는 근본 원인. realtime_autostart.json에 OPC UA 서버 접속 정보가 있음 (실시간 구독 시작 시 저장됨). +**How to apply:** Roo에게 위 4개 버그를 순서대로 수정 지시. 각 수정 후 `dotnet build` 확인. diff --git a/fastTable/step1.md b/fastTable/step1.md new file mode 100644 index 0000000..5121b5c --- /dev/null +++ b/fastTable/step1.md @@ -0,0 +1,69 @@ +# STEP 1 — 엔티티 추가 (`ExperionEntities.cs`) + +## 사전 확인 (작업 전 반드시 수행) + +1. `src/Core/Domain/Entities/ExperionEntities.cs` 파일을 열어 현재 내용을 읽는다. +2. 아래 항목을 확인하고 결과를 기록한다: + - [ ] `FastSession` 클래스가 이미 존재하는가? → 존재하면 STEP 1 건너뜀 + - [ ] `FastRecord` 클래스가 이미 존재하는가? → 존재하면 STEP 1 건너뜀 + - [ ] 파일 하단에 추가할 공간이 있는가? + - [ ] `using System.ComponentModel.DataAnnotations.Schema;` import가 있는가? → 없으면 추가 + +--- + +## 작업 내용 + +**파일**: `src/Core/Domain/Entities/ExperionEntities.cs` + +파일 하단에 아래 두 클래스를 추가한다. (기존 코드 수정 없음) + +```csharp +/// fastSession — 데이터 수집 세션 메타 +[Table("fast_session")] +public class FastSession +{ + [Column("id")] public int Id { get; set; } + [Column("name")] public string Name { get; set; } = string.Empty; + [Column("started_at")] public DateTime StartedAt { get; set; } + [Column("ended_at")] public DateTime? EndedAt { get; set; } + [Column("status")] public string Status { get; set; } = "Pending"; + // Status 허용값: Pending / Running / Completed / Cancelled / Failed / RowLimitReached + [Column("sampling_ms")] public int SamplingMs { get; set; } + [Column("duration_sec")] public int DurationSec { get; set; } + [Column("tag_list")] public string TagList { get; set; } = "[]"; // JSONB → string[] 직렬화 + [Column("row_count")] public int RowCount { get; set; } + [Column("retention_days")] public int? RetentionDays { get; set; } // null = 무한 보관 + [Column("pinned")] public bool Pinned { get; set; } +} + +/// fastRecord — 시계열 데이터 (Long 포맷: 태그 1행/시점) +[Table("fast_record")] +public class FastRecord +{ + [Column("id")] public int Id { get; set; } + [Column("session_id")] public int SessionId { get; set; } + [Column("recorded_at")] public DateTime RecordedAt { get; set; } + [Column("tagname")] public string TagName { get; set; } = string.Empty; + [Column("value")] public string? Value { get; set; } +} +``` + +--- + +## 사후 확인 (작업 후 반드시 수행) + +1. `ExperionEntities.cs` 파일을 다시 열어 추가된 내용을 읽는다. +2. 아래 항목을 하나씩 확인하고 결과를 [x] 로 기록한다 + - [ ] `FastSession` 클래스가 파일에 존재하는가? + - [ ] `FastRecord` 클래스가 파일에 존재하는가? + - [ ] `[Table("fast_session")]` 어트리뷰트가 올바르게 붙어 있는가? + - [ ] `[Table("fast_record")]` 어트리뷰트가 올바르게 붙어 있는가? + - [ ] `TagList` 필드의 기본값이 `"[]"` 문자열인가? (`string[]`이 아닌 `string` 타입) +3. `dotnet build src/Core` 실행 → 경고/에러 0개 확인 +4. 문제가 있으면 수정 후 다시 빌드 확인 + +--- + +## 완료 조건 +- `dotnet build src/Core` 결과: 에러 0, 경고 0 +- `FastSession`, `FastRecord` 두 클래스 모두 파일에 존재 \ No newline at end of file diff --git a/fastTable/step10.md b/fastTable/step10.md new file mode 100644 index 0000000..d406dae --- /dev/null +++ b/fastTable/step10.md @@ -0,0 +1,108 @@ +# STEP 10 — 정리 서비스 (`ExperionFastCleanupService.cs`) 신규 생성 + +## 사전 확인 (작업 전 반드시 수행) + +1. `src/Infrastructure/OpcUa/` 디렉토리 목록을 확인한다. +2. 아래 항목을 확인하고 기록한다: + - [x] STEP 6이 완료되어 `GetExpiredFastSessionsAsync`, `DeleteFastSessionAsync` 구현이 있는가? + - [x] `ExperionFastCleanupService.cs` 파일이 이미 존재하는가? → 있으면 내용 비교 후 누락 부분만 수정 + - [x] `IExperionDbService` 인터페이스에 위 두 메서드가 선언되어 있는가? + +--- + +## 작업 내용 + +**파일**: `src/Infrastructure/OpcUa/ExperionFastCleanupService.cs` (신규 생성) + +```csharp +using ExperionCrawler.Core.Application.Interfaces; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ExperionCrawler.Infrastructure.OpcUa; + +/// +/// fastSession 만료 데이터 정리 서비스. +/// 매일 03:00 UTC에 실행. +/// pinned = true 세션은 제외. +/// retention_days가 null인 세션은 무한 보관. +/// +public class ExperionFastCleanupService : BackgroundService +{ + private readonly IServiceProvider _sp; + private readonly ILogger _logger; + + public ExperionFastCleanupService( + IServiceProvider sp, + ILogger logger) + { + _sp = sp; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + // 매일 03:00 UTC까지 대기 + var now = DateTime.UtcNow; + var nextRun = new DateTime(now.Year, now.Month, now.Day, 3, 0, 0, DateTimeKind.Utc); + if (now >= nextRun) nextRun = nextRun.AddDays(1); + + var delay = nextRun - now; + _logger.LogInformation("[FastCleanup] 다음 정리 예약: {NextRun} (대기 {Delay})", nextRun, delay); + + await Task.Delay(delay, stoppingToken); + + // 만료 세션 조회 및 삭제 + using var scope = _sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var expiredList = (await db.GetExpiredFastSessionsAsync()).ToList(); + + foreach (var s in expiredList) + { + _logger.LogInformation("[FastCleanup] 세션 {Id} ({Name}) 삭제 — 만료됨", s.Id, s.Name); + await db.DeleteFastSessionAsync(s.Id); + } + + _logger.LogInformation("[FastCleanup] 정리 완료 — {Count}개 세션 삭제", expiredList.Count); + } + catch (OperationCanceledException) + { + // 정상 종료 + } + catch (Exception ex) + { + _logger.LogError(ex, "[FastCleanup] 오류 발생"); + // 오류 시 1시간 후 재시도 + await Task.Delay(TimeSpan.FromHours(1), stoppingToken); + } + } + } +} +``` + +--- + +## 사후 확인 (작업 후 반드시 수행) + +1. `ExperionFastCleanupService.cs` 파일을 열어 전체 내용을 읽는다. +2. 아래 항목을 하나씩 확인한다: + - [x] `BackgroundService`를 상속하는가? + - [x] `ExecuteAsync` 메서드가 있는가? + - [x] 매일 03:00 UTC 스케줄링 로직이 있는가? + - [x] `GetExpiredFastSessionsAsync()` 호출이 있는가? + - [x] 각 만료 세션에 대해 `DeleteFastSessionAsync` 호출이 있는가? + - [x] `OperationCanceledException` catch가 있는가? (정상 종료 처리) + - [x] 오류 시 재시도 로직이 있는가? +3. `dotnet build src/Web/ExperionCrawler.csproj` 실행 → 에러 0, 경고 0개 확인 +4. 문제가 있으면 수정 후 다시 빌드 확인 + +--- + +## 완료 조건 +- `dotnet build src/Web/ExperionCrawler.csproj` 결과: 에러 0, 경고 0 +- `ExperionFastCleanupService.cs` 파일 존재 및 빌드 통과 +- ✅ 완료일: 2026-04-29 \ No newline at end of file diff --git a/fastTable/step11.md b/fastTable/step11.md new file mode 100644 index 0000000..b2bca07 --- /dev/null +++ b/fastTable/step11.md @@ -0,0 +1,193 @@ +# STEP 11 — UI: index.html 구조 추가 + +## 사전 확인 (작업 전 반드시 수행) + +1. `src/Web/wwwroot/index.html` 파일을 열어 전체 구조를 읽는다. +2. 아래 항목을 확인하고 기록한다: + - [x] STEP 9가 완료되어 백엔드 전체가 빌드되는가? + - [x] `id="pane-fast"` div가 이미 존재하는가? → 없음 (작업 수행) + - [x] 사이드바 `
  • ` 메뉴에 `09 fastRecord` 항목이 있는가? → 없음 (작업 수행) + - [x] `id="modal-fast-new"` 모달이 이미 있는가? → 없음 (작업 수행) + - [x] ``에 uPlot CSS 링크가 있는가? → 없음 (작업 수행) + - [x] `` 직전에 uPlot JS 스크립트가 있는가? → 없음 (작업 수행) + - [x] 기존 탭 패널(pane-*)이 어떤 구조인지 파악한다 (추가 위치 확인) + +--- + +## 작업 1 — 사이드바 메뉴 항목 추가 + +**위치**: 기존 사이드바 `
  • ` 목록 마지막 항목 아래 + +```html +
  • 09 fastRecord
  • +``` + +--- + +## 작업 2 — fastRecord 패널 추가 + +**위치**: 기존 마지막 `tab-pane` div 아래 + +```html + +
    +
    +
    + + +
    +
    +
    +
    fastSession 목록
    + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    세션 상세
    +
    + + + + + +
    +
    +
    + +
    +
    +
    +
    + 0 / 0 (0%) + 경과: 0s +
    + + +
    + + + +
    +
    +
    + +
    +
    +
    + + + +``` + +--- + +## 작업 3 — uPlot 라이브러리 추가 + +**조건**: uPlot이 아직 없는 경우만 수행 + +``` +1. https://cdn.jsdelivr.net/npm/uplot@1.6.27/dist/uPlot.iife.min.js 를 다운로드하여 + src/Web/wwwroot/lib/uPlot.iife.min.js 에 저장 + +2. https://cdn.jsdelivr.net/npm/uplot@1.6.27/dist/uPlot.min.css 를 다운로드하여 + src/Web/wwwroot/lib/uPlot.min.css 에 저장 +``` + +**index.html `` 안에 추가**: +```html + +``` + +**index.html `` 직전에 추가**: +```html + +``` + +--- + +## 사후 확인 (작업 후 반드시 수행) + +1. `index.html` 파일을 다시 열어 추가된 내용을 읽는다. +2. 아래 항목을 하나씩 확인한다: + - [x] 사이드바에 `09 fastRecord` 메뉴 항목이 있는가? + - [x] `id="pane-fast"` div가 있는가? + - [x] `id="fast-session-list"` 요소가 있는가? + - [x] `id="fast-chart-container"` 요소가 있는가? + - [x] `id="fast-progress-bar"` 요소가 있는가? + - [x] `id="modal-fast-new"` 모달이 있는가? + - [x] 모달 안에 `id="fast-tag-select"` select가 있는가? + - [x] 버튼 5개(`btn-fast-stop`, `btn-fast-export-xlsx`, `btn-fast-export-csv`, `btn-fast-delete`, `btn-fast-pin`) 모두 있는가? + - [x] uPlot CSS, JS가 올바른 위치에 로드되는가? +3. 브라우저에서 해당 탭을 열어 HTML 구조가 렌더링되는지 육안으로 확인 (JS 기능은 STEP 12에서) + +--- + +## 완료 조건 +- 지정된 id를 가진 요소 모두 존재 +- uPlot 파일 로드 순서 올바름 (CSS → JS) \ No newline at end of file diff --git a/fastTable/step2.md b/fastTable/step2.md new file mode 100644 index 0000000..a138691 --- /dev/null +++ b/fastTable/step2.md @@ -0,0 +1,65 @@ +# STEP 2 — DbContext: DbSet + 인덱스 추가 (`ExperionDbContext.cs`) + +## 사전 확인 (작업 전 반드시 수행) + +1. `src/Infrastructure/Database/ExperionDbContext.cs` 파일을 열어 전체 내용을 읽는다. +2. 아래 항목을 확인하고 기록한다: + - [x] STEP 1이 완료되어 `FastSession`, `FastRecord` 클래스가 존재하는가? → 미완료면 STEP 1 먼저 수행 + - [x] `FastSessions` DbSet이 이미 선언되어 있는가? → 있으면 작업 1 건너뜀 + - [x] `FastRecords` DbSet이 이미 선언되어 있는가? → 있으면 작업 1 건너뜀 + - [x] `OnModelCreating`에 `FastSession` 인덱스 설정이 이미 있는가? → 있으면 작업 2 건너뜀 + - [x] 기존 DbSet 선언 위치(줄 번호)를 확인한다 + - [x] `OnModelCreating` 메서드의 끝 위치(줄 번호)를 확인한다 + +--- + +## 작업 1 — DbSet 추가 + +**위치**: 기존 DbSet 선언 블록 마지막 줄 바로 아래 + +```csharp +public DbSet FastSessions => Set(); +public DbSet FastRecords => Set(); +``` + +--- + +## 작업 2 — OnModelCreating 인덱스 추가 + +**위치**: `OnModelCreating` 메서드 내부, 기존 마지막 설정 블록 아래 + +```csharp +modelBuilder.Entity(e => +{ + e.HasKey(x => x.Id); + e.HasIndex(x => x.Status); + e.HasIndex(x => x.StartedAt); +}); + +modelBuilder.Entity(e => +{ + e.HasKey(x => x.Id); + e.HasIndex(x => x.SessionId); + e.HasIndex(x => new { x.SessionId, x.TagName, x.RecordedAt }); +}); +``` + +--- + +## 사후 확인 (작업 후 반드시 수행) + +1. `ExperionDbContext.cs` 파일을 다시 열어 변경 내용을 읽는다. +2. 아래 항목을 하나씩 확인한다: + - [x] `public DbSet FastSessions` 선언이 존재하는가? + - [x] `public DbSet FastRecords` 선언이 존재하는가? + - [x] `modelBuilder.Entity` 블록이 `OnModelCreating` 안에 있는가? + - [x] `modelBuilder.Entity` 블록이 `OnModelCreating` 안에 있는가? + - [x] `HasIndex(x => new { x.SessionId, x.TagName, x.RecordedAt })` 복합 인덱스가 있는가? +3. `dotnet build src/Infrastructure` 실행 → 에러/경고 0개 확인 +4. 문제가 있으면 수정 후 다시 빌드 확인 + +--- + +## 완료 조건 +- `dotnet build src/Web/ExperionCrawler.csproj` 결과: 에러 0, 경고 0 (기존 경고 포함) +- DbSet 2개, 인덱스 설정 2블록 모두 존재 \ No newline at end of file diff --git a/fastTable/step3.md b/fastTable/step3.md new file mode 100644 index 0000000..59da0f8 --- /dev/null +++ b/fastTable/step3.md @@ -0,0 +1,77 @@ +# STEP 3 — DB 초기화: 테이블 생성 + TimescaleDB hypertable + +## 사전 확인 (작업 전 반드시 수행) + +1. `src/Infrastructure/Database/ExperionDbContext.cs` 파일을 열어 `InitializeAsync()` 메서드를 찾는다. +2. 아래 항목을 확인하고 기록한다: + - [x] STEP 2가 완료되어 DbSet이 존재하는가? → 미완료면 STEP 2 먼저 수행 + - [x] `InitializeAsync()` 메서드가 존재하는가? (없으면 구현 위치를 확인) + - [x] `CREATE TABLE IF NOT EXISTS fast_session` SQL이 이미 있는가? → 있으면 이 STEP 건너뜀 + - [x] `create_hypertable('fast_record'...)` SQL이 이미 있는가? → 있으면 이 STEP 건너뜀 + - [x] 파일 상단에 `using System.Text.Json;` import가 있는가? → 없으면 추가 + +--- + +## 작업 내용 + +**파일**: `src/Infrastructure/Database/ExperionDbContext.cs` +**위치**: `ExperionDbService.InitializeAsync()` 메서드 내부, 기존 초기화 코드 마지막 줄 아래 + +```csharp +// ── fast_session / fast_record 테이블 생성 ──────────────────────────────── +await _ctx.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS fast_session ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + started_at TIMESTAMPTZ NOT NULL, + ended_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'Pending', + sampling_ms INTEGER NOT NULL, + duration_sec INTEGER NOT NULL, + tag_list JSONB NOT NULL DEFAULT '[]', + row_count INTEGER NOT NULL DEFAULT 0, + retention_days INTEGER, + pinned BOOLEAN NOT NULL DEFAULT FALSE + ) + """); + +await _ctx.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS fast_record ( + id SERIAL PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES fast_session(id) ON DELETE CASCADE, + recorded_at TIMESTAMPTZ NOT NULL, + tagname TEXT NOT NULL, + value TEXT + ) + """); + +// TimescaleDB hypertable 생성 (recorded_at 기준, chunk_interval = 1 day) +await _ctx.Database.ExecuteSqlRawAsync(""" + SELECT create_hypertable('fast_record', 'recorded_at', if_not_exists => TRUE) + """); + +await _ctx.Database.ExecuteSqlRawAsync(""" + SELECT set_chunk_time_interval('fast_record', INTERVAL '1 day') + """); +``` + +--- + +## 사후 확인 (작업 후 반드시 수행) + +1. `ExperionDbContext.cs` 파일을 다시 열어 추가된 SQL 블록을 읽는다. +2. 아래 항목을 하나씩 확인한다: + - [x] `CREATE TABLE IF NOT EXISTS fast_session` SQL이 존재하는가? + - [x] `CREATE TABLE IF NOT EXISTS fast_record` SQL이 존재하는가? + - [x] `fast_record`에 `REFERENCES fast_session(id) ON DELETE CASCADE` 가 있는가? + - [x] `create_hypertable('fast_record', 'recorded_at', if_not_exists => TRUE)` 가 있는가? + - [x] `set_chunk_time_interval('fast_record', INTERVAL '1 day')` 가 있는가? +3. `dotnet build src/Infrastructure` 실행 → 에러/경고 0개 확인 +4. (가능하면) 앱을 실제로 실행해 DB에 `fast_session`, `fast_record` 테이블이 생성되는지 확인 + +--- + +## 완료 조건 +- `dotnet build src/Infrastructure` 결과: 에러 0, 경고 0 (기존 경고 포함) +- SQL 5개 블록 모두 `InitializeAsync` 내에 존재 +- (선택) DB에서 `\d fast_record` 실행 시 hypertable 확인 \ No newline at end of file diff --git a/fastTable/step4.md b/fastTable/step4.md new file mode 100644 index 0000000..fd67d6d --- /dev/null +++ b/fastTable/step4.md @@ -0,0 +1,105 @@ +# STEP 4 — 인터페이스: DTO + IExperionFastService 추가 + +## 사전 확인 (작업 전 반드시 수행) + +1. `src/Core/Application/Interfaces/IExperionServices.cs` 파일을 열어 전체 내용을 읽는다. +2. 아래 항목을 확인하고 기록한다: + - [x] STEP 1이 완료되어 `FastSession`, `FastRecord` 클래스가 존재하는가? + - [x] `IExperionFastService` 인터페이스가 이미 존재하는가? → 있으면 이 STEP 건너뜀 + - [x] `FastSessionInfo` record가 이미 존재하는가? + - [x] `FastSessionStartRequest` record가 이미 존재하는가? + - [x] `FastSessionCreateRequest` record가 이미 존재하는가? + - [x] `FastQueryResult` record가 이미 존재하는가? + +--- + +## 작업 내용 + +**파일**: `src/Core/Application/Interfaces/IExperionServices.cs` + +파일 하단에 아래 내용을 추가한다. + +### DTO Records + +```csharp +// ── fastTable DTOs ──────────────────────────────────────────────────────────── + +public record FastSessionInfo( + int Id, + string Name, + DateTime StartedAt, + DateTime? EndedAt, + string Status, + int SamplingMs, + int DurationSec, + string[] TagList, + int RowCount, + int? RetentionDays, + bool Pinned +); + +public record FastSessionStartRequest( + string Name, + int SamplingMs, + int DurationSec, + string[] TagList, + int? RetentionDays = null +); + +public record FastSessionCreateRequest( + string Name, + int SamplingMs, + int DurationSec, + string[] TagList, + int? RetentionDays = null +); + +public record FastQueryResult( + int SessionId, + DateTime From, + DateTime To, + string[] TagNames, + IEnumerable Items, + int TotalCount +); + +public record PinRequest(bool Pinned); +``` + +### IExperionFastService 인터페이스 + +```csharp +public interface IExperionFastService +{ + Task StartSessionAsync(FastSessionStartRequest request); + Task StopSessionAsync(int sessionId); + Task DeleteSessionAsync(int sessionId); + Task PinSessionAsync(int sessionId, bool pinned); + Task GetSessionAsync(int sessionId); + Task> GetSessionsAsync(); + Task GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long"); + Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null); +} +``` + +--- + +## 사후 확인 (작업 후 반드시 수행) + +1. `IExperionServices.cs` 파일을 다시 열어 추가된 내용을 읽는다. +2. 아래 항목을 하나씩 확인한다: + - [x] `FastSessionInfo` record 존재 (11개 필드) + - [x] `FastSessionStartRequest` record 존재 + - [x] `FastSessionCreateRequest` record 존재 (StartRequest와 별도로) + - [x] `FastQueryResult` record 존재 + - [x] `PinRequest` record 존재 + - [x] `IExperionFastService` 인터페이스 존재 (메서드 8개) + - [x] `ExportCsvAsync`의 `Stream` 파라미터가 올바른가? +3. `dotnet build src/Web` 실행 → 에러 0개, 경고는 기존 9개 (TextToSqlController, ExperionOpcClient, ExperionRealtimeService) 확인 +4. 문제가 있으면 수정 후 다시 빌드 확인 + +--- + +## 완료 조건 +- `dotnet build src/Web` 결과: 에러 0, 경고 9개 (기존) +- DTO 5종 (`FastSessionInfo`, `FastSessionStartRequest`, `FastSessionCreateRequest`, `FastQueryResult`, `PinRequest`), 인터페이스 1개 (`IExperionFastService`) 모두 존재 \ No newline at end of file diff --git a/fastTable/step5.md b/fastTable/step5.md new file mode 100644 index 0000000..3d1008f --- /dev/null +++ b/fastTable/step5.md @@ -0,0 +1,78 @@ +# STEP 5 — 인터페이스: IExperionOpcClient + IExperionDbService 확장 + +## 사전 확인 (작업 전 반드시 수행) + +1. `src/Core/Application/Interfaces/IExperionOpcClient.cs` 파일을 열어 전체 내용을 읽는다. +2. `src/Core/Application/Interfaces/IExperionDbService.cs` 파일을 열어 전체 내용을 읽는다. +3. 아래 항목을 확인하고 기록한다: + - [x] STEP 4가 완료되어 DTO들이 존재하는가? + - [x] `IExperionOpcClient`에 `IsConnectedAsync` 메서드가 이미 있는가? + - [x] `IExperionOpcClient`에 `CreateSessionAsync` 메서드가 이미 있는가? + - [x] `IExperionDbService`에 `CreateFastSessionAsync` 메서드가 이미 있는가? + - [x] `IExperionDbService`에 `BatchInsertFastRecordsAsync` 메서드가 이미 있는가? + - [x] `IExperionDbService`에 `GetExpiredFastSessionsAsync` 메서드가 이미 있는가? + +--- + +## 작업 1 — IExperionOpcClient 확장 + +**파일**: `src/Core/Application/Interfaces/IExperionOpcClient.cs` + +인터페이스에 아래 두 메서드를 **추가**한다: + +```csharp +// fastTable용 메서드 +Task IsConnectedAsync(ApplicationConfiguration cfg); +Task CreateSessionAsync(ApplicationConfiguration cfg); +``` + +--- + +## 작업 2 — IExperionDbService 확장 + +**파일**: `src/Core/Application/Interfaces/IExperionDbService.cs` + +인터페이스에 아래 메서드들을 **추가**한다: + +```csharp +// ── FastSession ─────────────────────────────────────────────────────────────── +Task CreateFastSessionAsync(FastSessionCreateRequest request); +Task UpdateFastSessionStatusAsync(int sessionId, string status); +Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount); +Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned); +Task GetFastSessionAsync(int sessionId); +Task> GetFastSessionsAsync(); +Task DeleteFastSessionAsync(int sessionId); +Task> GetExpiredFastSessionsAsync(); + +// ── FastRecord ──────────────────────────────────────────────────────────────── +Task GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to); +Task BatchInsertFastRecordsAsync(IEnumerable records); +Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to); + +// ── 공통 (이미 없는 경우만) ────────────────────────────────────────────────── +Task GetNodeIdByTagNameAsync(string tagName); +``` + +--- + +## 사후 확인 (작업 후 반드시 수행) + +1. 두 파일을 다시 열어 변경 내용을 읽는다. +2. 아래 항목을 하나씩 확인한다: + - [x] `IExperionOpcClient`에 `IsConnectedAsync(ApplicationConfiguration cfg)` 존재 + - [x] `IExperionOpcClient`에 `CreateSessionAsync(ApplicationConfiguration cfg)` 존재 + - [x] `IExperionDbService`에 FastSession 관련 메서드 8개 모두 존재 + - [x] `IExperionDbService`에 FastRecord 관련 메서드 3개 모두 존재 + - [x] `IExperionDbService`에 `GetNodeIdByTagNameAsync` 존재 + - [x] 반환 타입이 올바른가? (`Task` nullable 포함) +3. `dotnet build src/Web` 실행 → 에러 14개 (구현체 미완료, STEP 6~7에서 해결) +4. 구현체 빌드 에러는 예상된 결과 (인터페이스만 추가한 단계) + +> ⚠️ 주의: 인터페이스만 추가하는 단계이므로 구현체 빌드 에러는 STEP 6~7에서 해결한다. + +--- + +## 완료 조건 +- `dotnet build src/Web` 결과: 에러 14개 (구현체 미완료, STEP 6~7에서 해결) +- 두 인터페이스에 지정된 메서드 시그니처 모두 존재 \ No newline at end of file diff --git a/fastTable/step6.md b/fastTable/step6.md new file mode 100644 index 0000000..bf78302 --- /dev/null +++ b/fastTable/step6.md @@ -0,0 +1,173 @@ +# STEP 6 — DB 서비스 구현: FastSession/FastRecord 메서드 추가 + +## 사전 확인 (작업 전 반드시 수행) + +1. `src/Infrastructure/Database/ExperionDbContext.cs` 파일을 열어 `ExperionDbService` 클래스를 찾는다. +2. 아래 항목을 확인하고 기록한다: + - [x] STEP 5가 완료되어 `IExperionDbService`에 Fast 메서드가 선언되어 있는가? + - [x] `ExperionDbService`가 `IExperionDbService`를 구현하는가? + - [x] 파일 상단에 `using System.Text.Json;` import가 있는가? → 없으면 추가 + - [x] `CreateFastSessionAsync` 구현이 이미 있는가? → 있으면 해당 메서드 건너뜀 + - [x] `BatchInsertFastRecordsAsync` 구현이 이미 있는가? + - [x] `ExportFastRecordsToCsvAsync` 구현이 이미 있는가? + +--- + +## 작업 내용 + +**파일**: `src/Infrastructure/Database/ExperionDbContext.cs` +**위치**: `ExperionDbService` 클래스 내부 마지막 메서드 아래 + +```csharp +// ── FastSession / FastRecord ───────────────────────────────────────────────── + +public async Task CreateFastSessionAsync(FastSessionCreateRequest request) +{ + var session = new FastSession + { + Name = request.Name, + SamplingMs = request.SamplingMs, + DurationSec = request.DurationSec, + TagList = JsonSerializer.Serialize(request.TagList), // string[] → JSONB + StartedAt = DateTime.UtcNow, + Status = "Pending", + RowCount = 0, + RetentionDays = request.RetentionDays, + Pinned = false + }; + _ctx.FastSessions.Add(session); + await _ctx.SaveChangesAsync(); + return session; +} + +public async Task UpdateFastSessionStatusAsync(int sessionId, string status) +{ + var session = await _ctx.FastSessions.FindAsync(sessionId); + if (session == null) return; + session.Status = status; + if (status is "Completed" or "Cancelled" or "Failed" or "RowLimitReached") + session.EndedAt = DateTime.UtcNow; + await _ctx.SaveChangesAsync(); +} + +public async Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount) +{ + var session = await _ctx.FastSessions.FindAsync(sessionId); + if (session == null) return; + session.RowCount = rowCount; + await _ctx.SaveChangesAsync(); +} + +public async Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned) +{ + var session = await _ctx.FastSessions.FindAsync(sessionId); + if (session == null) return; + session.Pinned = pinned; + await _ctx.SaveChangesAsync(); +} + +public async Task GetFastSessionAsync(int sessionId) + => await _ctx.FastSessions.FindAsync(sessionId); + +public async Task> GetFastSessionsAsync() + => await _ctx.FastSessions.OrderBy(x => x.StartedAt).ToListAsync(); + +public async Task DeleteFastSessionAsync(int sessionId) +{ + var session = await _ctx.FastSessions.FindAsync(sessionId); + if (session == null) return; + _ctx.FastSessions.Remove(session); + await _ctx.SaveChangesAsync(); +} + +public async Task> GetExpiredFastSessionsAsync() +{ + var now = DateTime.UtcNow; + return await _ctx.FastSessions + .Where(x => x.EndedAt != null + && !x.Pinned + && x.RetentionDays.HasValue + && x.EndedAt.Value.AddDays(x.RetentionDays.Value) < now) + .OrderBy(x => x.EndedAt) + .ToListAsync(); +} + +public async Task GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to) +{ + var query = _ctx.FastRecords.Where(x => x.SessionId == sessionId); + if (from.HasValue) query = query.Where(x => x.RecordedAt >= from.Value); + if (to.HasValue) query = query.Where(x => x.RecordedAt <= to.Value); + + var records = await query.OrderBy(x => x.RecordedAt).ToListAsync(); + var tagNames = records.Select(x => x.TagName).Distinct().OrderBy(x => x).ToArray(); + + return new FastQueryResult( + SessionId: sessionId, + From: from ?? records.MinBy(x => x.RecordedAt)?.RecordedAt ?? DateTime.UtcNow, + To: to ?? records.MaxBy(x => x.RecordedAt)?.RecordedAt ?? DateTime.UtcNow, + TagNames: tagNames, + Items: records, + TotalCount: records.Count + ); +} + +public async Task BatchInsertFastRecordsAsync(IEnumerable records) +{ + var list = records.ToList(); + if (list.Count == 0) return; + _ctx.FastRecords.AddRange(list); + await _ctx.SaveChangesAsync(); +} + +public async Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to) +{ + var result = await GetFastRecordsAsync(sessionId, from, to); + using var writer = new StreamWriter(stream, leaveOpen: true); + + var header = "recorded_at," + string.Join(",", result.TagNames.Select(t => $"\"{t}\"")); + await writer.WriteLineAsync(header); + + var grouped = result.Items + .GroupBy(x => x.RecordedAt) + .OrderBy(x => x.Key) + .Select(g => new { Time = g.Key, Values = g.ToDictionary(r => r.TagName, r => r.Value) }); + + foreach (var g in grouped) + { + var row = g.Time.ToString("o") + "," + + string.Join(",", result.TagNames.Select(t => + g.Values.TryGetValue(t, out var v) ? $"\"{v}\"" : "")); + await writer.WriteLineAsync(row); + } + await writer.FlushAsync(); +} + +public async Task GetNodeIdByTagNameAsync(string tagName) + => await _ctx.RealtimePoints + .Where(x => x.TagName == tagName) + .Select(x => x.NodeId) + .FirstOrDefaultAsync(); +``` + +--- + +## 사후 확인 (작업 후 반드시 수행) + +1. `ExperionDbContext.cs` 파일을 다시 열어 추가된 메서드 목록을 읽는다. +2. 아래 항목을 하나씩 확인한다: + - [x] `CreateFastSessionAsync` — `JsonSerializer.Serialize(request.TagList)` 사용하는가? + - [x] `UpdateFastSessionStatusAsync` — `EndedAt` 자동 설정 로직이 있는가? + - [x] `GetExpiredFastSessionsAsync` — `!x.Pinned` 조건이 있는가? + - [x] `GetFastRecordsAsync` — 반환 타입이 `FastQueryResult`인가? + - [x] `BatchInsertFastRecordsAsync` — 빈 리스트 early return이 있는가? + - [x] `ExportFastRecordsToCsvAsync` — PIVOT 그룹핑 로직이 있는가? + - [x] `GetNodeIdByTagNameAsync` — `_ctx.RealtimePoints` 에서 조회하는가? + - [x] 파일 상단에 `using System.Text.Json;` 가 있는가? +3. `dotnet build src/Web` 실행 → 에러 2개 (ExperionOpcClient 구현 미완료, STEP 7에서 해결) +4. ExperionOpcClient 구현 에러는 예상된 결과 (인터페이스만 추가한 단계) + +--- + +## 완료 조건 +- `dotnet build src/Web` 결과: 에러 2개 (ExperionOpcClient 구현 미완료, STEP 7에서 해결) +- Fast 관련 DB 메서드 12개 모두 구현됨 \ No newline at end of file diff --git a/fastTable/step7.md b/fastTable/step7.md new file mode 100644 index 0000000..9939760 --- /dev/null +++ b/fastTable/step7.md @@ -0,0 +1,400 @@ +# STEP 7 — ExperionFastService 신규 파일 생성 + +## 사전 확인 (작업 전 반드시 수행) + +1. `src/Infrastructure/OpcUa/` 디렉토리 목록을 확인한다. +2. 아래 항목을 확인하고 기록한다: + - [x] STEP 5, 6이 완료되어 인터페이스와 DB 메서드가 존재하는가? + - [x] `ExperionFastService.cs` 파일이 이미 존재하는가? → 존재하면 내용 비교 후 필요한 부분만 수정 (신규 생성) + - [x] `IExperionOpcClient`에 `IsConnectedAsync`, `CreateSessionAsync`가 구현되어 있는가? + - [x] `IOpcUaConfigProvider` 인터페이스가 존재하는가? (주입 경로 확인) + - [x] `Opc.Ua.Client` NuGet 패키지가 Infrastructure 프로젝트에 있는가? + +--- + +## 작업 내용 + +**파일**: `src/Infrastructure/OpcUa/ExperionFastService.cs` (신규 생성) + +```csharp +using System.Collections.Concurrent; +using System.Text.Json; +using ExperionCrawler.Core.Application.Interfaces; +using ExperionCrawler.Core.Domain.Entities; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Opc.Ua; +using Opc.Ua.Client; + +namespace ExperionCrawler.Infrastructure.OpcUa; + +/// +/// fastRecord 데이터 수집 서비스. +/// 세션별 별도 OPC UA Subscription을 관리하고, 2초마다 배치 INSERT. +/// IHostedService로 등록하여 앱 시작/종료 시 자동 관리. +/// +public class ExperionFastService : IExperionFastService, IHostedService, IDisposable +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly IOpcUaConfigProvider _configProvider; + private readonly IExperionOpcClient _opcClient; + + private readonly ConcurrentDictionary _sessions = new(); + + private CancellationTokenSource? _cts; + private Task? _monitorTask; + + private const int MaxConcurrentSessions = 3; + private const int MaxRowsPerSession = 5_000_000; + private const int FlushIntervalMs = 2_000; + + public ExperionFastService( + IServiceScopeFactory scopeFactory, + ILogger logger, + IOpcUaConfigProvider configProvider, + IExperionOpcClient opcClient) + { + _scopeFactory = scopeFactory; + _logger = logger; + _configProvider = configProvider; + _opcClient = opcClient; + } + + // ── IHostedService ──────────────────────────────────────────────────────── + + public async Task StartAsync(CancellationToken cancellationToken) + { + // 앱 시작 시 Running 상태 세션 → Failed 마킹 + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var sessions = await db.GetFastSessionsAsync(); + + foreach (var s in sessions.Where(s => s.Status == "Running")) + { + _logger.LogWarning("[Fast] 앱 시작 시 Running 세션 {Id} → Failed 마킹", s.Id); + await db.UpdateFastSessionStatusAsync(s.Id, "Failed"); + } + + _cts = new CancellationTokenSource(); + _monitorTask = Task.Run(() => MonitorLoopAsync(_cts.Token), _cts.Token); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _cts?.Cancel(); + if (_monitorTask != null) + await _monitorTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + + foreach (var kvp in _sessions) + kvp.Value.Cancel = true; + + await Task.Delay(2000).ConfigureAwait(false); // 마지막 flush 대기 + } + + public void Dispose() + { + _cts?.Dispose(); + } + + // ── IExperionFastService ────────────────────────────────────────────────── + + public async Task StartSessionAsync(FastSessionStartRequest request) + { + if (request.TagList.Length == 0 || request.TagList.Length > 8) + throw new ArgumentException("태그는 1~8개까지 가능합니다."); + + if (request.SamplingMs is not (100 or 250 or 500 or 1000)) + throw new ArgumentException("샘플링 간격은 100/250/500/1000ms 중 하나여야 합니다."); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var runningCount = (await db.GetFastSessionsAsync()).Count(s => s.Status == "Running"); + if (runningCount >= MaxConcurrentSessions) + throw new InvalidOperationException($"동시 실행 가능한 세션은 {MaxConcurrentSessions}개까지입니다."); + + var cfg = await _configProvider.GetConfigAsync(new ExperionServerConfig()); + if (string.IsNullOrEmpty(cfg?.EndpointUrl)) + throw new InvalidOperationException("서버 엔드포인트 URL이 설정되어 있지 않습니다."); + + if (!await _opcClient.IsConnectedAsync(cfg)) + throw new InvalidOperationException("OPC UA 서버에 연결되어 있지 않습니다."); + + // 노드 유효성 사전 검증 + foreach (var tagName in request.TagList) + { + var nodeId = await db.GetNodeIdByTagNameAsync(tagName); + if (string.IsNullOrEmpty(nodeId)) + throw new ArgumentException($"태그 '{tagName}'의 nodeId를 찾을 수 없습니다."); + + var readResult = await _opcClient.ReadTagAsync(cfg, nodeId); + if (!readResult.Success) + throw new ArgumentException($"태그 '{tagName}' 읽기 실패: {readResult.ErrorMessage}"); + } + + var session = await db.CreateFastSessionAsync(new FastSessionCreateRequest( + Name: request.Name, + SamplingMs: request.SamplingMs, + DurationSec: request.DurationSec, + TagList: request.TagList, + RetentionDays: request.RetentionDays)); + + var ctx = new FastSessionContext + { + SessionId = session.Id, + TagList = request.TagList, + SamplingMs = request.SamplingMs, + DurationSec = request.DurationSec, + StartedAt = DateTime.UtcNow, + Buffer = new ConcurrentQueue() + }; + + _sessions[session.Id] = ctx; + await StartSubscriptionAsync(ctx, cfg); + await db.UpdateFastSessionStatusAsync(session.Id, "Running"); + + _logger.LogInformation("[Fast] 세션 {Id} 시작 — 태그 {Count}개, {Ms}ms, {Sec}s", + session.Id, request.TagList.Length, request.SamplingMs, request.DurationSec); + + return MapToInfo(session); + } + + public async Task StopSessionAsync(int sessionId) + { + if (!_sessions.TryGetValue(sessionId, out var ctx)) + throw new InvalidOperationException($"세션 {sessionId}를 찾을 수 없습니다."); + + ctx.Cancel = true; + await FlushBufferAsync(ctx).ConfigureAwait(false); + await StopSubscriptionAsync(ctx).ConfigureAwait(false); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.UpdateFastSessionStatusAsync(sessionId, "Completed"); + await db.UpdateFastSessionRowCountAsync(sessionId, ctx.TotalRows); + + _sessions.TryRemove(sessionId, out _); + _logger.LogInformation("[Fast] 세션 {Id} 중지 — 총 {Count}행", sessionId, ctx.TotalRows); + } + + public async Task DeleteSessionAsync(int sessionId) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.DeleteFastSessionAsync(sessionId); + _sessions.TryRemove(sessionId, out _); + } + + public async Task PinSessionAsync(int sessionId, bool pinned) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.UpdateFastSessionPinnedAsync(sessionId, pinned); + } + + public async Task GetSessionAsync(int sessionId) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var session = await db.GetFastSessionAsync(sessionId); + return session == null ? null : MapToInfo(session); + } + + public async Task> GetSessionsAsync() + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return (await db.GetFastSessionsAsync()).Select(MapToInfo); + } + + public async Task GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long") + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.GetFastRecordsAsync(sessionId, from, to); + } + + public async Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.ExportFastRecordsToCsvAsync(sessionId, stream, from, to); + } + + // ── Private ──────────────────────────────────────────────────────────────── + + private async Task StartSubscriptionAsync(FastSessionContext ctx, ApplicationConfiguration cfg) + { + var session = await _opcClient.CreateSessionAsync(cfg); + var subscription = new Subscription(session.DefaultSubscription) + { + PublishingInterval = ctx.SamplingMs, + KeepAliveCount = 10 + }; + + foreach (var tagName in ctx.TagList) + { + var nodeId = await GetNodeIdAsync(tagName); + var item = new MonitoredItem(subscription) + { + StartNodeId = nodeId, + SamplingInterval = ctx.SamplingMs, + DisplayName = tagName + }; + item.Notification += (_, e) => OnNotification(ctx, e, tagName); + subscription.AddItem(item); + } + + await session.AddSubscriptionAsync(subscription); + subscription.Create(); + + ctx.Subscription = subscription; + ctx.Session = session; + } + + private async Task StopSubscriptionAsync(FastSessionContext ctx) + { + if (ctx.Subscription != null) + { + ctx.Subscription.Delete(false); + ctx.Subscription = null; + } + if (ctx.Session != null) + { + await ctx.Session.CloseAsync(); + await ctx.Session.DisposeAsync(); + ctx.Session = null; + } + } + + private void OnNotification(FastSessionContext ctx, MonitoredItemNotificationEventArgs e, string tagName) + { + if (ctx.Cancel) return; + + if (e.NotificationValue is MonitoredItemNotification notification) + { + ctx.Buffer.Enqueue(new FastRecord + { + SessionId = ctx.SessionId, + RecordedAt = DateTime.UtcNow, + TagName = tagName, + Value = notification.Value.Value?.ToString() + }); + ctx.TotalRows++; + } + } + + private async Task FlushBufferAsync(FastSessionContext ctx) + { + var buffer = new List(); + while (ctx.Buffer.TryDequeue(out var record)) + buffer.Add(record); + + if (buffer.Count == 0) return; + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.BatchInsertFastRecordsAsync(buffer); + await db.UpdateFastSessionRowCountAsync(ctx.SessionId, ctx.TotalRows); + + if (ctx.TotalRows >= MaxRowsPerSession) + { + ctx.Cancel = true; + await db.UpdateFastSessionStatusAsync(ctx.SessionId, "RowLimitReached"); + _sessions.TryRemove(ctx.SessionId, out _); + _logger.LogWarning("[Fast] 세션 {Id} RowLimitReached ({Max}행)", ctx.SessionId, MaxRowsPerSession); + } + } + + private async Task MonitorLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + await Task.Delay(FlushIntervalMs, ct); + + foreach (var kvp in _sessions.ToList()) + { + var ctx = kvp.Value; + if (ctx.Cancel) continue; + + if ((DateTime.UtcNow - ctx.StartedAt).TotalSeconds >= ctx.DurationSec) + { + ctx.Cancel = true; + await StopSessionAsync(ctx.SessionId); + continue; + } + + await FlushBufferAsync(ctx); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.LogError(ex, "[Fast] 모니터링 루프 오류"); + } + } + } + + private async Task GetNodeIdAsync(string tagName) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.GetNodeIdByTagNameAsync(tagName) ?? string.Empty; + } + + private static FastSessionInfo MapToInfo(FastSession s) => new( + Id: s.Id, + Name: s.Name, + StartedAt: s.StartedAt, + EndedAt: s.EndedAt, + Status: s.Status, + SamplingMs: s.SamplingMs, + DurationSec: s.DurationSec, + TagList: JsonSerializer.Deserialize(s.TagList) ?? [], + RowCount: s.RowCount, + RetentionDays: s.RetentionDays, + Pinned: s.Pinned); + + // ── Inner Class ──────────────────────────────────────────────────────────── + + private sealed class FastSessionContext + { + public int SessionId { get; set; } + public string[] TagList { get; set; } = []; + public int SamplingMs { get; set; } + public int DurationSec { get; set; } + public DateTime StartedAt { get; set; } + public ConcurrentQueue Buffer { get; set; } = new(); + public int TotalRows { get; set; } // 누적 행 수 + public bool Cancel { get; set; } + public ISession? Session { get; set; } + public Subscription? Subscription { get; set; } + } +} +``` + +--- + +## 사후 확인 (작업 후 반드시 수행) + +1. `ExperionFastService.cs` 파일을 읽어 전체 구조를 확인한다. +2. 아래 항목을 하나씩 확인한다: + - [x] 클래스가 `IExperionFastService`, `IHostedService`, `IDisposable` 모두 구현하는가? + - [x] `StartAsync` — Running 세션 Failed 마킹 로직이 있는가? + - [x] `OnNotification` — `MonitoredItemNotification` 타입 체크를 하는가? (`e.NotificationValue is MonitoredItemNotification`) + - [x] `FlushBufferAsync` — `ctx.TotalRows >= MaxRowsPerSession` 체크가 있는가? + - [x] `MapToInfo` — `JsonSerializer.Deserialize` 사용하는가? + - [x] `FastSessionContext.TotalRows` 필드가 있는가? + - [x] 파일 상단에 `using System.Text.Json;` 이 있는가? + - [x] `using Opc.Ua;`, `using Opc.Ua.Client;` 가 있는가? +3. `dotnet build src/Web` 실행 → 에러 0, 경고 14개 (기존 경고 포함) 확인 +4. 문제가 있으면 수정 후 다시 빌드 확인 + +--- + +## 완료 조건 +- `dotnet build src/Web` 결과: 에러 0, 경고 14개 (기존 경고 포함) +- `ExperionFastService.cs` 파일 존재 및 빌드 통과 \ No newline at end of file diff --git a/fastTable/step8.md b/fastTable/step8.md new file mode 100644 index 0000000..159bfd6 --- /dev/null +++ b/fastTable/step8.md @@ -0,0 +1,191 @@ +# STEP 8 — 컨트롤러 추가 (`ExperionFastController`) + +## 사전 확인 (작업 전 반드시 수행) + +1. `src/Web/Controllers/ExperionControllers.cs` 파일을 열어 전체 내용을 읽는다. +2. 아래 항목을 확인하고 기록한다: + - [x] STEP 7이 완료되어 `ExperionFastService`가 빌드되는가? + - [x] `ExperionFastController` 클래스가 이미 존재하는가? → 있으면 내용 비교 후 누락 엔드포인트만 추가 + - [x] `PinRequest` record가 이미 존재하는가? + - [x] 파일 상단에 `using Microsoft.AspNetCore.Mvc;` 가 있는가? + +--- + +## 작업 내용 + +**파일**: `src/Web/Controllers/ExperionControllers.cs` +**위치**: 파일 하단 (기존 컨트롤러 마지막 클래스 아래) + +```csharp +// ── FastTable / FastRecord ──────────────────────────────────────────────────── + +[ApiController] +[Route("api/fast")] +public class ExperionFastController : ControllerBase +{ + private readonly IExperionFastService _fastSvc; + + public ExperionFastController(IExperionFastService fastSvc) + => _fastSvc = fastSvc; + + /// 새 fastSession 시작 + [HttpPost("start")] + public async Task Start([FromBody] FastSessionStartRequest request) + { + try + { + var session = await _fastSvc.StartSessionAsync(request); + return Ok(new { id = session.Id, name = session.Name, status = session.Status, startedAt = session.StartedAt }); + } + catch (ArgumentException ex) { return BadRequest(new { error = ex.Message }); } + catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } + } + + /// 세션 중지 + [HttpPost("{id:int}/stop")] + public async Task Stop(int id) + { + try + { + await _fastSvc.StopSessionAsync(id); + return Ok(new { success = true, message = "세션이 중지되었습니다." }); + } + catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); } + } + + /// 세션 목록 조회 + [HttpGet("sessions")] + public async Task GetSessions() + { + var sessions = await _fastSvc.GetSessionsAsync(); + return Ok(new + { + total = sessions.Count(), + items = sessions.Select(s => new + { + id = s.Id, + name = s.Name, + status = s.Status, + samplingMs = s.SamplingMs, + durationSec = s.DurationSec, + tagCount = s.TagList.Length, + rowCount = s.RowCount, + startedAt = s.StartedAt, + endedAt = s.EndedAt, + retentionDays = s.RetentionDays, + pinned = s.Pinned + }) + }); + } + + /// 세션 상세 정보 + [HttpGet("{id:int}")] + public async Task GetSession(int id) + { + var session = await _fastSvc.GetSessionAsync(id); + if (session == null) return NotFound(); + return Ok(new + { + id = session.Id, + name = session.Name, + status = session.Status, + samplingMs = session.SamplingMs, + durationSec = session.DurationSec, + tagList = session.TagList, + rowCount = session.RowCount, + startedAt = session.StartedAt, + endedAt = session.EndedAt, + retentionDays = session.RetentionDays, + pinned = session.Pinned + }); + } + + /// 레코드 조회 (Long 포맷) + [HttpGet("{id:int}/records")] + public async Task GetRecords(int id, + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] string format = "long") + { + var result = await _fastSvc.GetRecordsAsync(id, from, to, format); + return Ok(new + { + sessionId = result.SessionId, + from = result.From, + to = result.To, + tagNames = result.TagNames, + total = result.TotalCount, + items = result.Items.Select(r => new + { + sessionId = r.SessionId, + recordedAt = r.RecordedAt, + tagName = r.TagName, + value = r.Value + }) + }); + } + + /// CSV Export (스트리밍) + [HttpGet("{id:int}/csv")] + public async Task ExportCsv(int id, + [FromQuery] DateTime? from, + [FromQuery] DateTime? to) + { + var ms = new MemoryStream(); + await _fastSvc.ExportCsvAsync(id, ms, from, to); + ms.Position = 0; + return File(ms, "text/csv", $"fast-{id}-{DateTime.Now:yyyyMMddHHmm}.csv"); + } + + /// 세션 삭제 + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + try + { + await _fastSvc.DeleteSessionAsync(id); + return Ok(new { success = true, message = "세션이 삭제되었습니다." }); + } + catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); } + } + + /// 세션 고정/해제 + [HttpPost("{id:int}/pin")] + public async Task Pin(int id, [FromBody] PinRequest request) + { + try + { + await _fastSvc.PinSessionAsync(id, request.Pinned); + return Ok(new { success = true, pinned = request.Pinned }); + } + catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); } + } +} + +public record PinRequest(bool Pinned); +``` + +--- + +## 사후 확인 (작업 후 반드시 수행) + +1. `ExperionControllers.cs` 파일을 다시 열어 추가된 컨트롤러를 읽는다. +2. 아래 항목을 하나씩 확인한다: + - [x] `[Route("api/fast")]` 라우트가 맞는가? + - [x] `POST /start` 엔드포인트 존재 + - [x] `POST /{id}/stop` 엔드포인트 존재 + - [x] `GET /sessions` 엔드포인트 존재 + - [x] `GET /{id}` 엔드포인트 존재 + - [x] `GET /{id}/records` 엔드포인트 존재 + - [x] `GET /{id}/csv` 엔드포인트 존재 + - [x] `DELETE /{id}` 엔드포인트 존재 + - [x] `POST /{id}/pin` 엔드포인트 존재 (총 8개 엔드포인트) + - [x] `PinRequest` record 존재 (중복 선언 아닌지 확인) +3. `dotnet build src/Web` 실행 → 에러/경고 0개 확인 +4. 문제가 있으면 수정 후 다시 빌드 확인 + +--- + +## 완료 조건 +- `dotnet build src/Web` 결과: 에러 0, 경고 0 +- `ExperionFastController` 8개 엔드포인트 모두 존재 \ No newline at end of file diff --git a/fastTable/step9.md b/fastTable/step9.md new file mode 100644 index 0000000..3f79533 --- /dev/null +++ b/fastTable/step9.md @@ -0,0 +1,79 @@ +# STEP 9 — DI 등록 (`Program.cs`) + 설정 (`appsettings.json`) + +## 사전 확인 (작업 전 반드시 수행) + +1. `src/Web/Program.cs` 파일을 열어 전체 내용을 읽는다. +2. `src/Web/appsettings.json` 파일을 열어 전체 내용을 읽는다. +3. 아래 항목을 확인하고 기록한다: + - [x] STEP 7이 완료되어 `ExperionFastService` 클래스가 존재하는가? + - [x] STEP 8이 완료되어 `ExperionFastController`가 존재하는가? + - [x] `Program.cs`에 `ExperionFastService` DI 등록이 이미 있는가? → 있으면 작업 1 건너뜀 + - [x] `Program.cs`에 `ExperionFastCleanupService` 등록이 이미 있는가? → 있으면 작업 1 건너뜀 + - [x] `appsettings.json`에 `"Fast"` 섹션이 이미 있는가? → 있으면 작업 2 건너뜀 + - [x] 기존 서비스 등록 위치(줄 번호)를 확인한다 (`builder.Services.Add...` 블록) + +--- + +## 작업 1 — Program.cs 서비스 등록 + +**위치**: 기존 `builder.Services` 등록 블록 마지막 줄 아래 + +```csharp +// ── FastTable Service ───────────────────────────────────────────────────────── +// 중요: Singleton으로 하나만 생성 후 IExperionFastService와 IHostedService 양쪽에 같은 인스턴스 공유 +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + +// ── FastTable Cleanup Service ───────────────────────────────────────────────── +builder.Services.AddHostedService(); +``` + +--- + +## 작업 2 — appsettings.json 설정 추가 + +**위치**: `appsettings.json` 최상위 JSON 객체 안, 마지막 속성 뒤 + +```json +"Fast": { + "MaxConcurrentSessions": 3, + "MaxRowsPerSession": 5000000, + "FlushIntervalMs": 2000 +} +``` + +--- + +## 사후 확인 (작업 후 반드시 수행) + +1. `Program.cs` 파일을 다시 열어 변경 내용을 읽는다. +2. `appsettings.json` 파일을 다시 열어 변경 내용을 읽는다. +3. 아래 항목을 하나씩 확인한다: + - [x] `AddSingleton()` 등록이 있는가? + - [x] `AddSingleton(sp => ...)` 등록이 있는가? + - [x] `AddHostedService(sp => sp.GetRequiredService())` 등록이 있는가? + - [x] 위 3줄이 올바른 순서인가? (Singleton 먼저, HostedService 마지막) + - [x] `AddHostedService()` 등록이 있는가? + - [x] `appsettings.json`에 `"Fast"` 섹션이 있고 JSON 형식이 올바른가? +4. `dotnet build src/Web` 실행 → 에러/경고 0개 확인 +5. 문제가 있으면 수정 후 다시 빌드 확인 + +> ⚠️ 주의: `AddHostedService()` 단독 사용 금지. +> 이렇게 하면 Singleton과 별개의 인스턴스가 생성되어 세션 상태가 공유되지 않음. + +--- + +## 완료 조건 +- `dotnet build src/Web` 결과: 에러 0, 경고 0 +- DI 등록 3줄 + Cleanup 등록 1줄 모두 존재 +- `appsettings.json`에 `"Fast"` 섹션 존재 + +## 완료 일시: 2026-04-29 + +- [x] `AddSingleton()` 등록 완료 +- [x] `AddSingleton(sp => ...)` 등록 완료 +- [x] `AddHostedService(sp => sp.GetRequiredService())` 등록 완료 +- [x] `AddHostedService()` 등록 완료 +- [x] `appsettings.json`에 `"Fast"` 섹션 추가 완료 +- [x] 빌드 검증 완료 (0 Error, 0 New Warning) \ No newline at end of file diff --git a/llm-model-change.md b/llm-model-change.md index ba7d884..e1e91c8 100644 --- a/llm-model-change.md +++ b/llm-model-change.md @@ -8,7 +8,7 @@ LLM 또는 임베딩 모델을 교체할 때 수정해야 할 모든 위치를 | 역할 | 현재 모델 / 설정 | |------|----------------| -| **LLM 추론** | `glm-4.7-flash` (vLLM, `localhost:8000/v1`) | +| **LLM 추론** | `Qwen/Qwen3-Coder-Next-FP8` (vLLM, `localhost:8000/v1`) | | **임베딩** | `nomic-embed-text` (Ollama, `localhost:11434`, 768-dim) | | **벡터 DB** | Qdrant `localhost:6333` | | **Qdrant 컬렉션 — 코드베이스** | `ws-65f457145aee80b2` (768-dim) | @@ -16,6 +16,102 @@ LLM 또는 임베딩 모델을 교체할 때 수정해야 할 모든 위치를 --- +## 현재 실행 중인 vLLM 기동 명령 (2026-04-28) + +### 기동 방법 + +```bash +cd ~/ai-models/spark-vllm-docker +./run-recipe.sh qwen3-coder-next-fp8 --solo +``` + +- `--setup` 없이 실행: 빌드/다운로드 생략, 바로 기동 +- `--solo`: 단일 노드, Ray 클러스터 없음 → `-tp 1` 자동 적용 + +### 실제 컨테이너 내부 vLLM 명령 (inspect 기준) + +```bash +vllm serve Qwen/Qwen3-Coder-Next-FP8 \ + --enable-auto-tool-choice \ + --tool-call-parser qwen3_coder \ + --gpu-memory-utilization 0.7 \ + --host 0.0.0.0 \ + --port 8000 \ + --kv-cache-dtype fp8 \ + --load-format fastsafetensors \ + --attention-backend flashinfer \ + --enable-prefix-caching \ + --max-model-len 131072 \ + -tp 1 +``` + +> 레시피 기본값(`tensor_parallel: 2`, `--distributed-executor-backend ray`)에서 `--solo` 모드로 인해 `-tp 1`로 변경되고 Ray 분산 옵션이 제거됨. + +### 컨테이너 실행 환경 + +| 항목 | 값 | +|------|----| +| 이미지 | `vllm-node:latest` | +| 네트워크 | `host` | +| GPU | all (NVIDIA_VISIBLE_DEVICES=all) | +| SHM | 64MB | +| Privileged | true | +| HF 캐시 마운트 | `~/.cache/huggingface:/root/.cache/huggingface` | +| 모델 경로 | `~/.cache/huggingface/hub/models--Qwen--Qwen3-Coder-Next-FP8` | + +### 실측 성능 (2026-04-28, `-tp 1`) + +| 항목 | 단순 코딩 | RAG 연동 복잡 코딩 | +|------|-----------|------------------| +| 출력 토큰 | 1,173 | 3,382 | +| 토큰 속도 | **48.2 tok/s** | **47.8 tok/s** | +| TTFT | 0.261s | 0.962s | +| 총 소요 시간 | 24.6s | 71.7s | + +**이전 모델 대비**: GLM-4.7-Flash (~16 tok/s) 대비 **약 3배 빠름** + +- 속도는 출력 길이·프롬프트 길이 무관하게 ~48 tok/s로 안정적 +- TTFT는 프롬프트 길이에 비례 (6,392 chars → 0.96s) +- RAG 검색(Qdrant + Ollama embed) 자체 오버헤드: 0.34s + +### vLLM 서버 누적 통계 (기동 후 초기 세션, 2026-04-28) + +다른 작업을 병행하는 상황에서 측정된 실사용 기준값: + +| 항목 | 값 | +|------|----| +| 완료 요청 | 22건 | +| 총 프롬프트 토큰 | 453,080 | +| 총 생성 토큰 | 23,147 | +| 평균 TTFT | 1.565s | +| 평균 E2E 레이턴시 | 13.3s / 요청 | +| **평균 tok/s (vLLM 내부)** | **44.4 tok/s** | +| KV 캐시 사용률 | 8.1% (484 GPU blocks, 여유 충분) | +| **프리픽스 캐시 히트율** | **85.7%** (388,064 / 453,080 tokens) | + +**핵심 포인트**: `--enable-prefix-caching` 효과로 프롬프트 토큰의 85.7%가 캐시에서 처리됨. +RAG 시스템 프롬프트·반복 컨텍스트 재사용 시 TTFT 대폭 절감. 다른 작업 병행 중에도 성능 유지. + +### GPU 상태 (nvidia-smi, 2026-04-28 23:32 — 작업 병행 중) + +``` +| GPU Name Temp Pwr:Usage GPU-Util | +| 0 NVIDIA GB10 60°C 25W 96% | + +프로세스: + VLLM::EngineCore 85,568 MiB (vLLM 모델 로드) + ollama 711 MiB (nomic-embed-text) + Xorg + gnome-shell 164 MiB (디스플레이) +``` + +- **GPU**: NVIDIA GB10 (단일 카드, `-tp 1`) +- **VRAM 사용**: 85,568 MiB (vLLM) + 711 MiB (Ollama) = **~86 GB** +- **GPU 사용률**: 96% (작업 처리 중) +- **소비 전력**: 25W (측정값 — 추론 중 실시간) +- **온도**: 60°C (정상 범위) + +--- + ## 변경 시나리오별 수정 위치 ### Case A — LLM 모델만 교체 (임베딩 유지) diff --git a/mcp-server/__pycache__/server.cpython-312.pyc b/mcp-server/__pycache__/server.cpython-312.pyc index c1b523b2690e4fcf6347edd15bd7b4034126326a..3e46a084c6dcace8ccd7a33a4ca5f108778b6b81 100644 GIT binary patch literal 22906 zcmbt+3s_s%o#(xJpcf#_!`OiB>nF(J=~o>5#DEu+i!Hv5muZB$1ZxW}wn|?F9D}vJy)$?t>PIqQv#14r)n>I7wxBL5_ zd#@yjY^R-TbMDbS|MNcQ|32q$)6&cwp4CS`cfC{3asNO+)F(xb@YlC%IqnQ6axI+5 ziyAlIscF&h6xX`7En4>0wdmMe-=b&ll$I2{b#6navBk(>^=?zAxy8(L8qTTjSg2MK zf8*<-_%Ai))OO^l@%Z~v%F7!07s@uYq>IKDt7vM;5X~(ML`zGinA(yhrnO{?=`A^; zwPhjdG>aMH0x?s}yp;8_PR`qsE8Zby_v=~~A>AnETw3@tFQnE=1$Ht^fTRSB&x6jx*Rikvoa%_Bt|Cv6Z5vAUWQrcH?+;iN2-eBW17#G*?! zb&c>`xArDbYv)3BYi-*DtFW}pwa2=glFRF+uUS-y1~c=#!VCFJ6qkI$UWM@c8_q%PUO(baeXp5uxbX_}KJo{yqJu?lZ>z(77_F$>SYBRXGk;2> zZsQ{shsWdfJNy_eUju%d+)}{a>S#Ue#1F%6htH2evuPrFxATb89ZB)|C8wjaCxWv%LuPbx8 zyncI^+u?8XN}Zn)rtUG)w-U+G?i$SExMrI+qU-WX{zzJf&+D;EPG1+s%Na@cVd$K0 zr_X1n{3mo(~%-^lgyLB2zum@dct3a#L{ z?{lK|oW^fdVS=2f8_`_StN6NC6V!BA)YSM}#W6gG^tAYQHKwMEDU0!`RU;bcpIR!< zHE}Ds3eLwL1!X$K@%YzmIHoyG{5{q(N0+ONZ9&oHY4_3M>%{8E;_H&Uon3wa>u4GxrWjCz%@EPk z3>=H3$Sa=a%jb`n8|&+<_Ey;s9;lC4YWMD{t+DTItf{S!WJnH|&uPbu+i4C1J_+;8 z(LB?(kEFD+eG}2qRQoWE9CqlJ+sY1mJDp`mT^_N^(dv}S(4Y=ytKU~P595?|wsw{H z7^W$cU!`5$krX>?u(S-tmEzCWhMaZH9S3H*4ffX&)JvOc^}bO z;Vl!^!Y|TL&KFeJ=M;SDOGQ;@s&rMWwePOhSCtw*rR7Llywh>a=jw5`;%Dq3!p)ZM zT13E-5Ie$k^1YaD4HxA63SsB42ruc>#nHss^bHcNZA?%~W0%uYRTt5VfzHluOrh~% z2ZlQ!xg!Qlul*5cH^w@}3n+(6T7^uJw0nU!?YpX)YH7BlmH6hPMhX(GkciIic6UnD zph`-~M9|0mZ{3oQEel4rjTK(8REBkx*YTBWjWrKHXb-Tid=D*4+=W42-EX}*KOp3% zBQdY?ZQkD02lXAax92_ay-0gANIO(_P8ZY#HKIPK6;noZmty;U#XTk|J|EWz-zD2=QjOyY%n%lf{26g@L+=+`iPR#Eh3^MNt z8da#TV$KtPt6QR^fu)WLC?e;pvh}GBXm7I3}VIZ!$M`@LQEM#Ldd&F)6$B3dvGKH7UA28oZ1p$v9;j-&=aRN>Fp0{qp-T{UM6s5ulI+a6Bc^-126sl`jDgjG{3i$62zGt(8kZ)A8sGb!Fb`S{@D!^cK5 zC#}T;x-W83{AYPwYQ~gx%_phpeYI0*3y2i#=Lh(y)U=begSA7p;RBPY0)FQ#Jah2W z!Qsl#qb~){2S)E5J2Y9iV=`ywfJsf-GRps4e^Ec`8ei}?IdA2RA9+Wbyz3j2MfXhR z)DP(6P@2h{I|j@jm)tqDc*rtbG2Aj-Kap-5KQy47GNzqO9ZVfMI{auT_iWGT%J9sXmO~)Zv>#Sw)$GvzdWXK&XlTWl#~woD4Ds(oM5ST9pk5s@WFLb~plERx>ylPQ zGoWm`04M%hwks6? zK}ZJy^AN!is_NtZL6 z^e5+p-}k*BG}YE)sHa~#A2C^IE0_d1rxlSDxvSIzNZ%Z~6df2* z`Y*`+i4Vi{&_$*7J(|+eFZstP+d68(i4)OtZ({_1MV$N0sgarCe%1^&dI?R7cVE*i z-G!W3?pn9g6VV5Nph2V{FuJ-TG8Xj_sUiRfW)YL<@U%N6Z@>ro@u=G_!)qQn>Z7eo z=v79mY4#%yw@ZYaWe1qlfkfh)ecwg^=GkB#SpU?qp;b>lK4q_YOdP5uepV~OuCWt=cue} zV^0(vY_7JUnrAOeKlOCggkDJtxAc>;cCVR7Ol*@UVlK`s_q*Cl54dl@Zc9Vpse-`IGd2y6I2 zRdsVw?Y_q9yM^5c8u!jAtE^&Oxos^4b4uO#A1rl!IZCBqPN@~;|G{E6QLz-vDV9xv zI(mP>5EM0_Oq*@3@=hq*jBQODsfdlTJj>VtXD&_u)g=WVu=$fIf$$g9on-inF{{d> zV54H8>fSv?Z1sM1OS7?X9BC)yjht+T=U_T~yzlPX1GO>SR@WdD6{6@0tSfr2E2Qlt z2vrR=iEBgHzD;OoysyXxL{QfNQhIMyy->JbIP48bzCz^KbD;6yK4I4(Wib}VDrh`V zQ+psug4ulS&5`I^XKBfoe1%gGie$oFCe!gUnXH!)jTfe$e?^%7#lW?(K5A--S`!h< z`M8+KC{W_e+0!#W7=XU`X<&AiN8bI>Ut;p!ri_FI2=V{Ck7bef?$<(_1H*D72)!x$ zd*(E#Kaelu6O`S~@ISbp=(*uJOQVeK%rbdLD#$|4Xl_l^SjJe#RUp8LtfM~$7382@ zQP^xS&wOAhlIE$%gqaum(Zguy9HD&>BVh38uU;VPLVVj(gP?>jj)D8a=mL|COplGs zoE{NM8)}<{&7|Cxfo-Mev>>R33wJjO2R5!RSFs%t!hVnm;-mVfkDn-xL8`U^5I1$jo;Kgz1xKqdz!R7VW=)1yn|K@;{xW0;2Ka zM&6#j__`pApLPlTx04=RnRlfhRZ_c8wlvUyF%h{^=`0y8iWsl3LZS1-5}bUR$O)=m zQ7o*$s->Er2@yXWBes;%41H;{x9L&0fRUbls~?FgwIRIg24ulnz*v ziiJbiB*WS?Ol*#%RyWo|GODTFRn=5`Q6ufbB9p3#-=o!~@`P5UtE7@3#iHav;t%k5 zopdmL+>J$CYSs%|Lph`RNlR&1S1L}gCzhoKz0V2rVUXQc;sMGC^t%Uh&PMtQk z^pp{vyGqryC$F8v)fq2c*@1UA?-k@l{Aa99wMDcll?jzs52(bsamYlNDJ;;}UXPvz zE()EFzVdXjFmv(}64_?_(c9RM!p!h#Fbp!@kG|4B-9JN)vtNIheOl z4mygJdir&eh6yDq10b(dQZmUac;1>-w*cE@UvF_j5A4N zv_VW&hHcOqkX-(5q08%bwRZnGi*aL;#fSm4*ECi)Nu5|Vb5>!-{0%2b0QnjIBrgR_ z>P}p9F_X|gBAIj&Br{7!5g{aGrZhgMgIW?2blhANlZq2dLVkLZsjHJo&vE6n$Q05e z8ZiY*Y5j<%olHE&OD5G=-wTy>2bsX;J))U33>{=fnD=y$+H&4=jQ5f2f(6JwHPDA$ zGvAySJu`Ie)e}NpowvD6U7;@xU?l+$VU0cg94#KA3#6WWomo_)FFg?rodP#YU_krH zkn=pWAWRPrO+Wj>R}rI(hiFG(8Ii$S* z^lN?85LrB#IUQo?TP+`=G7#LH-Vnul8nPH`?aeT-kCeQy9?LzSe)4r_CRqK1IhB@G zl1SJentAG+P3?z@_0;O;FGB_ADA<-27^uZ0Odo#>G6FFfH10)^$D(JRMwJ47L4=a$ zp;(CHpcqx%cTZE9N?D;KFW5dm86iQTC*2WY>T&GIx>3NVs) z`~1$%iyD{@z3@NSjO2(e05av`V6pwD7thl3){cuh3EDC)VuWz#bF@2s5i_iDcE9%# zr^gqupa^JCV4IYjOt}~!&2v%o+$k(ad1IWg*^JC27SXjHcK9QfR@g_~PG&lQ2@}Gu z-x)D8yP9nEG_)S}x>`|@L8+3p&8CkS!4I(2sOa{O`6WMV8nxQq>1$^ia~}~+TAPY$ zCe`74#LleeWSe^iiErc2cN{??5 z@cu)6cVD$wk8e7;WpK+!mK7706(3n_6BgUm^hKZKEE!!hkyO}ps$ox2DKfEj*)=WN95z?!MNoU26v6h;=-5|Gv33n&fl~oZ9+*< z9LW!v?vt0~HzmO6lNk(y+Lx6UC+MnE0zbt^g^4DAl=;Y4knx%7pHejQ%o~L5;1Hvt zRoVLlx`#NwtMU|cIcriSs_eRghKTdr)+_~_#fkW8+ir*3=bXo_&)taACu#qWUi;-R zu_n`@3kf_ep*&V7w*3p(^S8mMSfTQ6jA7G+#@((@YKKUm{zYGmQKc@$`Pu|Z6E82; zpCr=Eywc?1ka2S%rg_`8Z8v2oWi%!L#Qdoauz@x6GN|<`5{M#3F?d?`JsMcpQluS- zGA3JklwxiQXwOPJ5!iInQ+P!z2_1|5pec={DFg}DG1$xIDQQh`m9aCKLV6Cxe2hOI zX=(`#aKehdy&oGgr;Irt84D(i1>t3T{?2e^-#5eGY6~BBP2SxR-t$O!iTjGNb1u~G zUmyLuVE932xc%_t9#?pGM|jC2SB&nfmfT@uXw6G^oWEnVb8^MjNz1mdZkxQz6ZbD+ zWptmvem6L$L_swevvGVADZPvf^DT+*&175^`9K6Uw9`c`xF|RiL+ym%B_(N zq$9R})l@F#?g%Fz;6R&D^jdZZ`ezG}FWDXp;Dv}ztiyU5u<)D!o1 zN^`=b<9ZGFq6dsofq|*D*XWNK!Tm8acF+LxwZe6(cm`!GMj57FqnJYf)O4P^&G;D} zH~7iC%bv$gYI!iT8%N;O>W{gqf)`bAbrX8P`dSp&Eqpg}luvhh(A2T;8@1y)tPBGS70&k%U%*rGihoa1%PbiYLZE@R* zic9B;HZWm;;bU2di&Ru}R2=PcawB|Q=4=l*LHIoh68krr8XGEwZwtOaD=4_H7!0C2 z(CJ~3fXC02xDKILWeO7jos#60gkFYhb_ZyBsXjv6u#Qn%PK;Ix;8qMu(Gfk%0XLph zFd|?-><(Y6%OzVl;ej9xp$K9WSp6`Rdr^Nx2PLut68rNkQFOLB$m2k*O&KM%Hd`7q z_)Q33H~M|~)R3VeZO$A^eT_L8tu zMr|@stYme7VF|8UjwN5c!Z z4D3NLwSCub^Qy;uBO5MgLwiP>CWVR*^Vfy*st4{t@P&zryxDexzb?=toa5{j z)9VCJlUj6UQ&m1@L@9O;Fnw^MwQE*53;I z_oj$i`lq%C4q7R|$OZ)?!wB4K2pU8kVQst__D)7CRB8hl=CCOmMzp}t%YmVdy#{~G z!7~|^5RKGs=p9Uqu4&^GAnvpjG>YaCy=W0rE~Un?@_ZN26LuLlD%jdUINDEsN9kC}jr**gIjX9Pl+U}%^iL_$aW-J*T>+Jw29 z%JXA0FGA1CaNk@^3=@N$jH&n+{Dk0mrhdF?@}|&5iM-Gi~>t9(%0`(FoGara{0XE7w_;hVlADe z6e79Oc?59Wdxb&8*1zn?4JC$DMnD%R_%Q`PAvj)QZIynCA3kExFjN*MLgL;_+D#K%U6U)qDA?8CgTM!y6_ugaLgL3Tig3h!N0Pjefzh*;Fnwf4DAm zbW9)4*mT2cOiPF2AUjv3s$b+F%j`n#0p8C4)@lzQymxs2@S|s&$M~`3ujt-Pef9H$+6Qz96ar7Qbn%L*C2NN73He6v9JPh>HV|dae*Vx@_M*=$DIlz= zAgm$BjkJ^u8+^P?wl5Y_g&cx^|9l~r@eu#V8*p}GG!?yzGY-vYO1SyH@TU7G@(zU$ zcV4l2J~Vn5RrP|Z?!@3cxn|dPZnSV$iS}JlOvj+^m6P8ro4<%qX#0F8Dh=t2&XuQaOxT1 zB$Xh07~wSBrhX!vhFcI$!(#?UIPsoCIO+V4>^dY&UwmQaslJ%MlB)hFn&&G+I-#-Q zJEXI~Z65{QH;8GVp`f%1}42BTbe5-K2(1TDQo0iy$ib5uH@PyzCEC3@HC^gBo# zlimf)MrP)<7IoS}n5dxk593WfvWjMX7CfT}UaD>YLaH;p~|YIA5osN#I~OH0o$y|TPw+&@*Y zWY}@G=DJ?90H_=paVCdeH&PTKD%CRTkbY=0>HEk}6M!xMR}Vd>}<>#+}w#~2CdR!KnK zuDiGq+pZ$*yZTf_-(66)YlH4GSBUuKBK@uk!{rkCe)%pFA%9Il^(yUqOL>Z~(j%PX z8%$)G>CIlpP~U_E&;hkulK@CXQf_iTUlA5gMg%l%F`10#JPU3D9n*1xOlWRNCbaN2 z#=kgOiEGf4+BEe*-Z2^J@mM`UEr^RQL4yWOolHz*4H`63I!IbN5q`**%(sw>cFsZX zI)>h3`Z|W*^|xuEQM3g0485m9E;3$<9j{?>k*-(&m>%ezy%W%Tdf^wg$?$L)m>GtRHgpPZ zr*M-|M5Yy0Bqr@zVJj6H;4DE>6)K0>F#4+6hOaAQC^g1#I+NE_q<&CA>NRyu&2wHHL)7!Srg)mMbQLEHF(#gUma#8{A&E>_J#aoDhjHO+lzo$O8k(8DET%0}y2AMN zh@N$1w~X8wMy3NPNvA0ZA~q&P#1OK$`P_Mm^d5bk%Y@75mto!%q{^6A@wIlt8QG3A z(KzDR?vwrtlA8(XMi2qg&9>^hZi;k2wk{;kYs)FiRQ4lNiZQD`AIvSrso?Yp^!y9&8?x2Nt}k$%~1LHzPU{jOz(%lWAkFEmx>YA=@; zRA*}6OXU%NFH?_j4hoGqdW3Zx_Z=&_dXPC)erP-Q@=#925U9%MCNn&A&>YQsl5w(r zLfh;icM@i#(806_V+CA1zzgH_A=*L@v&tt+d%^kBS+99dtOW4T9n`FO4-j%HYb~w%d5@Y`G_(5$U?dUK#MC^_ zLuforOwWs%e{R=yU%sDCxUr{$4gk%2MBIi!_g#!=MeC(ZmFzT;;&HhO8aL7QrX-ZM zLQRjqhphxEHkxV4Ml%>&j|4}h1=GYu^rk!~Ds`N{0ulBEQ#;nH@%UR^b80JS zoLPOQcAPTp0U*BQ(ii_mHJ3UU33-+;O>J|a6YXt*hk$htc>{t&ataP1;iwSV z;GA@Ig}>BX?e*YN62IU->=el3rqD+Yx~%U4PXBt5Q(;IbSHK7A%wT=Er=!y$JnVFP zkCvM2_U>yu(2V0`f)v1=6qUGR$0j&{mWR^Ao&TEwl z_u!B^n~$!5pUS(Zsac?tTzA}sMWO75O7^v49c(1RHp(WHuScB9WvY*aWl4u7wg|0| zW8}g7>Uk8w>v4ArM-Mwaf}@WopA{U_OQq4#1S_tLPkXL zUL`C*`y5&+_y9uH!}cn0h1=m_?#`@`PKWe}h=chsZZTgyDFDnb9Yx^c1jLTf&u$9H z(!kDU_GoeF{!8Bc>Hotc0f=kZ9k>IpINoMX+U(vU@_&TaP0P#?%)Bn0 zqLRsi5Fj3I-n~aL$%QQOawf7<$OduqqUf~{&hxQ@JJ!k>JhrU7N zj_`D%ILLpGKf~kFg)|-4&Hqe`t7kNjvVKEFk0HPnM(UwD+%+PfKx(*%BfSw#X?cWi zO?2V@(zLbR>A}cK+k50A5q5T)g2!FDm0c3#+g=)jxI~f=xtRPj_s2f&gMzkD#TXyH z%lSdMb7cQSLEE2vq{w>Gl*OMgWbw&6Mu&<|^Ygnls2rA_(>0E|c9Cz8{yXwu)c*%w z(*Hz2jtp*}aw$z86WpXQzTdtFKcgPdO#M;rLVjIn`S_#n)cke#NYzAso!o(^OxgT% zhHQ+g^mkMxjcM}EtU*ewD<*Z8h;344$P%9+Ha|*VG;&`?0hl-$U$YZGr%q%euiS@? z2j9;cYaO?};hETY@K4Mw?MdqbUbc&!0hld6Vz%R8eN*+_wR@|WyR6iU@6uxwJWfF$ z1y4}$Bn99wIZ33zi2!GpyWB26T>#;ecG6dpIb=!uJjICj!(|+}eZwbhr??I)3O7{1 zjRh)MiH@bfiv^}(Tzcb^LJWfBY+U3)8eAW|DC*(Ng1H{ugKMsupZGuESJ_% z5^WBO}b>Tqq?8*hEMa;iza?OB@@W{6NSF>`@RGz9FT|KS|*GdyheX|@_t+`usgX6O6H29H*P|Iax-ALsu znL{g&7vn+?`0TU{whU{J+xvE-A$_|CHa@Wz$Hq3D&65k82ljky&Aqz3@RIK5X&2M5?8Ez-uWURxS#)r6`MpEwQ^JZN z!&LsV&pF+K8h)tyYTlCJ)=<&e&dI#uq3WsJyinmpu5GHQd~D^|;a5t+g*%50A??}J zsr8%Qyz`Yi$M2k6Uk!D6^{M($R_M_Ar5|LM&zh(xsHaz~3AMlEIq#WVvuSeqokJ-@ zM^2?rtu6_DV{GZfsvY>WbB&w$Xb5wPGVa9X)0JgT;EXa?k$1D_q?Ye&CzomIuRC-~H<;>HYv13tq)yQOCXYw_H-a8vD?ZUg?)QI)DV3qMxqJfTBaD>v;3a%Rm>LP~wstz>JWi+_z5w~4R(l&rd#P&!;*(Flw z$OMx)kUvU4n?Dd?c!LjnK%sm$WOw5iOJJ~Jg1^`%Z_uI@`ke9=KZ%wj^af3z_Ijp~FZyf|r?CuGT;X#*(`fiD*R`Bx;Vi{&q~`OhKi{t9_wv_M z)A;P!6)V$qsHOq8?&-g?=!r#75+I7uJ-m9ijbhbXRiTQlDnn=+(85i+gY8pjnH^mD5TvNyM zJCwFi^z(0GP;9Up=u!uIKF}^EvYIip{>GzisgKLza}p4+=5F#Relk5@I9#NJCY~X3?%?8A+~;{t|}y6J4x7+#12Ie#%~PH|^xY z#)!#pVi}PY4PkS{;PZ4vX{WY_fkg<^%6pXB^Sux}E#-rly(5et&cn>pkDVzE-(E;^D1e~DB$ zx|1Mpd9k=dtP%^8hI>L~8!QB@qD`#w4OX>SBNlIBRjjj+SS!|vC7W24(sr@*q?=)| z1BBYh&TWqIvfJhB9G|38#I0833x|b>fTD6F6ljnlCnT{UCOscR?maDg8^ln=gFZ`4paEG%URjio zHnW(%_>Ok|b?w@C0;Mlq(`G(E=^x%qUt9#c>G|s`$vM<{uod--Sct|@SPlr`5m||~ zw(Q!q3q*Q*`&!W{K-AOQ*RZ>BPeXTDP)0Th?rm;vZaTG-y7}U5?X_Fl{8TIQhJ`pB zpY?%;(u4+xkK2-u+&Le#@Eh?bXW=6-{! zF|lv9=%!(5OJj;_#4gw;KPm6np8{gNrqJVq$alnxp6Wo$3-4+dW>EU}BDncKoh>4d zSDozKa2Sm6oj&g%?gQ)p)l+#az>9J~!2=+{!;5_sTnqoKGP1iCfWEJUz=P%am(deX z$U{wQ9yYBzoofX0uWH{O?MD;Ka|xuqe{&MzYO19t)U9*{Z@KC*ss zS;@he5ETV1qQTB5J>WY^znoZ^yo0pa$(3u9E0a^p3$sYO@H*{)*)!00Xt{QAPCEyi z*XOjWi`q;QtxQfYzx$SU^$vIeiqnu0^0V6APLEFIuneug8!k!Z;*jkSV`iA(YuD)W z-hK~}mzCMYL)+DqBa2mo`G zN?ia*Fqs0KWsoKRu1m5ml5p0MzdFmwkDbk|j{MDe<{<-gmiFO9noKvXl}xUd(P!U% z<^EwnF4fnN_uBJF&{a!R*JI=tt|D^Ym2X^2E4ks?Veb~gij+%^lk~ewNXBLFZ)XjHK-yfwySZc>-VM+fZO0fez89mFgf9H%W z_m8a%Tl)vbN+zFfuc%H~Ge*YYPT1}iI1{!%8tvysmyBfz{i>N*k5-Ro+8LeUrP4E{ zXUkW0Cbnr+&**F!N@WWFFHSLgfXyjUGV_&9T6t`Hjs;YvhT&}IUihuP>@H_|spm}3 TBqi4ACY=r*-^tJKP diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml index 3c75ca9..183971a 100644 --- a/mcp-server/pyproject.toml +++ b/mcp-server/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "iiot-rag-mcp" version = "0.1.0" -description = "ExperionCrawler RAG MCP Server — Qdrant + GLM-4.7-Flash" +description = "ExperionCrawler Unified MCP Server — RAG + NL2SQL" requires-python = ">=3.10" dependencies = [ "mcp[cli]>=1.0.0", @@ -9,6 +9,7 @@ dependencies = [ "sentence-transformers>=3.0.0", "openai>=1.0.0", "httpx>=0.27.0", + "psycopg[binary]>=3.1.0", ] [project.scripts] @@ -17,3 +18,6 @@ iiot-rag-mcp = "server:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +only-include = ["server.py", "index_opc_docs.py"] diff --git a/mcp-server/server.py b/mcp-server/server.py index e66aedb..dd50b9b 100644 --- a/mcp-server/server.py +++ b/mcp-server/server.py @@ -1,14 +1,16 @@ #!/usr/bin/env python3 """ -ExperionCrawler RAG MCP Server -- 임베딩: Ollama nomic-embed-text (768-dim) — Roo Code 인덱스와 동일 모델 -- 벡터 DB: Qdrant localhost:6333 -- LLM: vLLM GLM-4.7-Flash localhost:8000/v1 -- 사용처: Claude Code MCP / Roo Code MCP (동일 서버) +ExperionCrawler Unified MCP Server +- RAG: Qdrant + Ollama nomic-embed-text + vLLM Qwen3-Coder-Next-FP8 +- NL2SQL: 자연어 → LLM SQL 생성 → PostgreSQL 실행 +- 사용처: + stdio 모드 (기본): Claude Code MCP / Roo Code MCP + HTTP 모드 (--http): C# McpClient (localhost:5001) """ from __future__ import annotations import sys +import json import logging import httpx from functools import lru_cache @@ -21,13 +23,23 @@ QDRANT_URL = "http://localhost:6333" OLLAMA_URL = "http://localhost:11434" EMBED_MODEL = "nomic-embed-text" # 768-dim, Roo Code 인덱스와 동일 VLLM_BASE_URL = "http://localhost:8000/v1" -VLLM_MODEL = "glm-4.7-flash" +VLLM_MODEL = "Qwen/Qwen3-Coder-Next-FP8" # Qdrant 컬렉션 COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드 COL_OPC_DOCS = "experion-opc-docs" # Experion HS R530 OPC UA 공식 문서 (266 chunks) -mcp = FastMCP("iiot-rag") +# PostgreSQL 연결 +DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform" +DB_TIMEOUT = 10 # 초 + +# C# McpClient(localhost:5001)와 통신: json_response+stateless로 단순 POST→JSON 방식 +mcp = FastMCP( + "iiot-rag", + port=5001, + json_response=True, + stateless_http=True, +) # ── 임베딩 (Ollama) ─────────────────────────────────────────────────────────── @@ -41,7 +53,7 @@ def _embed(text: str) -> list[float]: resp.raise_for_status() return resp.json()["embedding"] -# ── LLM (vLLM / GLM-4.7-Flash) ─────────────────────────────────────────────── +# ── LLM (vLLM / Qwen3-Coder-Next-FP8) ─────────────────────────────────────── @lru_cache(maxsize=1) def _llm(): @@ -79,7 +91,69 @@ def _search(collection: str, query: str, top_k: int, threshold: float = 0.25) -> return "\n\n---\n\n".join(parts) -# ── MCP 도구 ───────────────────────────────────────────────────────────────── +# ── DB 헬퍼 ────────────────────────────────────────────────────────────────── + +def _get_db_connection(): + """PostgreSQL DB 연결 획득.""" + import psycopg + return psycopg.connect(DB_CONNECTION_STRING, connect_timeout=DB_TIMEOUT) + + +def _validate_sql(sql: str) -> tuple[bool, str]: + """SQL 안전 검증 — SELECT만 허용, 위험 키워드 차단.""" + if len(sql) > 2000: + return False, "쿼리 길이 2000자를 초과했습니다." + dangerous = ['EXEC', 'DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'CREATE', 'GRANT', 'REVOKE'] + sql_upper = sql.upper() + for kw in dangerous: + if kw in sql_upper: + return False, f"허용되지 않은 키워드 '{kw}'를 사용했습니다." + if not sql_upper.strip().startswith('SELECT'): + return False, "단순 SELECT 쿼리만 허용됩니다." + if '..' in sql or '~' in sql: + return False, "파일 경로 표현은 허용되지 않습니다." + return True, "" + + +# DB 스키마 — LLM SQL 생성 시 컨텍스트로 사용 +_DB_SCHEMA = """ +PostgreSQL 시계열 데이터베이스 스키마 + +테이블: history_table (시계열 이력) + tagname TEXT - 태그명 (모두 소문자, 예: 'ficq-6113.pv') — 대소문자 구분 + node_id TEXT - OPC UA 노드 ID + value TEXT - 측정값, 수치 연산 시 ::double precision 캐스트 필요 + recorded_at TIMESTAMPTZ - 기록 시각(UTC), 스냅샷 주기 약 60초 + +테이블: realtime_table (실시간 최신값) + tagname TEXT - 태그명 (모두 소문자) + node_id TEXT - OPC UA 노드 ID + livevalue TEXT - 현재값 + timestamp TIMESTAMPTZ - 최종 갱신 시각 + +N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용): + 1분 버킷: date_trunc('minute', recorded_at) AS bucket + 2분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket + 5분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/300)*300) AS bucket + 10분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/600)*600) AS bucket + N분 버킷: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket + +예시 (2분 간격, 여러 태그): + SELECT to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/120)*120) AS bucket, + tagname, AVG(value::double precision) AS avg_val + FROM history_table + WHERE tagname IN ('tag1', 'tag2') + AND recorded_at >= NOW() - INTERVAL '3 hours' + GROUP BY bucket, tagname ORDER BY bucket, tagname + +규칙: + - SELECT만 허용 (INSERT/UPDATE/DELETE/DROP 등 불가) + - tagname은 모두 소문자로 정확히 입력 + - value 컬럼은 TEXT이므로 집계 시 ::double precision 캐스트 필수 + - time_bucket 함수 사용 금지 — 위의 to_timestamp/FLOOR/EPOCH 공식 사용 +""" + +# ── RAG 도구 ───────────────────────────────────────────────────────────────── @mcp.tool() def search_codebase(query: str, top_k: int = 6) -> str: @@ -103,10 +177,9 @@ def search_r530_docs(query: str, top_k: int = 5) -> str: 사용 시점: Experion HS R530의 OPC UA 설정, 인증서, 보안 정책, 포인트 주소 형식, 채널/컨트롤러 속성, 문제해결 등 제품 스펙과 동작을 알고 싶을 때. - ⚠️ ExperionCrawler 구현 코드를 찾으려면 search_codebase 사용. Args: - query: 검색어 (예: "certificate configuration", "endpoint security policy", "point address syntax") + query: 검색어 (예: "certificate configuration", "endpoint security policy") top_k: 반환 결과 수 (기본 5) """ return _search(COL_OPC_DOCS, query, top_k) @@ -114,7 +187,7 @@ def search_r530_docs(query: str, top_k: int = 5) -> str: @mcp.tool() def ask_iiot_llm(question: str, context: str = "") -> str: - """GLM-4.7-Flash에게 IIoT/OPC UA 질문 (컨텍스트 없이 LLM 직접 질문). + """Qwen3-Coder-Next에게 IIoT/OPC UA 질문 (컨텍스트 없이 LLM 직접 질문). 사용 시점: search_codebase 또는 search_r530_docs 결과를 context로 넘겨 종합 분석·답변이 필요할 때. 또는 일반 IIoT/OPC UA 개념 질문. @@ -143,14 +216,11 @@ def ask_iiot_llm(question: str, context: str = "") -> str: @mcp.tool() def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str: - """검색 → GLM-4.7-Flash 답변 생성 (통합 RAG). + """검색 → Qwen3-Coder-Next 답변 생성 (통합 RAG). 기본값: Experion HS R530 공식 문서만 검색 (search_docs=True, search_code=False). ExperionCrawler 코드도 함께 보려면 search_code=True 추가. - 사용 시점: Experion HS R530 제품 질문이나 ExperionCrawler 코드 질문에 - 검색+LLM 답변을 한 번에 얻고 싶을 때. - Args: question: 질문 search_docs: Experion HS R530 공식 문서 검색 여부 (기본 True) @@ -161,9 +231,228 @@ def rag_query(question: str, search_code: bool = False, search_docs: bool = True context_parts.append(f"=== Experion HS R530 공식 문서 ===\n{_search(COL_OPC_DOCS, question, 4)}") if search_code: context_parts.append(f"=== ExperionCrawler 구현 코드 ===\n{_search(COL_CODEBASE, question, 3)}") - return ask_iiot_llm(question, "\n\n".join(context_parts)) +# ── NL2SQL 도구 ─────────────────────────────────────────────────────────────── + +@mcp.tool() +def run_sql(sql: str) -> str: + """SQL 쿼리 실행 (SELECT만 허용). + + Args: + sql: 실행할 SELECT SQL 문자열 + + Returns: + JSON: { success, columns, count, data } 또는 { success, error } + """ + valid, err = _validate_sql(sql) + if not valid: + return json.dumps({"success": False, "error": f"SQL 검증 실패: {err}"}, ensure_ascii=False) + + try: + conn = _get_db_connection() + with conn.cursor() as cur: + cur.execute(sql) + rows = cur.fetchall() + columns = [desc[0] for desc in cur.description] + result_data = [dict(zip(columns, row)) for row in rows] + return json.dumps({ + "success": True, + "columns": columns, + "count": len(result_data), + "data": result_data + }, ensure_ascii=False, default=str) + except Exception as e: + return json.dumps({"success": False, "error": f"SQL 실행 실패: {e}"}, ensure_ascii=False) + + +@mcp.tool() +def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str: + """과거 값(PV) 히스토리 조회. + + Args: + tag_names: 태그 이름 목록 (예: ["ficq-6113.pv", "ti-6101.pv"]) + time_from: 시작 시간 (ISO 8601, 예: "2026-04-01T00:00:00") + time_to: 종료 시간 (ISO 8601, 예: "2026-04-02T00:00:00") + limit: 반환 행 수 제한 (기본 100, 최대 5000) + + Returns: + JSON: { success, tag_names, time_range, limit, data } + """ + try: + limit = min(limit, 5000) + conn = _get_db_connection() + with conn.cursor() as cur: + cur.execute( + """SELECT tagname, recorded_at, value + FROM history_table + WHERE tagname = ANY(%s) + AND recorded_at >= %s AND recorded_at <= %s + ORDER BY recorded_at, tagname + LIMIT %s""", + (tag_names, time_from, time_to, limit) + ) + rows = cur.fetchall() + data = [{"tag_name": r[0], "timestamp": r[1].isoformat(), "value": r[2]} for r in rows] + return json.dumps({ + "success": True, + "tag_names": tag_names, + "time_range": f"{time_from} ~ {time_to}", + "count": len(data), + "data": data + }, ensure_ascii=False, indent=2) + except Exception as e: + return json.dumps({"success": False, "error": f"히스토리 쿼리 실패: {e}"}, ensure_ascii=False) + + +@mcp.tool() +def get_tag_metadata(query: str, limit: int = 10) -> str: + """태그 메타데이터 검색 (realtime_table 기반). + + Args: + query: 태그명 검색어 (패턴 매칭) + limit: 반환 태그 수 제한 (기본 10) + + Returns: + JSON: { success, query, count, tags } + """ + try: + conn = _get_db_connection() + with conn.cursor() as cur: + cur.execute( + """SELECT tagname, livevalue, timestamp, node_id + FROM realtime_table + WHERE tagname ILIKE %s + ORDER BY tagname LIMIT %s""", + (f"%{query}%", limit) + ) + rows = cur.fetchall() + tags = [{"tag_name": r[0], "current_value": r[1], + "last_updated": r[2].isoformat() if r[2] else None, + "node_id": r[3]} for r in rows] + return json.dumps({"success": True, "query": query, "count": len(tags), "tags": tags}, + ensure_ascii=False, indent=2) + except Exception as e: + return json.dumps({"success": False, "error": f"태그 메타데이터 검색 실패: {e}"}, ensure_ascii=False) + + +@mcp.tool() +def list_drawings(unit_no: str | None = None) -> str: + """단위별 도면 목록 조회 (node_map_master.name 기반). + + Args: + unit_no: 단위 번호 접두사 (예: "A", "B"). None이면 전체 목록 + + Returns: + JSON: { success, unit_no, count, names } + """ + try: + conn = _get_db_connection() + with conn.cursor() as cur: + if unit_no: + cur.execute( + "SELECT DISTINCT name FROM node_map_master WHERE name ILIKE %s ORDER BY name LIMIT 100", + (f"{unit_no}%",) + ) + else: + cur.execute("SELECT DISTINCT name FROM node_map_master ORDER BY name LIMIT 100") + rows = cur.fetchall() + return json.dumps({"success": True, "unit_no": unit_no, + "count": len(rows), "names": [r[0] for r in rows]}, + ensure_ascii=False, indent=2) + except Exception as e: + return json.dumps({"success": False, "error": f"도면 목록 조회 실패: {e}"}, ensure_ascii=False) + + +@mcp.tool() +def query_with_nl(question: str) -> str: + """자연어 질문을 LLM이 SQL로 변환하고 시계열 DB를 조회합니다. + + Args: + question: 자연어 질문 (예: "FICQ-6113.PV 최근 1시간 값을 1분 단위로 표시") + + Returns: + JSON: { sql, success, columns, count, data } 또는 { sql, success, error } + """ + system = ( + "You are a PostgreSQL SQL expert.\n" + "Convert the user's question into a SELECT SQL using the schema below.\n" + "IMPORTANT rules:\n" + "- Use ONLY PostgreSQL syntax. No DATE_FORMAT, no INTERVAL N DAY.\n" + "- Time column is 'recorded_at' (TIMESTAMPTZ). Do NOT use 'timestamp'.\n" + "- NEVER use time_bucket(). For N-minute buckets use to_timestamp/FLOOR/EPOCH formula.\n" + "- INTERVAL rule:\n" + " * If the question specifies an interval (e.g. '2분 간격', '5-minute interval'):\n" + " use: to_timestamp(FLOOR(EXTRACT(EPOCH FROM recorded_at)/(N*60))*(N*60)) AS bucket\n" + " with GROUP BY bucket, tagname and AVG(value::double precision) AS avg_val\n" + " * If NO interval is specified: SELECT recorded_at, tagname, value — NO GROUP BY.\n" + "- Current year is 2026. '4월 27일' means 2026-04-27.\n" + "- All times in DB are UTC. Korean input is KST (UTC+9). Convert: KST 12:00 = UTC 03:00.\n" + "- value column is TEXT; cast with ::double precision only when aggregating.\n" + "- All tagnames are lowercase (e.g. 'ficq-6113.pv'). Match exactly.\n" + "- PostgreSQL LIKE: dot has no special meaning, no escaping needed.\n" + "- Return ONLY the SQL statement. No explanation, no markdown.\n\n" + f"{_DB_SCHEMA}" + ) + try: + resp = _llm().chat.completions.create( + model=VLLM_MODEL, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": question}, + ], + max_tokens=8192, + temperature=0.1, + ) + sql = (resp.choices[0].message.content or "").strip() + # 마크다운 코드 블록 제거 + if sql.startswith("```"): + lines = sql.splitlines() + sql = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip() + if not sql: + return json.dumps({"success": False, "sql": "", "error": "LLM이 SQL을 생성하지 못했습니다."}, ensure_ascii=False) + except Exception as e: + return json.dumps({"success": False, "sql": "", "error": f"LLM SQL 생성 실패: {e}"}, ensure_ascii=False) + + # SQL 실행 + raw = run_sql(sql) + result = json.loads(raw) + result["sql"] = sql + + # long format → pivot 변환 (tagname 컬럼이 있으면 자동 PIVOT) + if result.get("success") and "data" in result: + cols = result.get("columns", []) + data = result["data"] + if "tagname" in cols and data: + time_col = next((c for c in cols if c not in ("tagname", "value", "livevalue", "avg_val")), None) + val_col = next((c for c in ("avg_val", "value") if c in cols), cols[-1]) + if time_col: + tag_names_list = sorted(dict.fromkeys(row["tagname"] for row in data)) + pivoted: dict = {} + for row in data: + key = str(row[time_col]) + if key not in pivoted: + pivoted[key] = {time_col: row[time_col]} + pivoted[key][row["tagname"]] = row.get(val_col) + result["data"] = list(pivoted.values()) + result["columns"] = [time_col] + tag_names_list + result["count"] = len(result["data"]) + + return json.dumps(result, ensure_ascii=False, default=str) + + +# ── 엔트리포인트 ────────────────────────────────────────────────────────────── + +def main(): + """HTTP 모드로 실행 — C# McpClient (localhost:5001) 용.""" + mcp.run(transport="streamable-http") + + if __name__ == "__main__": - mcp.run(transport="stdio") + # --http 플래그: HTTP 모드 (C# McpClient 용) + # 플래그 없음: stdio 모드 (Claude Code / Roo Code MCP 용) + if "--http" in sys.argv: + mcp.run(transport="streamable-http") + else: + mcp.run(transport="stdio") diff --git a/mcp-server/uv.lock b/mcp-server/uv.lock index 7d66f2f..79532a0 100644 --- a/mcp-server/uv.lock +++ b/mcp-server/uv.lock @@ -546,6 +546,7 @@ dependencies = [ { name = "httpx" }, { name = "mcp", extra = ["cli"] }, { name = "openai" }, + { name = "psycopg", extra = ["binary"] }, { name = "qdrant-client" }, { name = "sentence-transformers" }, ] @@ -555,6 +556,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "mcp", extras = ["cli"], specifier = ">=1.0.0" }, { name = "openai", specifier = ">=1.0.0" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.1.0" }, { name = "qdrant-client", specifier = ">=1.9.0" }, { name = "sentence-transformers", specifier = ">=3.0.0" }, ] @@ -1237,6 +1239,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/d8/a763308a41e2ecfb6256ba0877d340c2f2b124c8b2746401863d96fa2c7a/psycopg_binary-3.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3385b58b2fe408a13d084c14b8dcf468cd36cbbe774408250facc128f9fa75c", size = 4609758, upload-time = "2026-02-18T16:46:33.132Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a9/f8a683e85400c1208685e7c895abc049dc13aa0b6ea989e6adf0a3681fe0/psycopg_binary-3.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1bef235a50a80f6aba05147002bc354559657cb6386dbd04d8e1c97d1d7cbe84", size = 4676740, upload-time = "2026-02-18T16:46:42.904Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7d/03512c4aaac8a58fc3b1221f38293aa517a1950d10ef8646c72c49addc7d/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:97c839717bf8c8df3f6d983a20949c4fb22e2a34ee172e3e427ede363feda27b", size = 5496335, upload-time = "2026-02-18T16:46:51.517Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bc/23319b4b1c2c0b810d225e1b6f16efbb16150074fc0ea96bfcabdf59ee09/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:48e500cf1c0984dacf1f28ea482c3cdbb4c2288d51c336c04bc64198ab21fc51", size = 5172032, upload-time = "2026-02-18T16:47:00.878Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/6d61dc0a56654c558a37b2d9b2094e470aa12621305cc7935fd769122e32/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb36a08859b9432d94ea6b26ec41a2f98f83f14868c91321d0c1e11f672eeae7", size = 6763107, upload-time = "2026-02-18T16:47:11.784Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b5/e2a3c90aa1059f5b5f593379caad7be3cc3c2ce1ddfc7730e39854e174fe/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dde92cfde09293fb63b3f547919ba7d73bd2654573c03502b3263dd0218e44e", size = 5006494, upload-time = "2026-02-18T16:47:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3e/bf126e0a1f864e191b7f3eeea667ee2ce13d582b036255fb8b12946d1f7a/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:78c9ce98caaf82ac8484d269791c1b403d7598633e0e4e2fa1097baae244e2f1", size = 4533850, upload-time = "2026-02-18T16:47:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d8/bb5e8d395deb945629aa0c65d12ab90ec3bfcbdf56be89e2a84d001864c9/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d593612758d0041cb13cb0003f7f8d3fabb7ad9319e651e78afae49b1cf5860e", size = 4223316, upload-time = "2026-02-18T16:47:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/c2/70/33eef61b0f0fd41ebf93b9699f44067313a45016827f67b3c8cc41f0a7ab/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f24e8e17035200a465c178e9ea945527ad0738118694184c450f1192a452ff25", size = 3954515, upload-time = "2026-02-18T16:47:30.434Z" }, + { url = "https://files.pythonhosted.org/packages/ea/db/27c2b3b9698e713e83e11e8540daa27516f9e90390ec21a41091cb15fcaf/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e7b607f0e14f2a4cf7e78a05ebd13df6144acfba87cb90842e70d3f125d9f53f", size = 4260274, upload-time = "2026-02-18T16:47:36.128Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3b/71e5d603059bf5474215f573a3e2d357a4e95672b26e04d41674400d4862/psycopg_binary-3.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:b27d3a23c79fa59557d2cc63a7e8bb4c7e022c018558eda36f9d7c4e6b99a6e0", size = 3557375, upload-time = "2026-02-18T16:47:42.799Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/6c5c54b815edeb30a281cfcea96dc93b3bb6be939aea022f00cab7aa1420/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6", size = 5172410, upload-time = "2026-02-18T16:48:05.665Z" }, + { url = "https://files.pythonhosted.org/packages/51/75/8206c7008b57de03c1ada46bd3110cc3743f3fd9ed52031c4601401d766d/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2", size = 6763408, upload-time = "2026-02-18T16:48:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5a/ea1641a1e6c8c8b3454b0fcb43c3045133a8b703e6e824fae134088e63bd/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6", size = 5006255, upload-time = "2026-02-18T16:48:22.176Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fb/538df099bf55ae1637d52d7ccb6b9620b535a40f4c733897ac2b7bb9e14c/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8", size = 4532694, upload-time = "2026-02-18T16:48:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d1/00780c0e187ea3c13dfc53bd7060654b2232cd30df562aac91a5f1c545ac/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d", size = 4222833, upload-time = "2026-02-18T16:48:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/a07f1ff713c51d64dc9f19f2c32be80299a2055d5d109d5853662b922cb4/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc", size = 3952818, upload-time = "2026-02-18T16:48:35.869Z" }, + { url = "https://files.pythonhosted.org/packages/d3/67/d33f268a7759b4445f3c9b5a181039b01af8c8263c865c1be7a6444d4749/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628", size = 4258061, upload-time = "2026-02-18T16:48:41.365Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3b/0d8d2c5e8e29ccc07d28c8af38445d9d9abcd238d590186cac82ee71fc84/psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40", size = 3558915, upload-time = "2026-02-18T16:48:46.679Z" }, + { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" }, + { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" }, + { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" }, + { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" }, + { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, + { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, + { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, + { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -2343,6 +2425,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" diff --git a/plans/roo-fasttable-implementation.md b/plans/roo-fasttable-implementation.md new file mode 100644 index 0000000..187bb46 --- /dev/null +++ b/plans/roo-fasttable-implementation.md @@ -0,0 +1,1614 @@ +# Roo 작업 지시: fastTable/fastRecord 기능 구현 + +--- + +## ⚠️ 필독 — 컨텍스트 관리 규칙 + +이 작업은 파일 11개를 수정/생성하는 대규모 작업이다. +**컨텍스트 오염 방지를 위해 아래 규칙을 철저히 따른다.** + +### 시작 전 (최초 1회) +1. `.roo.md` 읽기 +2. `.roo/rules-code/glm-code-rules.md` 읽기 +3. `task_state.md` 생성 — 아래 **초기 템플릿** 그대로 작성 + +### 단위 작업(Step)마다 반드시 +1. 해당 파일 **백업** → `/.rooBackup/YYYYMMDD_HHMMSS_파일명` 으로 복사 +2. `read_file` 로 수정 대상 파일 전체 확인 +3. 코드 수정 +4. `dotnet build` 실행 → 에러 0건 확인 +5. `task_state.md` 즉시 업데이트 (완료 표시 + 빌드 결과) + +### 이관 트리거 (즉시 중단 후 이관) +- Step 완료 시마다 컨텍스트 70% 이상 판단되면 즉시 이관 +- `task_state.md` 최신화 후 이관 신호 출력 +- 이관 후 첫 문장: **"task_state.md 를 읽고 [다음 Step 번호]부터 이어서 진행하세요"** + +--- + +## task_state.md 초기 템플릿 + +이 파일을 프로젝트 루트에 생성하고 시작한다: + +``` +## 작업명: fastTable/fastRecord 기능 구현 +## 시작시각: [실제 시작 시각 기록] +## 참조: plans/roo-fasttable-implementation.md + +## 전체 Step 목록 +- [ ] Step 1: ExperionEntities.cs — 엔티티 2개 추가 +- [ ] Step 2: IExperionServices.cs — IExperionDbService Fast 메서드 추가 +- [ ] Step 3: IExperionServices.cs — IExperionFastService + DTOs 추가 +- [ ] Step 4: ExperionDbContext.cs — DbSet + OnModelCreating 추가 +- [ ] Step 5: ExperionDbContext.cs — InitializeAsync DDL 추가 +- [ ] Step 6: ExperionDbContext.cs — Fast DB 메서드 구현 (Create/Update/Get) +- [ ] Step 7: ExperionDbContext.cs — Fast DB 메서드 구현 (Insert/Export/Expired) +- [ ] Step 8: ExperionFastService.cs — 신규 파일 생성 (상단부: 필드/생성자/IHostedService) +- [ ] Step 9: ExperionFastService.cs — StartSessionAsync 구현 +- [ ] Step 10: ExperionFastService.cs — OPC UA Subscription + OnNotification 구현 +- [ ] Step 11: ExperionFastService.cs — FlushBuffer + MonitorLoop + 헬퍼 구현 +- [ ] Step 12: ExperionFastCleanupService.cs — 신규 파일 생성 +- [ ] Step 13: ExperionControllers.cs — ExperionFastController 추가 +- [ ] Step 14: Program.cs — 서비스 등록 +- [ ] Step 15: appsettings.json — Fast 섹션 추가 +- [ ] Step 16: index.html — 탭 메뉴 + 패널 + 모달 추가 +- [ ] Step 17: app.js — Fast 함수 추가 + 탭 핸들러 연결 +- [ ] Step 18: style.css — Fast 전용 스타일 추가 + +## 완료된 Step +(없음) + +## 발견된 문제 +| Step | 파일 | 내용 | 상태 | +|------|------|------|------| +``` + +--- + +## 배경 + +`fastTable-coding-plan-byQwen3.md` 에 구현 계획이 있다. +Claude Sonnet 4.6이 기존 코드베이스와 비교 진단한 결과 **버그 11건** 이 발견되었다. +이 파일은 그 오류를 수정한 **올바른 구현 지침**이다. +원본 계획과 충돌 시 **이 파일의 지시를 우선**한다. + +--- + +## Step 1 — `ExperionEntities.cs` 엔티티 추가 + +**파일**: `src/Core/Domain/Entities/ExperionEntities.cs` + +파일 끝에 두 클래스를 추가한다. + +```csharp +/// fastSession — 데이터 수집 세션 메타 +[Table("fast_session")] +public class FastSession +{ + [Column("id")] public int Id { get; set; } + [Column("name")] public string Name { get; set; } = string.Empty; + [Column("started_at")] public DateTime StartedAt { get; set; } + [Column("ended_at")] public DateTime? EndedAt { get; set; } + [Column("status")] public string Status { get; set; } = "Pending"; + [Column("sampling_ms")] public int SamplingMs { get; set; } + [Column("duration_sec")] public int DurationSec { get; set; } + [Column("tag_list")] public string TagList { get; set; } = "[]"; + [Column("row_count")] public int RowCount { get; set; } + [Column("retention_days")] public int? RetentionDays { get; set; } + [Column("pinned")] public bool Pinned { get; set; } +} + +/// fastRecord — 시계열 데이터 (Long 포맷) +[Table("fast_record")] +public class FastRecord +{ + [Column("id")] public int Id { get; set; } + [Column("session_id")] public int SessionId { get; set; } + [Column("recorded_at")] public DateTime RecordedAt { get; set; } + [Column("tagname")] public string TagName { get; set; } = string.Empty; + [Column("value")] public string? Value { get; set; } +} +``` + +**완료 후**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 → `task_state.md` 에 `Step 1 완료` 기록 + +--- + +## Step 2 — `IExperionServices.cs` IExperionDbService Fast 메서드 추가 + +**파일**: `src/Core/Application/Interfaces/IExperionServices.cs` + +`IExperionDbService` 인터페이스의 마지막 메서드(`GetRealtimeNodeDataTypesAsync`) 바로 뒤에 추가: + +```csharp + // ── FastSession / FastRecord ────────────────────────────────────────────── + Task CreateFastSessionAsync(FastSessionCreateRequest request); + Task UpdateFastSessionStatusAsync(int sessionId, string status); + Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount); + Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned); + Task GetFastSessionAsync(int sessionId); + Task> GetFastSessionsAsync(); + Task DeleteFastSessionAsync(int sessionId); + Task GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to); + Task BatchInsertFastRecordsAsync(IEnumerable records); + Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to); + Task GetNodeIdByTagNameAsync(string tagName); + Task> GetExpiredFastSessionsAsync(); +``` + +**완료 후**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 → `task_state.md` 에 `Step 2 완료` 기록 + +--- + +## Step 3 — `IExperionServices.cs` IExperionFastService + DTOs 추가 + +**파일**: `src/Core/Application/Interfaces/IExperionServices.cs` + +파일 맨 끝에 추가: + +```csharp +// ── Fast Service ───────────────────────────────────────────────────────────── + +public interface IExperionFastService +{ + Task StartSessionAsync(FastSessionStartRequest request); + Task StopSessionAsync(int sessionId); + Task DeleteSessionAsync(int sessionId); + Task PinSessionAsync(int sessionId, bool pinned); + Task GetSessionAsync(int sessionId); + Task> GetSessionsAsync(); + Task GetRecordsAsync(int sessionId, DateTime? from, DateTime? to); + Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null); +} + +public record FastSessionStartRequest( + string Name, + int SamplingMs, + int DurationSec, + string[] TagList, + int? RetentionDays = null +); + +public record FastSessionCreateRequest( + string Name, + int SamplingMs, + int DurationSec, + string[] TagList, + int? RetentionDays +); + +public record FastSessionInfo( + int Id, + string Name, + DateTime StartedAt, + DateTime? EndedAt, + string Status, + int SamplingMs, + int DurationSec, + string[] TagList, + int RowCount, + int? RetentionDays, + bool Pinned +); + +public record FastQueryResult( + int SessionId, + DateTime From, + DateTime To, + string[] TagNames, + IEnumerable Items, + int TotalCount +); +``` + +**완료 후**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 → `task_state.md` 에 `Step 3 완료` 기록 + +--- + +## Step 4 — `ExperionDbContext.cs` DbSet + OnModelCreating 추가 + +**파일**: `src/Infrastructure/Database/ExperionDbContext.cs` + +#### 4-a. DbSet 추가 + +기존 `DbSet` 선언들이 있는 위치(파일 상단부)에 두 줄 추가: + +```csharp +public DbSet FastSessions => Set(); +public DbSet FastRecords => Set(); +``` + +#### 4-b. OnModelCreating 에 인덱스 추가 + +기존 `modelBuilder.Entity<...>` 블록들이 있는 `OnModelCreating` 메서드 안에 추가: + +```csharp +modelBuilder.Entity(e => +{ + e.HasKey(x => x.Id); + e.HasIndex(x => x.Status); + e.HasIndex(x => x.StartedAt); +}); + +modelBuilder.Entity(e => +{ + e.HasKey(x => x.Id); + e.HasIndex(x => x.SessionId); + e.HasIndex(x => new { x.SessionId, x.TagName, x.RecordedAt }); +}); +``` + +**완료 후**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 → `task_state.md` 에 `Step 4 완료` 기록 + +--- + +## Step 5 — `ExperionDbContext.cs` InitializeAsync DDL 추가 + +**파일**: `src/Infrastructure/Database/ExperionDbContext.cs` + +`InitializeAsync()` 메서드 내부, 기존 테이블 DDL 블록들 끝에 추가. + +> **주의**: `tag_list` 컬럼은 반드시 `TEXT`로 선언한다. 원본 계획의 `JSONB`는 EF Core `string` 타입과 충돌하여 INSERT 에러가 발생한다. + +```csharp +await _ctx.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS fast_session ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + started_at TIMESTAMPTZ NOT NULL, + ended_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'Pending', + sampling_ms INTEGER NOT NULL, + duration_sec INTEGER NOT NULL, + tag_list TEXT NOT NULL DEFAULT '[]', + row_count INTEGER NOT NULL DEFAULT 0, + retention_days INTEGER, + pinned BOOLEAN NOT NULL DEFAULT FALSE + ) + """); + +await _ctx.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS fast_record ( + id SERIAL PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES fast_session(id) ON DELETE CASCADE, + recorded_at TIMESTAMPTZ NOT NULL, + tagname TEXT NOT NULL, + value TEXT + ) + """); + +await _ctx.Database.ExecuteSqlRawAsync(""" + SELECT create_hypertable('fast_record', 'recorded_at', if_not_exists => TRUE) + """); + +await _ctx.Database.ExecuteSqlRawAsync(""" + SELECT set_chunk_time_interval('fast_record', INTERVAL '1 day') + """); +``` + +**완료 후**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 → `task_state.md` 에 `Step 5 완료` 기록 + +--- + +## Step 6 — `ExperionDbContext.cs` Fast DB 메서드 (Create / Update / Get) + +**파일**: `src/Infrastructure/Database/ExperionDbContext.cs` + +`ExperionDbService` 클래스 안, 기존 메서드들 끝에 아래 메서드들을 추가한다. + +> **주의**: `TagList`는 반드시 `JsonSerializer.Serialize()`로 저장한다. `string.Join(',')`은 JSON 형식이 아니라 파싱 불일치가 발생한다. + +```csharp +// ── FastSession / FastRecord ──────────────────────────────────────────────── + +public async Task CreateFastSessionAsync(FastSessionCreateRequest request) +{ + var session = new FastSession + { + Name = request.Name, + SamplingMs = request.SamplingMs, + DurationSec = request.DurationSec, + TagList = System.Text.Json.JsonSerializer.Serialize(request.TagList), + StartedAt = DateTime.UtcNow, + Status = "Pending", + RowCount = 0, + RetentionDays = request.RetentionDays, + Pinned = false + }; + _ctx.FastSessions.Add(session); + await _ctx.SaveChangesAsync(); + return session; +} + +public async Task UpdateFastSessionStatusAsync(int sessionId, string status) +{ + var session = await _ctx.FastSessions.FindAsync(sessionId); + if (session == null) return; + session.Status = status; + if (status is "Completed" or "Cancelled" or "Failed" or "RowLimitReached") + session.EndedAt = DateTime.UtcNow; + await _ctx.SaveChangesAsync(); +} + +public async Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount) +{ + await _ctx.FastSessions + .Where(x => x.Id == sessionId) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.RowCount, rowCount)); +} + +public async Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned) +{ + var session = await _ctx.FastSessions.FindAsync(sessionId); + if (session == null) return; + session.Pinned = pinned; + await _ctx.SaveChangesAsync(); +} + +public async Task GetFastSessionAsync(int sessionId) + => await _ctx.FastSessions.FindAsync(sessionId); + +public async Task> GetFastSessionsAsync() + => await _ctx.FastSessions.OrderByDescending(x => x.StartedAt).ToListAsync(); + +public async Task DeleteFastSessionAsync(int sessionId) +{ + var session = await _ctx.FastSessions.FindAsync(sessionId); + if (session == null) return; + _ctx.FastSessions.Remove(session); + await _ctx.SaveChangesAsync(); +} +``` + +**완료 후**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 → `task_state.md` 에 `Step 6 완료` 기록 + +--- + +## Step 7 — `ExperionDbContext.cs` Fast DB 메서드 (Insert / Export / Expired) + +**파일**: `src/Infrastructure/Database/ExperionDbContext.cs` + +Step 6에서 추가한 메서드들 바로 뒤에 이어서 추가: + +```csharp +public async Task GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to) +{ + var query = _ctx.FastRecords.Where(x => x.SessionId == sessionId); + if (from.HasValue) query = query.Where(x => x.RecordedAt >= from.Value); + if (to.HasValue) query = query.Where(x => x.RecordedAt <= to.Value); + + var records = await query.OrderBy(x => x.RecordedAt).ToListAsync(); + var tagNames = records.Select(x => x.TagName).Distinct().OrderBy(x => x).ToArray(); + var minTime = records.Count > 0 ? records[0].RecordedAt : DateTime.UtcNow; + var maxTime = records.Count > 0 ? records[^1].RecordedAt : DateTime.UtcNow; + + return new FastQueryResult(sessionId, from ?? minTime, to ?? maxTime, + tagNames, records, records.Count); +} + +public async Task BatchInsertFastRecordsAsync(IEnumerable records) +{ + var list = records.ToList(); + if (list.Count == 0) return; + _ctx.FastRecords.AddRange(list); + await _ctx.SaveChangesAsync(); +} + +public async Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, + DateTime? from, DateTime? to) +{ + var result = await GetFastRecordsAsync(sessionId, from, to); + using var writer = new StreamWriter(stream, leaveOpen: true); + await writer.WriteLineAsync("recorded_at," + string.Join(",", result.TagNames)); + + foreach (var g in result.Items.GroupBy(x => x.RecordedAt).OrderBy(g => g.Key)) + { + var values = g.ToDictionary(r => r.TagName, r => r.Value); + var row = g.Key.ToString("o") + "," + + string.Join(",", result.TagNames.Select( + t => values.TryGetValue(t, out var v) ? $"\"{v}\"" : "")); + await writer.WriteLineAsync(row); + } + await writer.FlushAsync(); +} + +public async Task GetNodeIdByTagNameAsync(string tagName) + => await _ctx.RealtimePoints + .Where(x => x.TagName == tagName) + .Select(x => x.NodeId) + .FirstOrDefaultAsync(); + +public async Task> GetExpiredFastSessionsAsync() +{ + var now = DateTime.UtcNow; + return await _ctx.FastSessions + .Where(x => x.EndedAt != null + && !x.Pinned + && x.RetentionDays.HasValue + && x.EndedAt.Value.AddDays(x.RetentionDays.Value) < now) + .ToListAsync(); +} +``` + +**완료 후**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 → `task_state.md` 에 `Step 7 완료` 기록 + +--- + +## Step 8 — `ExperionFastService.cs` 신규 생성 (상단부) + +**파일 신규 생성**: `src/Infrastructure/OpcUa/ExperionFastService.cs` + +이 Step에서는 파일 전체 골격 + 필드 + 생성자 + `IHostedService` 구현만 작성한다. + +```csharp +using System.Collections.Concurrent; +using System.Text.Json; +using ExperionCrawler.Core.Application.Interfaces; +using ExperionCrawler.Core.Domain.Entities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Opc.Ua; +using Opc.Ua.Client; +using ISession = Opc.Ua.Client.ISession; + +namespace ExperionCrawler.Infrastructure.OpcUa; + +public class ExperionFastService : IExperionFastService, IHostedService, IDisposable +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly IOpcUaConfigProvider _configProvider; + + private readonly ConcurrentDictionary _sessions = new(); + private CancellationTokenSource? _cts; + private Task? _monitorTask; + + private readonly int _maxConcurrentSessions = 3; + private readonly int _maxRowsPerSession = 5_000_000; + private readonly int _flushIntervalMs = 2_000; + + // ExperionServerConfig 는 realtime_autostart.json 에서 읽음 (RealtimeService 와 공유) + private static readonly string RealtimeFlagPath = + Path.GetFullPath("realtime_autostart.json"); + + public ExperionFastService( + IServiceScopeFactory scopeFactory, + ILogger logger, + IOpcUaConfigProvider configProvider) + { + _scopeFactory = scopeFactory; + _logger = logger; + _configProvider = configProvider; + } + + // ── IHostedService ──────────────────────────────────────────────────────── + + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var stale = (await db.GetFastSessionsAsync()) + .Where(s => s.Status == "Running").ToList(); + foreach (var s in stale) + { + _logger.LogWarning("[Fast] 앱 시작 — Running 세션 {Id} Failed 마킹", s.Id); + await db.UpdateFastSessionStatusAsync(s.Id, "Failed"); + } + _cts = new CancellationTokenSource(); + _monitorTask = Task.Run(() => MonitorLoopAsync(_cts.Token), _cts.Token); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _cts?.Cancel(); + if (_monitorTask != null) + await _monitorTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + foreach (var kvp in _sessions) + kvp.Value.Cancel = true; + await Task.Delay(2_000).ConfigureAwait(false); + } + + public void Dispose() { _cts?.Dispose(); } + + // ── 나머지 메서드는 Step 9~11 에서 추가 ────────────────────────────────── +} +``` + +**완료 후**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 → `task_state.md` 에 `Step 8 완료` 기록 + +--- + +## Step 9 — `ExperionFastService.cs` IExperionFastService 공개 메서드 구현 + +**파일**: `src/Infrastructure/OpcUa/ExperionFastService.cs` + +`Dispose()` 바로 뒤, `// 나머지 메서드` 주석을 지우고 아래로 교체한다: + +```csharp + // ── IExperionFastService ────────────────────────────────────────────────── + + public async Task StartSessionAsync(FastSessionStartRequest request) + { + if (request.TagList.Length == 0 || request.TagList.Length > 8) + throw new ArgumentException("태그는 1~8개여야 합니다."); + if (request.SamplingMs is not (100 or 250 or 500 or 1000)) + throw new ArgumentException("샘플링 간격은 100/250/500/1000ms 중 하나여야 합니다."); + + var serverCfg = await ReadServerConfigAsync(); + if (serverCfg == null) + throw new InvalidOperationException( + "OPC UA 서버 설정을 찾을 수 없습니다. 실시간 구독을 먼저 시작하세요."); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var runningCount = (await db.GetFastSessionsAsync()).Count(s => s.Status == "Running"); + if (runningCount >= _maxConcurrentSessions) + throw new InvalidOperationException( + $"동시 실행 가능한 세션은 최대 {_maxConcurrentSessions}개입니다."); + + var nodeIds = new Dictionary(); + foreach (var tag in request.TagList) + { + var nodeId = await db.GetNodeIdByTagNameAsync(tag); + if (string.IsNullOrEmpty(nodeId)) + throw new ArgumentException($"태그 '{tag}'의 nodeId를 찾을 수 없습니다."); + nodeIds[tag] = nodeId; + } + + var session = await db.CreateFastSessionAsync(new FastSessionCreateRequest( + request.Name, request.SamplingMs, request.DurationSec, + request.TagList, request.RetentionDays)); + + var ctx = new FastSessionContext + { + SessionId = session.Id, + TagList = request.TagList, + NodeIds = nodeIds, + SamplingMs = request.SamplingMs, + DurationSec = request.DurationSec, + StartedAt = DateTime.UtcNow, + }; + _sessions[session.Id] = ctx; + + try + { + await StartSubscriptionAsync(ctx, serverCfg); + await db.UpdateFastSessionStatusAsync(session.Id, "Running"); + _logger.LogInformation("[Fast] 세션 {Id} 시작 — 태그 {Count}개 {Ms}ms {Sec}s", + session.Id, request.TagList.Length, request.SamplingMs, request.DurationSec); + } + catch (Exception ex) + { + _sessions.TryRemove(session.Id, out _); + await db.UpdateFastSessionStatusAsync(session.Id, "Failed"); + throw new InvalidOperationException($"OPC UA 구독 시작 실패: {ex.Message}", ex); + } + + var tags = JsonSerializer.Deserialize(session.TagList) ?? []; + return MapToInfo(session, tags); + } + + public async Task StopSessionAsync(int sessionId) + { + if (!_sessions.TryGetValue(sessionId, out var ctx)) + throw new InvalidOperationException($"세션 {sessionId}를 찾을 수 없습니다."); + + ctx.Cancel = true; + await FlushBufferAsync(ctx); + await StopSubscriptionAsync(ctx); + _sessions.TryRemove(sessionId, out _); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.UpdateFastSessionStatusAsync(sessionId, "Completed"); + await db.UpdateFastSessionRowCountAsync(sessionId, ctx.TotalRows); + _logger.LogInformation("[Fast] 세션 {Id} 수동 중지 — 총 {Count}행", sessionId, ctx.TotalRows); + } + + public async Task DeleteSessionAsync(int sessionId) + { + if (_sessions.TryGetValue(sessionId, out var ctx)) + { + ctx.Cancel = true; + await StopSubscriptionAsync(ctx); + _sessions.TryRemove(sessionId, out _); + } + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.DeleteFastSessionAsync(sessionId); + } + + public async Task PinSessionAsync(int sessionId, bool pinned) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.UpdateFastSessionPinnedAsync(sessionId, pinned); + } + + public async Task GetSessionAsync(int sessionId) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var s = await db.GetFastSessionAsync(sessionId); + if (s == null) return null; + var tags = JsonSerializer.Deserialize(s.TagList) ?? []; + return MapToInfo(s, tags); + } + + public async Task> GetSessionsAsync() + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return (await db.GetFastSessionsAsync()).Select(s => + { + var tags = JsonSerializer.Deserialize(s.TagList) ?? []; + return MapToInfo(s, tags); + }); + } + + public async Task GetRecordsAsync(int sessionId, DateTime? from, DateTime? to) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.GetFastRecordsAsync(sessionId, from, to); + } + + public async Task ExportCsvAsync(int sessionId, Stream stream, + DateTime? from = null, DateTime? to = null) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.ExportFastRecordsToCsvAsync(sessionId, stream, from, to); + } +``` + +**완료 후**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 → `task_state.md` 에 `Step 9 완료` 기록 + +--- + +## Step 10 — `ExperionFastService.cs` OPC UA Subscription + OnNotification + +**파일**: `src/Infrastructure/OpcUa/ExperionFastService.cs` + +Step 9에서 추가한 `ExportCsvAsync` 바로 뒤에 추가: + +> **핵심 버그 수정**: `OnNotification` 의 `e.NotificationValue` 타입은 `MonitoredItemNotification` 이다. +> 원본 계획의 `DataChangeNotification` 은 잘못된 타입이며, 이 오류로 데이터가 수집되지 않는다. + +```csharp + // ── Private: OPC UA ──────────────────────────────────────────────────────── + + private async Task StartSubscriptionAsync( + FastSessionContext ctx, ExperionServerConfig serverCfg) + { + var appConfig = await _configProvider.GetConfigAsync(serverCfg); + var endpoint = await SelectEndpointAsync(appConfig, serverCfg.EndpointUrl); + var identity = new UserIdentity( + serverCfg.UserName, + System.Text.Encoding.UTF8.GetBytes(serverCfg.Password)); + + ctx.OpcSession = await new DefaultSessionFactory(null).CreateAsync( + appConfig, endpoint, false, "ExperionFastSession", + 60_000, identity, null, CancellationToken.None); + + var subscription = new Subscription(ctx.OpcSession.DefaultSubscription) + { + PublishingInterval = ctx.SamplingMs, + KeepAliveCount = 10, + LifetimeCount = 100, + }; + + foreach (var tag in ctx.TagList) + { + var nodeId = ctx.NodeIds[tag]; + var item = new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = new NodeId(nodeId), + AttributeId = Attributes.Value, + SamplingInterval = ctx.SamplingMs, + QueueSize = 1, + DiscardOldest = true, + DisplayName = tag + }; + // [버그 수정] MonitoredItemNotification — DataChangeNotification 아님 + item.Notification += (monItem, e) => + { + if (ctx.Cancel) return; + if (e.NotificationValue is MonitoredItemNotification notification) + { + ctx.Buffer.Enqueue(new FastRecord + { + SessionId = ctx.SessionId, + RecordedAt = DateTime.UtcNow, + TagName = monItem.DisplayName, + Value = notification.Value?.Value?.ToString() + }); + } + }; + subscription.AddItem(item); + } + + await ctx.OpcSession.AddSubscriptionAsync(subscription); +#pragma warning disable CS0618 + subscription.Create(); +#pragma warning restore CS0618 + ctx.Subscription = subscription; + } + + private static async Task SelectEndpointAsync( + ApplicationConfiguration appConfig, string endpointUrl) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var endpointConfig = EndpointConfiguration.Create(appConfig); + using var discovery = await DiscoveryClient.CreateAsync( + appConfig, new Uri(endpointUrl), DiagnosticsMasks.All, cts.Token); + var endpoints = await discovery.GetEndpointsAsync(null); + var selected = endpoints + .OrderByDescending(e => e.SecurityLevel) + .FirstOrDefault(e => e.SecurityPolicyUri.Contains("Basic256Sha256")) + ?? endpoints[0]; + return new ConfiguredEndpoint(null, selected, endpointConfig); + } + + private static async Task StopSubscriptionAsync(FastSessionContext ctx) + { + if (ctx.Subscription != null) + { +#pragma warning disable CS0618 + ctx.Subscription.Delete(false); +#pragma warning restore CS0618 + ctx.Subscription = null; + } + if (ctx.OpcSession != null) + { + try { await ctx.OpcSession.CloseAsync(); } catch { } + try { ctx.OpcSession.Dispose(); } catch { } + ctx.OpcSession = null; + } + } +``` + +**완료 후**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 → `task_state.md` 에 `Step 10 완료` 기록 + +--- + +## Step 11 — `ExperionFastService.cs` FlushBuffer + MonitorLoop + 헬퍼 + +**파일**: `src/Infrastructure/OpcUa/ExperionFastService.cs` + +Step 10에서 추가한 `StopSubscriptionAsync` 바로 뒤에 추가: + +> **버그 수정**: `TotalRows` 필드로 누적 관리. 원본 계획의 `ctx.Buffer.Count` 비교는 드레인 후 항상 0이라 작동하지 않음. + +```csharp + // ── Private: Flush + Monitor ────────────────────────────────────────────── + + private async Task FlushBufferAsync(FastSessionContext ctx) + { + var batch = new List(); + while (ctx.Buffer.TryDequeue(out var record)) + batch.Add(record); + + if (batch.Count == 0) return; + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.BatchInsertFastRecordsAsync(batch); + + // [버그 수정] 누적 총 행 수로 관리 + ctx.TotalRows += batch.Count; + await db.UpdateFastSessionRowCountAsync(ctx.SessionId, ctx.TotalRows); + + if (ctx.TotalRows >= _maxRowsPerSession) + { + _logger.LogWarning("[Fast] 세션 {Id} RowLimit 도달 — 자동 종료", ctx.SessionId); + ctx.Cancel = true; + await StopSubscriptionAsync(ctx); + _sessions.TryRemove(ctx.SessionId, out _); + await db.UpdateFastSessionStatusAsync(ctx.SessionId, "RowLimitReached"); + } + } + + private async Task MonitorLoopAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + await Task.Delay(_flushIntervalMs, ct); + + foreach (var kvp in _sessions.ToArray()) + { + var ctx = kvp.Value; + if (ctx.Cancel) continue; + + var elapsed = (DateTime.UtcNow - ctx.StartedAt).TotalSeconds; + if (elapsed >= ctx.DurationSec) + { + _logger.LogInformation("[Fast] 세션 {Id} 기간 만료 — 자동 종료", ctx.SessionId); + ctx.Cancel = true; + await FlushBufferAsync(ctx); + await StopSubscriptionAsync(ctx); + _sessions.TryRemove(ctx.SessionId, out _); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.UpdateFastSessionStatusAsync(ctx.SessionId, "Completed"); + await db.UpdateFastSessionRowCountAsync(ctx.SessionId, ctx.TotalRows); + continue; + } + + await FlushBufferAsync(ctx); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.LogError(ex, "[Fast] 모니터링 루프 오류"); + } + } + } + + private static async Task ReadServerConfigAsync() + { + if (!File.Exists(RealtimeFlagPath)) return null; + try + { + var json = await File.ReadAllTextAsync(RealtimeFlagPath); + return JsonSerializer.Deserialize(json); + } + catch { return null; } + } + + private static FastSessionInfo MapToInfo(FastSession s, string[] tags) + => new(s.Id, s.Name, s.StartedAt, s.EndedAt, s.Status, + s.SamplingMs, s.DurationSec, tags, + s.RowCount, s.RetentionDays, s.Pinned); + + // ── Inner Class ─────────────────────────────────────────────────────────── + + private sealed class FastSessionContext + { + public int SessionId { get; init; } + public string[] TagList { get; init; } = []; + public Dictionary NodeIds { get; init; } = new(); + public int SamplingMs { get; init; } + public int DurationSec { get; init; } + public DateTime StartedAt { get; init; } + public ConcurrentQueue Buffer { get; } = new(); + public int TotalRows { get; set; } + public bool Cancel { get; set; } + public ISession? OpcSession { get; set; } + public Subscription? Subscription { get; set; } + } +} +``` + +**완료 후**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 → `task_state.md` 에 `Step 11 완료` 기록 + +--- + +## Step 12 — `ExperionFastCleanupService.cs` 신규 생성 + +**파일 신규 생성**: `src/Infrastructure/OpcUa/ExperionFastCleanupService.cs` + +```csharp +using ExperionCrawler.Core.Application.Interfaces; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ExperionCrawler.Infrastructure.OpcUa; + +public class ExperionFastCleanupService : BackgroundService +{ + private readonly IServiceProvider _sp; + private readonly ILogger _logger; + + public ExperionFastCleanupService( + IServiceProvider sp, + ILogger logger) + { + _sp = sp; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + var now = DateTime.UtcNow; + var nextRun = new DateTime(now.Year, now.Month, now.Day, + 3, 0, 0, DateTimeKind.Utc); + if (now >= nextRun) nextRun = nextRun.AddDays(1); + await Task.Delay(nextRun - now, stoppingToken); + + using var scope = _sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var expired = (await db.GetExpiredFastSessionsAsync()).ToList(); + + foreach (var s in expired) + { + _logger.LogInformation("[FastCleanup] 세션 {Id} ({Name}) 삭제", s.Id, s.Name); + await db.DeleteFastSessionAsync(s.Id); + } + + if (expired.Count > 0) + _logger.LogInformation("[FastCleanup] 정리 완료 — {Count}개", expired.Count); + } + catch (OperationCanceledException) { } + catch (Exception ex) { _logger.LogError(ex, "[FastCleanup] 오류"); } + } + } +} +``` + +**완료 후**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 → `task_state.md` 에 `Step 12 완료` 기록 + +--- + +## Step 13 — `ExperionControllers.cs` ExperionFastController 추가 + +**파일**: `src/Web/Controllers/ExperionControllers.cs` + +파일 끝에 추가. **모든 `Ok(...)` 응답은 명시적 camelCase 익명 객체** (`PropertyNamingPolicy = null` 규칙 준수). + +```csharp +// ── FastTable ───────────────────────────────────────────────────────────────── + +[ApiController] +[Route("api/fast")] +public class ExperionFastController : ControllerBase +{ + private readonly IExperionFastService _fastSvc; + + public ExperionFastController(IExperionFastService fastSvc) + => _fastSvc = fastSvc; + + [HttpPost("start")] + public async Task Start([FromBody] FastSessionStartRequest request) + { + try + { + var s = await _fastSvc.StartSessionAsync(request); + return Ok(new { id = s.Id, name = s.Name, status = s.Status, startedAt = s.StartedAt }); + } + catch (ArgumentException ex) { return BadRequest(new { error = ex.Message }); } + catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } + } + + [HttpPost("{id:int}/stop")] + public async Task Stop(int id) + { + try + { + await _fastSvc.StopSessionAsync(id); + return Ok(new { success = true }); + } + catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); } + } + + [HttpGet("sessions")] + public async Task GetSessions() + { + var sessions = await _fastSvc.GetSessionsAsync(); + return Ok(new + { + total = sessions.Count(), + items = sessions.Select(s => new + { + id = s.Id, + name = s.Name, + status = s.Status, + samplingMs = s.SamplingMs, + durationSec = s.DurationSec, + tagCount = s.TagList.Length, + tagList = s.TagList, + rowCount = s.RowCount, + startedAt = s.StartedAt, + endedAt = s.EndedAt, + retentionDays = s.RetentionDays, + pinned = s.Pinned + }) + }); + } + + [HttpGet("{id:int}")] + public async Task GetSession(int id) + { + var s = await _fastSvc.GetSessionAsync(id); + if (s == null) return NotFound(); + return Ok(new + { + id = s.Id, + name = s.Name, + status = s.Status, + samplingMs = s.SamplingMs, + durationSec = s.DurationSec, + tagList = s.TagList, + rowCount = s.RowCount, + startedAt = s.StartedAt, + endedAt = s.EndedAt, + retentionDays = s.RetentionDays, + pinned = s.Pinned + }); + } + + [HttpGet("{id:int}/records")] + public async Task GetRecords(int id, + [FromQuery] DateTime? from, [FromQuery] DateTime? to) + { + var result = await _fastSvc.GetRecordsAsync(id, from, to); + return Ok(new + { + sessionId = result.SessionId, + from = result.From, + to = result.To, + tagNames = result.TagNames, + total = result.TotalCount, + items = result.Items.Select(r => new + { + sessionId = r.SessionId, + recordedAt = r.RecordedAt, + tagName = r.TagName, + value = r.Value + }) + }); + } + + [HttpGet("{id:int}/csv")] + public async Task ExportCsv(int id, + [FromQuery] DateTime? from, [FromQuery] DateTime? to) + { + var ms = new MemoryStream(); + await _fastSvc.ExportCsvAsync(id, ms, from, to); + ms.Position = 0; + return File(ms, "text/csv", $"fast-{id}-{DateTime.UtcNow:yyyyMMddHHmm}.csv"); + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + await _fastSvc.DeleteSessionAsync(id); + return Ok(new { success = true }); + } + + [HttpPost("{id:int}/pin")] + public async Task Pin(int id, [FromBody] FastPinRequest request) + { + await _fastSvc.PinSessionAsync(id, request.Pinned); + return Ok(new { success = true, pinned = request.Pinned }); + } +} + +public record FastPinRequest(bool Pinned); +``` + +**완료 후**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 → `task_state.md` 에 `Step 13 완료` 기록 + +--- + +## Step 14 — `Program.cs` 서비스 등록 + +**파일**: `src/Web/Program.cs` + +기존 OPC 서버 서비스 등록 블록 바로 뒤에 추가. + +> **버그 수정**: 원본 계획의 `AddSingleton()` 후 +> `GetRequiredService()` 는 콘크리트 타입 미등록으로 에러가 발생한다. +> 아래 3줄 패턴을 반드시 사용한다. + +```csharp +// ── FastTable Service ───────────────────────────────────────────────────────── +builder.Services.AddSingleton(); +builder.Services.AddSingleton( + sp => sp.GetRequiredService()); +builder.Services.AddHostedService( + sp => sp.GetRequiredService()); +builder.Services.AddHostedService(); +``` + +**완료 후**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 → `task_state.md` 에 `Step 14 완료` 기록 + +--- + +## Step 15 — `appsettings.json` Fast 섹션 추가 + +**파일**: `src/Web/appsettings.json` + +기존 JSON 객체 안에 항목 추가 (쉼표 위치 주의): + +```json +"Fast": { + "MaxConcurrentSessions": 3, + "MaxRowsPerSession": 5000000, + "FlushIntervalMs": 2000 +} +``` + +**완료 후**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 → `task_state.md` 에 `Step 15 완료` 기록 + +--- + +## Step 16 — `index.html` 탭 메뉴 + 패널 + 모달 추가 + +**파일**: `src/Web/wwwroot/index.html` + +#### 16-a. 사이드바 메뉴 항목 추가 + +기존 `08 OPC UA 서버` 메뉴 항목 바로 아래: + +```html +
  • 09 fastRecord
  • +``` + +#### 16-b. 패널 추가 + +기존 `pane-opcsvr` 패널 바로 아래: + +```html + + + + +``` + +**완료 후** 브라우저 열어서 09 탭 표시 여부 확인 (빌드 불필요, HTML만 수정) +→ `task_state.md` 에 `Step 16 완료` 기록 + +--- + +## Step 17 — `app.js` Fast 함수 추가 + 탭 핸들러 연결 + +**파일**: `src/Web/wwwroot/js/app.js` + +#### 17-a. 파일 끝에 Fast 함수 추가 + +> **버그 수정**: uPlot/XLSX 제거. 단순 table 렌더링과 CSV만 사용한다. + +```javascript +// ── fastRecord ──────────────────────────────────────────────────────────────── + +let _fastCurrentId = null; +let _fastPollTimer = null; + +async function fastSessionsLoad() { + const res = await fetch('/api/fast/sessions'); + if (!res.ok) return; + const data = await res.json(); + const list = document.getElementById('fast-session-list'); + if (!list) return; + list.innerHTML = ''; + (data.items || []).forEach(s => { + const div = document.createElement('div'); + div.className = 'fast-session-item' + + (s.id === _fastCurrentId ? ' active' : ''); + div.onclick = () => fastSelect(s.id); + const cls = s.status === 'Running' ? 'fast-badge-run' : 'fast-badge-done'; + div.innerHTML = ` +
    + ${s.name} + ${s.status} + ${s.pinned ? '📌' : ''} +
    +
    + ${s.tagCount}개 태그 · ${s.samplingMs}ms · ${_fastFmtDur(s.durationSec)} +
    +
    ${_fastFmtDt(s.startedAt)}
    `; + list.appendChild(div); + }); +} + +async function fastSelect(id) { + _fastCurrentId = id; + fastLivePollStop(); + await fastSessionsLoad(); + + const res = await fetch(`/api/fast/${id}`); + if (!res.ok) return; + const s = await res.json(); + + document.getElementById('fast-detail').style.display = ''; + document.getElementById('fast-detail-name').textContent = s.name; + const badge = document.getElementById('fast-detail-status'); + badge.textContent = s.status; + badge.className = 'fast-badge ' + + (s.status === 'Running' ? 'fast-badge-run' : 'fast-badge-done'); + document.getElementById('btn-fast-stop').style.display = + s.status === 'Running' ? '' : 'none'; + document.getElementById('btn-fast-pin').textContent = + s.pinned ? '📌 고정됨' : '📌 고정'; + + await fastRenderChart(id); + if (s.status === 'Running') fastLivePollStart(id); +} + +async function fastRenderChart(id) { + const res = await fetch(`/api/fast/${id}/records`); + if (!res.ok) return; + const data = await res.json(); + const container = document.getElementById('fast-chart-container'); + if (!container) return; + + if (!data.items || data.items.length === 0) { + container.innerHTML = + '
    수집된 데이터가 없습니다.
    '; + return; + } + + const latest = {}; + data.items.forEach(r => { latest[r.tagName] = r.value; }); + + let html = ` + `; + data.tagNames.forEach(t => { + html += ``; + }); + html += `
    태그명최신값
    ${t}${latest[t] ?? '-'}
    `; + html += `
    총 ${data.total.toLocaleString()}행
    `; + container.innerHTML = html; +} + +function fastLivePollStart(id) { + if (_fastPollTimer) return; + _fastPollTimer = setInterval(async () => { + if (!_fastCurrentId) { fastLivePollStop(); return; } + const res = await fetch(`/api/fast/${_fastCurrentId}`); + if (!res.ok) { fastLivePollStop(); return; } + const s = await res.json(); + + if (s.status !== 'Running') { + fastLivePollStop(); + await fastSelect(_fastCurrentId); + return; + } + const elapsed = Math.floor( + (Date.now() - new Date(s.startedAt).getTime()) / 1000); + const pct = Math.min(elapsed / s.durationSec * 100, 100).toFixed(1); + const bar = document.getElementById('fast-progress-bar'); + if (bar) bar.style.width = pct + '%'; + const pt = document.getElementById('fast-progress-text'); + if (pt) pt.textContent = `${s.rowCount.toLocaleString()}행`; + const et = document.getElementById('fast-elapsed-text'); + if (et) et.textContent = `경과: ${_fastFmtDur(elapsed)}`; + await fastRenderChart(_fastCurrentId); + }, 3000); +} + +function fastLivePollStop() { + if (_fastPollTimer) { clearInterval(_fastPollTimer); _fastPollTimer = null; } +} + +async function fastNewModal() { + const res = await fetch('/api/realtime/points'); + const select = document.getElementById('fast-tag-select'); + select.innerHTML = ''; + if (res.ok) { + const data = await res.json(); + (data.items || []).forEach(p => { + const opt = document.createElement('option'); + opt.value = p.tagName; + opt.textContent = p.tagName; + select.appendChild(opt); + }); + } + document.getElementById('fast-name').value = ''; + document.getElementById('modal-fast').style.display = ''; +} + +function fastModalClose() { + document.getElementById('modal-fast').style.display = 'none'; +} + +async function fastStart() { + const name = document.getElementById('fast-name').value.trim(); + if (!name) { alert('세션 이름을 입력하세요.'); return; } + const select = document.getElementById('fast-tag-select'); + const tags = Array.from(select.selectedOptions).map(o => o.value); + if (tags.length === 0) { alert('태그를 하나 이상 선택하세요.'); return; } + if (tags.length > 8) { alert('태그는 최대 8개입니다.'); return; } + + const samplingMs = parseInt(document.getElementById('fast-sampling-ms').value); + const durationSec = parseInt(document.getElementById('fast-duration-sec').value); + const retRaw = document.getElementById('fast-retention').value.trim(); + + const res = await fetch('/api/fast/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + Name: name, SamplingMs: samplingMs, DurationSec: durationSec, + TagList: tags, RetentionDays: retRaw ? parseInt(retRaw) : null + }) + }); + if (!res.ok) { + const err = await res.json(); + alert('오류: ' + (err.error || '알 수 없는 오류')); + return; + } + const data = await res.json(); + fastModalClose(); + await fastSessionsLoad(); + await fastSelect(data.id); +} + +async function fastStop() { + if (!_fastCurrentId) return; + const res = await fetch(`/api/fast/${_fastCurrentId}/stop`, { method: 'POST' }); + if (!res.ok) { alert('중지 실패'); return; } + fastLivePollStop(); + await fastSessionsLoad(); + await fastSelect(_fastCurrentId); +} + +async function fastDelete() { + if (!_fastCurrentId || !confirm('세션을 삭제하시겠습니까?')) return; + await fetch(`/api/fast/${_fastCurrentId}`, { method: 'DELETE' }); + _fastCurrentId = null; + fastLivePollStop(); + document.getElementById('fast-detail').style.display = 'none'; + await fastSessionsLoad(); +} + +async function fastTogglePin() { + if (!_fastCurrentId) return; + const res = await fetch(`/api/fast/${_fastCurrentId}`); + if (!res.ok) return; + const s = await res.json(); + await fetch(`/api/fast/${_fastCurrentId}/pin`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ Pinned: !s.pinned }) + }); + await fastSelect(_fastCurrentId); +} + +async function fastExportCsv() { + if (!_fastCurrentId) return; + const res = await fetch(`/api/fast/${_fastCurrentId}/csv`); + if (!res.ok) return; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `fast-${_fastCurrentId}-${new Date().toISOString().slice(0,10)}.csv`; + a.click(); + URL.revokeObjectURL(url); +} + +function _fastFmtDur(sec) { + if (sec < 60) return sec + 's'; + if (sec < 3600) return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's'; + return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm'; +} + +function _fastFmtDt(dt) { + return new Date(dt).toLocaleString('ko-KR'); +} +``` + +#### 17-b. 탭 핸들러에 조건 추가 + +기존 `showTab` 함수(또는 `data-tab` 처리 구간)에서 탭 전환 조건 블록 안에 추가: + +```javascript +if (tab === 'fast') fastSessionsLoad(); +``` + +**완료 후** → `task_state.md` 에 `Step 17 완료` 기록 + +--- + +## Step 18 — `style.css` Fast 전용 스타일 추가 + +**파일**: `src/Web/wwwroot/css/style.css` + +파일 끝에 추가: + +```css +/* ── fastRecord ───────────────────────────────────────────────────────────── */ +.fast-toolbar { display:flex; gap:8px; margin-bottom:12px; } +.fast-session-list { display:flex; flex-direction:column; gap:6px; margin-bottom:16px; } +.fast-session-item { background:#1e1e1e; border:1px solid #333; border-radius:6px; + padding:10px 14px; cursor:pointer; } +.fast-session-item:hover, +.fast-session-item.active { border-color:#f59e0b; } +.fast-si-top { display:flex; align-items:center; gap:8px; margin-bottom:4px; } +.fast-si-name { font-weight:600; } +.fast-si-meta { font-size:0.8rem; color:#888; } +.fast-badge { font-size:0.75rem; padding:2px 8px; border-radius:10px; } +.fast-badge-run { background:#166534; color:#86efac; } +.fast-badge-done { background:#1e3a5f; color:#93c5fd; } +.fast-detail-header { display:flex; align-items:center; gap:10px; + margin-bottom:10px; flex-wrap:wrap; } +.fast-detail-btns { display:flex; gap:6px; margin-left:auto; flex-wrap:wrap; } +.fast-progress-wrap { background:#333; border-radius:4px; height:12px; + margin-bottom:4px; overflow:hidden; } +.fast-progress-bar { height:100%; background:#f59e0b; transition:width 0.5s; } +.fast-progress-info { display:flex; justify-content:space-between; + font-size:0.8rem; color:#888; margin-bottom:12px; } +.fast-chart-container{ min-height:80px; } +.fast-table { width:100%; border-collapse:collapse; font-size:0.9rem; } +.fast-table th, +.fast-table td { border:1px solid #333; padding:6px 10px; } +.fast-table th { background:#1e1e1e; } +.fast-rec-count { margin-top:8px; font-size:0.8rem; color:#888; text-align:right; } +.fast-empty { color:#666; padding:24px; text-align:center; } +.fast-modal { background:#1a1a1a; border-radius:8px; padding:24px; + width:480px; max-width:95vw; } +.fast-modal-title { font-size:1.1rem; font-weight:600; margin-bottom:16px; } +.fast-input { width:100%; background:#111; border:1px solid #444; color:#eee; + border-radius:4px; padding:6px 8px; margin-bottom:10px; + box-sizing:border-box; } +.fast-row2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; } +.fast-modal-footer { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; } +``` + +**최종 빌드 검증**: +```bash +dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q +``` +→ 에러 0건 확인 +→ `task_state.md` 에 `Step 18 완료` 및 **전체 작업 완료** 기록 + +--- + +## 전체 완료 체크리스트 + +| Step | 파일 | 작업 내용 | +|------|------|----------| +| 1 | ExperionEntities.cs | FastSession, FastRecord 엔티티 추가 | +| 2 | IExperionServices.cs | IExperionDbService Fast 메서드 추가 | +| 3 | IExperionServices.cs | IExperionFastService + DTOs 추가 | +| 4 | ExperionDbContext.cs | DbSet + OnModelCreating 추가 | +| 5 | ExperionDbContext.cs | InitializeAsync DDL 추가 (TEXT, not JSONB) | +| 6 | ExperionDbContext.cs | Fast DB 메서드 (Create/Update/Get) | +| 7 | ExperionDbContext.cs | Fast DB 메서드 (Insert/Export/Expired) | +| 8 | ExperionFastService.cs | 신규 생성 — 골격 + IHostedService | +| 9 | ExperionFastService.cs | IExperionFastService 공개 메서드 | +| 10 | ExperionFastService.cs | OPC UA Subscription + OnNotification (버그 수정) | +| 11 | ExperionFastService.cs | FlushBuffer + MonitorLoop + 헬퍼 (TotalRows 누적) | +| 12 | ExperionFastCleanupService.cs | 신규 생성 | +| 13 | ExperionControllers.cs | ExperionFastController (camelCase 응답) | +| 14 | Program.cs | 올바른 DI 패턴 등록 | +| 15 | appsettings.json | Fast 섹션 추가 | +| 16 | index.html | 09 탭 + 패널 + 모달 | +| 17 | app.js | Fast 함수 + 탭 핸들러 연결 | +| 18 | style.css | Fast 전용 스타일 | diff --git a/src/Core/Application/Interfaces/IExperionServices.cs b/src/Core/Application/Interfaces/IExperionServices.cs index 9f866d6..84d79db 100644 --- a/src/Core/Application/Interfaces/IExperionServices.cs +++ b/src/Core/Application/Interfaces/IExperionServices.cs @@ -95,6 +95,28 @@ public interface IExperionDbService // ── OPC UA Server 지원 ──────────────────────────────────────────────────── /// realtime_table × node_map_master 조인 → nodeId → dataType 사전 반환 Task> GetRealtimeNodeDataTypesAsync(); + + // ── FastSession ─────────────────────────────────────────────────────────────── + Task CreateFastSessionAsync(FastSessionCreateRequest request); + Task UpdateFastSessionStatusAsync(int sessionId, string status); + Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount); + Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned); + Task GetFastSessionAsync(int sessionId); + Task> GetFastSessionsAsync(); + Task DeleteFastSessionAsync(int sessionId); + Task> GetExpiredFastSessionsAsync(); + + // ── FastRecord ──────────────────────────────────────────────────────────────── + Task GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to); + Task BatchInsertFastRecordsAsync(IEnumerable records); + Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to); + + // ── Realtime → Fast 복사용 ──────────────────────────────────────────────────── + /// realtime_table에서 태그명 목록으로 livevalue와 timestamp 가져오기 + Task> GetRealtimeRecordsByTagNamesAsync(IEnumerable tagNames); + + // ── 공통 (이미 없는 경우만) ────────────────────────────────────────────────── + Task GetNodeIdByTagNameAsync(string tagName); } // ── Realtime Service ───────────────────────────────────────────────────────── @@ -177,3 +199,60 @@ public record HistoryIntervalQueryResult( public record HistoryIntervalRow(DateTime TimeBucket, IReadOnlyDictionary Values); public record LiveValueUpdate(string NodeId, string? Value, DateTime Timestamp); + +// ── fastTable DTOs ──────────────────────────────────────────────────────────── + +public record FastSessionInfo( + int Id, + string Name, + DateTime StartedAt, + DateTime? EndedAt, + string Status, + int SamplingMs, + int DurationSec, + string[] TagList, + int RowCount, + int? RetentionDays, + bool Pinned +); + +public record FastSessionStartRequest( + string Name, + int SamplingMs, + int DurationSec, + string[] TagList, + int? RetentionDays = null +); + +public record FastSessionCreateRequest( + string Name, + int SamplingMs, + int DurationSec, + string[] TagList, + int? RetentionDays = null +); + +public record FastQueryResult( + int SessionId, + DateTime From, + DateTime To, + string[] TagNames, + IEnumerable Items, + int TotalCount +); + +public record PinRequest(bool Pinned); + +// ── fastTable Service ───────────────────────────────────────────────────────── + +public interface IExperionFastService +{ + Task StartSessionAsync(FastSessionStartRequest request); + Task StopSessionAsync(int sessionId); + Task DeleteSessionAsync(int sessionId); + Task PinSessionAsync(int sessionId, bool pinned); + Task GetSessionAsync(int sessionId); + Task> GetSessionsAsync(); + Task GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long"); + Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null); +} diff --git a/src/Infrastructure/Database/ExperionDbContext.cs b/src/Infrastructure/Database/ExperionDbContext.cs index f353dd6..eba2106 100644 --- a/src/Infrastructure/Database/ExperionDbContext.cs +++ b/src/Infrastructure/Database/ExperionDbContext.cs @@ -64,6 +64,7 @@ public class ExperionDbContext : DbContext e.HasKey(x => x.Id); e.HasIndex(x => x.Status); e.HasIndex(x => x.StartedAt); + e.Property(x => x.TagList).HasColumnType("jsonb"); }); modelBuilder.Entity(e => @@ -120,9 +121,28 @@ public class ExperionDbService : IExperionDbService ) """); - // TimescaleDB hypertable 생성 (recorded_at 기준, chunk_interval = 1 day) + // PK 마이그레이션: 기존 테이블 PK에 recorded_at 없으면 수정 (TimescaleDB hypertable 요건) await _ctx.Database.ExecuteSqlRawAsync(""" - SELECT create_hypertable('fast_record', 'recorded_at', if_not_exists => TRUE, migrate_data => TRUE) + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'fast_record' AND schemaname = 'public') + AND NOT EXISTS ( + SELECT 1 FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey) + WHERE t.relname = 'fast_record' AND c.contype = 'p' AND a.attname = 'recorded_at' + ) + THEN + ALTER TABLE fast_record DROP CONSTRAINT IF EXISTS fast_record_pkey; + ALTER TABLE fast_record ADD PRIMARY KEY (session_id, recorded_at, tagname); + END IF; + END $$; + """); + + // TimescaleDB hypertable 생성 (recorded_at 기준, chunk_interval = 1 day) + // create_default_indexes => FALSE: PK 인덱스를 TimescaleDB가 중복 생성하지 않도록 함 + await _ctx.Database.ExecuteSqlRawAsync(""" + SELECT create_hypertable('fast_record', 'recorded_at', if_not_exists => TRUE, migrate_data => TRUE, create_default_indexes => FALSE) """); await _ctx.Database.ExecuteSqlRawAsync(""" diff --git a/src/Infrastructure/OpcUa/ExperionOpcClient.cs b/src/Infrastructure/OpcUa/ExperionOpcClient.cs index 17c26e2..d142a1b 100644 --- a/src/Infrastructure/OpcUa/ExperionOpcClient.cs +++ b/src/Infrastructure/OpcUa/ExperionOpcClient.cs @@ -3,6 +3,7 @@ using ExperionCrawler.Core.Domain.Entities; using ExperionCrawler.Infrastructure.Certificates; using Microsoft.Extensions.Logging; using Opc.Ua; +using Opc.Ua.Configuration; using Opc.Ua.Client; using System.Text; using System.Collections.Concurrent; @@ -103,8 +104,8 @@ public class ExperionOpcClient : IExperionOpcClient return new ConfiguredEndpoint(null, selected, endpointConfig); } - // ── 세션 생성 ───────────────────────────────────────────────────────────── - private static async Task CreateSessionAsync( + // ── 세션 생성 (내부용) ────────────────────────────────────────────────────── + private static async Task CreateSessionInternalAsync( ApplicationConfiguration appConfig, ConfiguredEndpoint endpoint, ExperionServerConfig cfg, @@ -136,7 +137,7 @@ public class ExperionOpcClient : IExperionOpcClient _logger.LogInformation("정책 선택됨: {Policy}", endpoint.Description.SecurityPolicyUri); - session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerSession"); + session = await CreateSessionInternalAsync(appConfig, endpoint, cfg, "ExperionCrawlerSession"); var sessionId = session.SessionId?.ToString() ?? "N/A"; return new ExperionConnectResult(true, "연결 성공", sessionId, endpoint.Description.SecurityPolicyUri); @@ -169,7 +170,7 @@ public class ExperionOpcClient : IExperionOpcClient { var appConfig = await _configProvider.GetConfigAsync(cfg); var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, CancellationToken.None); - session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerReadSession"); + session = await CreateSessionInternalAsync(appConfig, endpoint, cfg, "ExperionCrawlerReadSession"); foreach (var nodeId in nodeList) { @@ -222,7 +223,7 @@ public class ExperionOpcClient : IExperionOpcClient { var appConfig = await _configProvider.GetConfigAsync(cfg); var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl, ct); - session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerNodeMapSession"); + session = await CreateSessionInternalAsync(appConfig, endpoint, cfg, "ExperionCrawlerNodeMapSession"); _logger.LogInformation("[ExperionOpc] 전체 노드맵 탐색 시작 (maxDepth={MaxDepth})", maxDepth); @@ -411,7 +412,7 @@ public class ExperionOpcClient : IExperionOpcClient { var appConfig = await _configProvider.GetConfigAsync(cfg); var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl); - session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerBrowseSession"); + session = await CreateSessionInternalAsync(appConfig, endpoint, cfg, "ExperionCrawlerBrowseSession"); var startNode = startNodeId != null ? new NodeId(startNodeId) @@ -551,4 +552,5 @@ public class ExperionOpcClient : IExperionOpcClient try { session.Dispose(); } catch { /* ignore already disposed */ } } } + } diff --git a/src/Web/Controllers/ExperionControllers.cs b/src/Web/Controllers/ExperionControllers.cs index 1ef2c7f..3f9e594 100644 --- a/src/Web/Controllers/ExperionControllers.cs +++ b/src/Web/Controllers/ExperionControllers.cs @@ -676,8 +676,14 @@ public class ExperionFastController : ControllerBase var session = await _fastSvc.StartSessionAsync(request); return Ok(new { id = session.Id, name = session.Name, status = session.Status, startedAt = session.StartedAt }); } - catch (ArgumentException ex) { return BadRequest(new { error = ex.Message }); } + catch (ArgumentException ex) { return BadRequest(new { error = ex.Message }); } catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } + catch (Exception ex) + { + var msgs = new List(); + for (var e = ex; e != null; e = e.InnerException) msgs.Add(e.Message); + return StatusCode(500, new { error = msgs[0], detail = string.Join(" → ", msgs.Skip(1)) }); + } } /// 세션 중지 diff --git a/src/Web/Controllers/TextToSqlController.cs b/src/Web/Controllers/TextToSqlController.cs index 246a25e..07bd76b 100644 --- a/src/Web/Controllers/TextToSqlController.cs +++ b/src/Web/Controllers/TextToSqlController.cs @@ -66,11 +66,22 @@ public class TextToSqlController : ControllerBase try { - var jsonData = System.Text.Json.JsonSerializer.Deserialize(result.Data!); + var jsonData = System.Text.Json.JsonSerializer.Deserialize?>(result.Data!); + _logger.LogInformation("[TextToSql] query-nl 응답: success={Success}, data={Data}", jsonData?.GetType(), jsonData); + + // data 필드가 JSON 문자열일 수 있으므로 다시 디시리얼라이즈 + if (jsonData != null && jsonData.TryGetValue("data", out var dataObj) && dataObj is string dataString) + { + _logger.LogInformation("[TextToSql] data 필드가 문자열임: {DataString}", dataString); + var parsedData = System.Text.Json.JsonSerializer.Deserialize?>(dataString); + jsonData["data"] = parsedData; + } + return Ok(new { success = true, data = jsonData }); } - catch + catch (Exception ex) { + _logger.LogError(ex, "[TextToSql] query-nl JSON 디시리얼라이즈 실패: {Data}", result.Data); return Ok(new { success = true, data = result.Data }); } } diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 7b94885..4d230ff 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -75,6 +75,15 @@ builder.Services.AddSingleton( builder.Services.AddHostedService( sp => sp.GetRequiredService()); +// ── FastTable Service ───────────────────────────────────────────────────────── +// 중요: Singleton으로 하나만 생성 후 IExperionFastService와 IHostedService 양쪽에 같은 인스턴스 공유 +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + +// ── FastTable Cleanup Service ───────────────────────────────────────────────── +builder.Services.AddHostedService(); + // ── CORS ────────────────────────────────────────────────────────────────────── builder.Services.AddCors(opt => opt.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); diff --git a/src/Web/appsettings.json b/src/Web/appsettings.json index ee09b02..8e132e0 100644 --- a/src/Web/appsettings.json +++ b/src/Web/appsettings.json @@ -24,5 +24,10 @@ "AllowAnonymous": true, "AllowedUsernames": [ "mngr" ], "AllowedPasswords": [ "mngr" ] + }, + "Fast": { + "MaxConcurrentSessions": 3, + "MaxRowsPerSession": 5000000, + "FlushIntervalMs": 2000 } } diff --git a/src/Web/wwwroot/css/style.css b/src/Web/wwwroot/css/style.css index c1ef3ba..715cda3 100644 --- a/src/Web/wwwroot/css/style.css +++ b/src/Web/wwwroot/css/style.css @@ -651,24 +651,6 @@ tr:last-child td { border-bottom: none; } .t2s-result-info { font-size: 13px; color: var(--t1); margin-bottom: 10px; padding: 8px 0; - display: flex; - align-items: center; - gap: 12px; -} - -/* Excel 다운로드 버튼 */ -.btn-excel { - padding: 4px 12px; - font-size: 12px; - border: 1px solid #217346; - border-radius: var(--r); - background: #217346; - color: #fff; - cursor: pointer; - white-space: nowrap; -} -.btn-excel:hover { - background: #1a5c38; } /* 조회 결과 컨테이너 - 스크롤 활성화 및 높이 증가 */ @@ -1222,6 +1204,12 @@ tr:last-child td { border-bottom: none; } background: var(--red); } +/* ── uPlot 스타일 ────────────────────────────────────────── */ +#fast-chart-container .u-wrap { background: #fff; border-radius: 4px; } +.u-legend { font-size: 12px !important; line-height: 1.5 !important; } +.u-legend th, .u-legend td { font-size: 12px !important; padding: 2px 6px !important; } +.u-series > * { font-size: 12px !important; } + /* ── Utility ─────────────────────────────────────────────── */ .hidden { display: none !important; } diff --git a/src/Web/wwwroot/index.html b/src/Web/wwwroot/index.html index 7641042..4e53f0c 100644 --- a/src/Web/wwwroot/index.html +++ b/src/Web/wwwroot/index.html @@ -5,6 +5,7 @@ ExperionCrawler +
    @@ -67,6 +68,10 @@ 09 Text-to-SQL +
    @@ -688,10 +693,9 @@
    🗣 자연어 쿼리
    - - - - + + +
    추천 쿼리: @@ -700,11 +704,6 @@
    - -
    - - -
    @@ -772,10 +771,104 @@ + +
    +
    +
    +

    fastRecord

    +

    고속 샘플링으로 실시간 데이터를 수집하고 트렌드를 분석합니다.

    +
    +
    FAST / RECORD
    +
    + + +
    +
    + 세션 목록 + +
    +
    +
    + + +
    +
    + 세션 상세 +
    + + + + + +
    +
    + +
    +
    +
    +
    + 0 / 0 (0%) + 경과: 0s +
    + +
    +
    +
    +
    + + + + diff --git a/src/Web/wwwroot/js/app.js b/src/Web/wwwroot/js/app.js index ad5eb05..2411218 100644 --- a/src/Web/wwwroot/js/app.js +++ b/src/Web/wwwroot/js/app.js @@ -14,6 +14,7 @@ document.querySelectorAll('.nav-item').forEach(item => { // hist: 탭 진입 시 API 호출 없음 if (tab === 'opcsvr') srvLoad(); if (tab === 't2s') t2sInitMode(); + if (tab === 'fast') fastSessionsLoad(); }); }); @@ -1388,14 +1389,32 @@ async function t2sParse() { resultContainer.innerHTML = '
    MCP 조회 중...
    '; try { const res = await api('POST', '/api/text-to-sql/query-nl', { query: input }); + console.log('[t2sParse] MCP 응답:', res); if (res.success) { const d = (typeof res.data === 'object' && res.data !== null) ? res.data : {}; + console.log('[t2sParse] 데이터 파싱:', d, 'type:', typeof d); + console.log('[t2sParse] d.data:', d.data, 'd.columns:', d.columns, 'd.count:', d.count); + + // d.data가 object이지만 rows/columns 필드가 없을 수 있으므로 확인 + const dataObj = d.data || (Array.isArray(d) ? d : null); + console.log('[t2sParse] dataObj:', dataObj); + if (d.success === false) { sqlTextarea.value = `오류: ${d.error || 'SQL 생성 실패'}`; resultContainer.innerHTML = `
    MCP 오류: ${esc(d.error || 'SQL 생성 실패')}
    `; } else { sqlTextarea.value = d.sql || '(SQL 없음)'; - t2sRenderTable({ rows: d.data || [], columns: d.columns || [], totalCount: d.count || 0 }); + // d.data가 object이지만 rows/columns 필드가 없을 수 있으므로 확인 + const rows = (dataObj && Array.isArray(dataObj.rows)) ? dataObj.rows : (Array.isArray(dataObj) ? dataObj : []); + const columns = (dataObj && Array.isArray(dataObj.columns)) ? dataObj.columns : []; + const totalCount = (dataObj && typeof dataObj.count === 'number') ? dataObj.count : (Array.isArray(dataObj) ? dataObj.length : 0); + console.log('[t2sParse] 최종 rows.length:', rows.length, 'columns:', columns); + + if (rows.length === 0) { + resultContainer.innerHTML = '
    조회 결과가 없습니다. (SQL은 생성됨)
    '; + } else { + t2sRenderTable({ rows, columns, totalCount }); + } } } else { sqlTextarea.value = `오류: ${res.error || '알 수 없는 오류'}`; @@ -1426,6 +1445,7 @@ async function t2sParse() { * t2sPivot - tagname 컬럼이 있으면 wide format으로 변환 (query_with_nl 서버 로직과 동일) */ function t2sPivot(columns, rows) { + console.log('[t2sPivot] 입력: columns:', columns, 'rows.length:', rows.length); if (!columns.includes('tagname') || !rows.length) return { columns, rows }; const timeCol = columns.find(c => !['tagname', 'value', 'livevalue', 'avg_val'].includes(c)); const valCol = ['value', 'avg_val'].find(c => columns.includes(c)) || columns[columns.length - 1]; @@ -1437,7 +1457,9 @@ function t2sPivot(columns, rows) { if (!pivoted[key]) pivoted[key] = { [timeCol]: row[timeCol] }; pivoted[key][row['tagname']] = row[valCol]; } - return { columns: [timeCol, ...tagNames], rows: Object.values(pivoted) }; + const result = { columns: [timeCol, ...tagNames], rows: Object.values(pivoted) }; + console.log('[t2sPivot] 결과: columns:', result.columns, 'rows.length:', result.rows.length); + return result; } /** @@ -1460,9 +1482,12 @@ async function t2sExecute() { try { if (t2sMode === 'mcp') { const res = await api('POST', '/api/text-to-sql/execute-mcp', { sql, limit }); + console.log('[t2sExecute] MCP execute 응답:', res); if (res.success) { const d = res.data || {}; + console.log('[t2sExecute] d.data:', d.data, 'd.columns:', d.columns); const { columns, rows } = t2sPivot(d.columns || [], d.data || []); + console.log('[t2sExecute] pivot 후 rows.length:', rows.length); t2sRenderTable({ rows, columns, totalCount: rows.length }); } else { resultContainer.innerHTML = `
    오류: ${res.error || '알 수 없는 오류'}
    `; @@ -1486,15 +1511,20 @@ async function t2sExecute() { function t2sRenderTable(result) { const container = document.getElementById('t2s-results'); + console.log('[t2sRenderTable] 입력 결과:', result); + // 백엔드 응답: columns, rows, totalCount (소문자) const rows = result.rows || []; const columns = result.columns || []; const totalCount = result.totalCount || 0; + console.log('[t2sRenderTable] rows.length:', rows.length, 'columns:', columns); + // ── 추가: 결과 저장 (export용) ── _t2sLastResult = rows.length > 0 ? { columns, rows } : null; if (!rows || rows.length === 0) { + console.log('[t2sRenderTable] 빈 결과 - 결과 없음 표시'); container.innerHTML = '
    결과가 없습니다.
    '; return; } @@ -2053,11 +2083,12 @@ function fmtVal(v) { // ═══════════════════════════════════════════════════════════════ let fastCurrentSessionId = null; let fastChart = null; +let fastLivePollTimer = null; +let fastChartTagNames = null; // 현재 차트에 그려진 태그 목록 (재생성 필요 여부 판단용) function fastModalClose() { document.getElementById('modal-fast-new').style.display = 'none'; } -let fastLivePollTimer = null; // ═══════════════════════════════════════════════════════════════ // fastRecord — API 함수 @@ -2071,31 +2102,53 @@ async function fastSessionsLoad() { const list = document.getElementById('fast-session-list'); list.innerHTML = ''; + if (!data.items || data.items.length === 0) { + list.innerHTML = '세션이 없습니다. + 신규를 눌러 시작하세요.'; + return; + } + + const statusColor = { + Running: '#4caf50', Completed: '#4363d8', + Cancelled: '#888', Failed: '#e55', + RowLimitReached: '#f58231', Pending: '#aaa' + }; + const statusLabel = { + Running: '실행중', Completed: '완료', Cancelled: '취소', + Failed: '실패', RowLimitReached: '행제한', Pending: '대기' + }; + data.items.forEach(s => { - const item = document.createElement('a'); - item.className = 'list-group-item list-group-item-action'; - item.href = '#'; - item.dataset.id = s.id; + const isActive = s.id === fastCurrentSessionId; + const dot = statusColor[s.status] ?? '#aaa'; + const label = statusLabel[s.status] ?? s.status; - const statusBadge = { - Running: '실행중', - Completed: '완료', - Cancelled: '취소', - Failed: '실패', - RowLimitReached: '행제한', - Pending: '대기' - }[s.status] ?? `${s.status}`; + const chip = document.createElement('div'); + chip.dataset.id = s.id; + chip.style.cssText = [ + 'display:flex;flex-direction:column;gap:3px', + 'padding:7px 11px;border-radius:var(--r)', + `background:${isActive ? 'rgba(229,85,85,.12)' : 'var(--s3)'}`, + `border:1px solid ${isActive ? 'var(--red,#e55)' : 'var(--bd)'}`, + 'cursor:pointer;min-width:120px;max-width:200px', + 'transition:border-color .15s,background .15s' + ].join(';'); - item.innerHTML = ` -
    -
    ${s.name}
    - ${statusBadge}${s.pinned ? ' 📌' : ''} + chip.innerHTML = ` +
    + + ${esc(s.name)} + ${s.pinned ? '📌' : ''} +
    -

    ${s.tagCount}tags · ${s.samplingMs}ms · ${fastFormatDuration(s.durationSec)}

    - ${fastFormatDateTime(s.startedAt)} +
    ${label} · ${s.tagCount}태그 · ${s.samplingMs}ms
    +
    ${fastFormatDuration(s.durationSec)} · ${fastFormatDateTime(s.startedAt).slice(0,10)}
    `; - item.onclick = e => { e.preventDefault(); fastSelect(s.id); }; - list.appendChild(item); + chip.querySelector('[data-del]').addEventListener('click', e => { + e.stopPropagation(); + fastDelete(s.id); + }); + chip.onclick = () => fastSelect(s.id); + list.appendChild(chip); }); } @@ -2149,7 +2202,7 @@ async function fastDelete(id) { fastClearChart(); document.getElementById('fast-session-title').textContent = '세션 상세'; ['btn-fast-stop','btn-fast-export-xlsx','btn-fast-export-csv','btn-fast-delete','btn-fast-pin'] - .forEach(id => document.getElementById(id).style.display = 'none'); + .forEach(btnId => document.getElementById(btnId).style.display = 'none'); await fastSessionsLoad(); } @@ -2169,6 +2222,13 @@ async function fastPin(id) { async function fastSelect(id) { fastCurrentSessionId = id; + // 칩 active 스타일 즉시 갱신 + document.querySelectorAll('#fast-session-list > div').forEach(chip => { + const isActive = parseInt(chip.dataset.id) === id; + chip.style.background = isActive ? 'rgba(229,85,85,.12)' : 'var(--s3)'; + chip.style.borderColor = isActive ? 'var(--red,#e55)' : 'var(--bd)'; + }); + const res = await fetch(`/api/fast/${id}`); if (!res.ok) { alert('세션 조회 실패'); return; } const session = await res.json(); @@ -2223,6 +2283,15 @@ async function fastRenderChart() { // uPlot data: [[x...], [y1...], [y2...], ...] const uData = [timesNum, ...data.tagNames.map(tag => times.map(t => grouped[t][tag] ?? null))]; + // 동일 태그 구성이면 setData()로 인플레이스 업데이트 → zoom/pan 상태 유지 + const tagsKey = data.tagNames.join('\0'); + if (fastChart && fastChartTagNames === tagsKey) { + fastChart.setData(uData, false); // false = 스케일 유지 (zoom 보존) + return; + } + + // 최초 생성 또는 태그 구성 변경 시 차트 재생성 + fastChartTagNames = tagsKey; fastClearChart(); const opts = { @@ -2240,9 +2309,9 @@ async function fastRenderChart() { ], series: [ {}, - ...data.tagNames.map((tag, i) => ({ + ...data.tagNames.map(tag => ({ label: tag, - stroke: fastTagColor(tag, i), + stroke: fastTagColor(tag), width: 2 })) ] @@ -2252,6 +2321,7 @@ async function fastRenderChart() { } function fastClearChart() { + fastChartTagNames = null; if (fastChart) { fastChart.destroy(); fastChart = null; @@ -2316,10 +2386,9 @@ function fastFormatDateTime(dt) { return new Date(dt).toLocaleString('ko-KR'); } -function fastTagColor(tag, idx) { +function fastTagColor(tag) { const palette = ['#e6194b','#3cb44b','#4363d8','#f58231','#911eb4', - '#42d4f4','#f032e6','#bfef45','#fabed4','#469990']; - if (idx !== undefined) return palette[idx % palette.length]; + '#42d4f4','#f032e6','#bfef45','#469990','#fabed4']; let sum = 0; for (let i = 0; i < tag.length; i++) sum += tag.charCodeAt(i); return palette[sum % palette.length]; @@ -2334,7 +2403,7 @@ document.getElementById('btn-fast-new')?.addEventListener('click', async () => { select.innerHTML = ''; document.getElementById('fast-session-name').value = ''; document.getElementById('fast-retention-days').value = ''; - document.getElementById('modal-fast-new').style.display = ''; + document.getElementById('modal-fast-new').style.display = 'flex'; try { const res = await fetch('/api/pointbuilder/points'); diff --git a/src/Web/wwwroot/lib/uPlot.iife.min.js b/src/Web/wwwroot/lib/uPlot.iife.min.js new file mode 100644 index 0000000..68d8346 --- /dev/null +++ b/src/Web/wwwroot/lib/uPlot.iife.min.js @@ -0,0 +1,2 @@ +/*! https://github.com/leeoniya/uPlot (v1.6.27) */ +var uPlot=function(){"use strict";const e="u-off",t="u-label",l="width",n="height",i="top",o="bottom",s="left",r="right",u="#000",a=u+"0",f="mousemove",c="mousedown",h="mouseup",d="mouseenter",p="mouseleave",m="dblclick",g="change",x="dppxchange",w="--",_="undefined"!=typeof window,b=_?document:null,v=_?window:null,k=_?navigator:null;let y,M;function S(e,t){if(null!=t){let l=e.classList;!l.contains(t)&&l.add(t)}}function E(e,t){let l=e.classList;l.contains(t)&&l.remove(t)}function z(e,t,l){e.style[t]=l+"px"}function D(e,t,l,n){let i=b.createElement(e);return null!=t&&S(i,t),null!=l&&l.insertBefore(i,n),i}function T(e,t){return D("div",e,t)}const P=new WeakMap;function A(t,l,n,i,o){let s="translate("+l+"px,"+n+"px)";s!=P.get(t)&&(t.style.transform=s,P.set(t,s),0>l||0>n||l>i||n>o?S(t,e):E(t,e))}const W=new WeakMap;function Y(e,t,l){let n=t+l;n!=W.get(e)&&(W.set(e,n),e.style.background=t,e.style.borderColor=l)}const C=new WeakMap;function F(e,t,l,n){let i=t+""+l;i!=C.get(e)&&(C.set(e,i),e.style.height=l+"px",e.style.width=t+"px",e.style.marginLeft=n?-t/2+"px":0,e.style.marginTop=n?-l/2+"px":0)}const H={passive:!0},R={...H,capture:!0};function G(e,t,l,n){t.addEventListener(e,l,n?R:H)}function I(e,t,l,n){t.removeEventListener(e,l,n?R:H)}function L(e,t,l,n){let i;l=l||0;let o=2147483647>=(n=n||t.length-1);for(;n-l>1;)i=o?l+n>>1:le((l+n)/2),e>t[i]?l=i:n=i;return e-t[l]>t[n]-e?n:l}function O(e,t,l,n){for(let i=1==n?t:l;i>=t&&l>=i;i+=n)if(null!=e[i])return i;return-1}function N(e,t,l,n){let i=ue(e),o=ue(t),s=10==l?ae:fe;e==t&&(-1==i?(e*=l,t/=l):(e/=l,t*=l));let r=1==o?ie:le,u=(1==i?le:ie)(s(te(e))),a=r(s(te(t))),f=re(l,u),c=re(l,a);return 10==l&&(0>u&&(f=Se(f,-u)),0>a&&(c=Se(c,-a))),n||2==l?(e=f*i,t=c*o):(e=Me(e,f),t=ye(t,c)),[e,t]}function j(e,t,l,n){let i=N(e,t,l,n);return 0==e&&(i[0]=0),0==t&&(i[1]=0),i}_&&function e(){let t=devicePixelRatio;y!=t&&(y=t,M&&I(g,M,e),M=matchMedia(`(min-resolution: ${y-.001}dppx) and (max-resolution: ${y+.001}dppx)`),G(g,M,e),v.dispatchEvent(new CustomEvent(x)))}();const U=.1,B={mode:3,pad:U},V={pad:0,soft:null,mode:0},J={min:V,max:V};function q(e,t,l,n){return Fe(l)?X(e,t,l):(V.pad=l,V.soft=n?0:null,V.mode=n?3:0,X(e,t,J))}function K(e,t){return null==e?t:e}function X(e,t,l){let n=l.min,i=l.max,o=K(n.pad,0),s=K(i.pad,0),r=K(n.hard,-he),u=K(i.hard,he),a=K(n.soft,he),f=K(i.soft,-he),c=K(n.mode,0),h=K(i.mode,0),d=t-e,p=ae(d),m=se(te(e),te(t)),g=ae(m),x=te(g-p);(1e-9>d||x>10)&&(d=0,0!=e&&0!=t||(d=1e-9,2==c&&a!=he&&(o=0),2==h&&f!=-he&&(s=0)));let w=d||m||1e3,_=ae(w),b=re(10,le(_)),v=Se(Me(e-w*(0==d?0==e?.1:1:o),b/10),9),k=a>e||1!=c&&(3!=c||v>a)&&(2!=c||a>v)?he:a,y=se(r,k>v&&e>=k?k:oe(k,v)),M=Se(ye(t+w*(0==d?0==t?.1:1:s),b/10),9),S=t>f||1!=h&&(3!=h||f>M)&&(2!=h||M>f)?-he:f,E=oe(u,M>S&&S>=t?S:se(S,M));return y==E&&0==y&&(E=100),[y,E]}const Z=new Intl.NumberFormat(_?k.language:"en-US"),$=e=>Z.format(e),Q=Math,ee=Q.PI,te=Q.abs,le=Q.floor,ne=Q.round,ie=Q.ceil,oe=Q.min,se=Q.max,re=Q.pow,ue=Q.sign,ae=Q.log10,fe=Q.log2,ce=(e,t=1)=>Q.asinh(e/t),he=1/0;function de(e){return 1+(0|ae((e^e>>31)-(e>>31)))}function pe(e,t,l){return oe(se(e,t),l)}function me(e){return"function"==typeof e?e:()=>e}const ge=e=>e,xe=(e,t)=>t,we=()=>null,_e=()=>!0,be=(e,t)=>e==t,ve=e=>Se(e,14);function ke(e,t){return ve(Se(ve(e/t))*t)}function ye(e,t){return ve(ie(ve(e/t))*t)}function Me(e,t){return ve(le(ve(e/t))*t)}function Se(e,t=0){if(Ye(e))return e;let l=10**t;return ne(e*l*(1+Number.EPSILON))/l}const Ee=new Map;function ze(e){return((""+e).split(".")[1]||"").length}function De(e,t,l,n){let i=[],o=n.map(ze);for(let s=t;l>s;s++){let t=te(s),l=Se(re(e,s),t);for(let e=0;n.length>e;e++){let r=n[e]*l,u=(0>r||0>s?t:0)+(o[e]>s?o[e]:0),a=Se(r,u);i.push(a),Ee.set(a,u)}}return i}const Te={},Pe=[],Ae=[null,null],We=Array.isArray,Ye=Number.isInteger;function Ce(e){return"string"==typeof e}function Fe(e){let t=!1;if(null!=e){let l=e.constructor;t=null==l||l==Object}return t}function He(e){return null!=e&&"object"==typeof e}const Re=Object.getPrototypeOf(Uint8Array);function Ge(e,t=Fe){let l;if(We(e)){let n=e.find((e=>null!=e));if(We(n)||t(n)){l=Array(e.length);for(let n=0;e.length>n;n++)l[n]=Ge(e[n],t)}else l=e.slice()}else if(e instanceof Re)l=e.slice();else if(t(e)){l={};for(let n in e)l[n]=Ge(e[n],t)}else l=e;return l}function Ie(e){let t=arguments;for(let l=1;t.length>l;l++){let n=t[l];for(let t in n)Fe(e[t])?Ie(e[t],Ge(n[t])):e[t]=Ge(n[t])}return e}function Le(e,t,l){for(let n,i=0,o=-1;t.length>i;i++){let s=t[i];if(s>o){for(n=s-1;n>=0&&null==e[n];)e[n--]=null;for(n=s+1;l>n&&null==e[n];)e[o=n++]=null}}}const Oe="undefined"==typeof queueMicrotask?e=>Promise.resolve().then(e):queueMicrotask,Ne=["January","February","March","April","May","June","July","August","September","October","November","December"],je=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];function Ue(e){return e.slice(0,3)}const Be=je.map(Ue),Ve=Ne.map(Ue),Je={MMMM:Ne,MMM:Ve,WWWW:je,WWW:Be};function qe(e){return(10>e?"0":"")+e}const Ke={YYYY:e=>e.getFullYear(),YY:e=>(e.getFullYear()+"").slice(2),MMMM:(e,t)=>t.MMMM[e.getMonth()],MMM:(e,t)=>t.MMM[e.getMonth()],MM:e=>qe(e.getMonth()+1),M:e=>e.getMonth()+1,DD:e=>qe(e.getDate()),D:e=>e.getDate(),WWWW:(e,t)=>t.WWWW[e.getDay()],WWW:(e,t)=>t.WWW[e.getDay()],HH:e=>qe(e.getHours()),H:e=>e.getHours(),h:e=>{let t=e.getHours();return 0==t?12:t>12?t-12:t},AA:e=>12>e.getHours()?"AM":"PM",aa:e=>12>e.getHours()?"am":"pm",a:e=>12>e.getHours()?"a":"p",mm:e=>qe(e.getMinutes()),m:e=>e.getMinutes(),ss:e=>qe(e.getSeconds()),s:e=>e.getSeconds(),fff:e=>function(e){return(10>e?"00":100>e?"0":"")+e}(e.getMilliseconds())};function Xe(e,t){t=t||Je;let l,n=[],i=/\{([a-z]+)\}|[^{]+/gi;for(;l=i.exec(e);)n.push("{"==l[0][0]?Ke[l[1]]:l[0]);return e=>{let l="";for(let i=0;n.length>i;i++)l+="string"==typeof n[i]?n[i]:n[i](e,t);return l}}const Ze=(new Intl.DateTimeFormat).resolvedOptions().timeZone,$e=e=>e%1==0,Qe=[1,2,2.5,5],et=De(10,-16,0,Qe),tt=De(10,0,16,Qe),lt=tt.filter($e),nt=et.concat(tt),it="{YYYY}",ot="\n"+it,st="{M}/{D}",rt="\n"+st,ut=rt+"/{YY}",at="{aa}",ft="{h}:{mm}"+at,ct="\n"+ft,ht=":{ss}",dt=null;function pt(e){let t=1e3*e,l=60*t,n=60*l,i=24*n,o=30*i,s=365*i;return[(1==e?De(10,0,3,Qe).filter($e):De(10,-3,0,Qe)).concat([t,5*t,10*t,15*t,30*t,l,5*l,10*l,15*l,30*l,n,2*n,3*n,4*n,6*n,8*n,12*n,i,2*i,3*i,4*i,5*i,6*i,7*i,8*i,9*i,10*i,15*i,o,2*o,3*o,4*o,6*o,s,2*s,5*s,10*s,25*s,50*s,100*s]),[[s,it,dt,dt,dt,dt,dt,dt,1],[28*i,"{MMM}",ot,dt,dt,dt,dt,dt,1],[i,st,ot,dt,dt,dt,dt,dt,1],[n,"{h}"+at,ut,dt,rt,dt,dt,dt,1],[l,ft,ut,dt,rt,dt,dt,dt,1],[t,ht,ut+" "+ft,dt,rt+" "+ft,dt,ct,dt,1],[e,ht+".{fff}",ut+" "+ft,dt,rt+" "+ft,dt,ct,dt,1]],function(t){return(r,u,a,f,c,h)=>{let d=[],p=c>=s,m=c>=o&&s>c,g=t(a),x=Se(g*e,3),w=yt(g.getFullYear(),p?0:g.getMonth(),m||p?1:g.getDate()),_=Se(w*e,3);if(m||p){let l=m?c/o:0,n=p?c/s:0,i=x==_?x:Se(yt(w.getFullYear()+n,w.getMonth()+l,1)*e,3),r=new Date(ne(i/e)),u=r.getFullYear(),a=r.getMonth();for(let o=0;f>=i;o++){let s=yt(u+n*o,a+l*o,1),r=s-t(Se(s*e,3));i=Se((+s+r)*e,3),i>f||d.push(i)}}else{let o=i>c?c:i,s=_+(le(a)-le(x))+ye(x-_,o);d.push(s);let p=t(s),m=p.getHours()+p.getMinutes()/l+p.getSeconds()/n,g=c/n,w=h/r.axes[u]._space;for(;s=Se(s+c,1==e?0:3),f>=s;)if(g>1){let e=le(Se(m+g,6))%24,l=t(s).getHours()-e;l>1&&(l=-1),s-=l*n,m=(m+g)%24,.7>Se((s-d[d.length-1])/c,3)*w||d.push(s)}else d.push(s)}return d}}]}const[mt,gt,xt]=pt(1),[wt,_t,bt]=pt(.001);function vt(e,t){return e.map((e=>e.map(((l,n)=>0==n||8==n||null==l?l:t(1==n||0==e[8]?l:e[1]+l)))))}function kt(e,t){return(l,n,i,o,s)=>{let r,u,a,f,c,h,d=t.find((e=>s>=e[0]))||t[t.length-1];return n.map((t=>{let l=e(t),n=l.getFullYear(),i=l.getMonth(),o=l.getDate(),s=l.getHours(),p=l.getMinutes(),m=l.getSeconds(),g=n!=r&&d[2]||i!=u&&d[3]||o!=a&&d[4]||s!=f&&d[5]||p!=c&&d[6]||m!=h&&d[7]||d[1];return r=n,u=i,a=o,f=s,c=p,h=m,g(l)}))}}function yt(e,t,l){return new Date(e,t,l)}function Mt(e,t){return t(e)}function St(e,t){return(l,n,i,o)=>null==o?w:t(e(n))}De(2,-53,53,[1]);const Et={show:!0,live:!0,isolate:!1,mount:()=>{},markers:{show:!0,width:2,stroke:function(e,t){let l=e.series[t];return l.width?l.stroke(e,t):l.points.width?l.points.stroke(e,t):null},fill:function(e,t){return e.series[t].fill(e,t)},dash:"solid"},idx:null,idxs:null,values:[]},zt=[0,0];function Dt(e,t,l,n=!0){return e=>{0==e.button&&(!n||e.target==t)&&l(e)}}function Tt(e,t,l,n=!0){return e=>{(!n||e.target==t)&&l(e)}}const Pt={show:!0,x:!0,y:!0,lock:!1,move:function(e,t,l){return zt[0]=t,zt[1]=l,zt},points:{show:function(e,t){let i=e.cursor.points,o=T(),s=i.size(e,t);z(o,l,s),z(o,n,s);let r=s/-2;z(o,"marginLeft",r),z(o,"marginTop",r);let u=i.width(e,t,s);return u&&z(o,"borderWidth",u),o},size:function(e,t){return e.series[t].points.size},width:0,stroke:function(e,t){let l=e.series[t].points;return l._stroke||l._fill},fill:function(e,t){let l=e.series[t].points;return l._fill||l._stroke}},bind:{mousedown:Dt,mouseup:Dt,click:Dt,dblclick:Dt,mousemove:Tt,mouseleave:Tt,mouseenter:Tt},drag:{setScale:!0,x:!0,y:!1,dist:0,uni:null,click:(e,t)=>{t.stopPropagation(),t.stopImmediatePropagation()},_x:!1,_y:!1},focus:{prox:-1,bias:0},left:-10,top:-10,idx:null,dataIdx:function(e,t,l){return l},idxs:null,event:null},At={show:!0,stroke:"rgba(0,0,0,0.07)",width:2},Wt=Ie({},At,{filter:xe}),Yt=Ie({},Wt,{size:10}),Ct=Ie({},At,{show:!1}),Ft='12px system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"',Ht="bold "+Ft,Rt={show:!0,scale:"x",stroke:u,space:50,gap:5,size:50,labelGap:0,labelSize:30,labelFont:Ht,side:2,grid:Wt,ticks:Yt,border:Ct,font:Ft,lineGap:1.5,rotate:0},Gt={show:!0,scale:"x",auto:!1,sorted:1,min:he,max:-he,idxs:[]};function It(e,t){return t.map((e=>null==e?"":$(e)))}function Lt(e,t,l,n,i,o,s){let r=[],u=Ee.get(i)||0;for(let e=l=s?l:Se(ye(l,i),u);n>=e;e=Se(e+i,u))r.push(Object.is(e,-0)?0:e);return r}function Ot(e,t,l,n,i){const o=[],s=e.scales[e.axes[t].scale].log,r=le((10==s?ae:fe)(l));i=re(s,r),10==s&&0>r&&(i=Se(i,-r));let u=l;do{o.push(u),u+=i,10==s&&(u=Se(u,Ee.get(i))),i*s>u||(i=u)}while(n>=u);return o}function Nt(e,t,l,n,i){let o=e.scales[e.axes[t].scale].asinh,s=n>o?Ot(e,t,se(o,l),n,i):[o],r=0>n||l>0?[]:[0];return(-o>l?Ot(e,t,se(o,-n),-l,i):[o]).reverse().map((e=>-e)).concat(r,s)}const jt=/./,Ut=/[12357]/,Bt=/[125]/,Vt=/1/,Jt=(e,t,l,n)=>e.map(((e,i)=>4==t&&0==e||i%n==0&&l.test(e.toExponential()[0>e?1:0])?e:null));function qt(e,t,l){let n=e.axes[l],i=n.scale,o=e.scales[i],s=e.valToPos,r=n._space,u=s(10,i),a=s(9,i)-ue)return Jt(t.slice().reverse(),o.distr,a,ie(r/e)).reverse()}return Jt(t,o.distr,a,1)}function Kt(e,t,l){let n=e.axes[l],i=n.scale,o=n._space,s=e.valToPos,r=te(s(1,i)-s(2,i));return o>r?Jt(t.slice().reverse(),3,jt,ie(o/r)).reverse():t}function Xt(e,t,l,n){return null==n?w:null==t?"":$(t)}const Zt={show:!0,scale:"y",stroke:u,space:30,gap:5,size:50,labelGap:0,labelSize:30,labelFont:Ht,side:3,grid:Wt,ticks:Yt,border:Ct,font:Ft,lineGap:1.5,rotate:0},$t={scale:null,auto:!0,sorted:0,min:he,max:-he},Qt=(e,t,l,n,i)=>i,el={show:!0,auto:!0,sorted:0,gaps:Qt,alpha:1,facets:[Ie({},$t,{scale:"x"}),Ie({},$t,{scale:"y"})]},tl={scale:"y",auto:!0,sorted:0,show:!0,spanGaps:!1,gaps:Qt,alpha:1,points:{show:function(e,t){let{scale:l,idxs:n}=e.series[0],i=e._data[0],o=e.valToPos(i[n[0]],l,!0),s=e.valToPos(i[n[1]],l,!0);return te(s-o)/(e.series[t].points.space*y)>=n[1]-n[0]},filter:null},values:null,min:he,max:-he,idxs:[],path:null,clip:null};function ll(e,t,l){return l/10}const nl={time:!0,auto:!0,distr:1,log:10,asinh:1,min:null,max:null,dir:1,ori:0},il=Ie({},nl,{time:!1,ori:1}),ol={};function sl(e){let t=ol[e];return t||(t={key:e,plots:[],sub(e){t.plots.push(e)},unsub(e){t.plots=t.plots.filter((t=>t!=e))},pub(e,l,n,i,o,s,r){for(let u=0;t.plots.length>u;u++)t.plots[u]!=l&&t.plots[u].pub(e,l,n,i,o,s,r)}},null!=e&&(ol[e]=t)),t}function rl(e,t,l){const n=e.mode,i=e.series[t],o=2==n?e._data[t]:e._data,s=e.scales,r=e.bbox;let u=o[0],a=2==n?o[1]:o[t],f=2==n?s[i.facets[0].scale]:s[e.series[0].scale],c=2==n?s[i.facets[1].scale]:s[i.scale],h=r.left,d=r.top,p=r.width,m=r.height,g=e.valToPosH,x=e.valToPosV;return 0==f.ori?l(i,u,a,f,c,g,x,h,d,p,m,ml,xl,_l,vl,yl):l(i,u,a,f,c,x,g,d,h,m,p,gl,wl,bl,kl,Ml)}function ul(e,t){let l=0,n=0,i=K(e.bands,Pe);for(let e=0;i.length>e;e++){let o=i[e];o.series[0]==t?l=o.dir:o.series[1]==t&&(n|=1==o.dir?1:2)}return[l,1==n?-1:2==n?1:3==n?2:0]}function al(e,t,l,n,i){let o=e.series[t],s=e.scales[2==e.mode?o.facets[1].scale:o.scale];return-1==i?s.min:1==i?s.max:3==s.distr?1==s.dir?s.min:s.max:0}function fl(e,t,l,n,i,o){return rl(e,t,((e,t,s,r,u,a,f,c,h,d,p)=>{let m=e.pxRound;const g=0==r.ori?xl:wl;let x,w;1==r.dir*(0==r.ori?1:-1)?(x=l,w=n):(x=n,w=l);let _=m(a(t[x],r,d,c)),b=m(f(s[x],u,p,h)),v=m(a(t[w],r,d,c)),k=m(f(1==o?u.max:u.min,u,p,h)),y=new Path2D(i);return g(y,v,k),g(y,_,k),g(y,_,b),y}))}function cl(e,t,l,n,i,o){let s=null;if(e.length>0){s=new Path2D;const r=0==t?_l:bl;let u=l;for(let t=0;e.length>t;t++){let l=e[t];if(l[1]>l[0]){let e=l[0]-u;e>0&&r(s,u,n,e,n+o),u=l[1]}}let a=l+i-u,f=10;a>0&&r(s,u,n-f/2,a,n+o+f)}return s}function hl(e,t,l,n,i,o,s){let r=[],u=e.length;for(let a=1==i?l:n;a>=l&&n>=a;a+=i)if(null===t[a]){let f=a,c=a;if(1==i)for(;++a<=n&&null===t[a];)c=a;else for(;--a>=l&&null===t[a];)c=a;let h=o(e[f]),d=c==f?h:o(e[c]),p=f-i;h=s>0||0>p||p>=u?h:o(e[p]);let m=c+i;d=0>s||0>m||m>=u?d:o(e[m]),h>d||r.push([h,d])}return r}function dl(e){return 0==e?ge:1==e?ne:t=>ke(t,e)}function pl(e){let t=0==e?ml:gl,l=0==e?(e,t,l,n,i,o)=>{e.arcTo(t,l,n,i,o)}:(e,t,l,n,i,o)=>{e.arcTo(l,t,i,n,o)},n=0==e?(e,t,l,n,i)=>{e.rect(t,l,n,i)}:(e,t,l,n,i)=>{e.rect(l,t,i,n)};return(e,i,o,s,r,u=0,a=0)=>{0==u&&0==a?n(e,i,o,s,r):(u=oe(u,s/2,r/2),a=oe(a,s/2,r/2),t(e,i+u,o),l(e,i+s,o,i+s,o+r,u),l(e,i+s,o+r,i,o+r,a),l(e,i,o+r,i,o,a),l(e,i,o,i+s,o,u),e.closePath())}}const ml=(e,t,l)=>{e.moveTo(t,l)},gl=(e,t,l)=>{e.moveTo(l,t)},xl=(e,t,l)=>{e.lineTo(t,l)},wl=(e,t,l)=>{e.lineTo(l,t)},_l=pl(0),bl=pl(1),vl=(e,t,l,n,i,o)=>{e.arc(t,l,n,i,o)},kl=(e,t,l,n,i,o)=>{e.arc(l,t,n,i,o)},yl=(e,t,l,n,i,o,s)=>{e.bezierCurveTo(t,l,n,i,o,s)},Ml=(e,t,l,n,i,o,s)=>{e.bezierCurveTo(l,t,i,n,s,o)};function Sl(){return(e,t,l,n,i)=>rl(e,t,((t,o,s,r,u,a,f,c,h,d,p)=>{let m,g,{pxRound:x,points:w}=t;0==r.ori?(m=ml,g=vl):(m=gl,g=kl);const _=Se(w.width*y,3);let b=(w.size-w.width)/2*y,v=Se(2*b,3),k=new Path2D,M=new Path2D,{left:S,top:E,width:z,height:D}=e.bbox;_l(M,S-v,E-v,z+2*v,D+2*v);const T=e=>{if(null!=s[e]){let t=x(a(o[e],r,d,c)),l=x(f(s[e],u,p,h));m(k,t+b,l),g(k,t,l,b,0,2*ee)}};if(i)i.forEach(T);else for(let e=l;n>=e;e++)T(e);return{stroke:_>0?k:null,fill:k,clip:M,flags:3}}))}function El(e){return(t,l,n,i,o,s)=>{n!=i&&(o!=n&&s!=n&&e(t,l,n),o!=i&&s!=i&&e(t,l,i),e(t,l,s))}}const zl=El(xl),Dl=El(wl);function Tl(e){const t=K(e?.alignGaps,0);return(e,l,n,i)=>rl(e,l,((o,s,r,u,a,f,c,h,d,p,m)=>{let g,x,w=o.pxRound,_=e=>w(f(e,u,p,h)),b=e=>w(c(e,a,m,d));0==u.ori?(g=xl,x=zl):(g=wl,x=Dl);const v=u.dir*(0==u.ori?1:-1),k={stroke:new Path2D,fill:null,clip:null,band:null,gaps:null,flags:1},y=k.stroke;let M,S,E,z=he,D=-he,T=_(s[1==v?n:i]),P=O(r,n,i,1*v),A=O(r,n,i,-1*v),W=_(s[P]),Y=_(s[A]),C=!1;for(let e=1==v?n:i;e>=n&&i>=e;e+=v){let t=_(s[e]),l=r[e];t==T?null!=l?(S=b(l),z==he&&(g(y,t,S),M=S),z=oe(S,z),D=se(S,D)):null===l&&(C=!0):(z!=he&&(x(y,T,z,D,M,S),E=T),null!=l?(S=b(l),g(y,t,S),z=D=M=S):(z=he,D=-he,null===l&&(C=!0)),T=t)}z!=he&&z!=D&&E!=T&&x(y,T,z,D,M,S);let[F,H]=ul(e,l);if(null!=o.fill||0!=F){let t=k.fill=new Path2D(y),n=b(o.fillTo(e,l,o.min,o.max,F));g(t,Y,n),g(t,W,n)}if(!o.spanGaps){let a=[];C&&a.push(...hl(s,r,n,i,v,_,t)),k.gaps=a=o.gaps(e,l,n,i,a),k.clip=cl(a,u.ori,h,d,p,m)}return 0!=H&&(k.band=2==H?[fl(e,l,n,i,y,-1),fl(e,l,n,i,y,1)]:fl(e,l,n,i,y,H)),k}))}function Pl(e,t,l,n,i){const o=e.length;if(2>o)return null;const s=new Path2D;if(l(s,e[0],t[0]),2==o)n(s,e[1],t[1]);else{let l=Array(o),n=Array(o-1),r=Array(o-1),u=Array(o-1);for(let l=0;o-1>l;l++)r[l]=t[l+1]-t[l],u[l]=e[l+1]-e[l],n[l]=r[l]/u[l];l[0]=n[0];for(let e=1;o-1>e;e++)0===n[e]||0===n[e-1]||n[e-1]>0!=n[e]>0?l[e]=0:(l[e]=3*(u[e-1]+u[e])/((2*u[e]+u[e-1])/n[e-1]+(u[e]+2*u[e-1])/n[e]),isFinite(l[e])||(l[e]=0));l[o-1]=n[o-2];for(let n=0;o-1>n;n++)i(s,e[n]+u[n]/3,t[n]+l[n]*u[n]/3,e[n+1]-u[n]/3,t[n+1]-l[n+1]*u[n]/3,e[n+1],t[n+1])}return s}const Al=new Set;function Wl(){for(let e of Al)e.syncRect(!0)}_&&(G("resize",v,Wl),G("scroll",v,Wl,!0),G(x,v,(()=>{Jl.pxRatio=y})));const Yl=Tl(),Cl=Sl();function Fl(e,t,l,n){return(n?[e[0],e[1]].concat(e.slice(2)):[e[0]].concat(e.slice(1))).map(((e,n)=>Hl(e,n,t,l)))}function Hl(e,t,l,n){return Ie({},0==t?l:n,e)}function Rl(e,t,l){return null==t?Ae:[t,l]}const Gl=Rl;function Il(e,t,l){return null==t?Ae:q(t,l,U,!0)}function Ll(e,t,l,n){return null==t?Ae:N(t,l,e.scales[n].log,!1)}const Ol=Ll;function Nl(e,t,l,n){return null==t?Ae:j(t,l,e.scales[n].log,!1)}const jl=Nl;function Ul(e,t,l,n,i){let o=se(de(e),de(t)),s=t-e,r=L(i/n*s,l);do{let e=l[r],t=n*e/s;if(t>=i&&17>=o+(5>e?Ee.get(e):0))return[e,t]}while(++r(t=ne((l=+n)*y))+"px")),t,l]}function Vl(e){e.show&&[e.font,e.labelFont].forEach((e=>{let t=Se(e[2]*y,1);e[0]=e[0].replace(/[0-9.]+px/,t+"px"),e[1]=t}))}function Jl(u,g,_){const k={mode:K(u.mode,1)},M=k.mode;function P(e,t){return((3==t.distr?ae(e>0?e:t.clamp(k,e,t.min,t.max,t.key)):4==t.distr?ce(e,t.asinh):e)-t._min)/(t._max-t._min)}function W(e,t,l,n){let i=P(e,t);return n+l*(-1==t.dir?1-i:i)}function C(e,t,l,n){let i=P(e,t);return n+l*(-1==t.dir?i:1-i)}function H(e,t,l,n){return 0==t.ori?W(e,t,l,n):C(e,t,l,n)}k.valToPosH=W,k.valToPosV=C;let R=!1;k.status=0;const O=k.root=T("uplot");null!=u.id&&(O.id=u.id),S(O,u.class),u.title&&(T("u-title",O).textContent=u.title);const V=D("canvas"),J=k.ctx=V.getContext("2d"),X=T("u-wrap",O);G("click",X,(e=>{e.target===$&&(Dn!=Mn||Tn!=Sn)&&Fn.click(k,e)}),!0);const Z=k.under=T("u-under",X);X.appendChild(V);const $=k.over=T("u-over",X),le=+K((u=Ge(u)).pxAlign,1),ue=dl(le);(u.plugins||[]).forEach((e=>{e.opts&&(u=e.opts(k,u)||u)}));const fe=u.ms||.001,de=k.series=1==M?Fl(u.series||[],Gt,tl,!1):function(e,t){return e.map(((e,l)=>0==l?null:Ie({},t,e)))}(u.series||[null],el),ge=k.axes=Fl(u.axes||[],Rt,Zt,!0),ve=k.scales={},Me=k.bands=u.bands||[];Me.forEach((e=>{e.fill=me(e.fill||null),e.dir=K(e.dir,-1)}));const De=2==M?de[1].facets[0].scale:de[0].scale,Ye={axes:function(){for(let e=0;ge.length>e;e++){let t=ge[e];if(!t.show||!t._show)continue;let l,n,u=t.side,a=u%2,f=t.stroke(k,e),c=0==u||3==u?-1:1;if(t.label){let e=ne((t._lpos+t.labelGap*c)*y);sn(t.labelFont[0],f,"center",2==u?i:o),J.save(),1==a?(l=n=0,J.translate(e,ne(Ut+Vt/2)),J.rotate((3==u?-ee:ee)/2)):(l=ne(jt+Bt/2),n=e),J.fillText(t.label,l,n),J.restore()}let[h,d]=t._found;if(0==d)continue;let p=ve[t.scale],m=0==a?Bt:Vt,g=0==a?jt:Ut,x=ne(t.gap*y),w=t._splits,_=2==p.distr?w.map((e=>en[e])):w,b=2==p.distr?en[w[1]]-en[w[0]]:h,v=t.ticks,M=t.border,S=v.show?ne(v.size*y):0,E=t._rotate*-ee/180,z=ue(t._pos*y),D=z+(S+x)*c;n=0==a?D:0,l=1==a?D:0,sn(t.font[0],f,1==t.align?s:2==t.align?r:E>0?s:0>E?r:0==a?"center":3==u?r:s,E||1==a?"middle":2==u?i:o);let T=t.font[1]*t.lineGap,P=w.map((e=>ue(H(e,p,m,g)))),A=t._values;for(let e=0;A.length>e;e++){let t=A[e];if(null!=t){0==a?l=P[e]:n=P[e],t=""+t;let i=-1==t.indexOf("\n")?[t]:t.split(/\n/gm);for(let e=0;i.length>e;e++){let t=i[e];E?(J.save(),J.translate(l,n+e*T),J.rotate(E),J.fillText(t,0,0),J.restore()):J.fillText(t,l,n+e*T)}}}v.show&&pn(P,v.filter(k,_,e,d,b),a,u,z,S,Se(v.width*y,3),v.stroke(k,e),v.dash,v.cap);let W=t.grid;W.show&&pn(P,W.filter(k,_,e,d,b),a,0==a?2:1,0==a?Ut:jt,0==a?Vt:Bt,Se(W.width*y,3),W.stroke(k,e),W.dash,W.cap),M.show&&pn([z],[1],0==a?1:0,0==a?1:2,1==a?Ut:jt,1==a?Vt:Bt,Se(M.width*y,3),M.stroke(k,e),M.dash,M.cap)}xi("drawAxes")},series:function(){Sl>0&&(de.forEach(((e,t)=>{if(t>0&&e.show&&(un(t,!1),un(t,!0),null==e._paths)){let l=2==M?[0,g[t][0].length-1]:function(e){let t=pe(El-1,0,Sl-1),l=pe(zl+1,0,Sl-1);for(;null==e[t]&&t>0;)t--;for(;null==e[l]&&Sl-1>l;)l++;return[t,l]}(g[t]);e._paths=e.paths(k,t,l[0],l[1])}})),de.forEach(((e,t)=>{if(t>0&&e.show){Ql!=e.alpha&&(J.globalAlpha=Ql=e.alpha),null!=e._paths&&an(t,!1);{let l=null!=e._paths?e._paths.gaps:null,n=e.points.show(k,t,El,zl,l),i=e.points.filter(k,t,n,l);(n||i)&&(e.points._paths=e.points.paths(k,t,El,zl,i),an(t,!0))}1!=Ql&&(J.globalAlpha=Ql=1),xi("drawSeries",t)}})))}},Re=(u.drawOrder||["axes","series"]).map((e=>Ye[e]));function Le(e){let t=ve[e];if(null==t){let l=(u.scales||Te)[e]||Te;if(null!=l.from)Le(l.from),ve[e]=Ie({},ve[l.from],l,{key:e});else{t=ve[e]=Ie({},e==De?nl:il,l),t.key=e;let n=t.time,i=t.range,o=We(i);if((e!=De||2==M&&!n)&&(!o||null!=i[0]&&null!=i[1]||(i={min:null==i[0]?B:{mode:1,hard:i[0],soft:i[0]},max:null==i[1]?B:{mode:1,hard:i[1],soft:i[1]}},o=!1),!o&&Fe(i))){let e=i;i=(t,l,n)=>null==l?Ae:q(l,n,e)}t.range=me(i||(n?Gl:e==De?3==t.distr?Ol:4==t.distr?jl:Rl:3==t.distr?Ll:4==t.distr?Nl:Il)),t.auto=me(!o&&t.auto),t.clamp=me(t.clamp||ll),t._min=t._max=null}}}Le("x"),Le("y"),1==M&&de.forEach((e=>{Le(e.scale)})),ge.forEach((e=>{Le(e.scale)}));for(let e in u.scales)Le(e);const Ne=ve[De],je=Ne.distr;let Ue,Be;0==Ne.ori?(S(O,"u-hz"),Ue=W,Be=C):(S(O,"u-vt"),Ue=C,Be=W);const Ve={};for(let e in ve){let t=ve[e];null==t.min&&null==t.max||(Ve[e]={min:t.min,max:t.max},t.min=t.max=null)}const Je=u.tzDate||(e=>new Date(ne(e/fe))),qe=u.fmtDate||Xe,Ke=1==fe?xt(Je):bt(Je),Ze=kt(Je,vt(1==fe?gt:_t,qe)),$e=St(Je,Mt("{YYYY}-{MM}-{DD} {h}:{mm}{aa}",qe)),Qe=[],et=k.legend=Ie({},Et,u.legend),tt=et.show,it=et.markers;let ot,st,rt;et.idxs=Qe,it.width=me(it.width),it.dash=me(it.dash),it.stroke=me(it.stroke),it.fill=me(it.fill);let ut,at=[],ft=[],ct=!1,ht={};if(et.live){const e=de[1]?de[1].values:null;ct=null!=e,ut=ct?e(k,1,0):{_:0};for(let e in ut)ht[e]=w}if(tt)if(ot=D("table","u-legend",O),rt=D("tbody",null,ot),et.mount(k,ot),ct){st=D("thead",null,ot,rt);let e=D("tr",null,st);for(var dt in D("th",null,e),ut)D("th",t,e).textContent=dt}else S(ot,"u-inline"),et.live&&S(ot,"u-live");const pt={show:!0},yt={show:!1},zt=new Map;function Dt(e,t,l,n=!0){const i=zt.get(t)||{},o=pl.bind[e](k,t,l,n);o&&(G(e,t,i[e]=o),zt.set(t,i))}function Tt(e,t){const l=zt.get(t)||{};for(let n in l)null!=e&&n!=e||(I(n,t,l[n]),delete l[n]);null==e&&zt.delete(t)}let At=0,Wt=0,Yt=0,Ct=0,Ft=0,Ht=0,jt=0,Ut=0,Bt=0,Vt=0;k.bbox={};let Jt=!1,$t=!1,Qt=!1,ol=!1,rl=!1,ul=!1;function fl(e,t,l){(l||e!=k.width||t!=k.height)&&cl(e,t),xn(!1),Qt=!0,$t=!0,0>pl.left||(ol=ul=!0),An()}function cl(e,t){k.width=At=Yt=e,k.height=Wt=Ct=t,Ft=Ht=0,function(){let e=!1,t=!1,l=!1,n=!1;ge.forEach((i=>{if(i.show&&i._show){let{side:o,_size:s}=i,r=s+(null!=i.label?i.labelSize:0);r>0&&(o%2?(Yt-=r,3==o?(Ft+=r,n=!0):l=!0):(Ct-=r,0==o?(Ht+=r,e=!0):t=!0))}})),vl[0]=e,vl[1]=l,vl[2]=t,vl[3]=n,Yt-=Ml[1]+Ml[3],Ft+=Ml[3],Ct-=Ml[2]+Ml[0],Ht+=Ml[0]}(),function(){let e=Ft+Yt,t=Ht+Ct,l=Ft,n=Ht;function i(i,o){switch(i){case 1:return e+=o,e-o;case 2:return t+=o,t-o;case 3:return l-=o,l+o;case 0:return n-=o,n+o}}ge.forEach((e=>{if(e.show&&e._show){let t=e.side;e._pos=i(t,e._size),null!=e.label&&(e._lpos=i(t,e.labelSize))}}))}();let l=k.bbox;jt=l.left=ke(Ft*y,.5),Ut=l.top=ke(Ht*y,.5),Bt=l.width=ke(Yt*y,.5),Vt=l.height=ke(Ct*y,.5)}const hl=3;k.setSize=function({width:e,height:t}){fl(e,t)};const pl=k.cursor=Ie({},Pt,{drag:{y:2==M}},u.cursor),ml=e=>{pl.event=e};pl.idxs=Qe,pl._lock=!1;let gl=pl.points;gl.show=me(gl.show),gl.size=me(gl.size),gl.stroke=me(gl.stroke),gl.width=me(gl.width),gl.fill=me(gl.fill);const xl=k.focus=Ie({},u.focus||{alpha:.3},pl.focus),wl=xl.prox>=0;let _l=[null];function bl(l,n){if(1==M||n>0){let e=1==M&&ve[l.scale].time,t=l.value;l.value=e?Ce(t)?St(Je,Mt(t,qe)):t||$e:t||Xt,l.label=l.label||(e?"Time":"Value")}if(n>0){l.width=null==l.width?1:l.width,l.paths=l.paths||Yl||we,l.fillTo=me(l.fillTo||al),l.pxAlign=+K(l.pxAlign,le),l.pxRound=dl(l.pxAlign),l.stroke=me(l.stroke||null),l.fill=me(l.fill||null),l._stroke=l._fill=l._paths=l._focus=null;let e=function(e){return Se(1*(3+2*(e||1)),3)}(se(1,l.width)),t=l.points=Ie({},{size:e,width:se(1,.2*e),stroke:l.stroke,space:2*e,paths:Cl,_stroke:null,_fill:null},l.points);t.show=me(t.show),t.filter=me(t.filter),t.fill=me(t.fill),t.stroke=me(t.stroke),t.paths=me(t.paths),t.pxAlign=l.pxAlign}if(tt){let i=function(l,n){if(0==n&&(ct||!et.live||2==M))return Ae;let i=[],o=D("tr","u-series",rt,rt.childNodes[n]);S(o,l.class),l.show||S(o,e);let s=D("th",null,o);if(it.show){let e=T("u-marker",s);if(n>0){let t=it.width(k,n);t&&(e.style.border=t+"px "+it.dash(k,n)+" "+it.stroke(k,n)),e.style.background=it.fill(k,n)}}let r=T(t,s);for(var u in r.textContent=l.label,n>0&&(it.show||(r.style.color=l.width>0?it.stroke(k,n):it.fill(k,n)),Dt("click",s,(e=>{if(pl._lock)return;ml(e);let t=de.indexOf(l);if((e.ctrlKey||e.metaKey)!=et.isolate){let e=de.some(((e,l)=>l>0&&l!=t&&e.show));de.forEach(((l,n)=>{n>0&&Nn(n,e?n==t?pt:yt:pt,!0,_i.setSeries)}))}else Nn(t,{show:!l.show},!0,_i.setSeries)}),!1),wl&&Dt(d,s,(e=>{pl._lock||(ml(e),Nn(de.indexOf(l),Vn,!0,_i.setSeries))}),!1)),ut){let e=D("td","u-value",o);e.textContent="--",i.push(e)}return[o,i]}(l,n);at.splice(n,0,i[0]),ft.splice(n,0,i[1]),et.values.push(null)}if(pl.show){Qe.splice(n,0,null);let e=function(e,t){if(t>0){let l=pl.points.show(k,t);if(l)return S(l,"u-cursor-pt"),S(l,e.class),A(l,-10,-10,Yt,Ct),$.insertBefore(l,_l[t]),l}}(l,n);e&&_l.splice(n,0,e)}xi("addSeries",n)}k.addSeries=function(e,t){t=null==t?de.length:t,e=1==M?Hl(e,t,Gt,tl):Hl(e,t,null,el),de.splice(t,0,e),bl(de[t],t)},k.delSeries=function(e){if(de.splice(e,1),tt){et.values.splice(e,1),ft.splice(e,1);let t=at.splice(e,1)[0];Tt(null,t.firstChild),t.remove()}pl.show&&(Qe.splice(e,1),_l.length>1&&_l.splice(e,1)[0].remove()),xi("delSeries",e)};const vl=[!1,!1,!1,!1];function kl(e,t,l){let[n,i,o,s]=l,r=t%2,u=0;return 0==r&&(s||i)&&(u=0==t&&!n||2==t&&!o?ne(Rt.size/3):0),1==r&&(n||o)&&(u=1==t&&!i||3==t&&!s?ne(Zt.size/2):0),u}const yl=k.padding=(u.padding||[kl,kl,kl,kl]).map((e=>me(K(e,kl)))),Ml=k._padding=yl.map(((e,t)=>e(k,t,vl,0)));let Sl,El=null,zl=null;const Dl=1==M?de[0].idxs:null;let Tl,Pl,Wl,Jl,ql,Kl,Xl,Zl,$l,Ql,en=null,tn=!1;function ln(e,t){if(g=null==e?[]:Ge(e,He),2==M){Sl=0;for(let e=1;de.length>e;e++)Sl+=g[e][0].length;k.data=g=e}else if(null==g[0]&&(g[0]=[]),k.data=g.slice(),en=g[0],Sl=en.length,2==je){g[0]=Array(Sl);for(let e=0;Sl>e;e++)g[0][e]=e}if(k._data=g,xn(!0),xi("setData"),2==je&&(Qt=!0),!1!==t){let e=Ne;e.auto(k,tn)?nn():On(De,e.min,e.max),ol=ol||pl.left>=0,ul=!0,An()}}function nn(){let e,t;tn=!0,1==M&&(Sl>0?(El=Dl[0]=0,zl=Dl[1]=Sl-1,e=g[0][El],t=g[0][zl],2==je?(e=El,t=zl):e==t&&(3==je?[e,t]=N(e,e,Ne.log,!1):4==je?[e,t]=j(e,e,Ne.log,!1):Ne.time?t=e+ne(86400/fe):[e,t]=q(e,t,U,!0))):(El=Dl[0]=e=null,zl=Dl[1]=t=null)),On(De,e,t)}function on(e,t,l,n,i,o){e??=a,l??=Pe,n??="butt",i??=a,o??="round",e!=Tl&&(J.strokeStyle=Tl=e),i!=Pl&&(J.fillStyle=Pl=i),t!=Wl&&(J.lineWidth=Wl=t),o!=ql&&(J.lineJoin=ql=o),n!=Kl&&(J.lineCap=Kl=n),l!=Jl&&J.setLineDash(Jl=l)}function sn(e,t,l,n){t!=Pl&&(J.fillStyle=Pl=t),e!=Xl&&(J.font=Xl=e),l!=Zl&&(J.textAlign=Zl=l),n!=$l&&(J.textBaseline=$l=n)}function rn(e,t,l,n,i=0){if(n.length>0&&e.auto(k,tn)&&(null==t||null==t.min)){let t=K(El,0),o=K(zl,n.length-1),s=null==l.min?3==e.distr?function(e,t,l){let n=he,i=-he;for(let o=t;l>=o;o++){let t=e[o];null!=t&&t>0&&(n>t&&(n=t),t>i&&(i=t))}return[n==he?1:n,i==-he?10:i]}(n,t,o):function(e,t,l,n){let i=he,o=-he;if(1==n)i=e[t],o=e[l];else if(-1==n)i=e[l],o=e[t];else for(let n=t;l>=n;n++){let t=e[n];null!=t&&(i>t&&(i=t),t>o&&(o=t))}return[i,o]}(n,t,o,i):[l.min,l.max];e.min=oe(e.min,l.min=s[0]),e.max=se(e.max,l.max=s[1])}}function un(e,t){let l=t?de[e].points:de[e];l._stroke=l.stroke(k,e),l._fill=l.fill(k,e)}function an(e,t){let l=t?de[e].points:de[e],{stroke:n,fill:i,clip:o,flags:s,_stroke:r=l._stroke,_fill:u=l._fill,_width:a=l.width}=l._paths;a=Se(a*y,3);let f=null,c=a%2/2;t&&null==u&&(u=a>0?"#fff":r);let h=1==l.pxAlign&&c>0;if(h&&J.translate(c,c),!t){let e=jt-a/2,t=Ut-a/2,l=Bt+a,n=Vt+a;f=new Path2D,f.rect(e,t,l,n)}t?cn(r,a,l.dash,l.cap,u,n,i,s,o):function(e,t,l,n,i,o,s,r,u,a,f){let c=!1;Me.forEach(((h,d)=>{if(h.series[0]==e){let e,p=de[h.series[1]],m=g[h.series[1]],x=(p._paths||Te).band;We(x)&&(x=1==h.dir?x[0]:x[1]);let w=null;p.show&&x&&function(e,t,l){for(t=K(t,0),l=K(l,e.length-1);l>=t;){if(null!=e[t])return!0;t++}return!1}(m,El,zl)?(w=h.fill(k,d)||o,e=p._paths.clip):x=null,cn(t,l,n,i,w,s,r,u,a,f,e,x),c=!0}})),c||cn(t,l,n,i,o,s,r,u,a,f)}(e,r,a,l.dash,l.cap,u,n,i,s,f,o),h&&J.translate(-c,-c)}k.setData=ln;const fn=3;function cn(e,t,l,n,i,o,s,r,u,a,f,c){on(e,t,l,n,i),(u||a||c)&&(J.save(),u&&J.clip(u),a&&J.clip(a)),c?(r&fn)==fn?(J.clip(c),f&&J.clip(f),dn(i,s),hn(e,o,t)):2&r?(dn(i,s),J.clip(c),hn(e,o,t)):1&r&&(J.save(),J.clip(c),f&&J.clip(f),dn(i,s),J.restore(),hn(e,o,t)):(dn(i,s),hn(e,o,t)),(u||a||c)&&J.restore()}function hn(e,t,l){l>0&&(t instanceof Map?t.forEach(((e,t)=>{J.strokeStyle=Tl=t,J.stroke(e)})):null!=t&&e&&J.stroke(t))}function dn(e,t){t instanceof Map?t.forEach(((e,t)=>{J.fillStyle=Pl=t,J.fill(e)})):null!=t&&e&&J.fill(t)}function pn(e,t,l,n,i,o,s,r,u,a){let f=s%2/2;1==le&&J.translate(f,f),on(r,s,u,a,r),J.beginPath();let c,h,d,p,m=i+(0==n||3==n?-o:o);0==l?(h=i,p=m):(c=i,d=m);for(let n=0;e.length>n;n++)null!=t[n]&&(0==l?c=d=e[n]:h=p=e[n],J.moveTo(c,h),J.lineTo(d,p));J.stroke(),1==le&&J.translate(-f,-f)}function mn(e){let t=!0;return ge.forEach(((l,n)=>{if(!l.show)return;let i=ve[l.scale];if(null==i.min)return void(l._show&&(t=!1,l._show=!1,xn(!1)));l._show||(t=!1,l._show=!0,xn(!1));let o=l.side,s=o%2,{min:r,max:u}=i,[a,f]=function(e,t,l,n){let i,o=ge[e];if(n>0){let s=o._space=o.space(k,e,t,l,n);i=Ul(t,l,o._incrs=o.incrs(k,e,t,l,n,s),n,s)}else i=[0,0];return o._found=i}(n,r,u,0==s?Yt:Ct);if(0==f)return;let c=l._splits=l.splits(k,n,r,u,a,f,2==i.distr),h=2==i.distr?c.map((e=>en[e])):c,d=2==i.distr?en[c[1]]-en[c[0]]:a,p=l._values=l.values(k,l.filter(k,h,n,f,d),n,f,d);l._rotate=2==o?l.rotate(k,p,n,f):0;let m=l._size;l._size=ie(l.size(k,p,n,e)),null!=m&&l._size!=m&&(t=!1)})),t}function gn(e){let t=!0;return yl.forEach(((l,n)=>{let i=l(k,n,vl,e);i!=Ml[n]&&(t=!1),Ml[n]=i})),t}function xn(e){de.forEach(((t,l)=>{l>0&&(t._paths=null,e&&(1==M?(t.min=null,t.max=null):t.facets.forEach((e=>{e.min=null,e.max=null}))))}))}let wn,_n,bn,vn,kn,yn,Mn,Sn,En,zn,Dn,Tn,Pn=!1;function An(){Pn||(Oe(Wn),Pn=!0)}function Wn(){Jt&&(function(){let e=Ge(ve,He);for(let t in e){let l=e[t],n=Ve[t];if(null!=n&&null!=n.min)Ie(l,n),t==De&&xn(!0);else if(t!=De||2==M)if(0==Sl&&null==l.from){let e=l.range(k,null,null,t);l.min=e[0],l.max=e[1]}else l.min=he,l.max=-he}if(Sl>0){de.forEach(((t,l)=>{if(1==M){let n=t.scale,i=e[n],o=Ve[n];if(0==l){let e=i.range(k,i.min,i.max,n);i.min=e[0],i.max=e[1],El=L(i.min,g[0]),zl=L(i.max,g[0]),zl-El>1&&(i.min>g[0][El]&&El++,g[0][zl]>i.max&&zl--),t.min=en[El],t.max=en[zl]}else t.show&&t.auto&&rn(i,o,t,g[l],t.sorted);t.idxs[0]=El,t.idxs[1]=zl}else if(l>0&&t.show&&t.auto){let[n,i]=t.facets,o=n.scale,s=i.scale,[r,u]=g[l];rn(e[o],Ve[o],n,r,n.sorted),rn(e[s],Ve[s],i,u,i.sorted),t.min=i.min,t.max=i.max}}));for(let t in e){let l=e[t],n=Ve[t];if(null==l.from&&(null==n||null==n.min)){let e=l.range(k,l.min==he?null:l.min,l.max==-he?null:l.max,t);l.min=e[0],l.max=e[1]}}}for(let t in e){let l=e[t];if(null!=l.from){let n=e[l.from];if(null==n.min)l.min=l.max=null;else{let e=l.range(k,n.min,n.max,t);l.min=e[0],l.max=e[1]}}}let t={},l=!1;for(let n in e){let i=e[n],o=ve[n];if(o.min!=i.min||o.max!=i.max){o.min=i.min,o.max=i.max;let e=o.distr;o._min=3==e?ae(o.min):4==e?ce(o.min,o.asinh):o.min,o._max=3==e?ae(o.max):4==e?ce(o.max,o.asinh):o.max,t[n]=l=!0}}if(l){de.forEach(((e,l)=>{2==M?l>0&&t.y&&(e._paths=null):t[e.scale]&&(e._paths=null)}));for(let e in t)Qt=!0,xi("setScale",e);pl.show&&pl.left>=0&&(ol=ul=!0)}for(let e in Ve)Ve[e]=null}(),Jt=!1),Qt&&(function(){let e=!1,t=0;for(;!e;){t++;let l=mn(t),n=gn(t);e=t==hl||l&&n,e||(cl(k.width,k.height),$t=!0)}}(),Qt=!1),$t&&(z(Z,s,Ft),z(Z,i,Ht),z(Z,l,Yt),z(Z,n,Ct),z($,s,Ft),z($,i,Ht),z($,l,Yt),z($,n,Ct),z(X,l,At),z(X,n,Wt),V.width=ne(At*y),V.height=ne(Wt*y),ge.forEach((({_el:t,_show:l,_size:n,_pos:i,side:o})=>{if(null!=t)if(l){let l=o%2==1;z(t,l?"left":"top",i-(3===o||0===o?n:0)),z(t,l?"width":"height",n),z(t,l?"top":"left",l?Ht:Ft),z(t,l?"height":"width",l?Ct:Yt),E(t,e)}else S(t,e)})),Tl=Pl=Wl=ql=Kl=Xl=Zl=$l=Jl=null,Ql=1,li(!0),xi("setSize"),$t=!1),At>0&&Wt>0&&(J.clearRect(0,0,V.width,V.height),xi("drawClear"),Re.forEach((e=>e())),xi("draw")),Gn.show&&rl&&(Ln(Gn),rl=!1),pl.show&&ol&&(ei(null,!0,!1),ol=!1),et.show&&et.live&&ul&&($n(),ul=!1),R||(R=!0,k.status=1,xi("ready")),tn=!1,Pn=!1}function Yn(e,t){let l=ve[e];if(null==l.from){if(0==Sl){let n=l.range(k,t.min,t.max,e);t.min=n[0],t.max=n[1]}if(t.min>t.max){let e=t.min;t.min=t.max,t.max=e}if(Sl>1&&null!=t.min&&null!=t.max&&1e-16>t.max-t.min)return;e==De&&2==l.distr&&Sl>0&&(t.min=L(t.min,g[0]),t.max=L(t.max,g[0]),t.min==t.max&&t.max++),Ve[e]=t,Jt=!0,An()}}k.redraw=(e,t)=>{Qt=t||!1,!1!==e?On(De,Ne.min,Ne.max):An()},k.setScale=Yn;let Cn=!1;const Fn=pl.drag;let Hn=Fn.x,Rn=Fn.y;pl.show&&(pl.x&&(wn=T("u-cursor-x",$)),pl.y&&(_n=T("u-cursor-y",$)),0==Ne.ori?(bn=wn,vn=_n):(bn=_n,vn=wn),Dn=pl.left,Tn=pl.top);const Gn=k.select=Ie({show:!0,over:!0,left:0,width:0,top:0,height:0},u.select),In=Gn.show?T("u-select",Gn.over?$:Z):null;function Ln(e,t){if(Gn.show){for(let t in e)Gn[t]=e[t],t in oi&&z(In,t,e[t]);!1!==t&&xi("setSelect")}}function On(e,t,l){Yn(e,{min:t,max:l})}function Nn(t,l,n,i){null!=l.focus&&function(e){if(e!=Bn){let t=null==e,l=1!=xl.alpha;de.forEach(((n,i)=>{let o=t||0==i||i==e;n._focus=t?null:o,l&&function(e,t){de[e].alpha=t,pl.show&&_l[e]&&(_l[e].style.opacity=t),tt&&at[e]&&(at[e].style.opacity=t)}(i,o?1:xl.alpha)})),Bn=e,l&&An()}}(t),null!=l.show&&de.forEach(((n,i)=>{0>=i||t!=i&&null!=t||(n.show=l.show,function(t){let l=tt?at[t]:null;de[t].show?l&&E(l,e):(l&&S(l,e),_l.length>1&&A(_l[t],-10,-10,Yt,Ct))}(i),On(2==M?n.facets[1].scale:n.scale,null,null),An())})),!1!==n&&xi("setSeries",t,l),i&&ki("setSeries",k,t,l)}let jn,Un,Bn;k.setSelect=Ln,k.setSeries=Nn,k.addBand=function(e,t){e.fill=me(e.fill||null),e.dir=K(e.dir,-1),Me.splice(t=null==t?Me.length:t,0,e)},k.setBand=function(e,t){Ie(Me[e],t)},k.delBand=function(e){null==e?Me.length=0:Me.splice(e,1)};const Vn={focus:!0};function Jn(e,t,l){let n=ve[t];l&&(e=e/y-(1==n.ori?Ht:Ft));let i=Yt;1==n.ori&&(i=Ct,e=i-e),-1==n.dir&&(e=i-e);let o=n._min,s=o+e/i*(n._max-o),r=n.distr;return 3==r?re(10,s):4==r?((e,t=1)=>Q.sinh(e)*t)(s,n.asinh):s}function qn(e,t){z(In,s,Gn.left=e),z(In,l,Gn.width=t)}function Kn(e,t){z(In,i,Gn.top=e),z(In,n,Gn.height=t)}tt&&wl&&Dt(p,ot,(e=>{pl._lock||(ml(e),null!=Bn&&Nn(null,Vn,!0,_i.setSeries))})),k.valToIdx=e=>L(e,g[0]),k.posToIdx=function(e,t){return L(Jn(e,De,t),g[0],El,zl)},k.posToVal=Jn,k.valToPos=(e,t,l)=>0==ve[t].ori?W(e,ve[t],l?Bt:Yt,l?jt:0):C(e,ve[t],l?Vt:Ct,l?Ut:0),k.batch=function(e){e(k),An()},k.setCursor=(e,t,l)=>{Dn=e.left,Tn=e.top,ei(null,t,l)};let Xn=0==Ne.ori?qn:Kn,Zn=1==Ne.ori?qn:Kn;function $n(e,t){null!=e&&(e.idxs?e.idxs.forEach(((e,t)=>{Qe[t]=e})):(e=>void 0===e)(e.idx)||Qe.fill(e.idx),et.idx=Qe[0]);for(let e=0;de.length>e;e++)(e>0||1==M&&!ct)&&Qn(e,Qe[e]);tt&&et.live&&function(){if(tt&&et.live)for(let e=2==M?1:0;de.length>e;e++){if(0==e&&ct)continue;let t=et.values[e],l=0;for(let n in t)ft[e][l++].firstChild.nodeValue=t[n]}}(),ul=!1,!1!==t&&xi("setLegend")}function Qn(e,t){let l,n=de[e],i=0==e&&2==je?en:g[e];ct?l=n.values(k,e,t)??ht:(l=n.value(k,null==t?null:i[t],e,t),l=null==l?ht:{_:l}),et.values[e]=l}function ei(e,t,l){let n;En=Dn,zn=Tn,[Dn,Tn]=pl.move(k,Dn,Tn),pl.show&&(bn&&A(bn,ne(Dn),0,Yt,Ct),vn&&A(vn,0,ne(Tn),Yt,Ct)),jn=he;let i=0==Ne.ori?Yt:Ct,o=1==Ne.ori?Yt:Ct;if(0>Dn||0==Sl||El>zl){n=null;for(let e=0;de.length>e;e++)e>0&&_l.length>1&&A(_l[e],-10,-10,Yt,Ct);wl&&Nn(null,Vn,!0,null==e&&_i.setSeries),et.live&&(Qe.fill(n),ul=!0)}else{let e,t,l;1==M&&(e=0==Ne.ori?Dn:Tn,t=Jn(e,De),n=L(t,g[0],El,zl),l=Ue(g[0][n],Ne,i,0));for(let e=2==M?1:0;de.length>e;e++){let s=de[e],r=Qe[e],u=1==M?g[e][r]:g[e][1][r],a=pl.dataIdx(k,e,n,t),f=1==M?g[e][a]:g[e][1][a];ul=ul||f!=u||a!=r,Qe[e]=a;let c=ye(a==n?l:Ue(1==M?g[0][a]:g[e][0][a],Ne,i,0),1);if(e>0&&s.show){let t,l,n=null==f?-10:ye(Be(f,1==M?ve[s.scale]:ve[s.facets[1].scale],o,0),1);if(wl&&n>=0&&1==M){let t=te(n-Tn);if(jn>t){let l=xl.bias;if(0!=l){let n=Jn(1==Ne.ori?Dn:Tn,s.scale),i=0>n?-1:1;i!=(0>f?-1:1)||(1==i?1==l?n>f:f>n:1==l?f>n:n>f)||(jn=t,Un=e)}else jn=t,Un=e}}if(0==Ne.ori?(t=c,l=n):(t=n,l=c),ul&&_l.length>1){Y(_l[e],pl.points.fill(k,e),pl.points.stroke(k,e));let n,i,o,s,r=!0,u=pl.points.bbox;if(null!=u){r=!1;let t=u(k,e);o=t.left,s=t.top,n=t.width,i=t.height}else o=t,s=l,n=i=pl.points.size(k,e);F(_l[e],n,i,r),A(_l[e],o,s,Yt,Ct)}}}}if(pl.idx=n,pl.left=Dn,pl.top=Tn,ul&&(et.idx=n,$n()),Gn.show&&Cn)if(null!=e){let[t,l]=_i.scales,[n,s]=_i.match,[r,u]=e.cursor.sync.scales,a=e.cursor.drag;if(Hn=a._x,Rn=a._y,Hn||Rn){let a,f,c,h,d,{left:p,top:m,width:g,height:x}=e.select,w=e.scales[t].ori,_=e.posToVal,b=null!=t&&n(t,r),v=null!=l&&s(l,u);b&&Hn?(0==w?(a=p,f=g):(a=m,f=x),c=ve[t],h=Ue(_(a,r),c,i,0),d=Ue(_(a+f,r),c,i,0),Xn(oe(h,d),te(d-h))):Xn(0,i),v&&Rn?(1==w?(a=p,f=g):(a=m,f=x),c=ve[l],h=Be(_(a,u),c,o,0),d=Be(_(a+f,u),c,o,0),Zn(oe(h,d),te(d-h))):Zn(0,o)}else si()}else{let e=te(En-kn),t=te(zn-yn);if(1==Ne.ori){let l=e;e=t,t=l}Hn=Fn.x&&e>=Fn.dist,Rn=Fn.y&&t>=Fn.dist;let l,n,s=Fn.uni;null!=s?Hn&&Rn&&(Hn=e>=s,Rn=t>=s,Hn||Rn||(t>e?Rn=!0:Hn=!0)):Fn.x&&Fn.y&&(Hn||Rn)&&(Hn=Rn=!0),Hn&&(0==Ne.ori?(l=Mn,n=Dn):(l=Sn,n=Tn),Xn(oe(l,n),te(n-l)),Rn||Zn(0,o)),Rn&&(1==Ne.ori?(l=Mn,n=Dn):(l=Sn,n=Tn),Zn(oe(l,n),te(n-l)),Hn||Xn(0,i)),Hn||Rn||(Xn(0,0),Zn(0,0))}if(Fn._x=Hn,Fn._y=Rn,null==e){if(l){if(null!=bi){let[e,t]=_i.scales;_i.values[0]=null!=e?Jn(0==Ne.ori?Dn:Tn,e):null,_i.values[1]=null!=t?Jn(1==Ne.ori?Dn:Tn,t):null}ki(f,k,Dn,Tn,Yt,Ct,n)}if(wl){let e=l&&_i.setSeries,t=xl.prox;null==Bn?jn>t||Nn(Un,Vn,!0,e):jn>t?Nn(null,Vn,!0,e):Un!=Bn&&Nn(Un,Vn,!0,e)}}!1!==t&&xi("setCursor")}k.setLegend=$n;let ti=null;function li(e=!1){e?ti=null:(ti=$.getBoundingClientRect(),xi("syncRect",ti))}function ni(e,t,l,n,i,o){pl._lock||Cn&&null!=e&&0==e.movementX&&0==e.movementY||(ii(e,t,l,n,i,o,0,!1,null!=e),null!=e?ei(null,!0,!0):ei(t,!0,!1))}function ii(e,t,l,n,i,o,s,r,u){if(null==ti&&li(!1),ml(e),null!=e)l=e.clientX-ti.left,n=e.clientY-ti.top;else{if(0>l||0>n)return Dn=-10,void(Tn=-10);let[e,s]=_i.scales,r=t.cursor.sync,[u,a]=r.values,[f,c]=r.scales,[h,d]=_i.match,p=t.axes[0].side%2==1,m=0==Ne.ori?Yt:Ct,g=1==Ne.ori?Yt:Ct,x=p?o:i,w=p?i:o,_=p?n:l,b=p?l:n;if(l=null!=f?h(e,f)?H(u,ve[e],m,0):-10:m*(_/x),n=null!=c?d(s,c)?H(a,ve[s],g,0):-10:g*(b/w),1==Ne.ori){let e=l;l=n,n=e}}u&&(l>1&&Yt-1>l||(l=ke(l,Yt)),n>1&&Ct-1>n||(n=ke(n,Ct))),r?(kn=l,yn=n,[Mn,Sn]=pl.move(k,l,n)):(Dn=l,Tn=n)}Object.defineProperty(k,"rect",{get:()=>(null==ti&&li(!1),ti)});const oi={width:0,height:0,left:0,top:0};function si(){Ln(oi,!1)}let ri,ui,ai,fi;function ci(e,t,l,n,i,o){Cn=!0,Hn=Rn=Fn._x=Fn._y=!1,ii(e,t,l,n,i,o,0,!0,!1),null!=e&&(Dt(h,b,hi,!1),ki(c,k,Mn,Sn,Yt,Ct,null));let{left:s,top:r,width:u,height:a}=Gn;ri=s,ui=r,ai=u,fi=a,si()}function hi(e,t,l,n,i,o){Cn=Fn._x=Fn._y=!1,ii(e,t,l,n,i,o,0,!1,!0);let{left:s,top:r,width:u,height:a}=Gn,f=u>0||a>0,c=ri!=s||ui!=r||ai!=u||fi!=a;if(f&&c&&Ln(Gn),Fn.setScale&&f&&c){let e=s,t=u,l=r,n=a;if(1==Ne.ori&&(e=r,t=a,l=s,n=u),Hn&&On(De,Jn(e,De),Jn(e+t,De)),Rn)for(let e in ve){let t=ve[e];e!=De&&null==t.from&&t.min!=he&&On(e,Jn(l+n,e),Jn(l,e))}si()}else pl.lock&&(pl._lock=!pl._lock,pl._lock||ei(null,!0,!1));null!=e&&(Tt(h,b),ki(h,k,Dn,Tn,Yt,Ct,null))}function di(e){pl._lock||(ml(e),nn(),si(),null!=e&&ki(m,k,Dn,Tn,Yt,Ct,null))}function pi(){ge.forEach(Vl),fl(k.width,k.height,!0)}G(x,v,pi);const mi={};mi.mousedown=ci,mi.mousemove=ni,mi.mouseup=hi,mi.dblclick=di,mi.setSeries=(e,t,l,n)=>{-1!=(l=(0,_i.match[2])(k,t,l))&&Nn(l,n,!0,!1)},pl.show&&(Dt(c,$,ci),Dt(f,$,ni),Dt(d,$,(e=>{ml(e),li(!1)})),Dt(p,$,(function(e){if(pl._lock)return;ml(e);let t=Cn;if(Cn){let e,t,l=!0,n=!0,i=10;0==Ne.ori?(e=Hn,t=Rn):(e=Rn,t=Hn),e&&t&&(l=i>=Dn||Dn>=Yt-i,n=i>=Tn||Tn>=Ct-i),e&&l&&(Dn=Mn>Dn?0:Yt),t&&n&&(Tn=Sn>Tn?0:Ct),ei(null,!0,!0),Cn=!1}Dn=-10,Tn=-10,ei(null,!0,!0),t&&(Cn=t)})),Dt(m,$,di),Al.add(k),k.syncRect=li);const gi=k.hooks=u.hooks||{};function xi(e,t,l){e in gi&&gi[e].forEach((e=>{e.call(null,k,t,l)}))}(u.plugins||[]).forEach((e=>{for(let t in e.hooks)gi[t]=(gi[t]||[]).concat(e.hooks[t])}));const wi=(e,t,l)=>l,_i=Ie({key:null,setSeries:!1,filters:{pub:_e,sub:_e},scales:[De,de[1]?de[1].scale:null],match:[be,be,wi],values:[null,null]},pl.sync);2==_i.match.length&&_i.match.push(wi),pl.sync=_i;const bi=_i.key,vi=sl(bi);function ki(e,t,l,n,i,o,s){_i.filters.pub(e,t,l,n,i,o,s)&&vi.pub(e,t,l,n,i,o,s)}function yi(){xi("init",u,g),ln(g||u.data,!1),Ve[De]?Yn(De,Ve[De]):nn(),rl=Gn.show&&(Gn.width>0||Gn.height>0),ol=ul=!0,fl(u.width,u.height)}return vi.sub(k),k.pub=function(e,t,l,n,i,o,s){_i.filters.sub(e,t,l,n,i,o,s)&&mi[e](null,t,l,n,i,o,s)},k.destroy=function(){vi.unsub(k),Al.delete(k),zt.clear(),I(x,v,pi),O.remove(),ot?.remove(),xi("destroy")},de.forEach(bl),ge.forEach((function(e,t){if(e._show=e.show,e.show){let l=ve[e.scale];null==l&&(e.scale=e.side%2?de[1].scale:De,l=ve[e.scale]);let n=l.time;e.size=me(e.size),e.space=me(e.space),e.rotate=me(e.rotate),We(e.incrs)&&e.incrs.forEach((e=>{!Ee.has(e)&&Ee.set(e,ze(e))})),e.incrs=me(e.incrs||(2==l.distr?lt:n?1==fe?mt:wt:nt)),e.splits=me(e.splits||(n&&1==l.distr?Ke:3==l.distr?Ot:4==l.distr?Nt:Lt)),e.stroke=me(e.stroke),e.grid.stroke=me(e.grid.stroke),e.ticks.stroke=me(e.ticks.stroke),e.border.stroke=me(e.border.stroke);let i=e.values;e.values=We(i)&&!We(i[0])?me(i):n?We(i)?kt(Je,vt(i,qe)):Ce(i)?function(e,t){let l=Xe(t);return(t,n)=>n.map((t=>l(e(t))))}(Je,i):i||Ze:i||It,e.filter=me(e.filter||(3>l.distr||10!=l.log?3==l.distr&&2==l.log?Kt:xe:qt)),e.font=Bl(e.font),e.labelFont=Bl(e.labelFont),e._size=e.size(k,null,t,0),e._space=e._rotate=e._incrs=e._found=e._splits=e._values=null,e._size>0&&(vl[t]=!0,e._el=T("u-axis",X))}})),_?_ instanceof HTMLElement?(_.appendChild(O),yi()):_(k,yi):yi(),k}Jl.assign=Ie,Jl.fmtNum=$,Jl.rangeNum=q,Jl.rangeLog=N,Jl.rangeAsinh=j,Jl.orient=rl,Jl.pxRatio=y,Jl.join=function(e,t){if(function(e){let t=e[0][0],l=t.length;for(let n=1;e.length>n;n++){let i=e[n][0];if(i.length!=l)return!1;if(i!=t)for(let e=0;l>e;e++)if(i[e]!=t[e])return!1}return!0}(e)){let t=e[0].slice();for(let l=1;e.length>l;l++)t.push(...e[l].slice(1));return function(e,t=100){const l=e.length;if(1>=l)return!0;let n=0,i=l-1;for(;i>=n&&null==e[n];)n++;for(;i>=n&&null==e[i];)i--;if(n>=i)return!0;const o=se(1,le((i-n+1)/t));for(let t=e[n],l=n+o;i>=l;l+=o){const n=e[l];if(null!=n){if(t>=n)return!1;t=n}}return!0}(t[0])||(t=function(e){let t=e[0],l=t.length,n=Array(l);for(let e=0;n.length>e;e++)n[e]=e;n.sort(((e,l)=>t[e]-t[l]));let i=[];for(let t=0;e.length>t;t++){let o=e[t],s=Array(l);for(let e=0;l>e;e++)s[e]=o[n[e]];i.push(s)}return i}(t)),t}let l=new Set;for(let t=0;e.length>t;t++){let n=e[t][0],i=n.length;for(let e=0;i>e;e++)l.add(n[e])}let n=[Array.from(l).sort(((e,t)=>e-t))],i=n[0].length,o=new Map;for(let e=0;i>e;e++)o.set(n[0][e],e);for(let l=0;e.length>l;l++){let s=e[l],r=s[0];for(let e=1;s.length>e;e++){let u=s[e],a=Array(i).fill(void 0),f=t?t[l][e]:1,c=[];for(let e=0;u.length>e;e++){let t=u[e],l=o.get(r[e]);null===t?0!=f&&(a[l]=t,2==f&&c.push(l)):a[l]=t}Le(a,c,i),n.push(a)}}return n},Jl.fmtDate=Xe,Jl.tzDate=function(e,t){let l;return"UTC"==t||"Etc/UTC"==t?l=new Date(+e+6e4*e.getTimezoneOffset()):t==Ze?l=e:(l=new Date(e.toLocaleString("en-US",{timeZone:t})),l.setMilliseconds(e.getMilliseconds())),l},Jl.sync=sl;{Jl.addGap=function(e,t,l){let n=e[e.length-1];n&&n[0]==t?n[1]=l:e.push([t,l])},Jl.clipGaps=cl;let e=Jl.paths={points:Sl};e.linear=Tl,e.stepped=function(e){const t=K(e.align,1),l=K(e.ascDesc,!1),n=K(e.alignGaps,0),i=K(e.extend,!1);return(e,o,s,r)=>rl(e,o,((u,a,f,c,h,d,p,m,g,x,w)=>{let _=u.pxRound,{left:b,width:v}=e.bbox,k=e=>_(d(e,c,x,m)),M=e=>_(p(e,h,w,g)),S=0==c.ori?xl:wl;const E={stroke:new Path2D,fill:null,clip:null,band:null,gaps:null,flags:1},z=E.stroke,D=c.dir*(0==c.ori?1:-1);s=O(f,s,r,1),r=O(f,s,r,-1);let T=M(f[1==D?s:r]),P=k(a[1==D?s:r]),A=P,W=P;i&&-1==t&&(W=b,S(z,W,T)),S(z,P,T);for(let e=1==D?s:r;e>=s&&r>=e;e+=D){let l=f[e];if(null==l)continue;let n=k(a[e]),i=M(l);1==t?S(z,n,T):S(z,A,i),S(z,n,i),T=i,A=n}let Y=A;i&&1==t&&(Y=b+v,S(z,Y,T));let[C,F]=ul(e,o);if(null!=u.fill||0!=C){let t=E.fill=new Path2D(z),l=M(u.fillTo(e,o,u.min,u.max,C));S(t,Y,l),S(t,W,l)}if(!u.spanGaps){let i=[];i.push(...hl(a,f,s,r,D,k,n));let h=u.width*y/2,d=l||1==t?h:-h,p=l||-1==t?-h:h;i.forEach((e=>{e[0]+=d,e[1]+=p})),E.gaps=i=u.gaps(e,o,s,r,i),E.clip=cl(i,c.ori,m,g,x,w)}return 0!=F&&(E.band=2==F?[fl(e,o,s,r,z,-1),fl(e,o,s,r,z,1)]:fl(e,o,s,r,z,F)),E}))},e.bars=function(e){const t=K((e=e||Te).size,[.6,he,1]),l=e.align||0,n=(e.gap||0)*y;let i=e.radius;i=null==i?[0,0]:"number"==typeof i?[i,0]:i;const o=me(i),s=1-t[0],r=K(t[1],he)*y,u=K(t[2],1)*y,a=K(e.disp,Te),f=K(e.each,(()=>{})),{fill:c,stroke:h}=a;return(e,t,i,d)=>rl(e,t,((p,m,g,x,w,_,b,v,k,M,S)=>{let E,z,D=p.pxRound;0==x.ori?[E,z]=o(e,t):[z,E]=o(e,t);const T=x.dir*(0==x.ori?1:-1),P=w.dir*(1==w.ori?1:-1);let A,W,Y=0==x.ori?_l:bl,C=0==x.ori?f:(e,t,l,n,i,o,s)=>{f(e,t,l,i,n,s,o)},[F,H]=ul(e,t),R=3==w.distr?1==F?w.max:w.min:0,G=b(R,w,S,k),I=D(p.width*y),L=!1,O=null,N=null,j=null,U=null;null==c||0!=I&&null==h||(L=!0,O=c.values(e,t,i,d),N=new Map,new Set(O).forEach((e=>{null!=e&&N.set(e,new Path2D)})),I>0&&(j=h.values(e,t,i,d),U=new Map,new Set(j).forEach((e=>{null!=e&&U.set(e,new Path2D)}))));let{x0:B,size:V}=a,J=!0;if(null!=B&&null!=V){m=B.values(e,t,i,d),2==B.unit&&(m=m.map((t=>e.posToVal(v+t*M,x.key,!0))));let l=V.values(e,t,i,d);W=2==V.unit?l[0]*M:_(l[0],x,M,v)-_(0,x,M,v),W/2>I||(I=0),W=D(pe(W-I,u,r)),A=1==T?-I/2:W+I/2}else{let e=M;if(m.length>1){let t=null;for(let l=0,n=1/0;m.length>l;l++)if(void 0!==g[l]){if(null!=t){let i=te(m[l]-m[t]);n>i&&(n=i,e=te(_(m[l],x,M,v)-_(m[t],x,M,v)))}t=l}}let t=e*s;W=e-t-n,W/2>I||(I=0),5>t+n&&(D=ge),W=D(pe(e-t,u,r)-I-n),A=(0==l?W/2:l==T?0:W)-l*T*n/2,W+I>e&&(J=!1)}const q={stroke:null,fill:null,clip:null,band:null,gaps:null,flags:3};let X;0!=H&&(q.band=new Path2D,X=D(b(1==H?w.max:w.min,w,S,k)));const Z=L?null:new Path2D,$=q.band;let{y0:Q,y1:ee}=a,ne=null;null!=Q&&null!=ee&&(g=ee.values(e,t,i,d),ne=Q.values(e,t,i,d));let ie=E*W,re=z*W;for(let l=1==T?i:d;l>=i&&d>=l;l+=T){let n=g[l];if(void 0===n)continue;let i=_(2!=x.distr||null!=a?m[l]:l,x,M,v),o=b(K(n,R),w,S,k);null!=ne&&null!=n&&(G=b(ne[l],w,S,k));let s=D(i-A),r=D(se(o,G)),u=D(oe(o,G)),f=r-u;if(null!=n){let i=0>n?re:ie,o=0>n?ie:re;L?(I>0&&null!=j[l]&&Y(U.get(j[l]),s,u+le(I/2),W,se(0,f-I),i,o),null!=O[l]&&Y(N.get(O[l]),s,u+le(I/2),W,se(0,f-I),i,o)):Y(Z,s,u+le(I/2),W,se(0,f-I),i,o),C(e,t,l,s-I/2,u,W+I,f)}0==H||null==n&&!J||(P*H==1?(r=u,u=X):(u=r,r=X),f=r-u,Y($,s-I/2,u,W+I,se(0,f),0,0))}return I>0?q.stroke=L?U:Z:L||(q._fill=0==p.width?p._fill:p._stroke??p._fill,q.width=0),q.fill=L?N:Z,q}))},e.spline=function(e){return function(e,t){const l=K(t?.alignGaps,0);return(t,n,i,o)=>rl(t,n,((s,r,u,a,f,c,h,d,p,m,g)=>{let x,w,_,b=s.pxRound,v=e=>b(c(e,a,m,d)),k=e=>b(h(e,f,g,p));0==a.ori?(x=ml,_=xl,w=yl):(x=gl,_=wl,w=Ml);const y=a.dir*(0==a.ori?1:-1);i=O(u,i,o,1),o=O(u,i,o,-1);let M=v(r[1==y?i:o]),S=M,E=[],z=[];for(let e=1==y?i:o;e>=i&&o>=e;e+=y)if(null!=u[e]){let t=v(r[e]);E.push(S=t),z.push(k(u[e]))}const D={stroke:e(E,z,x,_,w,b),fill:null,clip:null,band:null,gaps:null,flags:1},T=D.stroke;let[P,A]=ul(t,n);if(null!=s.fill||0!=P){let e=D.fill=new Path2D(T),l=k(s.fillTo(t,n,s.min,s.max,P));_(e,S,l),_(e,M,l)}if(!s.spanGaps){let e=[];e.push(...hl(r,u,i,o,y,v,l)),D.gaps=e=s.gaps(t,n,i,o,e),D.clip=cl(e,a.ori,d,p,m,g)}return 0!=A&&(D.band=2==A?[fl(t,n,i,o,T,-1),fl(t,n,i,o,T,1)]:fl(t,n,i,o,T,A)),D}))}(Pl,e)}}return Jl}(); diff --git a/src/Web/wwwroot/lib/uPlot.min.css b/src/Web/wwwroot/lib/uPlot.min.css new file mode 100644 index 0000000..a030d63 --- /dev/null +++ b/src/Web/wwwroot/lib/uPlot.min.css @@ -0,0 +1 @@ +.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;} \ No newline at end of file