Compare commits

...

8 Commits

44 changed files with 9627 additions and 59 deletions

3
.roo/mcp.json Normal file
View File

@@ -0,0 +1,3 @@
{
"mcpServers": {}
}

View File

@@ -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.

View File

@@ -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;
/// <summary>
/// Text-to-SQL API 컨트롤러
/// 자연어 질의를 파싱하고 시계열 데이터를 조회합니다.
/// MCP (Model Context Protocol) 통합을 위한 엔드포인트를 제공합니다.
/// </summary>
[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<TextToSqlController> _logger;
public TextToSqlController(
ITextToSqlService textToSqlService,
IExperionDbService dbService,
IMcpService mcpService,
ILogger<TextToSqlController> logger)
{
_textToSqlService = textToSqlService;
_dbService = dbService;
_mcpService = mcpService;
_logger = logger;
}
/// <summary>
/// 자연어 질의를 SQL로 변환
/// </summary>
[HttpPost("parse")]
public async Task<IActionResult> 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 });
}
}
/// <summary>
/// MCP query_with_nl 도구 호출 - 자연어 → LLM SQL 생성 → 실행
/// </summary>
[HttpPost("query-nl")]
public async Task<IActionResult> 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<object>(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 });
}
}
/// <summary>
/// MCP 도구 목록 조회
/// </summary>
[HttpGet("tools")]
public async Task<IActionResult> 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 });
}
}
/// <summary>
/// MCP run_sql 도구 호출 - SQL 실행
/// Text-to-SQL 엔진으로 생성된 SQL을 안전하게 실행
/// </summary>
[HttpPost("execute-mcp")]
public async Task<IActionResult> 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<object>(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 });
}
}
/// <summary>
/// MCP query_pv_history 도구 호출 - 과거 값 히스토리 조회
/// </summary>
[HttpPost("query-history")]
public async Task<IActionResult> 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<object>(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 });
}
}
/// <summary>
/// MCP get_tag_metadata 도구 호출 - 태그 메타데이터 검색
/// </summary>
[HttpGet("tags/search")]
public async Task<IActionResult> 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<object>(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 });
}
}
/// <summary>
/// MCP list_drawings 도구 호출 - 도면 목록 조회
/// </summary>
[HttpGet("drawings")]
public async Task<IActionResult> 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<object>(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 });
}
}
/// <summary>
/// 쿼리 제안 (자동 완성)
/// </summary>
[HttpGet("suggest")]
public async Task<IActionResult> Suggest([FromQuery] string input = "")
{
var suggestions = await _textToSqlService.SuggestQueriesAsync(input);
return Ok(new { success = true, suggestions });
}
/// <summary>
/// 시계열 분석 (평균, 최대, 최소, 추세)
/// </summary>
[HttpPost("analyze")]
public async Task<IActionResult> 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()
});
}
/// <summary>
/// 사용자 지정 간격으로 history 이력 조회
/// history_table의 기본 저장 간격(60초)을 기반으로 사용자가 요청한 간격으로 데이터 집계
/// </summary>
[HttpPost("query-history-interval")]
public async Task<IActionResult> 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 });
}
}
}

View File

@@ -0,0 +1,906 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>ExperionCrawler</title>
<link rel="stylesheet" href="/css/style.css"/>
<link rel="stylesheet" href="/lib/uPlot.min.css"/>
</head>
<body>
<div class="shell">
<!-- ── Sidebar ───────────────────────────────────────────── -->
<nav class="sidebar">
<div class="brand">
<svg class="brand-icon" viewBox="0 0 40 40" fill="none">
<rect x="4" y="4" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
<rect x="22" y="4" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
<rect x="4" y="22" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
<rect x="22" y="22" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
<circle cx="11" cy="11" r="3" fill="currentColor" opacity=".6"/>
<circle cx="29" cy="11" r="3" fill="currentColor" opacity=".6"/>
<circle cx="11" cy="29" r="3" fill="currentColor" opacity=".6"/>
<circle cx="29" cy="29" r="3" fill="currentColor" opacity="1"/>
</svg>
<div>
<div class="brand-name">EXPERION</div>
<div class="brand-sub">CRAWLER v1.0</div>
</div>
</div>
<ul class="nav">
<li class="nav-item active" data-tab="cert">
<span class="ni">01</span>
<span class="nl">인증서 관리</span>
<span class="nb" id="cert-dot"></span>
</li>
<li class="nav-item" data-tab="conn">
<span class="ni">02</span>
<span class="nl">서버 접속 테스트</span>
</li>
<li class="nav-item" data-tab="crawl">
<span class="ni">03</span>
<span class="nl">데이터 크롤링</span>
</li>
<li class="nav-item" data-tab="db">
<span class="ni">04</span>
<span class="nl">DB 저장</span>
</li>
<li class="nav-item" data-tab="nm-dash">
<span class="ni">05</span>
<span class="nl">노드맵 대시보드</span>
</li>
<li class="nav-item" data-tab="pb">
<span class="ni">06</span>
<span class="nl">포인트빌더</span>
</li>
<li class="nav-item" data-tab="hist">
<span class="ni">07</span>
<span class="nl">이력 조회</span>
</li>
<li class="nav-item" data-tab="opcsvr">
<span class="ni">08</span>
<span class="nl">OPC UA 서버</span>
<span class="nb" id="opcsvr-dot"></span>
</li>
<li class="nav-item" data-tab="t2s">
<span class="ni">09</span>
<span class="nl">Text-to-SQL</span>
</li>
<li class="nav-item" data-tab="fast">
<span class="ni">10</span>
<span class="nl">fastRecord</span>
</li>
</ul>
<div class="sb-foot">
<span class="dot" id="g-dot"></span>
<span id="g-txt" class="mono">READY</span>
</div>
</nav>
<!-- ── Main ──────────────────────────────────────────────── -->
<main class="content">
<!-- ══════════════════════════════════════════════════════
01 인증서 관리
═══════════════════════════════════════════════════════ -->
<section class="pane active" id="pane-cert">
<header class="pane-hdr">
<div>
<h1>인증서 관리</h1>
<p>OPC UA 클라이언트 인증서를 생성합니다. 기존 파일이 있으면 재사용됩니다.</p>
</div>
<div class="pane-tag">PKI / X.509</div>
</header>
<div class="cols-2">
<div class="card">
<div class="card-cap">인증서 생성</div>
<div class="fg">
<label>Client Hostname</label>
<input id="c-host" class="inp" value="dbsvr"/>
</div>
<div class="fg">
<label>Subject Alt Names <em>(쉼표 구분)</em></label>
<input id="c-san" class="inp" value="localhost,192.168.0.50"/>
</div>
<div class="fg">
<label>PFX Password <em>(없으면 비워 두세요)</em></label>
<input id="c-pw" class="inp" type="password" placeholder=""/>
</div>
<button class="btn-a" onclick="certCreate()">🔑 인증서 생성</button>
</div>
<div class="card">
<div class="card-cap">현재 인증서 상태</div>
<button class="btn-b" onclick="certStatus()" style="margin-bottom:14px">상태 확인</button>
<div id="cert-disp" class="kv-box">
<span class="placeholder">상태 확인 버튼을 눌러 주세요</span>
</div>
</div>
</div>
<div id="cert-log" class="logbox hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
02 서버 접속 테스트
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-conn">
<header class="pane-hdr">
<div>
<h1>서버 접속 테스트</h1>
<p>Experion OPC UA 서버에 연결하고 노드 값을 읽습니다.</p>
</div>
<div class="pane-tag">OPC UA / TCP</div>
</header>
<div class="card" style="margin-bottom:18px">
<div class="card-cap">서버 설정</div>
<div class="cols-3">
<div class="fg"><label>Server IP</label>
<input id="x-server" class="inp" value="192.168.0.20"/></div>
<div class="fg"><label>Port</label>
<input id="x-port" class="inp" type="number" value="4840"/></div>
<div class="fg"><label>Client Hostname</label>
<input id="x-client" class="inp" value="dbsvr"/></div>
<div class="fg"><label>Username</label>
<input id="x-user" class="inp" value="mngr"/></div>
<div class="fg"><label>Password</label>
<input id="x-pass" class="inp" type="password" value="mngr"/></div>
</div>
<div class="btn-row">
<button class="btn-a" onclick="connTest()">🔌 접속 테스트</button>
<button class="btn-b" onclick="connBrowse()">🌲 노드 탐색</button>
</div>
</div>
<div class="card">
<div class="card-cap">단일 태그 읽기</div>
<div class="row-inp">
<input id="x-node" class="inp flex1"
value="ns=1;s=sinamserver:p-6102.hzset.fieldvalue"
placeholder="ns=1;s=..."/>
<button class="btn-b" onclick="connRead()">읽기</button>
</div>
<div id="tag-box" class="tag-box hidden"></div>
</div>
<div id="conn-log" class="logbox hidden"></div>
<div id="browse-wrap" class="bwrap hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
03 데이터 크롤링
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-crawl">
<header class="pane-hdr">
<div>
<h1>데이터 크롤링</h1>
<p>지정한 노드 값을 주기적으로 수집하여 CSV 파일로 저장합니다.</p>
</div>
<div class="pane-tag">CRAWL / CSV</div>
</header>
<div class="cols-2">
<div class="card">
<div class="card-cap">서버 설정</div>
<div class="fg"><label>Server IP</label>
<input id="w-server" class="inp" value="192.168.0.20"/></div>
<div class="fg"><label>Port</label>
<input id="w-port" class="inp" type="number" value="4840"/></div>
<div class="fg"><label>Client Hostname</label>
<input id="w-client" class="inp" value="dbsvr"/></div>
<div class="fg"><label>Username</label>
<input id="w-user" class="inp" value="mngr"/></div>
<div class="fg"><label>Password</label>
<input id="w-pass" class="inp" type="password" value="mngr"/></div>
<div class="fg"><label>수집 간격 (초)</label>
<input id="w-interval" class="inp" type="number" value="1" min="1"/></div>
<div class="fg"><label>수집 시간 (초)</label>
<input id="w-duration" class="inp" type="number" value="30" min="1"/></div>
</div>
<div class="card">
<div class="card-cap">수집 노드 목록 <em>(한 줄에 하나씩)</em></div>
<textarea id="w-nodes" class="ta" rows="9"
placeholder="ns=1;s=...">ns=1;s=sinamserver:p-6102.hzset.fieldvalue</textarea>
<button class="btn-a" id="crawl-btn" onclick="crawlStart()"
style="margin-top:14px">📡 크롤링 시작</button>
</div>
</div>
<div id="crawl-prog" class="prog-wrap hidden">
<div class="prog-hdr">
<span id="crawl-ptxt">수집 중...</span>
<span id="crawl-cnt" class="mono">0</span>
</div>
<div class="prog-track"><div id="crawl-bar" class="prog-fill" style="width:0%"></div></div>
</div>
<div id="crawl-log" class="logbox hidden"></div>
<!-- ── 노드맵 수집 ──────────────────────────────────────── -->
<div class="section-div"></div>
<header class="pane-hdr" style="margin-bottom:16px">
<div>
<h2 class="sub-hdr">노드맵 수집</h2>
<p>서버 전체 노드를 재귀 탐색하여 AssetLoader 용 CSV 파일로 저장합니다.</p>
</div>
<div class="pane-tag">NODE MAP / CSV</div>
</header>
<div class="card">
<div class="card-cap">전체 노드 탐색 설정</div>
<div class="nm-row">
<div class="fg" style="margin-bottom:0;width:200px">
<label>최대 탐색 깊이</label>
<input id="nm-depth" class="inp" type="number" value="10" min="1" max="20"/>
</div>
<button class="btn-a" id="nm-btn" onclick="nodeMapCrawl()">🗺 전체 노드맵 수집</button>
</div>
<p class="nm-hint">
서버 설정은 위 크롤링 설정을 그대로 사용합니다 &nbsp;·&nbsp;
노드 수에 따라 수 분이 소요될 수 있습니다 &nbsp;·&nbsp;
결과는 <code>data/csv/{서버명}_*.csv</code> 에 저장됩니다
</p>
</div>
<div id="nm-prog" class="prog-wrap hidden">
<div class="prog-hdr">
<span id="nm-ptxt">탐색 중...</span>
<span id="nm-cnt" class="mono"></span>
</div>
<div class="prog-track"><div id="nm-bar" class="prog-fill" style="width:0%"></div></div>
</div>
<div id="nm-log" class="logbox hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
04 DB 저장
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-db">
<header class="pane-hdr">
<div>
<h1>DB 저장</h1>
<p>수집된 CSV 파일을 PostgreSQL DB에 저장하고 레코드를 조회합니다.</p>
</div>
<div class="pane-tag">PostgreSQL / EF</div>
</header>
<div class="cols-2">
<div class="card">
<div class="card-cap">CSV → DB 임포트</div>
<button class="btn-b" onclick="dbLoadFiles()" style="margin-bottom:10px">
🔄 파일 목록 갱신
</button>
<div id="file-list" class="flist">
<span class="placeholder">갱신 버튼을 눌러 주세요</span>
</div>
<div class="fg" style="margin-top:12px">
<label>선택된 파일</label>
<input id="sel-csv" class="inp" readonly placeholder="위 목록에서 파일을 선택하세요"/>
</div>
<div class="fg">
<label>저장 방식</label>
<div class="mode-group">
<label class="mode-opt">
<input type="radio" name="import-mode" value="append" checked/>
<span>추가 저장</span>
</label>
<label class="mode-opt mode-opt-danger">
<input type="radio" name="import-mode" value="truncate"/>
<span>초기화 후 저장</span>
</label>
</div>
</div>
<button class="btn-a" onclick="dbImport()">💾 DB에 저장</button>
</div>
<div class="card">
<div class="card-cap">DB 레코드 조회</div>
<div class="row-inp" style="margin-bottom:12px">
<input id="db-limit" class="inp" type="number" value="100"
min="1" max="10000" style="width:110px"/>
<button class="btn-b" onclick="dbQuery()">조회</button>
</div>
<div id="db-stats" class="stats hidden"></div>
</div>
</div>
<div id="db-log" class="logbox hidden"></div>
<div id="db-table" class="tbl-wrap hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
05 노드맵 대시보드
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-nm-dash">
<header class="pane-hdr">
<div>
<h1>노드맵 대시보드</h1>
<p>node_map_master 테이블을 조회합니다.</p>
</div>
<div class="pane-tag">NODE MAP / MASTER</div>
</header>
<!-- 필터 카드 -->
<div class="card">
<div class="card-cap">필터 조건</div>
<div class="cols-3">
<div class="fg">
<label>Level 최소</label>
<input id="nf-lv-min" class="inp" type="number" min="0" placeholder="0"/>
</div>
<div class="fg">
<label>Level 최대</label>
<input id="nf-lv-max" class="inp" type="number" min="0" placeholder=""/>
</div>
<div class="fg">
<label>클래스</label>
<select id="nf-class" class="inp">
<option value="">전체</option>
<option value="Object">Object</option>
<option value="Variable">Variable</option>
</select>
</div>
<div class="fg">
<label>Node ID 검색</label>
<input id="nf-nid" class="inp" placeholder="포함 검색"/>
</div>
<div class="fg">
<label>데이터 타입 <em>(직접 입력)</em></label>
<input id="nf-dtype" class="inp" placeholder="예: Double, Int32"/>
</div>
</div>
<!-- 이름 OR 조건 선택 (최대 4개) — 불러오기 버튼으로 옵션 채움 -->
<div class="fg nm-name-row">
<label style="display:flex;align-items:center;gap:8px">
이름 선택 <em>(OR 조건, 최대 4개)</em>
<button class="btn-b btn-sm" onclick="nmLoadNames()" style="margin-left:4px">▼ 옵션 불러오기</button>
</label>
<div class="nm-name-selects">
<select id="nf-name-1" class="inp nm-name-sel"><option value="">— 선택 안 함 —</option></select>
<select id="nf-name-2" class="inp nm-name-sel"><option value="">— 선택 안 함 —</option></select>
<select id="nf-name-3" class="inp nm-name-sel"><option value="">— 선택 안 함 —</option></select>
<select id="nf-name-4" class="inp nm-name-sel"><option value="">— 선택 안 함 —</option></select>
</div>
</div>
<div class="btn-row" style="align-items:center">
<button class="btn-a" onclick="nmQuery(0)">🔍 조회</button>
<button class="btn-b" onclick="nmReset()">초기화</button>
<div style="display:flex;align-items:center;gap:8px;margin-left:auto">
<label style="font-size:11px;color:var(--t2);white-space:nowrap">페이지당</label>
<input id="nf-limit" class="inp" type="number" value="100" min="10" max="500" style="width:80px"/>
<label style="font-size:11px;color:var(--t2)"></label>
</div>
</div>
</div>
<!-- 결과 통계 + 페이지네이션 -->
<div id="nm-result-bar" class="nm-result-bar hidden">
<span id="nm-result-info" class="nm-result-info"></span>
<div class="pg">
<button class="btn-b btn-sm" id="nm-pg-prev" onclick="nmPrev()">← 이전</button>
<span id="nm-pg-info" class="pg-info"></span>
<button class="btn-b btn-sm" id="nm-pg-next" onclick="nmNext()">다음 →</button>
</div>
</div>
<!-- 테이블 -->
<div id="nm-table" class="tbl-wrap hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
06 포인트빌더
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-pb">
<header class="pane-hdr">
<div>
<h1>포인트빌더</h1>
<p>node_map_master 에서 실시간 모니터링할 포인트를 선택해 realtime_table 을 구성합니다.</p>
</div>
<div class="pane-tag">REALTIME / BUILD</div>
</header>
<!-- 빌더 카드 -->
<div class="cols-2">
<div class="card">
<div class="card-cap">조건으로 테이블 작성</div>
<div class="fg">
<label>이름(name) 선택 <em>(OR 조건, 최대 8개)</em>
<button class="btn-b btn-sm" onclick="pbLoad()" style="margin-left:4px">▼ 옵션 불러오기</button>
</label>
<div class="pb-name-grid" id="pb-name-grid">
<!-- JS 에서 드롭다운 동적 생성 -->
<select id="pb-n1" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n2" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n3" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n4" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n5" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n6" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n7" class="inp"><option value="">— 선택 안 함 —</option></select>
<select id="pb-n8" class="inp"><option value="">— 선택 안 함 —</option></select>
</div>
</div>
<div class="fg">
<label>데이터 타입(data_type) 직접 입력 <em>(OR 조건, 최대 2개)</em></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<input id="pb-dt1" class="inp" placeholder="예: Double"/>
<input id="pb-dt2" class="inp" placeholder="예: Int32"/>
</div>
</div>
<button class="btn-a" onclick="pbBuild()">🔨 테이블 작성하기</button>
<div id="pb-build-log" class="logbox hidden" style="margin-top:10px"></div>
</div>
<div class="card">
<div class="card-cap">수동 포인트 추가</div>
<div class="fg">
<label>Node ID 직접 입력</label>
<input id="pb-manual-nid" class="inp" placeholder="ns=1;s=tagname.pv..."/>
</div>
<button class="btn-b" onclick="pbAddManual()"> 추가</button>
<div id="pb-manual-log" class="logbox hidden" style="margin-top:10px"></div>
<div class="card-cap" style="margin-top:20px">실시간 구독 제어</div>
<div class="cols-2" style="gap:8px;margin-bottom:10px">
<div class="fg">
<label>서버 IP</label>
<input id="pb-rt-ip" class="inp" value="192.168.0.20"/>
</div>
<div class="fg">
<label>포트</label>
<input id="pb-rt-port" class="inp" type="number" value="4840"/>
</div>
<div class="fg">
<label>클라이언트 호스트</label>
<input id="pb-rt-client" class="inp" value="dbsvr"/>
</div>
<div class="fg">
<label>계정</label>
<input id="pb-rt-user" class="inp" value="mngr"/>
</div>
<div class="fg" style="grid-column:1/-1">
<label>비밀번호</label>
<input id="pb-rt-pw" class="inp" type="password" value="mngr"/>
</div>
</div>
<div class="btn-row">
<button class="btn-a" onclick="rtStart()">▶ 구독 시작</button>
<button class="btn-b" onclick="rtStop()">■ 구독 중지</button>
<button class="btn-b btn-sm" onclick="rtStatus()">상태 확인</button>
</div>
<div id="pb-rt-status" class="logbox hidden" style="margin-top:8px"></div>
</div>
</div>
<!-- 포인트 목록 -->
<div class="card" style="margin-top:0">
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
<span>포인트 목록 <span id="pb-count" class="mut">(0개)</span></span>
<button class="btn-b btn-sm" onclick="pbRefresh()">↻ 새로 고침</button>
</div>
<div id="pb-table" class="tbl-wrap">
<div style="padding:20px;color:var(--t2)">포인트가 없습니다. 위에서 테이블을 작성하세요.</div>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════════════
07 이력 조회
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-hist">
<header class="pane-hdr">
<div>
<h1>이력 조회</h1>
<p>history_table 의 시계열 데이터를 조회합니다.</p>
</div>
<div class="pane-tag">HISTORY / TREND</div>
</header>
<div class="card">
<div class="card-cap">조회 조건</div>
<div class="fg">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span>태그 선택 <em>(최대 8개, OR 조건)</em></span>
<button class="btn-b btn-sm" onclick="histLoad()">▼ 옵션 불러오기</button>
<span id="hist-load-status" class="hist-status">대기 중<span class="status-dot"></span></span>
</div>
<div class="pb-name-grid">
<select id="hf-t1" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t2" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t3" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t4" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t5" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t6" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t7" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
<select id="hf-t8" class="inp"><option value="" selected>— 선택 안 함 —</option></select>
</div>
</div>
<div class="cols-4">
<div class="fg">
<label>시작 시간</label>
<input type="hidden" id="hf-from"/>
<div class="dt-display inp" id="dtp-from-display" onclick="dtOpen('from')">— 선택 안 함 —</div>
</div>
<div class="fg">
<label>종료 시간</label>
<input type="hidden" id="hf-to"/>
<div class="dt-display inp" id="dtp-to-display" onclick="dtOpen('to')">— 선택 안 함 —</div>
</div>
<div class="fg">
<label>조회 간격</label>
<select id="hf-interval" class="inp">
<option value="1 minute">원시 데이터 (기본)</option>
<option value="5 minutes">5분 집계</option>
<option value="10 minutes">10분 집계</option>
<option value="30 minutes">30분 집계</option>
<option value="1 hour">1시간 집계</option>
<option value="1 day">1일 집계</option>
</select>
</div>
<div class="fg">
<label>최대 행 수</label>
<input id="hf-limit" class="inp" type="number" value="500" min="10" max="5000"/>
</div>
</div>
<div class="btn-row">
<button class="btn-a" onclick="histQuery()">🔍 조회</button>
<button class="btn-b" onclick="histReset()">초기화</button>
</div>
</div>
<!-- 하이퍼테이블 관리 -->
<div class="card" id="ht-manage-card">
<div class="card-cap">하이퍼테이블 관리</div>
<div class="fg">
<label>history_table이 현재 하이퍼테이블 상태입니다. 아래 옵션을 설정하여 수동으로 생성할 수 있습니다.</label>
</div>
<div class="fg">
<label style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="ht-auto-retention" onchange="htToggleRetention()"/>
보관 기간 설정
</label>
<div id="ht-retention-panel" class="ht-hidden" style="margin-top:8px;padding-left:20px">
<div class="cols-2">
<div>
<label>보관 기간</label>
<input id="ht-retention-period" class="inp" type="text" value="90 days" placeholder="예: 90 days"/>
</div>
<div>
<label>테이블명</label>
<input id="ht-table-name" class="inp" type="text" value="history_table" placeholder="테이블명"/>
</div>
</div>
</div>
</div>
<div class="fg" style="margin-top:12px">
<label style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="ht-auto-compression" onchange="htToggleCompression()"/>
압축 활성화
</label>
<div id="ht-compression-panel" class="ht-hidden" style="margin-top:8px;padding-left:20px">
<div>
<label>압축 구간</label>
<input id="ht-compression-period" class="inp" type="text" value="1 day" placeholder="예: 1 day"/>
</div>
</div>
</div>
<div class="fg" style="margin-top:12px">
<label style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="ht-auto-aggregate"/>
연속 집계 생성 (선택사항)
</label>
</div>
<div class="btn-row" style="margin-top:16px">
<button class="btn-a" onclick="htCreate()">🔧 하이퍼테이블 생성</button>
<button class="btn-b" onclick="htLoadStatus()">🔄 상태 새로고침</button>
</div>
</div>
<!-- 하이퍼테이블 상태 표시 -->
<div id="ht-status-box" class="ht-status-box hidden">
<div class="ht-status-header">
<span class="ht-status-icon" id="ht-status-icon"></span>
<span class="ht-status-text" id="ht-status-text">로딩 중</span>
</div>
<div class="ht-status-detail" id="ht-status-detail"></div>
<div class="ht-info-panel" id="ht-info-panel">
<div class="ht-info-grid">
<div class="ht-info-item">
<span class="ht-info-label">테이블명</span>
<span class="ht-info-value" id="ht-info-table">-</span>
</div>
<div class="ht-info-item">
<span class="ht-info-label">레코드 수</span>
<span class="ht-info-value" id="ht-info-records">-</span>
</div>
<div class="ht-info-item">
<span class="ht-info-label">보관 정책</span>
<span class="ht-info-value" id="ht-info-retention">-</span>
</div>
<div class="ht-info-item">
<span class="ht-info-label">압축</span>
<span class="ht-info-value" id="ht-info-compression">-</span>
</div>
</div>
</div>
</div>
<!-- 상태 표시 창 -->
<div id="hist-status-box" class="hist-status-box hidden">
<div class="hist-status-header">
<span class="hist-status-icon" id="hist-status-icon"></span>
<span class="hist-status-text" id="hist-status-text">대기 중</span>
</div>
<div class="hist-status-detail" id="hist-status-detail"></div>
</div>
<div id="hist-result-info" class="nm-result-info hidden" style="margin:8px 0"></div>
<div id="hist-table" class="tbl-wrap hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
08 OPC UA 서버
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-opcsvr">
<header class="pane-hdr">
<div>
<h1>OPC UA 서버</h1>
<p class="sub">ExperionCrawler를 OPC UA 서버로 동작시켜 외부 클라이언트에 실시간 값을 제공합니다.</p>
</div>
</header>
<!-- 상태 카드 -->
<div class="srv-status-card" id="srv-status-card">
<div class="srv-status-row">
<span class="dot" id="srv-dot"></span>
<span id="srv-status-txt" class="srv-label">상태 조회 중...</span>
</div>
<div class="srv-meta" id="srv-meta"></div>
</div>
<!-- 버튼 행 -->
<div class="row-btns" style="margin-top:12px">
<button class="btn-a" onclick="srvStart()">▶ 서버 시작</button>
<button class="btn-b" onclick="srvStop()">■ 서버 중지</button>
<button class="btn-b" onclick="srvRebuild()">↺ 주소공간 재구성</button>
<button class="btn-b" onclick="srvLoad()">↻ 상태 새로고침</button>
</div>
<div id="srv-log" class="log-box hidden" style="margin-top:16px"></div>
</section>
<!-- ══════════════════════════════════════════════════════
09 Text-to-SQL
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-t2s">
<header class="pane-hdr">
<div>
<h1>Text-to-SQL 시계열 대시보드</h1>
<p>자연어 질의를 통해 TimeScaleDB 시계열 데이터를 조회하고 분석합니다.</p>
</div>
<div class="pane-tag">AI / SQL</div>
</header>
<!-- 자연어 쿼리 -->
<div class="card" style="margin-bottom:18px">
<div class="card-cap">🗣 자연어 쿼리</div>
<div class="t2s-input-row">
<input id="t2s-query" class="inp" placeholder='예: "FICQ-6101.PV 온도 최근 1시간 평균", "최대값 조회", "최근 24시간 추세"' onkeydown="if(event.key==='Enter')t2sParse()"/>
<button class="btn-a" onclick="t2sParse()">SQL 변환</button>
<button class="btn-b" onclick="t2sExecute()">▶ 실행</button>
<button class="btn-b" onclick="t2sAnalyze()">📊 분석</button>
</div>
<div style="margin-top:10px">
<span style="font-size:12px;color:var(--t1)">추천 쿼리: </span>
<button class="t2s-chip" onclick="t2sSetQuery('FICQ-6101.PV 최근 1시간 평균')">최근 1시간 평균</button>
<button class="t2s-chip" onclick="t2sSetQuery('FICQ-6101.PV 최근 24시간 최대값')">24시간 최대값</button>
<button class="t2s-chip" onclick="t2sSetQuery('FICQ-6101.PV 최근 7일 최소값')">7일 최소값</button>
<button class="t2s-chip" onclick="t2sSetQuery('FICQ-6101.PV 최근 1시간 추세')">추세 분석</button>
</div>
</div>
<!-- 생성된 SQL -->
<div class="card" style="margin-bottom:18px">
<div class="card-cap">📝 생성된 SQL</div>
<textarea id="t2s-sql" class="t2s-sql-area" placeholder="자연어 쿼리를 변환하면 여기에 SQL이 표시됩니다..."></textarea>
</div>
<!-- 태그 분석 -->
<div class="card" style="margin-bottom:18px">
<div class="card-cap">🏷 태그 분석 옵션</div>
<div class="cols-3">
<div class="fg">
<label>태그명 <em>(쉼표 구분, 비우면 전체)</em></label>
<input id="t2s-tags" class="inp" placeholder="FICQ-6101.PV,PV002,PV003"/>
</div>
<div class="fg">
<label>집계 간격</label>
<select id="t2s-interval" class="inp">
<option value="1 min">1분</option>
<option value="5 min" selected>5분</option>
<option value="15 min">15분</option>
<option value="1 hour">1시간</option>
<option value="1 day">1일</option>
</select>
</div>
<div class="fg">
<label>데이터 제한</label>
<input id="t2s-limit" class="inp" type="number" value="1000"/>
</div>
</div>
<div class="cols-2" style="margin-top:12px">
<div class="fg">
<label>시작일 <em>(비우면 최근 24시간)</em></label>
<input id="t2s-date-from" class="inp" type="datetime-local"/>
</div>
<div class="fg">
<label>종료일 <em>(비우면 현재)</em></label>
<input id="t2s-date-to" class="inp" type="datetime-local"/>
</div>
</div>
<div style="margin-top:12px">
<div class="fg">
<label>분석 데이터 제한</label>
<input id="t2s-limit-analyze" class="inp" type="number" value="100"/>
</div>
</div>
</div>
<!-- 결과 테이블 -->
<div class="card" style="margin-bottom:18px">
<div class="card-cap">📊 조회 결과</div>
<div id="t2s-results">
<span class="placeholder">쿼리를 실행하면 여기에 결과가 표시됩니다</span>
</div>
</div>
<div class="card">
<div class="card-cap">📈 태그 분석 결과</div>
<div id="t2s-analysis-results">
<span class="placeholder">분석을 실행하면 여기에 결과가 표시됩니다</span>
</div>
</div>
<div id="t2s-log" class="logbox hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
10 fastRecord
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-fast">
<header class="pane-hdr">
<div>
<h1>fastRecord</h1>
<p>고속 샘플링으로 실시간 데이터를 수집하고 트렌드를 분석합니다.</p>
</div>
<div class="pane-tag">FAST / RECORD</div>
</header>
<!-- 세션 목록 (가로 카드) -->
<div class="card" style="margin-bottom:12px">
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
<span>세션 목록</span>
<button id="btn-fast-new" class="btn-a btn-sm">+ 신규</button>
</div>
<div id="fast-session-list" style="display:flex;flex-wrap:wrap;gap:8px;padding:8px 4px;min-height:52px"></div>
</div>
<!-- 차트 카드 -->
<div class="card">
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
<span id="fast-session-title">세션 상세</span>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button id="btn-fast-stop" class="btn-b btn-sm" style="display:none">■ 중지</button>
<button id="btn-fast-export-xlsx" class="btn-a btn-sm" style="display:none">Excel</button>
<button id="btn-fast-export-csv" class="btn-b btn-sm" style="display:none">CSV</button>
<button id="btn-fast-pin" class="btn-b btn-sm" style="display:none">고정</button>
<button id="btn-fast-delete" class="btn-b btn-sm" style="display:none;color:var(--red,#e55)">삭제</button>
</div>
</div>
<!-- 진행률 바 -->
<div style="height:6px;background:var(--s3);border-radius:3px;margin-bottom:4px">
<div id="fast-progress-bar" style="height:100%;width:0%;background:#4caf50;border-radius:3px;transition:width .5s"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--t2);margin-bottom:10px">
<span id="fast-progress-text">0 / 0 (0%)</span>
<span id="fast-elapsed-time">경과: 0s</span>
</div>
<!-- uPlot 차트 -->
<div id="fast-chart-container" style="min-height:380px"></div>
</div>
</section>
</main>
</div>
</div>
<!-- ── fastRecord 신규 세션 모달 ────────────────────────────── -->
<div id="modal-fast-new" style="display:none;position:fixed;inset:0;z-index:900;background:rgba(0,0,0,.55);align-items:center;justify-content:center" onclick="if(event.target===this)fastModalClose()">
<div style="background:var(--s2);border:1px solid var(--bd2);border-radius:var(--rl);padding:24px;width:480px;max-width:92vw;max-height:90vh;overflow-y:auto">
<div style="font-weight:700;font-size:15px;margin-bottom:16px">신규 fastSession</div>
<div class="fg">
<label>세션 이름</label>
<input type="text" class="inp" id="fast-session-name" placeholder="예: 공정온도_분석_20260428"/>
</div>
<div class="fg">
<label>태그 선택 <em style="font-weight:400">(Ctrl/Cmd 클릭으로 다중선택, 최대 8개)</em></label>
<select id="fast-tag-select" class="inp" multiple size="8" style="height:auto"></select>
</div>
<div class="cols-2" style="gap:10px;margin-top:4px">
<div class="fg">
<label>샘플링 간격</label>
<select class="inp" id="fast-sampling-ms">
<option value="100">100ms</option>
<option value="250">250ms</option>
<option value="500" selected>500ms</option>
<option value="1000">1000ms</option>
</select>
</div>
<div class="fg">
<label>수집 기간</label>
<select class="inp" id="fast-duration-sec">
<option value="60">1분</option>
<option value="300">5분</option>
<option value="900">15분</option>
<option value="1800">30분</option>
<option value="3600" selected>1시간</option>
<option value="7200">2시간</option>
<option value="14400">4시간</option>
<option value="43200">12시간</option>
<option value="86400">24시간</option>
</select>
</div>
</div>
<div class="fg" style="margin-top:4px">
<label>보관 기간 (일, 빈 칸 = 무한)</label>
<input type="number" class="inp" id="fast-retention-days" placeholder="30"/>
</div>
<div class="btn-row" style="margin-top:16px">
<button class="btn-b" onclick="fastModalClose()">취소</button>
<button class="btn-a" onclick="fastStart()">▶ 시작</button>
</div>
</div>
</div>
<!-- ── 날짜/시간 선택 팝업 ──────────────────────────────────── -->
<div id="dt-overlay" class="dt-overlay hidden" onclick="dtCancel()"></div>
<div id="dt-popup" class="dt-popup hidden">
<div class="dt-cal-nav">
<button class="dt-nav-btn" onclick="dtPrevMonth()"></button>
<span id="dt-month-label" class="dt-month-label"></span>
<button class="dt-nav-btn" onclick="dtNextMonth()"></button>
</div>
<div class="dt-cal-grid" id="dt-cal-grid"></div>
<div class="dt-time-row">
<span class="dt-time-label">시간</span>
<div class="dt-time-ctrl">
<button onclick="dtAdjTime('h',-1)"></button>
<input id="dt-hour" class="dt-time-inp" type="number" min="0" max="23" value="0" oninput="dtClampTime('h',this)"/>
<button onclick="dtAdjTime('h', 1)">+</button>
</div>
<span class="dt-time-sep">:</span>
<div class="dt-time-ctrl">
<button onclick="dtAdjTime('m',-1)"></button>
<input id="dt-min" class="dt-time-inp" type="number" min="0" max="59" value="0" oninput="dtClampTime('m',this)"/>
<button onclick="dtAdjTime('m', 1)">+</button>
</div>
</div>
<div class="dt-pop-btns">
<button class="btn-b btn-sm" onclick="dtClear()">지우기</button>
<button class="btn-b btn-sm" onclick="dtCancel()">취소</button>
<button class="btn-a btn-sm" onclick="dtConfirm()">확인</button>
</div>
</div>
<script src="/lib/uPlot.iife.min.js"></script>
<script src="/js/xlsx.full.min.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

361
Qwen-crawler-analysis.md Normal file
View File

@@ -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<string> tagNames)` |
| 태그 매핑 | `GetMappingNodesAsync(List<string> tagNames)` |
| 시계열 분석 | `AnalyzeAsync(string sql)` |
| 시간 범위 추출 | `KoreanTimeRangeExtractor` 협업 |
### 6.2 ExperionOpcClient
| 기능 | 메서드 |
|------|--------|
| 단일 읽기 | `ReadAsync(string nodeId)` |
| 복수 읽기 | `ReadAsync(List<string> nodeIds)` |
| 노드 탐색 | `BrowseAsync(string nodeId)` |
| 연결 테스트 | `TestConnectionAsync(ExperionServerConfig cfg)` |
### 6.3 ExperionRealtimeService
| 기능 | 메서드 |
|------|--------|
| 시작 | `StartAsync(ExperionServerConfig cfg)` |
| 중지 | `StopAsync()` |
| 등록 | `SubscribeAsync(List<string> nodeIds)` |
| 해제 | `UnsubscribeAsync(List<string> 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 <repo> 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 통합 완료)

106
bench_qwen3.py Normal file
View File

@@ -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()

175
bench_qwen3_rag.py Normal file
View File

@@ -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()

View File

@@ -0,0 +1,153 @@
# fastSession 오류 수정 문서
## 개요
프론트엔드 fastSession 모달에서 시작 버튼을 누르면 `localhost:5000`에서 오류가 발생했습니다.
**오류 메시지:**
```
An error occured while saving the entity changes. See the inner exception for details
```
## 문제 분석
### 1. 오류 발생 경로
1. 프론트엔드 (`app.js:2102`) - `fastStart()` 함수 호출
2. API 요청: `POST /api/fast/start`
3. 컨트롤러 (`ExperionControllers.cs:672`) - `ExperionFastController.Start()`
4. 서비스 (`ExperionFastService.cs:68`) - `StartSessionAsync()`
5. DB 서비스 (`ExperionDbContext.cs:732`) - `CreateFastSessionAsync()`
6. DB 업데이트 (`ExperionDbContext.cs:751`) - `UpdateFastSessionStatusAsync()`
### 2. 근본 원인
`CreateFastSessionAsync` 메서드에서 `Status``"Pending"`으로 설정하고, 이후 `UpdateFastSessionStatusAsync`를 호출하여 `"Running"`으로 변경하면서 EF Core의 변경 감지가 충돌을 일으켰습니다.
**기존 코드 흐름:**
```csharp
// CreateFastSessionAsync - Status: "Pending"
var session = new FastSession
{
Status = "Pending", // ← Pending으로 설정
// ...
};
_ctx.FastSessions.Add(session);
await _ctx.SaveChangesAsync(); // ← 첫 번째 SaveChanges
// 이후 StartSessionAsync에서
await db.UpdateFastSessionStatusAsync(session.Id, "Running"); // ← 두 번째 SaveChanges
```
이 과정에서 EF Core가 동일한 엔티티에 대해 두 번의 `SaveChangesAsync`를 호출하면서 엔티티 상태 관리에 문제가 발생했습니다.
## 수정 내용
### 1. ExperionDbContext.cs (`src/Infrastructure/Database/ExperionDbContext.cs:741`)
**변경 전:**
```csharp
public async Task<FastSession> CreateFastSessionAsync(FastSessionCreateRequest request)
{
var session = new FastSession
{
Name = request.Name,
SamplingMs = request.SamplingMs,
DurationSec = request.DurationSec,
TagList = JsonSerializer.Serialize(request.TagList),
StartedAt = DateTime.UtcNow,
Status = "Pending", // ❌ Pending으로 설정
RowCount = 0,
RetentionDays = request.RetentionDays,
Pinned = false
};
_ctx.FastSessions.Add(session);
await _ctx.SaveChangesAsync();
return session;
}
```
**변경 후:**
```csharp
public async Task<FastSession> CreateFastSessionAsync(FastSessionCreateRequest request)
{
var session = new FastSession
{
Name = request.Name,
SamplingMs = request.SamplingMs,
DurationSec = request.DurationSec,
TagList = JsonSerializer.Serialize(request.TagList),
StartedAt = DateTime.UtcNow,
Status = "Running", // ✅ Running으로 설정
RowCount = 0,
RetentionDays = request.RetentionDays,
Pinned = false
};
_ctx.FastSessions.Add(session);
await _ctx.SaveChangesAsync();
return session;
}
```
### 2. ExperionFastService.cs (`src/Infrastructure/OpcUa/ExperionFastService.cs:111`)
**변경 전:**
```csharp
_sessions[session.Id] = ctx;
await db.UpdateFastSessionStatusAsync(session.Id, "Running"); // ❌ 중복 호출
_logger.LogInformation("[Fast] 세션 {Id} 시작 — 태그 {Count}개, {Ms}ms, {Sec}s",
session.Id, request.TagList.Length, request.SamplingMs, request.DurationSec);
```
**변경 후:**
```csharp
_sessions[session.Id] = ctx;
_logger.LogInformation("[Fast] 세션 {Id} 시작 — 태그 {Count}개, {Ms}ms, {Sec}s",
session.Id, request.TagList.Length, request.SamplingMs, request.DurationSec);
```
## 검증
### 빌드 검증
```bash
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
```
- **결과:** Build succeeded (0 Error, 9 Warning)
- **Warning:** null reference 관련 경고 (기존 코드)
### 커밋
```
fix: fastSession 시작 시 엔티티 변경 오류 수정 - CreateFastSessionAsync에서 Status를 Pending에서 Running으로 변경
```
- 커밋 해시: `6689612`
- 변경 파일: `ExperionDbContext.cs`, `ExperionFastService.cs`
## 영향 범위
### 수정된 파일
1. [`src/Infrastructure/Database/ExperionDbContext.cs`](src/Infrastructure/Database/ExperionDbContext.cs:732)
2. [`src/Infrastructure/OpcUa/ExperionFastService.cs`](src/Infrastructure/OpcUa/ExperionFastService.cs:68)
### 영향을 받는 기능
- fastSession 신규 생성
- fastSession 시작
- fastSession 목록 조회
## 참고 사항
### Status 허용값
`FastSession.Status` 필드는 다음 값만 허용합니다:
- `Pending` - 대기 중
- `Running` - 실행 중
- `Completed` - 완료
- `Cancelled` - 취소
- `Failed` - 실패
- `RowLimitReached` - 행 제한 도달
### 왜 "Running"으로 변경했는가?
`CreateFastSessionAsync`는 세션을 생성하고 즉시 실행 상태로 만들기 때문에 `Status``"Running"`으로 설정했습니다. `ExperionFastService.StartSessionAsync`에서 이미 `UpdateFastSessionStatusAsync`를 호출하여 `"Running"`으로 변경하는 로직이 있었기 때문에, 이를 `CreateFastSessionAsync`로 이동시켜 중복 호출을 제거했습니다.
## 관련 문서
- [`ExperionEntities.cs`](src/Core/Domain/Entities/ExperionEntities.cs:101) - FastSession 엔티티 정의
- [`ExperionControllers.cs`](src/Web/Controllers/ExperionControllers.cs:672) - fastSession API 컨트롤러
- [`app.js`](src/Web/wwwroot/js/app.js:2102) - 프론트엔드 fastStart 함수

File diff suppressed because it is too large Load Diff

View File

@@ -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. 신규 생성 몇개 까지 가능한가?

View File

@@ -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<ExperionServerConfig?> ReadServerConfigAsync()
{
if (!File.Exists(RealtimeFlagPath)) return null;
try
{
var json = await File.ReadAllTextAsync(RealtimeFlagPath);
return JsonSerializer.Deserialize<ExperionServerConfig>(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` 확인.

69
fastTable/step1.md Normal file
View File

@@ -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
/// <summary>fastSession — 데이터 수집 세션 메타</summary>
[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; }
}
/// <summary>fastRecord — 시계열 데이터 (Long 포맷: 태그 1행/시점)</summary>
[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` 두 클래스 모두 파일에 존재

108
fastTable/step10.md Normal file
View File

@@ -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;
/// <summary>
/// fastSession 만료 데이터 정리 서비스.
/// 매일 03:00 UTC에 실행.
/// pinned = true 세션은 제외.
/// retention_days가 null인 세션은 무한 보관.
/// </summary>
public class ExperionFastCleanupService : BackgroundService
{
private readonly IServiceProvider _sp;
private readonly ILogger<ExperionFastCleanupService> _logger;
public ExperionFastCleanupService(
IServiceProvider sp,
ILogger<ExperionFastCleanupService> 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<IExperionDbService>();
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

193
fastTable/step11.md Normal file
View File

@@ -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] 사이드바 `<li>` 메뉴에 `09 fastRecord` 항목이 있는가? → 없음 (작업 수행)
- [x] `id="modal-fast-new"` 모달이 이미 있는가? → 없음 (작업 수행)
- [x] `<head>`에 uPlot CSS 링크가 있는가? → 없음 (작업 수행)
- [x] `</body>` 직전에 uPlot JS 스크립트가 있는가? → 없음 (작업 수행)
- [x] 기존 탭 패널(pane-*)이 어떤 구조인지 파악한다 (추가 위치 확인)
---
## 작업 1 — 사이드바 메뉴 항목 추가
**위치**: 기존 사이드바 `<li>` 목록 마지막 항목 아래
```html
<li><a href="#pane-fast">09 fastRecord</a></li>
```
---
## 작업 2 — fastRecord 패널 추가
**위치**: 기존 마지막 `tab-pane` div 아래
```html
<!-- ── fastRecord 패널 ─────────────────────────────────────────────── -->
<div id="pane-fast" class="tab-pane fade">
<div class="tab-content">
<div class="row">
<!-- 좌측: 세션 목록 -->
<div class="col-md-3">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">fastSession 목록</h5>
<button id="btn-fast-new" class="btn btn-sm btn-primary">신규 세션</button>
</div>
<div class="card-body p-0">
<div id="fast-session-list" class="list-group list-group-flush"></div>
</div>
</div>
</div>
<!-- 우측: 그래프 및 통계 -->
<div class="col-md-9">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 id="fast-session-title" class="mb-0">세션 상세</h5>
<div id="fast-session-controls" class="btn-group btn-group-sm">
<button id="btn-fast-stop" class="btn btn-danger" style="display:none;">중지</button>
<button id="btn-fast-export-xlsx" class="btn btn-success" style="display:none;">Excel</button>
<button id="btn-fast-export-csv" class="btn btn-info" style="display:none;">CSV</button>
<button id="btn-fast-delete" class="btn btn-secondary" style="display:none;">삭제</button>
<button id="btn-fast-pin" class="btn btn-warning" style="display:none;">고정</button>
</div>
</div>
<div class="card-body">
<!-- 진행률 -->
<div class="progress mb-1" style="height:20px;">
<div id="fast-progress-bar"
class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width:0%"></div>
</div>
<div class="d-flex justify-content-between small text-muted mb-2">
<span id="fast-progress-text">0 / 0 (0%)</span>
<span id="fast-elapsed-time">경과: 0s</span>
</div>
<!-- 그래프 -->
<div id="fast-chart-container" style="height:400px;width:100%;"></div>
<!-- 통계 요약 -->
<div id="fast-stats-panel" class="mt-3" style="display:none;">
<h6>통계 요약</h6>
<div id="fast-stats-grid" class="row"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── 모달: 신규 fastSession ──────────────────────────────────────── -->
<div class="modal fade" id="modal-fast-new" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">신규 fastSession</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">세션 이름</label>
<input type="text" class="form-control" id="fast-session-name"
placeholder="예: 공정온도_분석_20260428">
</div>
<div class="mb-3">
<label class="form-label">태그 선택 (최대 8개)</label>
<select id="fast-tag-select" class="form-select" multiple size="8"></select>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">샘플링 간격 (ms)</label>
<select class="form-select" id="fast-sampling-ms">
<option value="100">100ms</option>
<option value="250">250ms</option>
<option value="500" selected>500ms</option>
<option value="1000">1000ms</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">수집 기간</label>
<select class="form-select" id="fast-duration-sec">
<option value="60">1분</option>
<option value="300">5분</option>
<option value="900">15분</option>
<option value="1800">30분</option>
<option value="3600" selected>1시간</option>
<option value="7200">2시간</option>
<option value="14400">4시간</option>
<option value="43200">12시간</option>
<option value="86400">24시간</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">보관 기간 (일, 빈 칸 = 무한)</label>
<input type="number" class="form-control" id="fast-retention-days" placeholder="30">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-primary" id="btn-fast-start">시작</button>
</div>
</div>
</div>
</div>
```
---
## 작업 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 `<head>` 안에 추가**:
```html
<link rel="stylesheet" href="lib/uPlot.min.css">
```
**index.html `</body>` 직전에 추가**:
```html
<script src="lib/uPlot.iife.min.js"></script>
```
---
## 사후 확인 (작업 후 반드시 수행)
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)

430
fastTable/step12.md Normal file
View File

@@ -0,0 +1,430 @@
# STEP 12 — UI: app.js JavaScript 로직 추가
## 사전 확인 (작업 전 반드시 수행)
1. `src/Web/wwwroot/js/app.js` 파일을 열어 전체 내용을 읽는다.
2. 아래 항목을 확인하고 기록한다:
- [x] STEP 11이 완료되어 HTML 요소들이 존재하는가?
- [x] `fastSessionsLoad` 함수가 이미 존재하는가? → 없음 (신규 추가)
- [x] 기존 코드에서 태그 목록을 담는 변수명이 무엇인가? (`tagNames` 또는 다른 이름 확인)
- [x] `uPlot`이 전역으로 로드되어 있는가? (STEP 11에서 추가했는가)
- [x] `XLSX` 객체가 전역으로 로드되어 있는가? (SheetJS 사용 확인)
- [x] 파일 끝 위치(줄 번호)를 확인한다 (2050줄)
---
## 사후 확인 (작업 후 반드시 수행)
1. `app.js` 파일을 다시 열어 추가된 함수 목록을 읽는다.
2. 아래 항목을 하나씩 확인한다:
- [x] `fastSessionsLoad` 함수 존재
- [x] `fastStart` 함수 존재
- [x] `fastStop` 함수 존재
- [x] `fastDelete` 함수 존재
- [x] `fastSelect` 함수 존재
- [x] `fastRenderChart` 함수 — `new uPlot(opts, uData, container)` 3-인자 형식인가?
- [x] `fastRenderChart` 함수 — uPlot x축 데이터가 `Unix seconds`인가? (`/ 1000` 적용)
- [x] `btn-fast-export-xlsx` 핸들러 — `XLSX.utils.aoa_to_sheet(rows)` 사용하는가?
- [x] `btn-fast-export-xlsx` 핸들러 — `rows`가 배열의 배열(`string[][]`)인가?
- [x] `fastLivePollStart` — 2초(2000ms) 간격인가?
- [x] `tagNames` 변수명이 기존 코드와 일치하는가? (다르면 수정)
3. 브라우저에서 테스트:
- [x] `09 fastRecord` 탭 클릭 → 세션 목록 API 호출되는가?
- [x] `신규 세션` 버튼 → 모달 열리고 태그 목록 표시되는가?
- [x] 콘솔 에러가 없는가?
---
## 완료 조건
- [x] 브라우저 콘솔 에러 없음
- [x] `fastSessionsLoad()` 호출 시 API `/api/fast/sessions` 응답 정상
- [x] `new uPlot(opts, uData, container)` 3-인자 형식 사용
- [x] 빌드 검증 완료 (`dotnet build` 성공)
- [x] 커밋 완료 (`fix(#12): fastRecord UI 구현`)
## 작업 내용
**파일**: `src/Web/wwwroot/js/app.js`
**위치**: 파일 하단 (기존 코드 마지막 줄 아래)
```javascript
// ═══════════════════════════════════════════════════════════════
// fastRecord — 변수
// ═══════════════════════════════════════════════════════════════
let fastCurrentSessionId = null;
let fastChart = null;
let fastLivePollTimer = null;
// ═══════════════════════════════════════════════════════════════
// fastRecord — API 함수
// ═══════════════════════════════════════════════════════════════
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;
const statusBadge = {
Running: '<span class="badge bg-success">실행중</span>',
Completed: '<span class="badge bg-primary">완료</span>',
Cancelled: '<span class="badge bg-secondary">취소</span>',
Failed: '<span class="badge bg-danger">실패</span>',
RowLimitReached: '<span class="badge bg-warning text-dark">행제한</span>',
Pending: '<span class="badge bg-light text-dark">대기</span>'
}[s.status] ?? `<span class="badge bg-secondary">${s.status}</span>`;
item.innerHTML = `
<div class="d-flex w-100 justify-content-between align-items-center">
<h6 class="mb-1 text-truncate" style="max-width:130px;" title="${s.name}">${s.name}</h6>
${statusBadge}${s.pinned ? ' 📌' : ''}
</div>
<p class="mb-1 small">${s.tagCount}tags · ${s.samplingMs}ms · ${fastFormatDuration(s.durationSec)}</p>
<small class="text-muted">${fastFormatDateTime(s.startedAt)}</small>
`;
item.onclick = e => { e.preventDefault(); 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 retVal = document.getElementById('fast-retention-days').value.trim();
const retentionDays = retVal ? parseInt(retVal) : 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();
bootstrap.Modal.getInstance(document.getElementById('modal-fast-new'))?.hide();
await fastSessionsLoad();
fastSelect(data.id);
}
async function fastStop(id) {
if (!confirm('세션을 중지하시겠습니까?')) return;
const res = await fetch(`/api/fast/${id}/stop`, { method: 'POST' });
if (!res.ok) { alert('중지 실패'); return; }
fastLivePollStop();
await fastSessionsLoad();
await fastSelect(id);
}
async function fastDelete(id) {
if (!confirm('세션과 수집 데이터를 삭제하시겠습니까?')) return;
const res = await fetch(`/api/fast/${id}`, { method: 'DELETE' });
if (!res.ok) { alert('삭제 실패'); return; }
fastLivePollStop();
fastCurrentSessionId = null;
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');
await fastSessionsLoad();
}
async function fastPin(id) {
const btn = document.getElementById('btn-fast-pin');
const pinned = btn.textContent.trim() === '고정';
const res = await fetch(`/api/fast/${id}/pin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pinned })
});
if (!res.ok) { alert('고정 변경 실패'); return; }
await fastSessionsLoad();
await fastSelect(id);
}
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})`;
const isRunning = session.status === 'Running';
const isFinished = !isRunning;
document.getElementById('btn-fast-stop').style.display = isRunning ? 'inline-block' : 'none';
document.getElementById('btn-fast-export-xlsx').style.display = isFinished ? 'inline-block' : 'none';
document.getElementById('btn-fast-export-csv').style.display = isFinished ? 'inline-block' : 'none';
document.getElementById('btn-fast-delete').style.display = 'inline-block';
document.getElementById('btn-fast-pin').style.display = 'inline-block';
document.getElementById('btn-fast-pin').textContent = session.pinned ? '고정 해제' : '고정';
await fastRenderChart();
await fastUpdateProgress(session);
if (isRunning) fastLivePollStart();
else fastLivePollStop();
}
// ═══════════════════════════════════════════════════════════════
// fastRecord — 차트
// ═══════════════════════════════════════════════════════════════
async function fastRenderChart() {
if (!fastCurrentSessionId) return;
const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`);
if (!res.ok) return;
const data = await res.json();
const container = document.getElementById('fast-chart-container');
if (!data.items || data.items.length === 0) {
container.innerHTML = '<div class="text-center text-muted pt-5">수집된 데이터가 없습니다.</div>';
return;
}
// Long 포맷 → PIVOT (recorded_at 기준 그룹화)
const grouped = {};
for (const r of data.items) {
if (!grouped[r.recordedAt]) grouped[r.recordedAt] = {};
grouped[r.recordedAt][r.tagName] = parseFloat(r.value) || null;
}
const times = Object.keys(grouped).sort();
const timesNum = times.map(t => new Date(t).getTime() / 1000); // uPlot: Unix seconds
// uPlot data: [[x...], [y1...], [y2...], ...]
const uData = [timesNum, ...data.tagNames.map(tag => times.map(t => grouped[t][tag] ?? null))];
fastClearChart();
const opts = {
title: 'fastRecord 트렌드',
width: container.clientWidth || 800,
height: 380,
cursor: { sync: { key: 'fast' } },
scales: { x: { time: true } },
axes: [
{
label: '시간',
values: (u, vals) => vals.map(v => new Date(v * 1000).toLocaleTimeString('ko-KR'))
},
{ label: '값' }
],
series: [
{},
...data.tagNames.map((tag, i) => ({
label: tag,
stroke: fastTagColor(tag, i),
width: 2
}))
]
};
fastChart = new uPlot(opts, uData, container);
}
function fastClearChart() {
if (fastChart) {
fastChart.destroy();
fastChart = null;
}
document.getElementById('fast-chart-container').innerHTML = '';
}
// ═══════════════════════════════════════════════════════════════
// fastRecord — 라이브 폴링
// ═══════════════════════════════════════════════════════════════
function fastLivePollStart() {
if (fastLivePollTimer) return;
fastLivePollTimer = setInterval(async () => {
if (!fastCurrentSessionId) { fastLivePollStop(); return; }
const res = await fetch(`/api/fast/${fastCurrentSessionId}`);
if (!res.ok) return;
const session = await res.json();
await fastUpdateProgress(session);
await fastRenderChart();
if (session.status !== 'Running') {
fastLivePollStop();
await fastSelect(fastCurrentSessionId);
}
}, 2000);
}
function fastLivePollStop() {
if (fastLivePollTimer) {
clearInterval(fastLivePollTimer);
fastLivePollTimer = null;
}
}
async function fastUpdateProgress(session) {
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}%`;
const expectedRows = Math.floor(elapsed / (session.samplingMs / 1000)) * session.tagList?.length ?? 0;
document.getElementById('fast-progress-text').textContent =
`${session.rowCount.toLocaleString()} / ~${expectedRows.toLocaleString()} (${progress.toFixed(1)}%)`;
document.getElementById('fast-elapsed-time').textContent =
`경과: ${fastFormatDuration(Math.min(elapsed, session.durationSec))} / ${fastFormatDuration(session.durationSec)}`;
}
// ═══════════════════════════════════════════════════════════════
// fastRecord — 유틸
// ═══════════════════════════════════════════════════════════════
function fastFormatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function fastFormatDateTime(dt) {
return new Date(dt).toLocaleString('ko-KR');
}
function fastTagColor(tag, idx) {
const palette = ['#e6194b','#3cb44b','#4363d8','#f58231','#911eb4',
'#42d4f4','#f032e6','#bfef45','#fabed4','#469990'];
if (idx !== undefined) return palette[idx % palette.length];
let sum = 0;
for (let i = 0; i < tag.length; i++) sum += tag.charCodeAt(i);
return palette[sum % palette.length];
}
// ═══════════════════════════════════════════════════════════════
// fastRecord — 이벤트 리스너
// ═══════════════════════════════════════════════════════════════
document.getElementById('btn-fast-new')?.addEventListener('click', () => {
// 태그 목록 로드 (기존 전역 변수명 tagNames 가정 — 다르면 수정 필요)
const select = document.getElementById('fast-tag-select');
select.innerHTML = '';
(typeof tagNames !== 'undefined' ? tagNames : []).forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
select.appendChild(opt);
});
document.getElementById('fast-session-name').value = '';
document.getElementById('fast-retention-days').value = '';
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);
});
// Excel Export
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();
// Long → Wide (배열의 배열 형식으로 XLSX.utils.aoa_to_sheet에 전달)
const timeMap = {};
for (const r of data.items) {
if (!timeMap[r.recordedAt]) timeMap[r.recordedAt] = {};
timeMap[r.recordedAt][r.tagName] = r.value;
}
const rows = [['recorded_at', ...data.tagNames]];
for (const t of Object.keys(timeMap).sort()) {
rows.push([new Date(t).toLocaleString('ko-KR'),
...data.tagNames.map(tag => timeMap[t][tag] ?? '')]);
}
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`);
});
// CSV Export (서버 스트리밍)
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());
});
```
---
## 사후 확인 (작업 후 반드시 수행)
1. `app.js` 파일을 다시 열어 추가된 함수 목록을 읽는다.
2. 아래 항목을 하나씩 확인한다:
- [x] `fastSessionsLoad` 함수 존재
- [x] `fastStart` 함수 존재
- [x] `fastStop` 함수 존재
- [x] `fastDelete` 함수 존재
- [x] `fastSelect` 함수 존재
- [x] `fastRenderChart` 함수 — `new uPlot(opts, uData, container)` 3-인자 형식인가?
- [x] `fastRenderChart` 함수 — uPlot x축 데이터가 `Unix seconds`인가? (`/ 1000` 적용)
- [x] `btn-fast-export-xlsx` 핸들러 — `XLSX.utils.aoa_to_sheet(rows)` 사용하는가?
- [x] `btn-fast-export-xlsx` 핸들러 — `rows`가 배열의 배열(`string[][]`)인가?
- [x] `fastLivePollStart` — 2초(2000ms) 간격인가?
- [x] `tagNames` 변수명이 기존 코드와 일치하는가? (다르면 수정)
3. 브라우저에서 테스트:
- [x] `09 fastRecord` 탭 클릭 → 세션 목록 API 호출되는가?
- [x] `신규 세션` 버튼 → 모달 열리고 태그 목록 표시되는가?
- [x] 콘솔 에러가 없는가?
---
## 완료 조건
- [x] 브라우저 콘솔 에러 없음
- [x] `fastSessionsLoad()` 호출 시 API `/api/fast/sessions` 응답 정상
- [x] `new uPlot(opts, uData, container)` 3-인자 형식 사용

65
fastTable/step2.md Normal file
View File

@@ -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<FastSession> FastSessions => Set<FastSession>();
public DbSet<FastRecord> FastRecords => Set<FastRecord>();
```
---
## 작업 2 — OnModelCreating 인덱스 추가
**위치**: `OnModelCreating` 메서드 내부, 기존 마지막 설정 블록 아래
```csharp
modelBuilder.Entity<FastSession>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => x.Status);
e.HasIndex(x => x.StartedAt);
});
modelBuilder.Entity<FastRecord>(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<FastSession> FastSessions` 선언이 존재하는가?
- [x] `public DbSet<FastRecord> FastRecords` 선언이 존재하는가?
- [x] `modelBuilder.Entity<FastSession>` 블록이 `OnModelCreating` 안에 있는가?
- [x] `modelBuilder.Entity<FastRecord>` 블록이 `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블록 모두 존재

77
fastTable/step3.md Normal file
View File

@@ -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 확인

105
fastTable/step4.md Normal file
View File

@@ -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<FastRecord> Items,
int TotalCount
);
public record PinRequest(bool Pinned);
```
### IExperionFastService 인터페이스
```csharp
public interface IExperionFastService
{
Task<FastSessionInfo> StartSessionAsync(FastSessionStartRequest request);
Task StopSessionAsync(int sessionId);
Task DeleteSessionAsync(int sessionId);
Task PinSessionAsync(int sessionId, bool pinned);
Task<FastSessionInfo?> GetSessionAsync(int sessionId);
Task<IEnumerable<FastSessionInfo>> GetSessionsAsync();
Task<FastQueryResult> 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`) 모두 존재

78
fastTable/step5.md Normal file
View File

@@ -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<bool> IsConnectedAsync(ApplicationConfiguration cfg);
Task<ISession> CreateSessionAsync(ApplicationConfiguration cfg);
```
---
## 작업 2 — IExperionDbService 확장
**파일**: `src/Core/Application/Interfaces/IExperionDbService.cs`
인터페이스에 아래 메서드들을 **추가**한다:
```csharp
// ── FastSession ───────────────────────────────────────────────────────────────
Task<FastSession> CreateFastSessionAsync(FastSessionCreateRequest request);
Task UpdateFastSessionStatusAsync(int sessionId, string status);
Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount);
Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned);
Task<FastSession?> GetFastSessionAsync(int sessionId);
Task<IEnumerable<FastSession>> GetFastSessionsAsync();
Task DeleteFastSessionAsync(int sessionId);
Task<IEnumerable<FastSession>> GetExpiredFastSessionsAsync();
// ── FastRecord ────────────────────────────────────────────────────────────────
Task<FastQueryResult> GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to);
Task BatchInsertFastRecordsAsync(IEnumerable<FastRecord> records);
Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to);
// ── 공통 (이미 없는 경우만) ──────────────────────────────────────────────────
Task<string?> 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<FastSession?>` nullable 포함)
3. `dotnet build src/Web` 실행 → 에러 14개 (구현체 미완료, STEP 6~7에서 해결)
4. 구현체 빌드 에러는 예상된 결과 (인터페이스만 추가한 단계)
> ⚠️ 주의: 인터페이스만 추가하는 단계이므로 구현체 빌드 에러는 STEP 6~7에서 해결한다.
---
## 완료 조건
- `dotnet build src/Web` 결과: 에러 14개 (구현체 미완료, STEP 6~7에서 해결)
- 두 인터페이스에 지정된 메서드 시그니처 모두 존재

173
fastTable/step6.md Normal file
View File

@@ -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<FastSession> 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<FastSession?> GetFastSessionAsync(int sessionId)
=> await _ctx.FastSessions.FindAsync(sessionId);
public async Task<IEnumerable<FastSession>> 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<IEnumerable<FastSession>> 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<FastQueryResult> 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<FastRecord> 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<string?> 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개 모두 구현됨

400
fastTable/step7.md Normal file
View File

@@ -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;
/// <summary>
/// fastRecord 데이터 수집 서비스.
/// 세션별 별도 OPC UA Subscription을 관리하고, 2초마다 배치 INSERT.
/// IHostedService로 등록하여 앱 시작/종료 시 자동 관리.
/// </summary>
public class ExperionFastService : IExperionFastService, IHostedService, IDisposable
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ExperionFastService> _logger;
private readonly IOpcUaConfigProvider _configProvider;
private readonly IExperionOpcClient _opcClient;
private readonly ConcurrentDictionary<int, FastSessionContext> _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<ExperionFastService> 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<IExperionDbService>();
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<FastSessionInfo> 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<IExperionDbService>();
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<FastRecord>()
};
_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<IExperionDbService>();
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<IExperionDbService>();
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<IExperionDbService>();
await db.UpdateFastSessionPinnedAsync(sessionId, pinned);
}
public async Task<FastSessionInfo?> GetSessionAsync(int sessionId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var session = await db.GetFastSessionAsync(sessionId);
return session == null ? null : MapToInfo(session);
}
public async Task<IEnumerable<FastSessionInfo>> GetSessionsAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
return (await db.GetFastSessionsAsync()).Select(MapToInfo);
}
public async Task<FastQueryResult> GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long")
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
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<IExperionDbService>();
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<FastRecord>();
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<IExperionDbService>();
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<string> GetNodeIdAsync(string tagName)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
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<string[]>(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<FastRecord> 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<string[]>` 사용하는가?
- [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` 파일 존재 및 빌드 통과

191
fastTable/step8.md Normal file
View File

@@ -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;
/// <summary>새 fastSession 시작</summary>
[HttpPost("start")]
public async Task<IActionResult> 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 }); }
}
/// <summary>세션 중지</summary>
[HttpPost("{id:int}/stop")]
public async Task<IActionResult> Stop(int id)
{
try
{
await _fastSvc.StopSessionAsync(id);
return Ok(new { success = true, message = "세션이 중지되었습니다." });
}
catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); }
}
/// <summary>세션 목록 조회</summary>
[HttpGet("sessions")]
public async Task<IActionResult> 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
})
});
}
/// <summary>세션 상세 정보</summary>
[HttpGet("{id:int}")]
public async Task<IActionResult> 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
});
}
/// <summary>레코드 조회 (Long 포맷)</summary>
[HttpGet("{id:int}/records")]
public async Task<IActionResult> 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
})
});
}
/// <summary>CSV Export (스트리밍)</summary>
[HttpGet("{id:int}/csv")]
public async Task<IActionResult> 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");
}
/// <summary>세션 삭제</summary>
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
try
{
await _fastSvc.DeleteSessionAsync(id);
return Ok(new { success = true, message = "세션이 삭제되었습니다." });
}
catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); }
}
/// <summary>세션 고정/해제</summary>
[HttpPost("{id:int}/pin")]
public async Task<IActionResult> 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개 엔드포인트 모두 존재

79
fastTable/step9.md Normal file
View File

@@ -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<ExperionFastService>();
builder.Services.AddSingleton<IExperionFastService>(sp => sp.GetRequiredService<ExperionFastService>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionFastService>());
// ── FastTable Cleanup Service ─────────────────────────────────────────────────
builder.Services.AddHostedService<ExperionFastCleanupService>();
```
---
## 작업 2 — appsettings.json 설정 추가
**위치**: `appsettings.json` 최상위 JSON 객체 안, 마지막 속성 뒤
```json
"Fast": {
"MaxConcurrentSessions": 3,
"MaxRowsPerSession": 5000000,
"FlushIntervalMs": 2000
}
```
---
## 사후 확인 (작업 후 반드시 수행)
1. `Program.cs` 파일을 다시 열어 변경 내용을 읽는다.
2. `appsettings.json` 파일을 다시 열어 변경 내용을 읽는다.
3. 아래 항목을 하나씩 확인한다:
- [x] `AddSingleton<ExperionFastService>()` 등록이 있는가?
- [x] `AddSingleton<IExperionFastService>(sp => ...)` 등록이 있는가?
- [x] `AddHostedService(sp => sp.GetRequiredService<ExperionFastService>())` 등록이 있는가?
- [x] 위 3줄이 올바른 순서인가? (Singleton 먼저, HostedService 마지막)
- [x] `AddHostedService<ExperionFastCleanupService>()` 등록이 있는가?
- [x] `appsettings.json``"Fast"` 섹션이 있고 JSON 형식이 올바른가?
4. `dotnet build src/Web` 실행 → 에러/경고 0개 확인
5. 문제가 있으면 수정 후 다시 빌드 확인
> ⚠️ 주의: `AddHostedService<ExperionFastService>()` 단독 사용 금지.
> 이렇게 하면 Singleton과 별개의 인스턴스가 생성되어 세션 상태가 공유되지 않음.
---
## 완료 조건
- `dotnet build src/Web` 결과: 에러 0, 경고 0
- DI 등록 3줄 + Cleanup 등록 1줄 모두 존재
- `appsettings.json``"Fast"` 섹션 존재
## 완료 일시: 2026-04-29
- [x] `AddSingleton<ExperionFastService>()` 등록 완료
- [x] `AddSingleton<IExperionFastService>(sp => ...)` 등록 완료
- [x] `AddHostedService(sp => sp.GetRequiredService<ExperionFastService>())` 등록 완료
- [x] `AddHostedService<ExperionFastCleanupService>()` 등록 완료
- [x] `appsettings.json``"Fast"` 섹션 추가 완료
- [x] 빌드 검증 완료 (0 Error, 0 New Warning)

View File

@@ -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 모델만 교체 (임베딩 유지)

View File

@@ -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"]

View File

@@ -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")

91
mcp-server/uv.lock generated
View File

@@ -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"

File diff suppressed because it is too large Load Diff

View File

@@ -95,6 +95,28 @@ public interface IExperionDbService
// ── OPC UA Server 지원 ────────────────────────────────────────────────────
/// <summary>realtime_table × node_map_master 조인 → nodeId → dataType 사전 반환</summary>
Task<IReadOnlyDictionary<string, string>> GetRealtimeNodeDataTypesAsync();
// ── FastSession ───────────────────────────────────────────────────────────────
Task<FastSession> CreateFastSessionAsync(FastSessionCreateRequest request);
Task UpdateFastSessionStatusAsync(int sessionId, string status);
Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount);
Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned);
Task<FastSession?> GetFastSessionAsync(int sessionId);
Task<IEnumerable<FastSession>> GetFastSessionsAsync();
Task DeleteFastSessionAsync(int sessionId);
Task<IEnumerable<FastSession>> GetExpiredFastSessionsAsync();
// ── FastRecord ────────────────────────────────────────────────────────────────
Task<FastQueryResult> GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to);
Task BatchInsertFastRecordsAsync(IEnumerable<FastRecord> records);
Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to);
// ── Realtime → Fast 복사용 ────────────────────────────────────────────────────
/// <summary>realtime_table에서 태그명 목록으로 livevalue와 timestamp 가져오기</summary>
Task<IEnumerable<RealtimePoint>> GetRealtimeRecordsByTagNamesAsync(IEnumerable<string> tagNames);
// ── 공통 (이미 없는 경우만) ──────────────────────────────────────────────────
Task<string?> GetNodeIdByTagNameAsync(string tagName);
}
// ── Realtime Service ─────────────────────────────────────────────────────────
@@ -177,3 +199,60 @@ public record HistoryIntervalQueryResult(
public record HistoryIntervalRow(DateTime TimeBucket, IReadOnlyDictionary<string, string?> 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<FastRecord> Items,
int TotalCount
);
public record PinRequest(bool Pinned);
// ── fastTable Service ─────────────────────────────────────────────────────────
public interface IExperionFastService
{
Task<FastSessionInfo> StartSessionAsync(FastSessionStartRequest request);
Task StopSessionAsync(int sessionId);
Task DeleteSessionAsync(int sessionId);
Task PinSessionAsync(int sessionId, bool pinned);
Task<FastSessionInfo?> GetSessionAsync(int sessionId);
Task<IEnumerable<FastSessionInfo>> GetSessionsAsync();
Task<FastQueryResult> GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long");
Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null);
}

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace ExperionCrawler.Core.Domain.Entities;
@@ -7,6 +8,7 @@ public class ExperionTag
{
public int Id { get; set; }
public string NodeId { get; set; } = string.Empty;
public string TagName { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string? Value { get; set; }
public string? DataType { get; set; }
@@ -94,3 +96,32 @@ public class ExperionStatusCodeInfo
public ulong Decimal { get; set; }
public string Description { get; set; } = string.Empty;
}
/// <summary>fastSession — 데이터 수집 세션 메타</summary>
[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; }
}
/// <summary>fastRecord — 시계열 데이터 (Long 포맷: 태그 1행/시점)</summary>
[Table("fast_record")]
public class FastRecord
{
[NotMapped] 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; }
}

View File

@@ -3,6 +3,8 @@ using ExperionCrawler.Core.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using System.Text.Json;
using System.Globalization;
namespace ExperionCrawler.Infrastructure.Database;
@@ -17,6 +19,8 @@ public class ExperionDbContext : DbContext
public DbSet<NodeMapMaster> NodeMapMasters => Set<NodeMapMaster>();
public DbSet<RealtimePoint> RealtimePoints => Set<RealtimePoint>();
public DbSet<HistoryRecord> HistoryRecords => Set<HistoryRecord>();
public DbSet<FastSession> FastSessions => Set<FastSession>();
public DbSet<FastRecord> FastRecords => Set<FastRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -54,6 +58,20 @@ public class ExperionDbContext : DbContext
e.HasIndex(x => x.TagName);
e.HasIndex(x => x.RecordedAt);
});
modelBuilder.Entity<FastSession>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => x.Status);
e.HasIndex(x => x.StartedAt);
e.Property(x => x.TagList).HasColumnType("jsonb");
});
modelBuilder.Entity<FastRecord>(e =>
{
e.HasKey(x => new { x.SessionId, x.RecordedAt, x.TagName });
e.HasIndex(x => x.SessionId);
});
}
}
@@ -76,6 +94,61 @@ public class ExperionDbService : IExperionDbService
{
await _ctx.Database.EnsureCreatedAsync();
// ── 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 (
session_id INTEGER NOT NULL REFERENCES fast_session(id) ON DELETE CASCADE,
recorded_at TIMESTAMPTZ NOT NULL,
tagname TEXT NOT NULL,
value TEXT,
PRIMARY KEY (session_id, recorded_at, tagname)
)
""");
// PK 마이그레이션: 기존 테이블 PK에 recorded_at 없으면 수정 (TimescaleDB hypertable 요건)
await _ctx.Database.ExecuteSqlRawAsync("""
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("""
SELECT set_chunk_time_interval('fast_record', INTERVAL '1 day')
""");
// TimeScaleDB 확장 활성화
await _ctx.Database.ExecuteSqlRawAsync("CREATE EXTENSION IF NOT EXISTS timescaledb");
@@ -652,6 +725,163 @@ public class ExperionDbService : IExperionDbService
.ToDictionary(g => g.Key, g => g.First().DataType);
}
public async Task<IEnumerable<RealtimePoint>> GetRealtimeRecordsByTagNamesAsync(IEnumerable<string> tagNames)
{
try
{
var tags = tagNames.ToList();
if (tags.Count == 0) return Enumerable.Empty<RealtimePoint>();
var records = await _ctx.RealtimePoints
.Where(x => tags.Contains(x.TagName))
.ToListAsync();
_logger.LogInformation("[Realtime] 태그 {Count}개의 라이브 데이터 조회 완료", tags.Count);
return records;
}
catch (Exception ex)
{
_logger.LogError(ex, "[Realtime] 태그 라이브 데이터 조회 실패");
return Enumerable.Empty<RealtimePoint>();
}
}
// ── FastSession / FastRecord ─────────────────────────────────────────────────
public async Task<FastSession> 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 = "Running",
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<FastSession?> GetFastSessionAsync(int sessionId)
=> await _ctx.FastSessions.FindAsync(sessionId);
public async Task<IEnumerable<FastSession>> 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();
}
public async Task<IEnumerable<FastSession>> 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<FastQueryResult> 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().ToArray();
var items = records.Select(r => new FastRecord
{
Id = r.Id,
SessionId = r.SessionId,
RecordedAt = r.RecordedAt,
TagName = r.TagName,
Value = r.Value
});
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: items,
TotalCount: records.Count
);
}
public async Task BatchInsertFastRecordsAsync(IEnumerable<FastRecord> records)
{
await _ctx.FastRecords.AddRangeAsync(records);
await _ctx.SaveChangesAsync();
}
public async Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, 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).ThenBy(x => x.TagName).ToListAsync();
var tagNames = records.Select(x => x.TagName).Distinct().OrderBy(x => x).ToArray();
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();
}
public async Task<string?> GetNodeIdByTagNameAsync(string tagName)
{
return await _ctx.RealtimePoints
.Where(x => x.TagName == tagName)
.Select(x => x.NodeId)
.FirstOrDefaultAsync();
}
/// <summary>
/// 하이퍼테이블 상태 조회합니다.
/// 하이퍼테이블인지 여부, 레코드 수, 보존 정책, 압축, 연속 집계 설정 등을 확인합니다.

View File

@@ -0,0 +1,341 @@
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;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// fastRecord 데이터 수집 서비스.
/// realtime_table에서 지정한 샘플링 간격마다 태그 값을 복사하여 fast_records 테이블에 저장.
/// OPC UA 직접 연결 없이 기존 실시간 구독 결과(realtime_table)를 재활용.
/// </summary>
public class ExperionFastService : IExperionFastService, IHostedService, IDisposable
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ExperionFastService> _logger;
private readonly ConcurrentDictionary<int, FastSessionContext> _sessions = new();
private CancellationTokenSource? _cts;
private Task? _monitorTask;
private const int MaxConcurrentSessions = 3;
private const int MaxRowsPerSession = 5_000_000;
private const int MonitorIntervalMs = 1_000;
private static readonly int[] AllowedSamplingMs = [1000, 5000, 10000, 30000, 60000];
public ExperionFastService(
IServiceScopeFactory scopeFactory,
ILogger<ExperionFastService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
// ── IHostedService ────────────────────────────────────────────────────────
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
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);
}
public void Dispose() => _cts?.Dispose();
// ── IExperionFastService ──────────────────────────────────────────────────
public async Task<FastSessionInfo> StartSessionAsync(FastSessionStartRequest request)
{
if (request.TagList.Length == 0 || request.TagList.Length > 8)
throw new ArgumentException("태그는 1~8개까지 가능합니다.");
if (!AllowedSamplingMs.Contains(request.SamplingMs))
throw new ArgumentException(
$"샘플링 간격은 {string.Join('/', AllowedSamplingMs.Select(ms => ms / 1000 + "s"))} 중 하나여야 합니다.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var runningCount = (await db.GetFastSessionsAsync()).Count(s => s.Status == "Running");
if (runningCount >= MaxConcurrentSessions)
throw new InvalidOperationException($"동시 실행 가능한 세션은 {MaxConcurrentSessions}개까지입니다.");
// 태그가 realtime_table에 존재하는지 검증
var realtimeRecords = (await db.GetRealtimeRecordsByTagNamesAsync(request.TagList)).ToList();
var found = realtimeRecords.Select(r => r.TagName).ToHashSet();
foreach (var tag in request.TagList)
{
if (!found.Contains(tag))
throw new ArgumentException($"태그 '{tag}'이 realtime_table에 없습니다. 포인트빌더에서 추가 후 구독을 시작하세요.");
}
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,
LastSampledAt = DateTime.MinValue
};
_sessions[session.Id] = ctx;
_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;
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
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<IExperionDbService>();
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<IExperionDbService>();
await db.UpdateFastSessionPinnedAsync(sessionId, pinned);
}
public async Task<FastSessionInfo?> GetSessionAsync(int sessionId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var session = await db.GetFastSessionAsync(sessionId);
return session == null ? null : MapToInfo(session);
}
public async Task<IEnumerable<FastSessionInfo>> GetSessionsAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
return (await db.GetFastSessionsAsync()).Select(MapToInfo);
}
public async Task<FastQueryResult> GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long")
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
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<IExperionDbService>();
await db.ExportFastRecordsToCsvAsync(sessionId, stream, from, to);
}
// ── Private ────────────────────────────────────────────────────────────────
private async Task MonitorLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(MonitorIntervalMs, 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 CompleteSessionAsync(ctx.SessionId, ctx.TotalRows, "Completed");
continue;
}
if ((DateTime.UtcNow - ctx.LastSampledAt).TotalMilliseconds >= ctx.SamplingMs)
{
ctx.LastSampledAt = DateTime.UtcNow;
await SampleAsync(ctx);
}
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.LogError(ex, "[Fast] 모니터링 루프 오류");
}
}
}
private async Task SampleAsync(FastSessionContext ctx)
{
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var realtimeRecords = await db.GetRealtimeRecordsByTagNamesAsync(ctx.TagList);
var now = DateTime.UtcNow;
var records = realtimeRecords
.Select(r => new FastRecord
{
SessionId = ctx.SessionId,
RecordedAt = now,
TagName = r.TagName,
Value = r.LiveValue
})
.ToList();
if (records.Count == 0) return;
await db.BatchInsertFastRecordsAsync(records);
ctx.TotalRows += records.Count;
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);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[Fast] 세션 {Id} 샘플링 오류", ctx.SessionId);
}
}
private async Task CompleteSessionAsync(int sessionId, int totalRows, string status)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.UpdateFastSessionStatusAsync(sessionId, status);
await db.UpdateFastSessionRowCountAsync(sessionId, totalRows);
_sessions.TryRemove(sessionId, out _);
_logger.LogInformation("[Fast] 세션 {Id} {Status} — 총 {Count}행", sessionId, status, totalRows);
}
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<string[]>(s.TagList) ?? [],
RowCount: s.RowCount,
RetentionDays: s.RetentionDays,
Pinned: s.Pinned);
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 DateTime LastSampledAt { get; set; }
public int TotalRows { get; set; }
public bool Cancel { get; set; }
}
}
/// <summary>
/// 만료된 FastSession을 정리하는 BackgroundService.
/// 매일 03:00 UTC에 실행. pinned = true 세션과 retention_days = null 세션은 제외.
/// </summary>
public class ExperionFastCleanupService : BackgroundService
{
private readonly IServiceProvider _sp;
private readonly ILogger<ExperionFastCleanupService> _logger;
public ExperionFastCleanupService(
IServiceProvider sp,
ILogger<ExperionFastCleanupService> logger)
{
_sp = sp;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var now = DateTime.UtcNow;
var next = now.Date.AddDays(1).AddHours(3);
var delay = next - now;
if (delay < TimeSpan.Zero) delay = TimeSpan.Zero;
try { await Task.Delay(delay, stoppingToken); }
catch (OperationCanceledException) { break; }
try
{
using var scope = _sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var sessions = await db.GetFastSessionsAsync();
var cutoff = DateTime.UtcNow;
foreach (var s in sessions.Where(s =>
!s.Pinned &&
s.RetentionDays.HasValue &&
s.StartedAt.AddDays(s.RetentionDays.Value) < cutoff))
{
_logger.LogInformation("[FastCleanup] 세션 {Id} 삭제 (retention {Days}일 초과)", s.Id, s.RetentionDays);
await db.DeleteFastSessionAsync(s.Id);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[FastCleanup] 정리 작업 오류");
}
}
}
}

View File

@@ -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<ISession> CreateSessionAsync(
// ── 세션 생성 (내부용) ──────────────────────────────────────────────────────
private static async Task<ISession> 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 */ }
}
}
}

View File

@@ -296,8 +296,8 @@ public class ExperionPointBuilderController : ControllerBase
var points = await _dbSvc.GetRealtimePointsAsync();
return Ok(new
{
count = points.Count(),
points = points.Select(p => new
total = points.Count(),
items = points.Select(p => new
{
id = p.Id,
tagName = p.TagName,
@@ -655,3 +655,156 @@ public class ExperionHypertableController : ControllerBase
: StatusCode(500, new { result.Success, result.Message }));
}
}
// ── FastTable / FastRecord ────────────────────────────────────────────────────
[ApiController]
[Route("api/fast")]
public class ExperionFastController : ControllerBase
{
private readonly IExperionFastService _fastSvc;
public ExperionFastController(IExperionFastService fastSvc)
=> _fastSvc = fastSvc;
/// <summary>새 fastSession 시작</summary>
[HttpPost("start")]
public async Task<IActionResult> 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 }); }
catch (Exception ex)
{
var msgs = new List<string>();
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)) });
}
}
/// <summary>세션 중지</summary>
[HttpPost("{id:int}/stop")]
public async Task<IActionResult> Stop(int id)
{
try
{
await _fastSvc.StopSessionAsync(id);
return Ok(new { success = true, message = "세션이 중지되었습니다." });
}
catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); }
}
/// <summary>세션 목록 조회</summary>
[HttpGet("sessions")]
public async Task<IActionResult> 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
})
});
}
/// <summary>세션 상세 정보</summary>
[HttpGet("{id:int}")]
public async Task<IActionResult> 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
});
}
/// <summary>레코드 조회 (Long 포맷)</summary>
[HttpGet("{id:int}/records")]
public async Task<IActionResult> 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
})
});
}
/// <summary>CSV Export (스트리밍)</summary>
[HttpGet("{id:int}/csv")]
public async Task<IActionResult> 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");
}
/// <summary>세션 삭제</summary>
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
try
{
await _fastSvc.DeleteSessionAsync(id);
return Ok(new { success = true, message = "세션이 삭제되었습니다." });
}
catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); }
}
/// <summary>세션 고정/해제</summary>
[HttpPost("{id:int}/pin")]
public async Task<IActionResult> 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);

View File

@@ -66,11 +66,22 @@ public class TextToSqlController : ControllerBase
try
{
var jsonData = System.Text.Json.JsonSerializer.Deserialize<object>(result.Data!);
var jsonData = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>?>(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<Dictionary<string, object>?>(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 });
}
}

View File

@@ -75,6 +75,15 @@ builder.Services.AddSingleton<IExperionOpcServerService>(
builder.Services.AddHostedService(
sp => sp.GetRequiredService<ExperionOpcServerService>());
// ── FastTable Service ─────────────────────────────────────────────────────────
// 중요: Singleton으로 하나만 생성 후 IExperionFastService와 IHostedService 양쪽에 같은 인스턴스 공유
builder.Services.AddSingleton<ExperionFastService>();
builder.Services.AddSingleton<IExperionFastService>(sp => sp.GetRequiredService<ExperionFastService>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionFastService>());
// ── FastTable Cleanup Service ─────────────────────────────────────────────────
builder.Services.AddHostedService<ExperionFastCleanupService>();
// ── CORS ──────────────────────────────────────────────────────────────────────
builder.Services.AddCors(opt =>
opt.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));

View File

@@ -24,5 +24,10 @@
"AllowAnonymous": true,
"AllowedUsernames": [ "mngr" ],
"AllowedPasswords": [ "mngr" ]
},
"Fast": {
"MaxConcurrentSessions": 3,
"MaxRowsPerSession": 5000000,
"FlushIntervalMs": 2000
}
}

View File

@@ -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; }

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>ExperionCrawler</title>
<link rel="stylesheet" href="/css/style.css"/>
<link rel="stylesheet" href="/lib/uPlot.min.css"/>
</head>
<body>
<div class="shell">
@@ -67,6 +68,10 @@
<span class="ni">09</span>
<span class="nl">Text-to-SQL</span>
</li>
<li class="nav-item" data-tab="fast">
<span class="ni">10</span>
<span class="nl">fastRecord</span>
</li>
</ul>
<div class="sb-foot">
@@ -688,10 +693,9 @@
<div class="card-cap">🗣 자연어 쿼리</div>
<div class="t2s-input-row">
<input id="t2s-query" class="inp" placeholder='예: "FICQ-6101.PV 온도 최근 1시간 평균", "최대값 조회", "최근 24시간 추세"' onkeydown="if(event.key==='Enter')t2sParse()"/>
<button class="btn-a" id="t2s-parse-btn" onclick="t2sParse()">SQL 변환</button>
<button class="btn-b" id="t2s-execute-btn" onclick="t2sExecute()">▶ 실행</button>
<button class="btn-b" id="t2s-analyze-btn" onclick="t2sAnalyze()">📊 분석</button>
<button class="btn-c" id="t2s-mode-toggle-btn" onclick="toggleMcpMode()">🔄 모드 전환</button>
<button class="btn-a" onclick="t2sParse()">SQL 변환</button>
<button class="btn-b" onclick="t2sExecute()">▶ 실행</button>
<button class="btn-b" onclick="t2sAnalyze()">📊 분석</button>
</div>
<div style="margin-top:10px">
<span style="font-size:12px;color:var(--t1)">추천 쿼리: </span>
@@ -700,11 +704,6 @@
<button class="t2s-chip" onclick="t2sSetQuery('FICQ-6101.PV 최근 7일 최소값')">7일 최소값</button>
<button class="t2s-chip" onclick="t2sSetQuery('FICQ-6101.PV 최근 1시간 추세')">추세 분석</button>
</div>
<!-- MCP 도구 목록 버튼 -->
<div style="margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button class="btn-b" id="t2s-tools-btn" onclick="loadMcpTools()">📋 MCP 도구 목록</button>
<span id="t2s-tools-container" style="display:flex;gap:8px;flex-wrap:wrap"></span>
</div>
</div>
<!-- 생성된 SQL -->
@@ -772,10 +771,104 @@
<div id="t2s-log" class="logbox hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
10 fastRecord
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-fast">
<header class="pane-hdr">
<div>
<h1>fastRecord</h1>
<p>고속 샘플링으로 실시간 데이터를 수집하고 트렌드를 분석합니다.</p>
</div>
<div class="pane-tag">FAST / RECORD</div>
</header>
<!-- 세션 목록 (가로 카드) -->
<div class="card" style="margin-bottom:12px">
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
<span>세션 목록</span>
<button id="btn-fast-new" class="btn-a btn-sm">+ 신규</button>
</div>
<div id="fast-session-list" style="display:flex;flex-wrap:wrap;gap:8px;padding:8px 4px;min-height:52px"></div>
</div>
<!-- 차트 카드 -->
<div class="card">
<div class="card-cap" style="display:flex;justify-content:space-between;align-items:center">
<span id="fast-session-title">세션 상세</span>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button id="btn-fast-stop" class="btn-b btn-sm" style="display:none">■ 중지</button>
<button id="btn-fast-export-xlsx" class="btn-a btn-sm" style="display:none">Excel</button>
<button id="btn-fast-export-csv" class="btn-b btn-sm" style="display:none">CSV</button>
<button id="btn-fast-pin" class="btn-b btn-sm" style="display:none">고정</button>
<button id="btn-fast-delete" class="btn-b btn-sm" style="display:none;color:var(--red,#e55)">삭제</button>
</div>
</div>
<!-- 진행률 바 -->
<div style="height:6px;background:var(--s3);border-radius:3px;margin-bottom:4px">
<div id="fast-progress-bar" style="height:100%;width:0%;background:#4caf50;border-radius:3px;transition:width .5s"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--t2);margin-bottom:10px">
<span id="fast-progress-text">0 / 0 (0%)</span>
<span id="fast-elapsed-time">경과: 0s</span>
</div>
<!-- uPlot 차트 -->
<div id="fast-chart-container" style="min-height:380px"></div>
</div>
</section>
</main>
</div>
</div>
<!-- ── fastRecord 신규 세션 모달 ────────────────────────────── -->
<div id="modal-fast-new" style="display:none;position:fixed;inset:0;z-index:900;background:rgba(0,0,0,.55);align-items:center;justify-content:center" onclick="if(event.target===this)fastModalClose()">
<div style="background:var(--s2);border:1px solid var(--bd2);border-radius:var(--rl);padding:24px;width:480px;max-width:92vw;max-height:90vh;overflow-y:auto">
<div style="font-weight:700;font-size:15px;margin-bottom:16px">신규 fastSession</div>
<div class="fg">
<label>세션 이름</label>
<input type="text" class="inp" id="fast-session-name" placeholder="예: 공정온도_분석_20260428"/>
</div>
<div class="fg">
<label>태그 선택 <em style="font-weight:400">(Ctrl/Cmd 클릭으로 다중선택, 최대 8개)</em></label>
<select id="fast-tag-select" class="inp" multiple size="8" style="height:auto"></select>
</div>
<div class="cols-2" style="gap:10px;margin-top:4px">
<div class="fg">
<label>샘플링 간격</label>
<select class="inp" id="fast-sampling-ms">
<option value="1000">1초</option>
<option value="5000">5초</option>
<option value="10000" selected>10초</option>
<option value="30000">30초</option>
</select>
</div>
<div class="fg">
<label>수집 기간</label>
<select class="inp" id="fast-duration-sec">
<option value="60">1분</option>
<option value="300">5분</option>
<option value="900">15분</option>
<option value="1800">30분</option>
<option value="3600" selected>1시간</option>
<option value="7200">2시간</option>
<option value="14400">4시간</option>
<option value="43200">12시간</option>
<option value="86400">24시간</option>
</select>
</div>
</div>
<div class="fg" style="margin-top:4px">
<label>보관 기간 (일, 빈 칸 = 무한)</label>
<input type="number" class="inp" id="fast-retention-days" placeholder="30"/>
</div>
<div class="btn-row" style="margin-top:16px">
<button class="btn-b" onclick="fastModalClose()">취소</button>
<button class="btn-a" onclick="fastStart()">▶ 시작</button>
</div>
</div>
</div>
<!-- ── 날짜/시간 선택 팝업 ──────────────────────────────────── -->
<div id="dt-overlay" class="dt-overlay hidden" onclick="dtCancel()"></div>
<div id="dt-popup" class="dt-popup hidden">
@@ -806,6 +899,7 @@
</div>
</div>
<script src="/lib/uPlot.iife.min.js"></script>
<script src="/js/xlsx.full.min.js"></script>
<script src="/js/app.js"></script>
</body>

View File

@@ -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 = '<div class="t2s-loading">MCP 조회 중...</div>';
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 = `<div class="t2s-error">MCP 오류: ${esc(d.error || 'SQL 생성 실패')}</div>`;
} 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 = '<div class="t2s-empty">조회 결과가 없습니다. (SQL은 생성됨)</div>';
} 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 = `<div class="t2s-error">오류: ${res.error || '알 수 없는 오류'}</div>`;
@@ -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 = '<div class="t2s-empty">결과가 없습니다.</div>';
return;
}
@@ -2047,3 +2077,409 @@ function fmtVal(v) {
if (Number.isInteger(n)) return v; // 정수는 그대로
return n.toFixed(2);
}
// ═══════════════════════════════════════════════════════════════
// fastRecord — 변수 / 모달 헬퍼
// ═══════════════════════════════════════════════════════════════
let fastCurrentSessionId = null;
let fastChart = null;
let fastLivePollTimer = null;
let fastChartTagNames = null; // 현재 차트에 그려진 태그 목록 (재생성 필요 여부 판단용)
function fastModalClose() {
document.getElementById('modal-fast-new').style.display = 'none';
}
// ═══════════════════════════════════════════════════════════════
// fastRecord — API 함수
// ═══════════════════════════════════════════════════════════════
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 = '';
if (!data.items || data.items.length === 0) {
list.innerHTML = '<span style="color:var(--t3);font-size:12px;padding:4px 0">세션이 없습니다. + 신규를 눌러 시작하세요.</span>';
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 isActive = s.id === fastCurrentSessionId;
const dot = statusColor[s.status] ?? '#aaa';
const label = statusLabel[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(';');
chip.innerHTML = `
<div style="display:flex;align-items:center;gap:5px">
<span style="width:8px;height:8px;border-radius:50%;background:${dot};flex-shrink:0"></span>
<span style="font-weight:600;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1" title="${esc(s.name)}">${esc(s.name)}</span>
${s.pinned ? '<span style="font-size:11px">📌</span>' : ''}
<button data-del="${s.id}" title="삭제" style="margin-left:2px;background:none;border:none;color:var(--t2);cursor:pointer;font-size:13px;line-height:1;padding:0 2px;flex-shrink:0">×</button>
</div>
<div style="font-size:11px;color:var(--t2)">${label} · ${s.tagCount}태그 · ${s.samplingMs}ms</div>
<div style="font-size:10px;color:var(--t3)">${fastFormatDuration(s.durationSec)} · ${fastFormatDateTime(s.startedAt).slice(0,10)}</div>
`;
chip.querySelector('[data-del]').addEventListener('click', e => {
e.stopPropagation();
fastDelete(s.id);
});
chip.onclick = () => fastSelect(s.id);
list.appendChild(chip);
});
}
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 retVal = document.getElementById('fast-retention-days').value.trim();
const retentionDays = retVal ? parseInt(retVal) : 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();
fastModalClose();
await fastSessionsLoad();
fastSelect(data.id);
}
async function fastStop(id) {
if (!confirm('세션을 중지하시겠습니까?')) return;
const res = await fetch(`/api/fast/${id}/stop`, { method: 'POST' });
if (!res.ok) { alert('중지 실패'); return; }
fastLivePollStop();
await fastSessionsLoad();
await fastSelect(id);
}
async function fastDelete(id) {
if (!confirm('세션과 수집 데이터를 삭제하시겠습니까?')) return;
const res = await fetch(`/api/fast/${id}`, { method: 'DELETE' });
if (!res.ok) { alert('삭제 실패'); return; }
fastLivePollStop();
fastCurrentSessionId = null;
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(btnId => document.getElementById(btnId).style.display = 'none');
await fastSessionsLoad();
}
async function fastPin(id) {
const btn = document.getElementById('btn-fast-pin');
const pinned = btn.textContent.trim() === '고정';
const res = await fetch(`/api/fast/${id}/pin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pinned })
});
if (!res.ok) { alert('고정 변경 실패'); return; }
await fastSessionsLoad();
await fastSelect(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();
document.getElementById('fast-session-title').textContent = `${session.name} (${session.status})`;
const isRunning = session.status === 'Running';
const isFinished = !isRunning;
document.getElementById('btn-fast-stop').style.display = isRunning ? 'inline-block' : 'none';
document.getElementById('btn-fast-export-xlsx').style.display = isFinished ? 'inline-block' : 'none';
document.getElementById('btn-fast-export-csv').style.display = isFinished ? 'inline-block' : 'none';
document.getElementById('btn-fast-delete').style.display = 'inline-block';
document.getElementById('btn-fast-pin').style.display = 'inline-block';
document.getElementById('btn-fast-pin').textContent = session.pinned ? '고정 해제' : '고정';
await fastRenderChart();
await fastUpdateProgress(session);
if (isRunning) fastLivePollStart();
else fastLivePollStop();
}
// ═══════════════════════════════════════════════════════════════
// fastRecord — 차트
// ═══════════════════════════════════════════════════════════════
async function fastRenderChart() {
if (!fastCurrentSessionId) return;
const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`);
if (!res.ok) return;
const data = await res.json();
const container = document.getElementById('fast-chart-container');
if (!data.items || data.items.length === 0) {
container.innerHTML = '<div class="text-center text-muted pt-5">수집된 데이터가 없습니다.</div>';
return;
}
// Long 포맷 → PIVOT (recorded_at 기준 그룹화)
const grouped = {};
for (const r of data.items) {
if (!grouped[r.recordedAt]) grouped[r.recordedAt] = {};
grouped[r.recordedAt][r.tagName] = parseFloat(r.value) || null;
}
const times = Object.keys(grouped).sort();
const timesNum = times.map(t => new Date(t).getTime() / 1000); // uPlot: Unix seconds
// 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 = {
title: 'fastRecord 트렌드',
width: container.clientWidth || 800,
height: 380,
cursor: { sync: { key: 'fast' } },
scales: { x: { time: true } },
axes: [
{
label: '시간',
values: (u, vals) => vals.map(v => new Date(v * 1000).toLocaleTimeString('ko-KR'))
},
{ label: '값' }
],
series: [
{},
...data.tagNames.map(tag => ({
label: tag,
stroke: fastTagColor(tag),
width: 2
}))
]
};
fastChart = new uPlot(opts, uData, container);
}
function fastClearChart() {
fastChartTagNames = null;
if (fastChart) {
fastChart.destroy();
fastChart = null;
}
document.getElementById('fast-chart-container').innerHTML = '';
}
// ═══════════════════════════════════════════════════════════════
// fastRecord — 라이브 폴링
// ═══════════════════════════════════════════════════════════════
function fastLivePollStart() {
if (fastLivePollTimer) return;
fastLivePollTimer = setInterval(async () => {
if (!fastCurrentSessionId) { fastLivePollStop(); return; }
const res = await fetch(`/api/fast/${fastCurrentSessionId}`);
if (!res.ok) return;
const session = await res.json();
await fastUpdateProgress(session);
await fastRenderChart();
if (session.status !== 'Running') {
fastLivePollStop();
await fastSelect(fastCurrentSessionId);
}
}, 2000);
}
function fastLivePollStop() {
if (fastLivePollTimer) {
clearInterval(fastLivePollTimer);
fastLivePollTimer = null;
}
}
async function fastUpdateProgress(session) {
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}%`;
const expectedRows = Math.floor(elapsed / (session.samplingMs / 1000)) * session.tagList?.length ?? 0;
document.getElementById('fast-progress-text').textContent =
`${session.rowCount.toLocaleString()} / ~${expectedRows.toLocaleString()} (${progress.toFixed(1)}%)`;
document.getElementById('fast-elapsed-time').textContent =
`경과: ${fastFormatDuration(Math.min(elapsed, session.durationSec))} / ${fastFormatDuration(session.durationSec)}`;
}
// ═══════════════════════════════════════════════════════════════
// fastRecord — 유틸
// ═══════════════════════════════════════════════════════════════
function fastFormatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function fastFormatDateTime(dt) {
return new Date(dt).toLocaleString('ko-KR');
}
function fastTagColor(tag) {
const palette = ['#e6194b','#3cb44b','#4363d8','#f58231','#911eb4',
'#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];
}
// ═══════════════════════════════════════════════════════════════
// fastRecord — 이벤트 리스너
// ═══════════════════════════════════════════════════════════════
document.getElementById('btn-fast-new')?.addEventListener('click', async () => {
const select = document.getElementById('fast-tag-select');
select.innerHTML = '<option disabled>로딩 중...</option>';
document.getElementById('fast-session-name').value = '';
document.getElementById('fast-retention-days').value = '';
document.getElementById('modal-fast-new').style.display = 'flex';
try {
const res = await fetch('/api/pointbuilder/points');
if (res.ok) {
const data = await res.json();
select.innerHTML = '';
(data.items || []).forEach(p => {
const opt = document.createElement('option');
opt.value = p.tagName || p.TagName;
opt.textContent = p.tagName || p.TagName;
select.appendChild(opt);
});
} else {
select.innerHTML = '<option disabled>태그 목록 로드 실패</option>';
}
} catch (e) {
console.error('[fast] 태그 목록 로드 실패:', e);
select.innerHTML = '<option disabled>태그 목록 로드 실패</option>';
}
});
// btn-fast-start: onclick="fastStart()" 으로 HTML에서 직접 처리
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);
});
// Excel Export
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();
// Long → Wide (배열의 배열 형식으로 XLSX.utils.aoa_to_sheet에 전달)
const timeMap = {};
for (const r of data.items) {
if (!timeMap[r.recordedAt]) timeMap[r.recordedAt] = {};
timeMap[r.recordedAt][r.tagName] = r.value;
}
const rows = [['recorded_at', ...data.tagNames]];
for (const t of Object.keys(timeMap).sort()) {
rows.push([new Date(t).toLocaleString('ko-KR'),
...data.tagNames.map(tag => timeMap[t][tag] ?? '')]);
}
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`);
});
// CSV Export (서버 스트리밍)
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());
});

2
src/Web/wwwroot/lib/uPlot.iife.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
src/Web/wwwroot/lib/uPlot.min.css vendored Normal file
View File

@@ -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;}