Compare commits
3 Commits
7330711499
...
5cacc5dbb5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cacc5dbb5 | ||
|
|
de728f013a | ||
|
|
c6e284404c |
278
.opencode/skills/experion-crawler/SKILL.md
Normal file
278
.opencode/skills/experion-crawler/SKILL.md
Normal file
@@ -0,0 +1,278 @@
|
||||
---
|
||||
name: experion-crawler
|
||||
description: ExperionCrawler .NET 8 project — OPC UA data acquisition, PostgreSQL/TimescaleDB, P&ID DXF/PDF parsing, MCP server bridge, high-frequency data capture. Use when building, debugging, or modifying this project.
|
||||
license: MIT
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
domain: iiot
|
||||
framework: dotnet
|
||||
---
|
||||
|
||||
## Build / Run / Test
|
||||
|
||||
| Action | Command | Working Dir |
|
||||
|--------|---------|-------------|
|
||||
| Build | `dotnet build src/Web/ExperionCrawler.csproj` | repo root |
|
||||
| Run (dev) | `dotnet run` | `src/Web/` |
|
||||
| Tests | `dotnet test` | repo root |
|
||||
| Publish | `dotnet publish -c Release -o /opt/ExperionCrawler` | `src/Web/` |
|
||||
|
||||
Single project, linux-arm64 target. `src/Core/` and `src/Infrastructure/` are included via `<Compile Include>` globs.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── Core/ — Interfaces, Domain entities, DTOs, Application Services
|
||||
├── Infrastructure/ — OpcUa/, Database/, Certificates/, Csv/, Mcp/
|
||||
└── Web/ — Program.cs, Controllers/, wwwroot/ (SPA)
|
||||
```
|
||||
|
||||
- Controllers: `src/Web/Controllers/ExperionControllers.cs` (single file)
|
||||
- Interfaces: `src/Core/Application/Interfaces/IExperionServices.cs` (single file)
|
||||
|
||||
## Database — PostgreSQL (TimescaleDB)
|
||||
|
||||
**Docker container** `iiot-timescaledb` (image: `timescale/timescaledb-ha:pg16`):
|
||||
- Port: `localhost:5432`
|
||||
- Volume: `iiot-pgdata` → `/var/lib/postgresql/data`
|
||||
- Init scripts: `/opt/iiot-platform/timescaledb/init/`
|
||||
- Container IP: `172.17.0.3`
|
||||
- Network: `bridge` (default)
|
||||
|
||||
**Docker env credentials:**
|
||||
- DB: `iiot_platform`, User: `iiot_admin`, Pass: `ChangeMe2026`
|
||||
|
||||
**appsettings.json connection strings** (`src/Web/appsettings.json`):
|
||||
```
|
||||
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;Include Error Detail=true
|
||||
```
|
||||
|
||||
**Key tables:** `raw_node_map`, `node_map_master`, `realtime_table`, `history_table`, `fast_session`, `fast_record`, `tag_metadata`, `pid_equipment`, `pid_graph_status`
|
||||
|
||||
**Quick CLI access:** `psql -U postgres -d iiot_platform -h localhost`
|
||||
|
||||
## Critical Convention — JSON camelCase
|
||||
|
||||
`PropertyNamingPolicy = null` so C# PascalCase becomes JSON keys. Frontend expects camelCase:
|
||||
|
||||
```csharp
|
||||
// ✅ Correct
|
||||
return Ok(new { id = x.Id, tagName = x.TagName, nodeId = x.NodeId, liveValue = x.LiveValue, timestamp = x.Timestamp });
|
||||
|
||||
// ❌ Broken — shorthand = PascalCase keys
|
||||
return Ok(new { x.Id, x.TagName });
|
||||
// ❌ Broken — typed object = PascalCase keys
|
||||
return Ok(myDto);
|
||||
```
|
||||
|
||||
**Checklist for every new endpoint:**
|
||||
- [ ] All anonymous object keys are camelCase (`id`, `tagName`, `nodeId` ...)
|
||||
- [ ] No `new { x.SomeProp }` shorthand anywhere
|
||||
- [ ] No typed record/class passed directly to `Ok()`
|
||||
- [ ] C# reserved words (`class`) prefixed with `@`
|
||||
|
||||
## API Endpoints (all in `src/Web/Controllers/ExperionControllers.cs`)
|
||||
|
||||
| Controller | Route | Method | Purpose |
|
||||
|---|---|---|---|
|
||||
| Certificate | `/api/certificate/status` | GET | Certificate status |
|
||||
| | `/api/certificate/create` | POST | Create certificate |
|
||||
| Connection | `/api/connection/test` | POST | Test OPC UA connection |
|
||||
| | `/api/connection/read` | POST | Read single tag |
|
||||
| | `/api/connection/browse` | POST | Browse nodes |
|
||||
| Crawl | `/api/crawl/nodemap` | POST | Full node map crawl → CSV |
|
||||
| | `/api/crawl/start` | POST | Sync crawl with tags |
|
||||
| Database | `/api/database/files` | GET | List CSV files |
|
||||
| | `/api/database/import` | POST | Import CSV → DB |
|
||||
| | `/api/database/records` | GET | Query records |
|
||||
| PointBuilder | `/api/pointbuilder/build` | POST | Build realtime_table from node_map_master |
|
||||
| | `/api/pointbuilder/preview` | POST | Preview matching points |
|
||||
| | `/api/pointbuilder/apply` | POST | Apply selected points |
|
||||
| | `/api/pointbuilder/points` | GET | Get realtime_table points |
|
||||
| | `/api/pointbuilder/add` | POST | Add point by node_id |
|
||||
| | `/api/pointbuilder/{id}` | DELETE | Delete point |
|
||||
| Tags | `/api/tags/metadata/reload` | POST | Reload metadata from OPC UA |
|
||||
| | `/api/tags/metadata` | GET | Get tag metadata |
|
||||
| Realtime | `/api/realtime/start` | POST | Start subscription |
|
||||
| | `/api/realtime/stop` | POST | Stop subscription |
|
||||
| | `/api/realtime/status` | GET | Subscription status |
|
||||
| History | `/api/history/tagnames` | GET | Tagnames from realtime_table |
|
||||
| | `/api/history/query` | GET | Query history (tags, time range, limit) |
|
||||
| OpcServer | `/api/opcserver/status` | GET | OPC UA server status |
|
||||
| | `/api/opcserver/start` | POST | Start OPC UA server |
|
||||
| | `/api/opcserver/stop` | POST | Stop OPC UA server |
|
||||
| | `/api/opcserver/rebuild` | POST | Rebuild address space |
|
||||
| NodeMap | `/api/nodemap/names` | GET | Distinct name values |
|
||||
| | `/api/nodemap/stats` | GET | node_map_master stats |
|
||||
| | `/api/nodemap/query` | GET | Query with filters + pagination |
|
||||
| Hypertable | `/api/experion/hypertable/status` | GET | TimescaleDB hypertable status |
|
||||
| | `/api/experion/hypertable/create` | POST | Create hypertable manually |
|
||||
| Fast | `/api/fast/start` | POST | Start fast session |
|
||||
| | `/api/fast/{id}/stop` | POST | Stop session |
|
||||
| | `/api/fast/sessions` | GET | List sessions |
|
||||
| | `/api/fast/{id}` | GET | Session details |
|
||||
| | `/api/fast/{id}/records` | GET | Records (long format) |
|
||||
| | `/api/fast/{id}/csv` | GET | Export CSV streaming |
|
||||
| | `/api/fast/{id}` | DELETE | Delete session |
|
||||
| | `/api/fast/{id}/pin` | POST | Pin/unpin session |
|
||||
| P&ID | `/api/pid/extract` | POST | Extract from DXF/PDF (100MB limit) |
|
||||
| | `/api/pid/equipment` | GET | Equipment list (paginated) |
|
||||
| | `/api/pid/statistics` | GET | P&ID statistics |
|
||||
| | `/api/pid/{id}/confidence` | PUT | Update confidence (0-1) |
|
||||
| | `/api/pid/{id}/activate` | POST | Activate equipment |
|
||||
| | `/api/pid/{id}/deactivate` | POST | Deactivate equipment |
|
||||
| | `/api/pid/mappings` | GET | Tag mappings (paginated) |
|
||||
| | `/api/pid/mappings` | POST | Create mapping |
|
||||
| | `/api/pid/mappings/{id}` | PUT | Update mapping |
|
||||
| | `/api/pid/mappings/{id}` | DELETE | Clear mapping |
|
||||
| | `/api/pid/mappings/available-tags` | GET | Available tags for mapping |
|
||||
| | `/api/pid/export/csv` | GET | Export CSV |
|
||||
| | `/api/pid/export/excel` | GET | Export Excel (.xlsx) |
|
||||
|
||||
P&ID controllers are conditional — enabled by `PidControllers:Enabled` in config (default: true).
|
||||
|
||||
## Service Interfaces & Implementations
|
||||
|
||||
All interfaces in `src/Core/Application/Interfaces/IExperionServices.cs`.
|
||||
|
||||
| Interface | Impl | Lifetime |
|
||||
|---|---|---|
|
||||
| `IExperionCertificateService` | `ExperionCertificateService` | Singleton |
|
||||
| `IExperionStatusCodeService` | `ExperionStatusCodeService` | Singleton |
|
||||
| `IOpcUaConfigProvider` | `OpcUaConfigProvider` | Singleton |
|
||||
| `IExperionOpcClient` | `ExperionOpcClient` | Scoped |
|
||||
| `IExperionCsvService` | `ExperionCsvService` | Scoped |
|
||||
| `IExperionDbService` | `ExperionDbService` | Scoped |
|
||||
| `IExperionRealtimeService` | `ExperionRealtimeService` | Singleton + HostedService |
|
||||
| `IExperionOpcServerService` | `ExperionOpcServerService` | Singleton + HostedService |
|
||||
| `IExperionFastService` | `ExperionFastService` | Singleton + HostedService |
|
||||
| `IMetadataLoaderService` | `MetadataLoaderService` | Singleton |
|
||||
| `ITextToSqlService` | — | Scoped |
|
||||
| `IMcpService` | `McpService` | Singleton |
|
||||
| `IPidExtractorService` | — | — |
|
||||
| `ITagMappingService` | — | — |
|
||||
|
||||
Singleton + HostedService pattern: same instance shared via `sp.GetRequiredService<T>()`.
|
||||
|
||||
## Background Services
|
||||
|
||||
All Singleton + HostedService, registered in `Program.cs`:
|
||||
- `ExperionRealtimeService` — OPC UA subscription, 500ms batch flush
|
||||
- `ExperionHistoryService` — snapshot 60s realtime_table → history_table
|
||||
- `ExperionOpcServerService` — OPC UA server (port 4841)
|
||||
- `McpServerHostedService` — Python MCP server process lifecycle (`uv run server.py --http`)
|
||||
- `ExperionFastService` — high-frequency data capture
|
||||
- `ExperionFastCleanupService` — expired session cleanup
|
||||
|
||||
Autostart flag files: `realtime_autostart.json`, `opcserver_autostart.json`
|
||||
|
||||
## Frontend
|
||||
|
||||
Vanilla JS SPA in `wwwroot/`. No build step. Tab navigation, no auto-fire on entry.
|
||||
|
||||
**API helper:** `async function api(method, path, body)` → wraps fetch, JSON in/out, returns parsed JSON
|
||||
**All responses use camelCase:** `d.success`, `d.records`, `d.total`, `d.tagNames`, `d.nodeId`, `d.running`, `d.subscribedCount`, `d.sessionId`
|
||||
**`wwwroot/js/app.js`** (3075 lines) — main app logic
|
||||
**`wwwroot/js/pid-viewer.js`** (416 lines) — canvas-based P&ID graph viewer
|
||||
|
||||
## appsettings.json — Key Config
|
||||
|
||||
| Key | Value | Notes |
|
||||
|---|---|---|
|
||||
| `ConnectionStrings:DefaultConnection` | PostgreSQL `iiot_platform` | Main app DB |
|
||||
| `ConnectionStrings:ExperionDbConnection` | PostgreSQL `postgres` | Utility connection |
|
||||
| `OpcUaServer:Port` | 4841 | OPC UA server port |
|
||||
| `OpcUaServer:EnableSecurity` | false | No security for now |
|
||||
| `PidControllers:Enabled` | true | P&ID feature flag |
|
||||
| `Fast:MaxConcurrentSessions` | 3 | |
|
||||
| `Fast:MaxRowsPerSession` | 5000000 | |
|
||||
| `Fast:FlushIntervalMs` | 2000 | |
|
||||
| `McpServer:WorkingDirectory` | `../../mcp-server` | |
|
||||
| `Kestrel:Endpoints:Http:Url` | `http://0.0.0.0:5000` | App port |
|
||||
|
||||
## OPC UA Gotchas
|
||||
|
||||
- SDK v1.5.378.134 — use `DefaultSessionFactory.CreateAsync()` (not obsolete `Session.Create()`)
|
||||
- `Subscription.Create()`/`Delete()`/`ApplyChanges()` → async variants
|
||||
- Certificate validation AFTER `OpcUaConfigProvider.GetConfigAsync()`
|
||||
- Wrap `SelectEndpointAsync` with 10s CancellationTokenSource (OS default 127s)
|
||||
- Tag address format: `ns=3;s=ficq-6113.pv`
|
||||
|
||||
## MCP Server (Python)
|
||||
|
||||
**Location:** `mcp-server/` — Python FastMCP server on port `5001`
|
||||
|
||||
**Startup:** `McpServerHostedService` launches `uv run server.py --http` in `McpServer:WorkingDirectory` (default `../../mcp-server`). Pings `localhost:5001` up to 30s to confirm ready.
|
||||
|
||||
**C# → Python bridge:**
|
||||
- `IMcpService` → `McpService` → `McpClient` (Singleton)
|
||||
- `McpClient` uses JSON-RPC over HTTP to `http://localhost:5001/mcp`
|
||||
- All DTOs use `[JsonPropertyName]` for snake_case JSON keys
|
||||
- `McpQueryResult { Success, Error, Data }`
|
||||
|
||||
**Python MCP server dependencies:** `mcp[cli]`, `fastapi`, `qdrant-client`, `sentence-transformers`, `openai`, `httpx`, `psycopg`, `ezdxf`, `paddleocr`, `pymupdf`
|
||||
|
||||
**Python infra stack:**
|
||||
- **LLM:** vLLM serving Qwen3.6-27B-FP8 at `http://localhost:8000/v1`
|
||||
- **Embeddings:** Ollama `nomic-embed-text` at `http://localhost:11434`
|
||||
- **Vector DB:** Qdrant at `http://localhost:6333` (2 collections: codebase + OPC docs)
|
||||
- **Task workers:** `worker/rag_worker.py` (:5002), `worker/nl2sql_worker.py` (:5003)
|
||||
|
||||
### MCP Tools (exposed to C# via `IMcpService`)
|
||||
|
||||
| Tool | C# Method | Purpose |
|
||||
|---|---|---|
|
||||
| `run_sql` | `RunSqlAsync(sql)` | Execute SELECT SQL |
|
||||
| `query_pv_history` | `QueryPvHistoryAsync(...)` | History query by tag/time |
|
||||
| `get_tag_metadata` | `GetTagMetadataAsync(query, limit)` | Tag search |
|
||||
| `list_drawings` | `ListDrawingsAsync(unitNo)` | Drawing list |
|
||||
| `query_with_nl` | `QueryWithNlAsync(question)` | NL → LLM → SQL → pivot |
|
||||
|
||||
## RAG (Retrieval-Augmented Generation)
|
||||
|
||||
### Python-side RAG Tools (in `server.py`)
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `search_codebase(query, top_k)` | Qdrant search in ExperionCrawler C# code collection |
|
||||
| `search_r530_docs(query, top_k)` | Qdrant search in Honeywell Experion HS R530 docs collection (266 chunks from `.htm` files) |
|
||||
| `ask_iiot_llm(question, context)` | Direct Qwen3.6 Q&A with optional context |
|
||||
| `rag_query(question, search_code, search_docs)` | Search + LLM synthesis in one call |
|
||||
|
||||
### C#-side Text-to-SQL (local, no MCP)
|
||||
|
||||
**Service:** `ITextToSqlService` → `TextToSqlService` (Scoped), registered in `Program.cs`
|
||||
|
||||
**Pipeline:**
|
||||
1. `ParseNaturalLanguageAsync(input)` — Korean NL parser (tag names, time ranges, aggregates), up to 8 tags
|
||||
2. `SqlValidator` — 7-stage validation pipeline:
|
||||
- SELECT-only, dangerous keywords block, forbidden clauses, function whitelist, table allowlist, subquery depth (max 4), injection patterns
|
||||
3. `ExecuteQueryAsync(sql, limit)` — parameterized Npgsql, tag existence check
|
||||
4. `AnalyzeAsync(dto)` — per-tag statistics (AVG, MIN, MAX, STDDEV, FIRST, LAST)
|
||||
|
||||
**Allowed tables (from `SqlValidatorOptions` in Program.cs):** `history_table`, `node_map_master`, `realtime_table`, `tag_metadata`, `v_tag_summary`
|
||||
|
||||
### Text-to-SQL Controller (`/api/text-to-sql/*`)
|
||||
|
||||
| Route | Method | Source | Description |
|
||||
|---|---|---|---|
|
||||
| `/parse` | POST | C# local | Korean NL → SQL |
|
||||
| `/query-nl` | POST | MCP | NL → LLM → SQL → pivot |
|
||||
| `/tools` | GET | MCP | List MCP tools |
|
||||
| `/execute-mcp` | POST | MCP | Execute SQL via MCP |
|
||||
| `/query-history` | POST | MCP | History query |
|
||||
| `/tags/search` | GET | MCP | Tag search |
|
||||
| `/drawings` | GET | MCP | Drawing list |
|
||||
| `/suggest` | GET | C# local | Autocomplete suggestions |
|
||||
| `/analyze` | POST | C# local | Time-series statistics |
|
||||
| `/query-history-interval` | POST | C# local | Custom-interval aggregation |
|
||||
|
||||
### One-time Doc Indexing
|
||||
|
||||
`mcp-server/index_opc_docs.py` — reads `.htm` files from `/home/windpacer/projects/Experion_opcua_documents`, chunks (600 chars, 100 overlap), embeds with Ollama, upserts to Qdrant `experion-opc-docs`.
|
||||
|
||||
## Deploy
|
||||
|
||||
`sudo bash deploy.sh` → publishes to `/opt/ExperionCrawler`, creates systemd service `experioncrawler`, runs as `www-data`.
|
||||
21
mcp-server/config.py
Normal file
21
mcp-server/config.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
_SERVER_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_MODEL_FILE = os.path.join(_SERVER_DIR, "llm-model.json")
|
||||
|
||||
_DEFAULT_MODEL = "Qwen3.6-27B-FP8"
|
||||
|
||||
|
||||
def get_vllm_model() -> str:
|
||||
env = os.environ.get("VLLM_MODEL")
|
||||
if env:
|
||||
return env
|
||||
if not os.path.isfile(_MODEL_FILE):
|
||||
return _DEFAULT_MODEL
|
||||
try:
|
||||
with open(_MODEL_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data.get("vllm_model", _DEFAULT_MODEL)
|
||||
except Exception:
|
||||
return _DEFAULT_MODEL
|
||||
3
mcp-server/llm-model.json
Normal file
3
mcp-server/llm-model.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"vllm_model": "Qwen3.6-27B-FP8"
|
||||
}
|
||||
@@ -13,10 +13,11 @@ class MappingResult(BaseModel):
|
||||
confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence score from 0 to 1")
|
||||
|
||||
class IntelligentMapper:
|
||||
def __init__(self, graph: nx.Graph, system_tags: List[str], api_client: Optional[AsyncOpenAI] = None):
|
||||
self.graph = graph # Phase 2에서 생성된 NetworkX 그래프
|
||||
self.system_tags = system_tags # Experion 시스템의 전체 태그 리스트
|
||||
def __init__(self, graph: nx.Graph, system_tags: List[str], api_client: Optional[AsyncOpenAI] = None, model_name: str = "Qwen3.6-27B-FP8"):
|
||||
self.graph = graph
|
||||
self.system_tags = system_tags
|
||||
self.client = api_client
|
||||
self.model_name = model_name
|
||||
|
||||
def get_node_context(self, node_id: str) -> str:
|
||||
"""노드의 주변 위상 정보를 텍스트로 변환 (확장된 컨텍스트 제공)"""
|
||||
@@ -84,7 +85,7 @@ class IntelligentMapper:
|
||||
|
||||
try:
|
||||
response = await self.client.chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8", # MCP 서버 설정 모델 사용
|
||||
model=model_name,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_format={ "type": "json_object" }
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ExperionCrawler Unified MCP Server
|
||||
- RAG: Qdrant + Ollama nomic-embed-text + vLLM Qwen3.6-27B-FP8
|
||||
- RAG: Qdrant + Ollama nomic-embed-text + vLLM (llm-model.json)
|
||||
- NL2SQL: 자연어 → LLM SQL 생성 → PostgreSQL 실행
|
||||
- 사용처:
|
||||
stdio 모드 (기본): Claude Code MCP / Roo Code MCP
|
||||
@@ -24,7 +24,8 @@ QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
|
||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
EMBED_MODEL = os.environ.get("EMBED_MODEL", "nomic-embed-text")
|
||||
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
|
||||
from config import get_vllm_model
|
||||
VLLM_MODEL = get_vllm_model()
|
||||
|
||||
# Qdrant 컬렉션
|
||||
COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드
|
||||
@@ -67,7 +68,7 @@ async def _embed(text: str) -> list[float]:
|
||||
|
||||
return await asyncio.to_thread(_call_embed)
|
||||
|
||||
# ── LLM (vLLM / Qwen3.6-27B-FP8) ─────────────────────────────────────
|
||||
# ── LLM (vLLM) ──────────────────────────────────────────────────────
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _llm():
|
||||
@@ -377,7 +378,7 @@ async def search_r530_docs(query: str, top_k: int = 5) -> str:
|
||||
|
||||
@mcp.tool()
|
||||
def ask_iiot_llm(question: str, context: str = "") -> str:
|
||||
"""Qwen3.6-27B-FP8에게 IIoT/OPC UA 질문 (컨텍스트 없이 LLM 직접 질문).
|
||||
"""LLM에게 IIoT/OPC UA 질문 (컨텍스트 없이 LLM 직접 질문).
|
||||
|
||||
사용 시점: search_codebase 또는 search_r530_docs 결과를 context로 넘겨
|
||||
종합 분석·답변이 필요할 때. 또는 일반 IIoT/OPC UA 개념 질문.
|
||||
@@ -393,7 +394,7 @@ def ask_iiot_llm(question: str, context: str = "") -> str:
|
||||
)
|
||||
user_msg = f"컨텍스트:\n{context}\n\n질문: {question}" if context else question
|
||||
resp = _llm().chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_msg},
|
||||
@@ -406,7 +407,7 @@ def ask_iiot_llm(question: str, context: str = "") -> str:
|
||||
|
||||
@mcp.tool()
|
||||
async def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str:
|
||||
"""검색 → Qwen3.6-27B-FP8 답변 생성 (통합 RAG).
|
||||
"""검색 → LLM 답변 생성 (통합 RAG).
|
||||
|
||||
기본값: Experion HS R530 공식 문서만 검색 (search_docs=True, search_code=False).
|
||||
ExperionCrawler 코드도 함께 보려면 search_code=True 추가.
|
||||
@@ -612,7 +613,7 @@ async def query_with_nl(question: str) -> str:
|
||||
try:
|
||||
def _call_llm():
|
||||
return _llm().chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": question},
|
||||
@@ -699,7 +700,7 @@ async def extract_pid_tags(text: str, source_type: str) -> str:
|
||||
|
||||
def _call_llm():
|
||||
return _llm().chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated_text}"},
|
||||
@@ -805,7 +806,7 @@ async def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str:
|
||||
|
||||
def _call_llm():
|
||||
return _llm().chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": f"P&ID Tags:\n{pid_str}\n\nExperion Tags:\n{experion_str}"},
|
||||
@@ -896,7 +897,7 @@ async def parse_pid_dxf(filepath: str) -> str:
|
||||
|
||||
def _call_llm():
|
||||
return _llm().chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": f"Source: dxf\n\nText:\n{truncated_text}"},
|
||||
@@ -1009,7 +1010,7 @@ async def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
|
||||
|
||||
def _call_llm():
|
||||
return _llm().chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": f"Source: pdf\n\nText:\n{truncated_text}"},
|
||||
@@ -1111,7 +1112,7 @@ async def build_pid_graph_parallel(filepath: str) -> str:
|
||||
# Mapper 설정
|
||||
from openai import AsyncOpenAI
|
||||
api_client = AsyncOpenAI(base_url=VLLM_BASE_URL, api_key="dummy")
|
||||
mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client)
|
||||
mapper = IntelligentMapper(builder.G, system_tags, api_client=api_client, model_name=VLLM_MODEL)
|
||||
|
||||
# 분류별 노드 분리
|
||||
nodes = list(builder.G.nodes())
|
||||
|
||||
@@ -34,7 +34,8 @@ DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://post
|
||||
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
|
||||
|
||||
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
|
||||
from config import get_vllm_model
|
||||
VLLM_MODEL = get_vllm_model()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -150,7 +151,7 @@ async def _generate_sql(natural_language: str) -> str:
|
||||
)
|
||||
|
||||
response = await client.chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": natural_language},
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
환경 변수:
|
||||
VLLM_BASE_URL: vLLM 엔드포인트 (기본: http://localhost:8000/v1)
|
||||
VLLM_MODEL: 모델명 (기본: Qwen3.6-27B-FP8)
|
||||
VLLM_MODEL: 모델명 (기본: llm-model.json 참조)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -22,6 +22,9 @@ import sys
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from config import get_vllm_model
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
||||
@@ -84,7 +87,7 @@ def call_llm(system_prompt: str, user_text: str, max_tokens: int = 65536) -> Lis
|
||||
from openai import OpenAI
|
||||
|
||||
base_url = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||
model = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
|
||||
model = os.environ.get("VLLM_MODEL") or get_vllm_model()
|
||||
|
||||
client = OpenAI(base_url=base_url, api_key="dummy")
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ import uvicorn
|
||||
# ── 설정 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
|
||||
from config import get_vllm_model
|
||||
VLLM_MODEL = get_vllm_model()
|
||||
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
|
||||
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
|
||||
|
||||
@@ -173,7 +174,7 @@ def _extract_pid_tags(text: str, source_type: str) -> str:
|
||||
)
|
||||
truncated = text[:100000]
|
||||
resp = _llm().chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
|
||||
@@ -202,7 +203,7 @@ def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
|
||||
"- Output ONLY the JSON array.\n"
|
||||
)
|
||||
resp = _llm().chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": (
|
||||
@@ -247,7 +248,7 @@ def _parse_pid_dxf(filepath: str) -> str:
|
||||
ensure_ascii=False, indent=2)
|
||||
|
||||
resp = _llm().chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
|
||||
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
|
||||
@@ -273,7 +274,7 @@ def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
|
||||
ensure_ascii=False, indent=2)
|
||||
|
||||
resp = _llm().chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
|
||||
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
|
||||
|
||||
@@ -31,7 +31,8 @@ import uvicorn
|
||||
# ── 설정 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
|
||||
from config import get_vllm_model
|
||||
VLLM_MODEL = get_vllm_model()
|
||||
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
|
||||
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
|
||||
|
||||
@@ -174,7 +175,7 @@ def _extract_pid_tags(text: str, source_type: str) -> str:
|
||||
)
|
||||
truncated = text[:100000]
|
||||
resp = _llm().chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": f"Source: {source_type}\n\nText:\n{truncated}"},
|
||||
@@ -203,7 +204,7 @@ def _match_pid_tags(pid_tags: list, experion_tags: list) -> str:
|
||||
"- Output ONLY the JSON array.\n"
|
||||
)
|
||||
resp = _llm().chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": (
|
||||
@@ -248,7 +249,7 @@ def _parse_pid_dxf(filepath: str) -> str:
|
||||
ensure_ascii=False, indent=2)
|
||||
|
||||
resp = _llm().chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
|
||||
{"role": "user", "content": f"Source: dxf\n\nText:\n{text[:8000]}"},
|
||||
@@ -274,7 +275,7 @@ def _parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
|
||||
ensure_ascii=False, indent=2)
|
||||
|
||||
resp = _llm().chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": _TAG_EXTRACT_SYSTEM},
|
||||
{"role": "user", "content": f"Source: pdf\n\nText:\n{text[:12000]}"},
|
||||
|
||||
@@ -32,7 +32,8 @@ import httpx
|
||||
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
|
||||
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
|
||||
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
|
||||
from config import get_vllm_model
|
||||
VLLM_MODEL = get_vllm_model()
|
||||
EMBED_MODEL = os.environ.get("EMBED_MODEL", "nomic-embed-text")
|
||||
|
||||
COL_CODEBASE = os.environ.get("COL_CODEBASE", "ws-65f457145aee80b2")
|
||||
@@ -105,7 +106,7 @@ async def _ask_llm(question: str, context: str = "") -> str:
|
||||
prompt = question
|
||||
|
||||
response = await client.chat.completions.create(
|
||||
model="Qwen3.6-27B-FP8",
|
||||
model=VLLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": prompt},
|
||||
|
||||
644
plans/이벤트-히스토리-테이블-UI플랜.md
Normal file
644
plans/이벤트-히스토리-테이블-UI플랜.md
Normal file
@@ -0,0 +1,644 @@
|
||||
# 이벤트 히스토리 UI 신설 — 상세 코딩 플랜
|
||||
|
||||
## 진단 보고서 (2026-05-11)
|
||||
|
||||
diagnosis-checklist.md 8단계 순서대로 진단한 결과 **실행 가능한 플랜**임. 중대한 오진은 없음. 아래는 단계별 확인 사항과 발견된 소규모 이슈.
|
||||
|
||||
### STEP 1 — 맥락 파악
|
||||
|
||||
- **역할**: 백엔드 API 3개는 구현·동작 중인데 UI 전무 → 접근 불가 문제 해결
|
||||
- **레이어**: 순수 프론트엔드 (HTML/JS/CSS) — 백엔드 수정 없음
|
||||
- **관련 문서**: AGENTS.md (camelCase 규칙), CODING_CONVENTIONS.md
|
||||
|
||||
### STEP 2 — 구조 탐색
|
||||
|
||||
| 항목 | 실제 값 | 플랜 가정 | 일치 |
|
||||
|------|---------|-----------|------|
|
||||
| `style.css` 총 줄수 | 1489 | 1489 | ✅ |
|
||||
| `index.html` 총 줄수 | 1205 | — | — |
|
||||
| `index.html` `</main>` 위치 | 1119 | 파일 말미 | ⚠️ |
|
||||
| `app.js` 총 줄수 | 3075 | — | — |
|
||||
| `histReset()` 위치 | 1158~1169 | ~1175 | ⚠️ |
|
||||
| `data-tab="pid"` 위치 | 75-78 | 75-78 | ✅ |
|
||||
|
||||
### STEP 3 — 코드 읽기 (핵심 검증)
|
||||
|
||||
| 검증 대상 | 실제 상태 | 플랜 가정 | 일치 |
|
||||
|-----------|-----------|-----------|------|
|
||||
| `api()` 함수 존재 | `app.js:40` | 존재 | ✅ |
|
||||
| `esc()` 함수 존재 | `app.js:22` | 존재 | ✅ |
|
||||
| `dtOpen(target)` 패턴 | `hf-${target}`, `dtp-${target}-display` 참조 | 동일 | ✅ |
|
||||
| `dtClearField(target)` 패턴 | 동일 패턴 | 동일 | ✅ |
|
||||
| 탭 핸들러 자동 호출 없음 | `app.js:5-18` — evt 케이스 없음 | 불필요 | ✅ |
|
||||
| CSS 변수 (`--s2`, `--bd`, `--r`, `--fm`, `--t0`, `--t2`) | 모두 정의됨 | 사용 | ✅ |
|
||||
| `.hidden` 클래스 | `style.css:1274` | 재사용 | ✅ |
|
||||
| `.nm-cls` 클래스 | `style.css:469` | 재사용 | ✅ |
|
||||
| `.hist-status` 클래스 | `style.css:1216` | 재사용 | ✅ |
|
||||
| `.nm-result-info` 클래스 | `style.css:454` | 재사용 | ✅ |
|
||||
| 백엔드 camelCase 응답 | `ExperionControllers.cs:1192-1207` | 일치 | ✅ |
|
||||
| 백엔드 digital-tags 응답 | `{ tagName: "..." }` | 일치 | ✅ |
|
||||
|
||||
### STEP 4 — 호출 계층 지도
|
||||
|
||||
```
|
||||
사용자 클릭 (이벤트 조회)
|
||||
→ evtQuery()
|
||||
→ api('GET', '/api/event-history?params')
|
||||
→ EventHistoryController.Query()
|
||||
→ _db.QueryEventHistoryAsync()
|
||||
→ DB (external I/O)
|
||||
→ _evtBuildTable(d.data) ← 순수 함수, side effect 없음
|
||||
→ DOM innerHTML 업데이트
|
||||
```
|
||||
|
||||
에러 처리: Controller 레벨 try-catch 전체 포착 → `{ success: false, error: "..." }` 반환. JS 측 `if (!d.success) throw`로 캐치. **계층 적절**.
|
||||
|
||||
### STEP 5 — 패턴 매칭 결과
|
||||
|
||||
| 체크 | 항목 | 결과 |
|
||||
|------|------|------|
|
||||
| 미정의 변수 참조 | `api`, `esc`, `dtClearField` 모두 정의됨 | ✅ |
|
||||
| XSS (내부 HTML) | `esc()`로 모든 서버 응답 문자열 이스케이프 | ✅ |
|
||||
| `.innerHTML` + 서버 데이터 | `_evtBuildTable`에서 `esc()` 적용済み | ✅ |
|
||||
| 날짜 변환 | `new Date(fromRaw).toISOString()` — 기존 hist 탭과 동일 패턴 | ✅ |
|
||||
|
||||
### STEP 6 — 교차 검증
|
||||
|
||||
| 의심 항목 | Q1-Q4 결과 | 결론 |
|
||||
|-----------|-----------|------|
|
||||
| `histReset()` 줄번호 불일치 (1175 vs 1169) | Q3: 의도적 아님, 단순 추정치 차이 | LOW, 수정 제안만 |
|
||||
| `</main>`가 "파일 말미"가 아님 (1119/1205) | Q3: 설명상 불명확하나 삽입 로직은 정확 | LOW, 명시화 제안 |
|
||||
|
||||
### 발견 사항
|
||||
|
||||
### 1. `histReset()` 종료 줄번호 불일치 (LOW)
|
||||
|
||||
**문제**: 플랜 439줄 "histReset() 함수 끝 (현재 ~1175줄) 뒤"라고 했으나 실제 `histReset()`은 `app.js:1158-1169`에서 종료됨. 1171줄부터 "07-2 하이퍼테이블 관리" 섹션 시작.
|
||||
**근거**: `app.js:1158-1173`
|
||||
**영향**: 동작에는 영향 없음. 삽입 위치를 "histReset() 뒤"로 이해하면 정확함. 줄번호만 불일치.
|
||||
**수정**: "~1175줄" → "1169줄"로 정정
|
||||
|
||||
### 2. `</main>`가 파일 말미가 아님 (LOW)
|
||||
|
||||
**문제**: 플랜 181줄 "#pane-pid 섹션 (현재 파일 말미) 바로 뒤, </main> 닫는 태그 앞"이라고 했으나 `</main>`는 `index.html:1119`에 위치하고 파일은 1205줄. 1119~1205줄 사이에 fastRecord 모달, dt-popup 등 외부 요소가 있음.
|
||||
**근거**: `index.html:1117-1205`
|
||||
**영향**: 삽입 위치 자체는 정확함 (`</main>` 앞). "파일 말미"라는 표현이 혼동을 줄 수 있음.
|
||||
**수정**: "파일 말미" → "1117줄 (#pane-pid 종료)"로 정정
|
||||
|
||||
---
|
||||
|
||||
**종합**: 플랜 실행 가능. CSS 변수, 헬퍼 함수, 클래스, 백엔드 응답 형식 등 모든 전제가 검증됨. 발견된 2개 이슈 모두 LOW (표현 명확화 수준).
|
||||
|
||||
---
|
||||
|
||||
## 0. 현황 진단
|
||||
|
||||
### UI 반영 여부
|
||||
| 항목 | 상태 |
|
||||
|------|------|
|
||||
| 사이드바 탭 (`data-tab="evt"`) | ❌ 없음 |
|
||||
| `#pane-evt` 섹션 | ❌ 없음 |
|
||||
| JS 함수 (`evtQuery` 등) | ❌ 없음 |
|
||||
| CSS 이벤트 배지 스타일 | ❌ 없음 |
|
||||
|
||||
**결론**: 백엔드 API 3개(`GET /api/event-history`, `/summary`, `/digital-tags`)는 완전 구현·동작 중이나 **UI가 전무**하여 접근 불가.
|
||||
|
||||
### 기존 API 엔드포인트 (구현 완료)
|
||||
| 엔드포인트 | 설명 | 주요 파라미터 |
|
||||
|-----------|------|--------------|
|
||||
| `GET /api/event-history` | 이벤트 조회 | tagName, area, section, eventType, from, to, limit |
|
||||
| `GET /api/event-history/summary` | 구간별 집계 | area, section, from, to |
|
||||
| `GET /api/event-history/digital-tags` | 디지털 태그 목록 | — |
|
||||
|
||||
### 응답 형식
|
||||
```json
|
||||
// GET /api/event-history
|
||||
{
|
||||
"success": true,
|
||||
"count": 42,
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"tagName": "P6-FIC101.instate0",
|
||||
"nodeId": "...",
|
||||
"prevValue": "RUN",
|
||||
"currValue": "L-STOP",
|
||||
"eventType": "TRIP",
|
||||
"eventTime": "2026-05-11T03:00:00Z",
|
||||
"area": "P6",
|
||||
"section": "1-2차",
|
||||
"durationSeconds": 3600,
|
||||
"metadata": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// GET /api/event-history/summary
|
||||
{
|
||||
"success": true,
|
||||
"count": 3,
|
||||
"data": [
|
||||
{
|
||||
"section": "1-2차",
|
||||
"totalEvents": 10,
|
||||
"tripCount": 3,
|
||||
"runCount": 3,
|
||||
"alarmCount": 2,
|
||||
"changeCount": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 구현 대상 파일
|
||||
|
||||
| 파일 | 수정 내용 |
|
||||
|------|----------|
|
||||
| `src/Web/wwwroot/index.html` | 탭 항목(12번) + `#pane-evt` 섹션 추가 |
|
||||
| `src/Web/wwwroot/js/app.js` | `evtLoadTags`, `evtQuery`, `evtSummary`, `evtBuildTable`, `evtBuildSummary`, `evtReset` 함수 추가 |
|
||||
| `src/Web/wwwroot/css/style.css` | `.evt-badge`, `.evt-summary-*` 스타일 추가 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 구현 순서 (Dependency Graph)
|
||||
|
||||
```
|
||||
Step 1: CSS — .evt-badge, .evt-summary-grid, .evt-summary-item
|
||||
↓
|
||||
Step 2: HTML — 사이드바 탭 항목(12번) 추가
|
||||
↓
|
||||
Step 3: HTML — #pane-evt 섹션 전체 구조
|
||||
↓
|
||||
Step 4: JS — evtLoadTags(), evtQuery(), evtSummary()
|
||||
↓
|
||||
Step 5: JS — evtBuildTable(), evtBuildSummary(), evtReset()
|
||||
↓
|
||||
Step 6: 검증
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Step-by-Step 코딩 계획
|
||||
|
||||
### Step 1: CSS 추가
|
||||
|
||||
**파일**: `src/Web/wwwroot/css/style.css`
|
||||
**위치**: 파일 말미 (현재 1489줄 끝)
|
||||
|
||||
```css
|
||||
/* ── Event History ─────────────────────────────────────────── */
|
||||
.evt-badge {
|
||||
display: inline-block;
|
||||
font-family: var(--fm); font-size: 10px; font-weight: 700;
|
||||
letter-spacing: .06em; padding: 2px 8px; border-radius: 3px;
|
||||
text-transform: uppercase; white-space: nowrap;
|
||||
}
|
||||
.evt-badge.trip { background: rgba(239,68,68,.18); color: #f87171; }
|
||||
.evt-badge.run { background: rgba(16,185,129,.18); color: #34d399; }
|
||||
.evt-badge.alarm { background: rgba(245,158,11,.18); color: #fbbf24; }
|
||||
.evt-badge.normal { background: rgba(148,163,184,.18); color: #94a3b8; }
|
||||
.evt-badge.change { background: rgba(96,165,250,.18); color: #60a5fa; }
|
||||
|
||||
.evt-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.evt-summary-item {
|
||||
background: var(--s2);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.evt-summary-section {
|
||||
font-family: var(--fm); font-size: 13px; font-weight: 700;
|
||||
color: var(--t0); margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.evt-summary-counts {
|
||||
display: flex; gap: 10px; flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.evt-count {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-family: var(--fm); font-size: 11px; color: var(--t2);
|
||||
}
|
||||
|
||||
.evt-count strong { font-size: 14px; color: var(--t0); }
|
||||
|
||||
.evt-total {
|
||||
font-family: var(--fm); font-size: 11px; color: var(--t2);
|
||||
margin-top: 8px; padding-top: 8px;
|
||||
border-top: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.evt-total strong { color: var(--t0); }
|
||||
```
|
||||
|
||||
**검증**:
|
||||
- [ ] `.evt-badge.trip` 의 색상이 `#f87171` (red-400) 계열인지 확인
|
||||
- [ ] `.evt-summary-grid`가 반응형으로 동작하는지 확인 (`auto-fill, minmax`)
|
||||
|
||||
---
|
||||
|
||||
### Step 2: HTML — 사이드바 탭 항목 추가
|
||||
|
||||
**파일**: `src/Web/wwwroot/index.html`
|
||||
**위치**: `<li class="nav-item" data-tab="pid">` 블록 (현재 75-78줄) **뒤**
|
||||
|
||||
```html
|
||||
<li class="nav-item" data-tab="evt">
|
||||
<span class="ni">12</span>
|
||||
<span class="nl">이벤트 히스토리</span>
|
||||
</li>
|
||||
```
|
||||
|
||||
**검증**:
|
||||
- [ ] 탭 번호가 기존 11번(P&ID 추출) 다음인지 확인
|
||||
- [ ] `data-tab="evt"`가 `#pane-evt`와 매핑되는지 확인 (탭 클릭 핸들러가 `pane-${tab}` 패턴 사용)
|
||||
|
||||
---
|
||||
|
||||
### Step 3: HTML — #pane-evt 섹션 추가
|
||||
|
||||
**파일**: `src/Web/wwwroot/index.html`
|
||||
**위치**: `#pane-pid` 섹션 (현재 파일 말미) 바로 뒤, `</main>` 닫는 태그 앞
|
||||
|
||||
```html
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
12 이벤트 히스토리
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="pane" id="pane-evt">
|
||||
<header class="pane-hdr">
|
||||
<div>
|
||||
<h1>이벤트 히스토리</h1>
|
||||
<p>디지털 포인트 상태 변경 이벤트를 조회합니다. (event_history_table)</p>
|
||||
</div>
|
||||
<div class="pane-tag">EVENT / DIGITAL</div>
|
||||
</header>
|
||||
|
||||
<!-- 조회 조건 카드 -->
|
||||
<div class="card">
|
||||
<div class="card-cap">조회 조건</div>
|
||||
|
||||
<!-- 태그 필터 -->
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:13px">
|
||||
<span>태그 필터</span>
|
||||
<button class="btn-b btn-sm" onclick="evtLoadTags()">▼ 태그 목록 불러오기</button>
|
||||
<span id="evt-tag-status" class="hist-status"></span>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<select id="ef-tag" class="inp">
|
||||
<option value="">— 전체 태그 —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 필터 4열 -->
|
||||
<div class="cols-4">
|
||||
<div class="fg">
|
||||
<label>이벤트 타입</label>
|
||||
<select id="ef-event-type" class="inp">
|
||||
<option value="">전체</option>
|
||||
<option value="TRIP">TRIP</option>
|
||||
<option value="RUN">RUN</option>
|
||||
<option value="ALARM">ALARM</option>
|
||||
<option value="NORMAL">NORMAL</option>
|
||||
<option value="CHANGE">CHANGE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>Area <em>(예: P6)</em></label>
|
||||
<input id="ef-area" class="inp" type="text" placeholder="비워두면 전체"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>Section <em>(예: 1-2차)</em></label>
|
||||
<input id="ef-section" class="inp" type="text" placeholder="비워두면 전체"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>최대 행 수</label>
|
||||
<input id="ef-limit" class="inp" type="number" value="500" min="10" max="5000"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시간 범위 -->
|
||||
<div class="cols-2">
|
||||
<div class="fg">
|
||||
<label>시작 시간</label>
|
||||
<input type="hidden" id="hf-evt-from"/>
|
||||
<div class="dt-display inp" id="dtp-evt-from-display" onclick="dtOpen('evt-from')">— 선택 안 함 —</div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>종료 시간</label>
|
||||
<input type="hidden" id="hf-evt-to"/>
|
||||
<div class="dt-display inp" id="dtp-evt-to-display" onclick="dtOpen('evt-to')">— 선택 안 함 —</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn-a" onclick="evtQuery()">🔍 이벤트 조회</button>
|
||||
<button class="btn-b" onclick="evtSummary()">📊 구간 요약</button>
|
||||
<button class="btn-b" onclick="evtReset()">초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 요약 결과 카드 (처음에는 숨김) -->
|
||||
<div id="evt-summary-card" class="card hidden">
|
||||
<div class="card-cap">구간별 이벤트 요약</div>
|
||||
<div id="evt-summary-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- 조회 결과 -->
|
||||
<div id="evt-result-info" class="nm-result-info hidden" style="margin:8px 0"></div>
|
||||
<div id="evt-table" class="tbl-wrap hidden"></div>
|
||||
</section>
|
||||
```
|
||||
|
||||
**설계 결정**:
|
||||
- 날짜 피커: 기존 `dtOpen(target)` 재사용. target이 `'evt-from'`이면 `hf-evt-from`, `dtp-evt-from-display` ID를 자동으로 참조 — **기존 코드 수정 없음**
|
||||
- `hist-status` 클래스 재사용 (기존 스타일 이미 존재)
|
||||
- `.hidden` 클래스 재사용 (기존 스타일 이미 존재)
|
||||
- `nm-result-info` 클래스 재사용 (조회 결과 건수 표시용)
|
||||
|
||||
**검증**:
|
||||
- [ ] `dtOpen('evt-from')` 호출 시 `hf-evt-from`, `dtp-evt-from-display` 를 정확히 참조하는지 확인
|
||||
- [ ] `class="pane"` (active 없음) — 탭 클릭 시 JS가 active 추가하므로 정상
|
||||
- [ ] `</main>` 닫는 태그 앞에 삽입했는지 확인
|
||||
|
||||
---
|
||||
|
||||
### Step 4+5: JS 함수 추가
|
||||
|
||||
**파일**: `src/Web/wwwroot/js/app.js`
|
||||
**위치**: `histReset()` 함수 (현재 1158줄) 블록 **뒤**에 추가
|
||||
|
||||
```javascript
|
||||
// ── Event History ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function evtLoadTags() {
|
||||
const statusEl = document.getElementById('evt-tag-status');
|
||||
statusEl.textContent = '⏳ 조회 중...';
|
||||
try {
|
||||
const d = await api('GET', '/api/event-history/digital-tags');
|
||||
const tags = d.data || [];
|
||||
const sel = document.getElementById('ef-tag');
|
||||
sel.innerHTML = '<option value="">— 전체 태그 —</option>' +
|
||||
tags.map(t => `<option value="${esc(t.tagName)}">${esc(t.tagName)}</option>`).join('');
|
||||
statusEl.textContent = `✅ ${tags.length}개`;
|
||||
} catch (e) {
|
||||
statusEl.textContent = `❌ ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function evtQuery() {
|
||||
const tag = document.getElementById('ef-tag').value;
|
||||
const eventType = document.getElementById('ef-event-type').value;
|
||||
const area = document.getElementById('ef-area').value.trim();
|
||||
const section = document.getElementById('ef-section').value.trim();
|
||||
const limit = document.getElementById('ef-limit').value || 500;
|
||||
const fromRaw = document.getElementById('hf-evt-from').value;
|
||||
const toRaw = document.getElementById('hf-evt-to').value;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (tag) params.set('tagName', tag);
|
||||
if (eventType) params.set('eventType', eventType);
|
||||
if (area) params.set('area', area);
|
||||
if (section) params.set('section', section);
|
||||
params.set('limit', limit);
|
||||
if (fromRaw) params.set('from', new Date(fromRaw).toISOString());
|
||||
if (toRaw) params.set('to', new Date(toRaw).toISOString());
|
||||
|
||||
const infoEl = document.getElementById('evt-result-info');
|
||||
const tableEl = document.getElementById('evt-table');
|
||||
infoEl.textContent = '⏳ 조회 중...';
|
||||
infoEl.classList.remove('hidden');
|
||||
tableEl.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const d = await api('GET', `/api/event-history?${params}`);
|
||||
if (!d.success) throw new Error(d.error || '조회 실패');
|
||||
infoEl.textContent = `총 ${d.count}건`;
|
||||
tableEl.innerHTML = _evtBuildTable(d.data);
|
||||
tableEl.classList.remove('hidden');
|
||||
} catch (e) {
|
||||
infoEl.textContent = `❌ ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function evtSummary() {
|
||||
const area = document.getElementById('ef-area').value.trim();
|
||||
const section = document.getElementById('ef-section').value.trim();
|
||||
const fromRaw = document.getElementById('hf-evt-from').value;
|
||||
const toRaw = document.getElementById('hf-evt-to').value;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (area) params.set('area', area);
|
||||
if (section) params.set('section', section);
|
||||
if (fromRaw) params.set('from', new Date(fromRaw).toISOString());
|
||||
if (toRaw) params.set('to', new Date(toRaw).toISOString());
|
||||
|
||||
const card = document.getElementById('evt-summary-card');
|
||||
const content = document.getElementById('evt-summary-content');
|
||||
content.textContent = '⏳ 집계 중...';
|
||||
card.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const d = await api('GET', `/api/event-history/summary?${params}`);
|
||||
if (!d.success) throw new Error(d.error || '조회 실패');
|
||||
content.innerHTML = _evtBuildSummary(d.data);
|
||||
} catch (e) {
|
||||
content.textContent = `❌ ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function _evtBadge(t) {
|
||||
const cls = { TRIP:'trip', RUN:'run', ALARM:'alarm', NORMAL:'normal', CHANGE:'change' }[t] || 'change';
|
||||
return `<span class="evt-badge ${cls}">${esc(t)}</span>`;
|
||||
}
|
||||
|
||||
function _evtFmtTime(dt) {
|
||||
if (!dt) return '—';
|
||||
return new Date(dt).toLocaleString('ko-KR', {
|
||||
year:'numeric', month:'2-digit', day:'2-digit',
|
||||
hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false
|
||||
});
|
||||
}
|
||||
|
||||
function _evtBuildTable(rows) {
|
||||
if (!rows || !rows.length)
|
||||
return '<div style="padding:24px;text-align:center;color:var(--t2)">데이터 없음</div>';
|
||||
const html = rows.map(r => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap;color:var(--t2);font-family:var(--fm);font-size:11px">${_evtFmtTime(r.eventTime)}</td>
|
||||
<td><code style="font-size:11px;color:var(--blu)">${esc(r.tagName)}</code></td>
|
||||
<td>${_evtBadge(r.eventType)}</td>
|
||||
<td style="color:var(--t2)">${esc(r.prevValue ?? '—')}</td>
|
||||
<td style="color:var(--t0);font-weight:600">${esc(r.currValue)}</td>
|
||||
<td>${r.area ? `<span class="nm-cls">${esc(r.area)}</span>` : '—'}</td>
|
||||
<td>${r.section ? `<span class="nm-cls">${esc(r.section)}</span>` : '—'}</td>
|
||||
<td style="font-family:var(--fm);font-size:11px;color:var(--t2)">${r.durationSeconds != null ? r.durationSeconds + 's' : '—'}</td>
|
||||
</tr>`).join('');
|
||||
return `
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>시간</th>
|
||||
<th>태그명</th>
|
||||
<th>이벤트</th>
|
||||
<th>이전값</th>
|
||||
<th>현재값</th>
|
||||
<th>Area</th>
|
||||
<th>Section</th>
|
||||
<th>지속(초)</th>
|
||||
</tr></thead>
|
||||
<tbody>${html}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function _evtBuildSummary(data) {
|
||||
if (!data || !data.length)
|
||||
return '<div style="padding:12px;color:var(--t2)">데이터 없음</div>';
|
||||
return `<div class="evt-summary-grid">${data.map(s => `
|
||||
<div class="evt-summary-item">
|
||||
<div class="evt-summary-section">${esc(s.section)}</div>
|
||||
<div class="evt-summary-counts">
|
||||
<div class="evt-count">${_evtBadge('TRIP')} <strong>${s.tripCount}</strong></div>
|
||||
<div class="evt-count">${_evtBadge('RUN')} <strong>${s.runCount}</strong></div>
|
||||
<div class="evt-count">${_evtBadge('ALARM')} <strong>${s.alarmCount}</strong></div>
|
||||
<div class="evt-count">${_evtBadge('CHANGE')} <strong>${s.changeCount}</strong></div>
|
||||
</div>
|
||||
<div class="evt-total">합계 <strong>${s.totalEvents}</strong>건</div>
|
||||
</div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function evtReset() {
|
||||
document.getElementById('ef-tag').value = '';
|
||||
document.getElementById('ef-event-type').value = '';
|
||||
document.getElementById('ef-area').value = '';
|
||||
document.getElementById('ef-section').value = '';
|
||||
document.getElementById('ef-limit').value = '500';
|
||||
dtClearField('evt-from');
|
||||
dtClearField('evt-to');
|
||||
document.getElementById('evt-result-info').classList.add('hidden');
|
||||
document.getElementById('evt-table').classList.add('hidden');
|
||||
document.getElementById('evt-summary-card').classList.add('hidden');
|
||||
document.getElementById('evt-tag-status').textContent = '';
|
||||
}
|
||||
```
|
||||
|
||||
**설계 결정**:
|
||||
- `_evtBuildTable`, `_evtBuildSummary`, `_evtBadge`, `_evtFmtTime`에 `_` 접두사 → 내부 헬퍼임을 명시
|
||||
- `dtClearField('evt-from')` 재사용 — 기존 함수가 `hf-${target}`, `dtp-${target}-display` 패턴으로 동작하므로 수정 없음
|
||||
- 탭 진입 시 자동 API 호출 없음 (탭 핸들러에 `if (tab === 'evt')` 추가 불필요)
|
||||
- `api()` 헬퍼 함수 재사용 (기존 코드에 이미 정의됨)
|
||||
|
||||
**검증**:
|
||||
- [ ] `api('GET', '/api/event-history/digital-tags')` 응답의 `d.data` 배열 안에 `{ tagName: "..." }` 형태인지 확인
|
||||
- [ ] `new Date(fromRaw).toISOString()` — `fromRaw`는 `"2026-05-11T03:00"` 형태 → ISO 8601 UTC로 변환됨 (기존 hist 탭과 동일 패턴)
|
||||
- [ ] `_evtBuildTable(d.data)` — `d.data`가 빈 배열이면 "데이터 없음" 메시지 표시
|
||||
- [ ] `evtReset()`이 summary 카드도 숨기는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 4. 파일별 추가 위치 요약
|
||||
|
||||
### `style.css`
|
||||
```
|
||||
파일 말미 (1489줄 끝) → .evt-badge ~ .evt-total 블록 추가
|
||||
```
|
||||
|
||||
### `index.html`
|
||||
```
|
||||
[위치 1] 75-78줄 <li data-tab="pid"> 블록 뒤:
|
||||
→ <li data-tab="evt"> 탭 항목 추가
|
||||
|
||||
[위치 2] </main> 닫는 태그 앞 (현재 파일 말미):
|
||||
→ #pane-evt 섹션 전체 추가
|
||||
```
|
||||
|
||||
### `app.js`
|
||||
```
|
||||
histReset() 함수 끝 (현재 ~1175줄) 뒤:
|
||||
→ evtLoadTags ~ evtReset 전체 블록 추가
|
||||
```
|
||||
|
||||
**탭 핸들러 수정 없음**: `evt` 탭은 진입 시 API 자동 호출 없음 (사용자가 버튼 클릭 시에만 실행).
|
||||
|
||||
---
|
||||
|
||||
## 5. 검증 절차
|
||||
|
||||
### Stage A — 빌드/렌더링
|
||||
- [ ] 브라우저에서 사이드바에 "12 이벤트 히스토리" 탭이 보이는지 확인
|
||||
- [ ] 탭 클릭 시 `#pane-evt`가 활성화되는지 확인
|
||||
|
||||
### Stage B — 태그 목록 로드
|
||||
```
|
||||
▼ 태그 목록 불러오기 버튼 클릭
|
||||
→ GET /api/event-history/digital-tags
|
||||
→ ef-tag 드롭다운에 디지털 태그 목록 채워짐
|
||||
→ 태그 상태에 "✅ N개" 표시
|
||||
```
|
||||
|
||||
### Stage C — 이벤트 조회
|
||||
```
|
||||
시작/종료 시간 선택 → 🔍 이벤트 조회 버튼
|
||||
→ GET /api/event-history?from=...&to=...&limit=500
|
||||
→ 결과 테이블 렌더링 (시간, 태그, 배지, 이전값/현재값, Area, Section, 지속시간)
|
||||
→ 이벤트 타입별 배지 색상 확인 (TRIP:red, RUN:green, ALARM:amber)
|
||||
```
|
||||
|
||||
### Stage D — 구간 요약
|
||||
```
|
||||
📊 구간 요약 버튼
|
||||
→ GET /api/event-history/summary?from=...&to=...
|
||||
→ evt-summary-card 표시
|
||||
→ Section별 카드 그리드 렌더링 (TRIP/RUN/ALARM/CHANGE 각 건수)
|
||||
```
|
||||
|
||||
### Stage E — 필터 조합
|
||||
```
|
||||
- eventType=TRIP 선택 후 조회 → TRIP 이벤트만 표시
|
||||
- area=P6 입력 후 조회 → P6 area만 표시
|
||||
- tagName 선택 후 조회 → 해당 태그만 표시
|
||||
```
|
||||
|
||||
### Stage F — 초기화
|
||||
```
|
||||
초기화 버튼 → 모든 필터 리셋, 결과 테이블 숨김, 요약 카드 숨김
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 주의사항
|
||||
|
||||
1. **날짜 피커 재사용**: `dtOpen('evt-from')` 호출 시 `hf-evt-from`(hidden input), `dtp-evt-from-display`(표시 div) ID를 자동 참조. **기존 dt-picker 코드 수정 불필요**.
|
||||
|
||||
2. **탭 진입 시 API 없음**: 탭 핸들러 (`app.js` 7-18줄)에 `evt` 케이스 추가 불필요. 사이드 이펙트 없음.
|
||||
|
||||
3. **from/to 기본값**: from/to 미입력 시 백엔드에서 자동으로 최근 1일 (`DateTime.UtcNow.AddDays(-1)` ~ `DateTime.UtcNow`) 적용. 빈 파라미터 전송 시에도 정상 동작.
|
||||
|
||||
4. **`esc()` 필수**: XSS 방지를 위해 서버 응답의 모든 문자열 필드에 `esc()` 적용. 특히 tagName, currValue, prevValue.
|
||||
|
||||
5. **`nm-cls` 재사용**: Area/Section 표시에 기존 `.nm-cls` 배지 스타일 재사용 (별도 CSS 불필요).
|
||||
|
||||
6. **`hist-status` 재사용**: 태그 로드 상태 표시에 기존 `.hist-status` 클래스 재사용.
|
||||
|
||||
---
|
||||
|
||||
## 7. Todo List
|
||||
|
||||
| # | 작업 | 파일 | 상태 | 검증 방법 |
|
||||
|---|------|------|------|-----------|
|
||||
| 1 | `.evt-badge` ~ `.evt-total` CSS 추가 | `style.css` | ⬜ | 브라우저 개발자도구 CSS 확인 |
|
||||
| 2 | `<li data-tab="evt">` 탭 항목 추가 | `index.html` | ⬜ | 사이드바 탭 노출 확인 |
|
||||
| 3 | `#pane-evt` 섹션 전체 추가 | `index.html` | ⬜ | 탭 클릭 시 pane 활성화 확인 |
|
||||
| 4 | `evtLoadTags()` ~ `evtReset()` 함수 추가 | `app.js` | ⬜ | 버튼 클릭 → API 호출 확인 |
|
||||
| 5 | 태그 목록 로드 테스트 | — | ⬜ | 드롭다운에 디지털 태그 표시 |
|
||||
| 6 | 이벤트 조회 테스트 | — | ⬜ | 결과 테이블 + 배지 렌더링 확인 |
|
||||
| 7 | 구간 요약 테스트 | — | ⬜ | 요약 카드 그리드 렌더링 확인 |
|
||||
| 8 | 필터 조합 테스트 | — | ⬜ | TRIP 전용, Area 전용 등 확인 |
|
||||
| 9 | 초기화 테스트 | — | ⬜ | 모든 필드 리셋 확인 |
|
||||
1095
plans/이벤트-히스토리-테이블-코딩플랜.md
Normal file
1095
plans/이벤트-히스토리-테이블-코딩플랜.md
Normal file
File diff suppressed because it is too large
Load Diff
603
plans/이벤트-히스토리-테이블-플랜.md
Normal file
603
plans/이벤트-히스토리-테이블-플랜.md
Normal file
@@ -0,0 +1,603 @@
|
||||
# 이벤트 기반 디지털 포인트 히스토리 테이블 플랜
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 목적
|
||||
- 기존 periodic snapshot (60초마다)에서 **event-driven** 방식으로 변경
|
||||
- 디지털 포인트의 상태 변경 時만 기록하여 스토리지 절약
|
||||
|
||||
### 현재 상태
|
||||
- `ExperionHistoryService`가 60초마다 `realtime_table` 전체를 `history_table`에 스냅샷
|
||||
- 디지털/아날로그 구분 없이 모든 포인트가 Periodic으로 기록
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 1: 기존 히스토리 루틴에서 디지털 포인트 제외
|
||||
|
||||
### 구현 위치
|
||||
`src/Infrastructure/Database/ExperionDbContext.cs:730` - `SnapshotToHistoryAsync()`
|
||||
|
||||
### 변경 내용
|
||||
1. 디지털 태그 식별 기준 정의
|
||||
2. 기존 SnapshotToHistoryAsync 메서드에 Where 조건 추가
|
||||
|
||||
### 예상 코드 변경
|
||||
|
||||
```csharp
|
||||
// 메서드 시그니처에 디지털 필터 옵션 추가
|
||||
public async Task<int> SnapshotToHistoryAsync(bool includeDigital = false)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var query = _ctx.RealtimePoints.AsQueryable();
|
||||
|
||||
// 디지털 제외 (기본값)
|
||||
if (!includeDigital)
|
||||
{
|
||||
var digitalTagNames = await GetDigitalTagNamesAsync();
|
||||
query = query.Where(p => !digitalTagNames.Contains(p.TagName));
|
||||
}
|
||||
|
||||
var points = await query.ToListAsync();
|
||||
if (points.Count == 0) return 0;
|
||||
|
||||
var rows = points.Select(p => new HistoryRecord
|
||||
{
|
||||
TagName = p.TagName,
|
||||
NodeId = p.NodeId,
|
||||
Value = p.LiveValue,
|
||||
RecordedAt = now
|
||||
}).ToList();
|
||||
|
||||
await _ctx.HistoryRecords.AddRangeAsync(rows);
|
||||
var saved = await _ctx.SaveChangesAsync();
|
||||
_logger.LogDebug("[ExperionDb] history 스냅샷: {Count}건 @ {Time:HH:mm:ss}", saved, now);
|
||||
return saved;
|
||||
}
|
||||
|
||||
// 디지털 태그 목록 조회 (캐싱 고려)
|
||||
private async Task<HashSet<string>> GetDigitalTagNamesAsync()
|
||||
{
|
||||
// node_map_master에서 data_type = i=7594 인 태그 조회
|
||||
// 또는 tag_metadata에서 value 패턴이 {X | STATE | } 형태인 태그
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 2: 이벤트 히스토리 테이블 설계
|
||||
|
||||
### 테이블: event_history_table
|
||||
|
||||
```sql
|
||||
-- 이벤트 히스토리 테이블 생성
|
||||
CREATE TABLE event_history_table (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tagname TEXT NOT NULL,
|
||||
node_id TEXT NOT NULL,
|
||||
prev_value TEXT, -- 이전 상태 (예: "{0 | L-STOP | }")
|
||||
curr_value TEXT, -- 현재 상태 (예: "{0 | RUN | }")
|
||||
event_type TEXT NOT NULL, -- CHANGE, TRIP, RUN, ALARM, NORMAL, INTERLOCK
|
||||
event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
area TEXT, -- P1, P6, P8 등 (tag_metadata에서 조회)
|
||||
section TEXT, -- 6-1차, 6-2차 (태그 번호 기반: 61xx=6-1차, 62xx=6-2차)
|
||||
duration_seconds INT, -- 이전 상태 지속 시간 (초)
|
||||
metadata JSONB, -- 추가 정보 (선택): {"alarm_priority": 3, "interlock_tag": "lica-6113-trip"}
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 인덱스 (쿼리 성능 최적화)
|
||||
CREATE INDEX idx_event_history_tagname_time ON event_history_table(tagname, event_time DESC);
|
||||
CREATE INDEX idx_event_history_area_time ON event_history_table(area, event_time DESC);
|
||||
CREATE INDEX idx_event_history_section_time ON event_history_table(section, event_time DESC);
|
||||
CREATE INDEX idx_event_history_event_type ON event_history_table(event_type, event_time DESC);
|
||||
CREATE INDEX idx_event_history_tagname_event_type ON event_history_table(tagname, event_type, event_time DESC);
|
||||
|
||||
-- 테이블 설명 주석
|
||||
COMMENT ON TABLE event_history_table IS '디지털 포인트 상태 변경 이벤트 히스토리';
|
||||
COMMENT ON COLUMN event_history_table.event_type IS 'CHANGE: 일반 변경, TRIP: 정지, RUN: 가동, ALARM: 알람, NORMAL: 정상복귀, INTERLOCK: 인터록';
|
||||
```
|
||||
|
||||
### event_type 정의
|
||||
|
||||
| event_type | 설명 | 트리거 조건 |
|
||||
|------------|------|-------------|
|
||||
| `CHANGE` | 일반 상태 변경 | prev_value != curr_value |
|
||||
| `TRIP` | 장치 정지 | curr_value에 "L-STOP", "STOP", "TRIP" 포함 |
|
||||
| `RUN` | 장치 가동 | curr_value에 "RUN", "START" 포함 |
|
||||
| `ALARM` | 알람 발생 | alarm 상태 감지 |
|
||||
| `NORMAL` | 정상 복귀 | alarm clear |
|
||||
| `INTERLOCK` | 인터록 발생 | 인터록 관련 태그 (-il-rst, -trip) |
|
||||
| `SHUTDOWN` | 계획정지 | 명시적 shutdown 신호 |
|
||||
| `STARTUP` | 계획가동 | 명시적 startup 신호 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 3: 디지털 포인트 상태변화 감지 및 기록
|
||||
|
||||
### 4.1 새 서비스: DigitalEventDetectorService
|
||||
|
||||
**파일**: `src/Infrastructure/OpcUa/DigitalEventDetectorService.cs`
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// 디지털 포인트의 상태 변경을 감지하여 event_history_table에 기록하는 BackgroundService.
|
||||
/// 1초 간격으로 realtime_table을 검사하여 변경 사항을 기록.
|
||||
/// </summary>
|
||||
public class DigitalEventDetectorService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<DigitalEventDetectorService> _logger;
|
||||
private readonly Dictionary<string, DigitalPointState> _previousStates = new();
|
||||
private readonly HashSet<string> _digitalTagNames = new();
|
||||
private readonly ConcurrentDictionary<string, string> _areaCache = new();
|
||||
private readonly int _checkIntervalMs = 1000;
|
||||
private readonly int _debounceSeconds = 5; // 동일 상태 반복 방지
|
||||
|
||||
private record DigitalPointState(string Value, DateTime Timestamp, string? EventType);
|
||||
|
||||
public DigitalEventDetectorService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<DigitalEventDetectorService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("[DigitalEventDetector] 시작 — 감지 간격: {Interval}ms", _checkIntervalMs);
|
||||
|
||||
// 초기 디지털 태그 목록 로드
|
||||
await LoadDigitalTagNamesAsync();
|
||||
// 현재 상태 로드 (서비스 재시작 시 상태 손실 방지)
|
||||
await LoadCurrentStatesAsync();
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_checkIntervalMs, stoppingToken);
|
||||
await DetectAndRecordChangesAsync();
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[DigitalEventDetector] 감지 오류");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("[DigitalEventDetector] 종료");
|
||||
}
|
||||
|
||||
private async Task LoadDigitalTagNamesAsync()
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||||
|
||||
var digitalTags = await db.GetDigitalTagNamesAsync();
|
||||
_digitalTagNames.UnionWith(digitalTags);
|
||||
_logger.LogInformation("[DigitalEventDetector] 디지털 태그 {Count}개 로드됨", _digitalTagNames.Count);
|
||||
}
|
||||
|
||||
private async Task LoadCurrentStatesAsync()
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||||
var points = await db.GetDigitalPointsAsync();
|
||||
foreach (var p in points)
|
||||
_previousStates[p.TagName] = new DigitalPointState(p.LiveValue, DateTime.UtcNow, null);
|
||||
_logger.LogInformation("[DigitalEventDetector] 현재 상태 {Count}개 로드", _previousStates.Count);
|
||||
}
|
||||
|
||||
private async Task DetectAndRecordChangesAsync()
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||||
|
||||
// 디지털 포인트만 조회
|
||||
var currentPoints = await db.GetDigitalPointsAsync();
|
||||
|
||||
foreach (var point in currentPoints)
|
||||
{
|
||||
var tagName = point.TagName;
|
||||
var currValue = point.LiveValue;
|
||||
|
||||
// 첫 등장 시 이전 값 초기화 (이벤트 기록 안 함)
|
||||
if (!_previousStates.TryGetValue(tagName, out var prevState))
|
||||
{
|
||||
_previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 값 변경 감지
|
||||
if (prevState.Value != currValue)
|
||||
{
|
||||
var eventType = DetermineEventType(prevState.Value, currValue);
|
||||
|
||||
// Debounce: 동일 상태로의 반복만 방지, 상태 전환(TRIP→RUN 등)은 항상 기록
|
||||
if (prevState.EventType == eventType && prevState.Value == currValue &&
|
||||
(DateTime.UtcNow - prevState.Timestamp).TotalSeconds < _debounceSeconds)
|
||||
{
|
||||
_previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, eventType);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이벤트 기록
|
||||
var duration = (int)(DateTime.UtcNow - prevState.Timestamp).TotalSeconds;
|
||||
await db.RecordDigitalEventAsync(new DigitalEventRecord
|
||||
{
|
||||
TagName = tagName,
|
||||
NodeId = point.NodeId,
|
||||
PrevValue = prevState.Value,
|
||||
CurrValue = currValue,
|
||||
EventType = eventType,
|
||||
EventTime = DateTime.UtcNow,
|
||||
DurationSeconds = duration,
|
||||
Area = ExtractArea(tagName),
|
||||
Section = ExtractSection(tagName),
|
||||
Metadata = BuildMetadata(tagName, eventType, currValue)
|
||||
});
|
||||
|
||||
_logger.LogDebug("[DigitalEventDetector] {Tag}: {Event} ({Prev} → {Curr})",
|
||||
tagName, eventType, prevState.Value, currValue);
|
||||
|
||||
_previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, eventType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string DetermineEventType(string? prevValue, string currValue)
|
||||
{
|
||||
if (currValue.Contains("L-STOP") || currValue.Contains("STOP") || currValue.Contains("TRIP"))
|
||||
return "TRIP";
|
||||
if (currValue.Contains("RUN") || currValue.Contains("START"))
|
||||
return "RUN";
|
||||
if (currValue.Contains("ALARM"))
|
||||
return "ALARM";
|
||||
if (prevValue?.Contains("ALARM") == true && !currValue.Contains("ALARM"))
|
||||
return "NORMAL";
|
||||
return "CHANGE";
|
||||
}
|
||||
|
||||
private string? ExtractArea(string tagName)
|
||||
{
|
||||
if (_areaCache.TryGetValue(tagName, out var area)) return area;
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||||
area = db.GetAreaByTagNameAsync(tagName).GetAwaiter().GetResult();
|
||||
|
||||
if (area != null)
|
||||
_areaCache[tagName] = area;
|
||||
return area;
|
||||
}
|
||||
|
||||
private string? ExtractSection(string tagName)
|
||||
{
|
||||
// 태그 번호로 구간 추출: ficq-6101 → 6-1차, p-6202 → 6-2차
|
||||
// 첫 번째 숫자-두 번째 숫자 패턴 (일반화)
|
||||
var match = Regex.Match(tagName, @"-(\d)(\d)\d{2}");
|
||||
if (match.Success) return $"{match.Groups[1]}-{match.Groups[2]}차";
|
||||
return "기타";
|
||||
}
|
||||
|
||||
private string? BuildMetadata(string tagName, string eventType, string currValue)
|
||||
{
|
||||
// 인터록 태그인 경우 메타데이터 추가
|
||||
if (tagName.Contains("-il-") || tagName.Contains("-trip"))
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
interlock_tag = tagName,
|
||||
event_type = eventType,
|
||||
raw_value = currValue
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 IExperionServices 인터페이스 확장
|
||||
|
||||
**참고**: `DigitalEventRecord`는 서비스 계층 DTO, `EventHistoryRecord`는 EF Core Entity 클래스로 별도 정의 필요
|
||||
|
||||
```csharp
|
||||
// src/Core/Application/Interfaces/IExperionServices.cs에 추가
|
||||
public interface IExperionDbService
|
||||
{
|
||||
// ... 기존 메서드 ...
|
||||
|
||||
/// <summary>디지털 태그 이름 목록 조회</summary>
|
||||
Task<IEnumerable<string>> GetDigitalTagNamesAsync();
|
||||
|
||||
/// <summary>디지털 포인트 현재 값 조회</summary>
|
||||
Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync();
|
||||
|
||||
/// <summary>디지털 이벤트 기록</summary>
|
||||
Task<int> RecordDigitalEventAsync(DigitalEventRecord record);
|
||||
|
||||
/// <summary>태그명으로 area 조회 (tag_metadata 기반)</summary>
|
||||
Task<string?> GetAreaByTagNameAsync(string tagName);
|
||||
}
|
||||
|
||||
// 새로운 모델 클래스 (서비스 계층 DTO)
|
||||
public class DigitalEventRecord
|
||||
{
|
||||
public string TagName { get; set; } = "";
|
||||
public string NodeId { get; set; } = "";
|
||||
public string? PrevValue { get; set; }
|
||||
public string CurrValue { get; set; } = "";
|
||||
public string EventType { get; set; } = "";
|
||||
public DateTime EventTime { get; set; }
|
||||
public int? DurationSeconds { get; set; }
|
||||
public string? Area { get; set; }
|
||||
public string? Section { get; set; }
|
||||
public string? Metadata { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2.1 EventHistoryRecord Entity 클래스 정의
|
||||
|
||||
```csharp
|
||||
// src/Core/Domain/Entities/ExperionEntities.cs에 추가
|
||||
/// <summary>event_history_table — 디지털 포인트 상태 변경 이벤트</summary>
|
||||
[Table("event_history_table")]
|
||||
public class EventHistoryRecord
|
||||
{
|
||||
[Column("id")] public int Id { get; set; }
|
||||
[Column("tagname")] public string TagName { get; set; } = string.Empty;
|
||||
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
|
||||
[Column("prev_value")] public string? PrevValue { get; set; }
|
||||
[Column("curr_value")] public string CurrValue { get; set; } = string.Empty;
|
||||
[Column("event_type")] public string EventType { get; set; } = string.Empty;
|
||||
[Column("event_time")] public DateTime EventTime { get; set; } = DateTime.UtcNow;
|
||||
[Column("area")] public string? Area { get; set; }
|
||||
[Column("section")] public string? Section { get; set; }
|
||||
[Column("duration_seconds")] public int? DurationSeconds { get; set; }
|
||||
[Column("metadata")] public string? Metadata { get; set; }
|
||||
[Column("created_at")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 ExperionDbContext 구현 추가
|
||||
|
||||
```csharp
|
||||
// src/Infrastructure/Database/ExperionDbContext.cs에 추가
|
||||
|
||||
public async Task<IEnumerable<string>> GetDigitalTagNamesAsync()
|
||||
{
|
||||
// node_map_master에서 data_type = 'i=7594' 인 태그 조회
|
||||
// 또는 정규식으로 value 패턴이 {X | STATE | } 형태인 태그
|
||||
return await _ctx.RealtimePoints
|
||||
.Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
|
||||
.Select(p => p.TagName)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync()
|
||||
{
|
||||
return await _ctx.RealtimePoints
|
||||
.Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<string?> GetAreaByTagNameAsync(string tagName)
|
||||
{
|
||||
// tag_metadata에서 base_tag=tagName, attribute='area' 조회
|
||||
// value = "{12 | P6 | }" → "P6" 파싱
|
||||
var meta = await _ctx.TagMetadata
|
||||
.Where(m => m.BaseTag == tagName && m.Attribute == "area")
|
||||
.Select(m => m.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (string.IsNullOrEmpty(meta)) return null;
|
||||
|
||||
// "{12 | P6 | }" 패턴에서 area 코드 추출
|
||||
var match = System.Text.RegularExpressions.Regex.Match(meta, @"{\s*\d+\s*\|\s*(\w+)\s*\|");
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
public async Task<int> RecordDigitalEventAsync(DigitalEventRecord record)
|
||||
{
|
||||
var row = new EventHistoryRecord
|
||||
{
|
||||
TagName = record.TagName,
|
||||
NodeId = record.NodeId,
|
||||
PrevValue = record.PrevValue,
|
||||
CurrValue = record.CurrValue,
|
||||
EventType = record.EventType,
|
||||
EventTime = record.EventTime,
|
||||
DurationSeconds = record.DurationSeconds,
|
||||
Area = record.Area,
|
||||
Section = record.Section,
|
||||
Metadata = record.Metadata
|
||||
};
|
||||
|
||||
await _ctx.EventHistoryRecords.AddAsync(row);
|
||||
return await _ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// DbSet 추가
|
||||
public DbSet<EventHistoryRecord> EventHistoryRecords => Set<EventHistoryRecord>();
|
||||
```
|
||||
|
||||
### 4.4 Program.cs 등록
|
||||
|
||||
```csharp
|
||||
// src/Web/Program.cs에 추가
|
||||
builder.Services.AddHostedService<DigitalEventDetectorService>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 4: 보고서 기능 설계
|
||||
|
||||
### 5.1 요구사항 분석
|
||||
|
||||
**입력 예시**: "지난밤 6차 플랜트의 현황 보고"
|
||||
|
||||
**출력 형식**:
|
||||
```
|
||||
[제목] 2026-05-10 6차 플랜트 현황 보고
|
||||
[시간] 2026-05-09 00:00:00 ~ 2026-05-10 00:00:00
|
||||
|
||||
[6-1차 (태그명: %-61%)]
|
||||
- 00:00:00 p-6102 Trip 정지 → 00:05:30 가동 (정지 시간: 5분 30초)
|
||||
- 알람 발생: 3회
|
||||
- ficq-6101.qv.value (투입량): 12,500 kg
|
||||
- ficq-6118.qv.value (생산량): 11,200 kg
|
||||
|
||||
[6-2차 (태그명: %-62%)]
|
||||
- 02:30:00 p-6201 Trip 정지 → 02:45:00 가동 (정지 시간: 15분)
|
||||
- 알람 발생: 1회
|
||||
```
|
||||
|
||||
### 5.2 필요한 데이터
|
||||
|
||||
| 데이터 | 출처 | 설명 |
|
||||
|--------|------|------|
|
||||
| 구간별 이벤트 | event_history_table | 6-1차 (61xx), 6-2차 (62xx) |
|
||||
| Trip/Run 쌍 | event_history_table | 정지~가동 시간 계산 |
|
||||
| 알람 횟수 | event_history_table | event_type = 'ALARM' count |
|
||||
| 투입량 | history_table 또는 별도 테이블 | ficq-6101, ficq-6201 |
|
||||
| 생산량 | history_table 또는 별도 테이블 | ficq-6118, ficq-6218 |
|
||||
|
||||
### 5.3 구간(세션) 구분 로직
|
||||
|
||||
**참고**: Phase 3의 `ExtractSection`과 동일한 로직으로 통일 (진단 HIGH #1 반영)
|
||||
|
||||
```csharp
|
||||
private string? ExtractSection(string tagName)
|
||||
{
|
||||
// 태그 번호로 구간 추출: ficq-6101 → 6-1차, p-6202 → 6-2차
|
||||
// 첫 번째 숫자-두 번째 숫자 패턴 (일반화)
|
||||
var match = Regex.Match(tagName, @"-(\d)(\d)\d{2}");
|
||||
if (match.Success) return $"{match.Groups[1]}-{match.Groups[2]}차";
|
||||
return "기타";
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 적산값 처리
|
||||
|
||||
**문제**: history_table의 periodic snapshot에서 차이를 계산하면 노이즈가 많음
|
||||
|
||||
**해결방안**: event_history_table에 적산 정보 기록
|
||||
|
||||
```csharp
|
||||
// DigitalEventDetectorService에 추가
|
||||
// TRIP 발생 시 현재 적산값 저장
|
||||
// RUN 발생 시 (TRIP 시점 적산값 - 현재 적산값) = 정지 시간 동안의 미투입량
|
||||
|
||||
// 또는 별도 테이블
|
||||
CREATE TABLE accumulated_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tagname TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL, -- TRIP_START, RUN_START
|
||||
value_at_event DOUBLE,
|
||||
event_time TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 5.5 보고서 쿼리 예시
|
||||
|
||||
```sql
|
||||
-- 6차 플랜트 6-1차 구간 이벤트 요약
|
||||
SELECT
|
||||
section,
|
||||
event_type,
|
||||
COUNT(*) as count,
|
||||
MIN(event_time) as first_occurrence,
|
||||
MAX(event_time) as last_occurrence
|
||||
FROM event_history_table
|
||||
WHERE area = 'P6'
|
||||
AND section = '6-1차'
|
||||
AND event_time BETWEEN '2026-05-09 00:00:00' AND '2026-05-10 00:00:00'
|
||||
GROUP BY section, event_type
|
||||
ORDER BY event_type;
|
||||
|
||||
-- Trip별 정지 시간 계산 (TRIP → RUN 쌍 찾기)
|
||||
WITH trip_events AS (
|
||||
SELECT
|
||||
tagname,
|
||||
event_time as trip_time,
|
||||
LEAD(event_time) OVER (PARTITION BY tagname ORDER BY event_time) as run_time,
|
||||
LEAD(event_type) OVER (PARTITION BY tagname ORDER BY event_time) as next_event_type
|
||||
FROM event_history_table
|
||||
WHERE event_type = 'TRIP' AND area = 'P6'
|
||||
)
|
||||
SELECT
|
||||
tagname,
|
||||
trip_time,
|
||||
run_time,
|
||||
EXTRACT(EPOCH FROM (run_time - trip_time)) / 60 as duration_minutes
|
||||
FROM trip_events
|
||||
WHERE next_event_type = 'RUN';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 구현 로드맵
|
||||
|
||||
| Phase | 작업 | 우선순위 | 예상 시간 |
|
||||
|-------|------|----------|-----------|
|
||||
| 1 | 디지털 태그 식별 로직 추가 | 높음 | 1일 |
|
||||
| 1 | SnapshotToHistoryAsync 수정 | 높음 | 0.5일 |
|
||||
| 2 | event_history_table 생성 | 높음 | 0.5일 |
|
||||
| 3 | DigitalEventDetectorService 구현 | 높음 | 2일 |
|
||||
| 3 | IExperionServices 확장 | 높음 | 1일 |
|
||||
| 4 | 보고서 쿼리 작성 | 중간 | 2일 |
|
||||
| 4 | API 엔드포인트 추가 | 중간 | 1일 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 고려사항
|
||||
|
||||
### 7.1 중복 이벤트 방지
|
||||
- Debounce 로직 적용 (기본 5초)
|
||||
- 동일 상태로의 반복 발생 시 무시
|
||||
|
||||
### 7.2 마이그레이션
|
||||
- 기존 history_table의 디지털 데이터 보존 또는 별도 아카이브
|
||||
- 새로운 event_history_table로의 데이터 이전 (선택)
|
||||
|
||||
### 7.3 성능
|
||||
- 디지털 태그 수: ~500개
|
||||
- 감지 주기: 1초
|
||||
- 예상 INSERT: 초당 수십 건 (변경 시)
|
||||
|
||||
### 7.4 확장성
|
||||
- 알람 우선순위 정보 메타데이터에 추가
|
||||
- 인터록 체인 추적 (상위/interlock 원인 태그 기록)
|
||||
|
||||
---
|
||||
|
||||
## 8. 부록: 테스트 계획
|
||||
|
||||
### 8.1 단위 테스트
|
||||
- DetermineEventType 메서드 테스트
|
||||
- ExtractSection 메서드 테스트
|
||||
|
||||
### 8.2 통합 테스트
|
||||
- 실제 OPC UA 연결 없이 모의 데이터로 동작 확인
|
||||
- event_history_table에 올바른 기록 확인
|
||||
|
||||
### 8.3 성능 테스트
|
||||
- 500개 디지털 태그 처리 시 CPU/메모리 사용량
|
||||
- DB INSERT 성능
|
||||
|
||||
---
|
||||
|
||||
## 9. 참고 자료
|
||||
|
||||
- OPC UA LocalizedText (i=7594): 로컬라이즈된 텍스트 타입, `{locale | text | }` 형태
|
||||
- Experion 태그 명명 규칙: {type}-{unit}{number}.{attribute}
|
||||
- P6 플랜트 구조: 61xx = 6-1차, 62xx = 6-2차
|
||||
@@ -74,6 +74,7 @@ public interface IExperionDbService
|
||||
Task<int> BuildRealtimeTableAsync(IEnumerable<PointBuilderGroupDto> groups);
|
||||
Task<PointBuilderPreviewResult> PreviewRealtimeBuildAsync(IEnumerable<(string GroupKey, PointBuilderGroupDto Group)> groups);
|
||||
Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedNodeIds);
|
||||
Task<int> AppendPointsAsync(IEnumerable<string> nodeIds);
|
||||
Task<IEnumerable<RealtimePoint>> GetRealtimePointsAsync();
|
||||
Task<RealtimePoint> AddRealtimePointAsync(string nodeId);
|
||||
Task<bool> DeleteRealtimePointAsync(int id);
|
||||
@@ -81,7 +82,7 @@ public interface IExperionDbService
|
||||
Task<int> BatchUpdateLiveValuesAsync(IEnumerable<LiveValueUpdate> updates);
|
||||
|
||||
// ── HistoryTable ──────────────────────────────────────────────────────────
|
||||
Task<int> SnapshotToHistoryAsync();
|
||||
Task<int> SnapshotToHistoryAsync(bool includeDigital = false);
|
||||
Task<IEnumerable<string>> GetTagNamesAsync();
|
||||
Task<HistoryQueryResult> QueryHistoryAsync(
|
||||
IEnumerable<string> tagNames, DateTime? from, DateTime? to, int limit);
|
||||
@@ -119,6 +120,27 @@ public interface IExperionDbService
|
||||
|
||||
// ── 공통 (이미 없는 경우만) ──────────────────────────────────────────────────
|
||||
Task<string?> GetNodeIdByTagNameAsync(string tagName);
|
||||
|
||||
// ── Digital Event History ──────────────────────────────────────────────────
|
||||
/// <summary>디지털 태그 이름 목록 조회 (value 패턴 또는 tag_metadata 기반)</summary>
|
||||
Task<IEnumerable<string>> GetDigitalTagNamesAsync();
|
||||
|
||||
/// <summary>디지털 포인트 현재 값 조회</summary>
|
||||
Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync();
|
||||
|
||||
/// <summary>디지털 이벤트 기록</summary>
|
||||
Task<int> RecordDigitalEventAsync(DigitalEventRecord record);
|
||||
|
||||
/// <summary>디지털 이벤트 배치 기록</summary>
|
||||
Task<int> BatchRecordDigitalEventsAsync(IEnumerable<DigitalEventRecord> records);
|
||||
|
||||
/// <summary>태그명으로 area 조회 (tag_metadata 기반)</summary>
|
||||
Task<string?> GetAreaByTagNameAsync(string tagName);
|
||||
|
||||
/// <summary>이벤트 히스토리 조회</summary>
|
||||
Task<IEnumerable<EventHistoryRow>> QueryEventHistoryAsync(
|
||||
string? tagName, string? area, string? section,
|
||||
string? eventType, DateTime from, DateTime to, int limit = 500);
|
||||
}
|
||||
|
||||
// ── Realtime Service ─────────────────────────────────────────────────────────
|
||||
@@ -320,3 +342,34 @@ public interface IMetadataLoaderService
|
||||
/// </summary>
|
||||
Task<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null);
|
||||
}
|
||||
|
||||
/// <summary>디지털 이벤트 기록용 서비스 계층 DTO</summary>
|
||||
public class DigitalEventRecord
|
||||
{
|
||||
public string TagName { get; set; } = "";
|
||||
public string NodeId { get; set; } = "";
|
||||
public string? PrevValue { get; set; }
|
||||
public string CurrValue { get; set; } = "";
|
||||
public string EventType { get; set; } = "";
|
||||
public DateTime EventTime { get; set; }
|
||||
public int? DurationSeconds { get; set; }
|
||||
public string? Area { get; set; }
|
||||
public string? Section { get; set; }
|
||||
public string? Metadata { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>이벤트 히스토리 조회 결과 행</summary>
|
||||
public class EventHistoryRow
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string TagName { get; set; } = "";
|
||||
public string NodeId { get; set; } = "";
|
||||
public string? PrevValue { get; set; }
|
||||
public string CurrValue { get; set; } = "";
|
||||
public string EventType { get; set; } = "";
|
||||
public DateTime EventTime { get; set; }
|
||||
public string? Area { get; set; }
|
||||
public string? Section { get; set; }
|
||||
public int? DurationSeconds { get; set; }
|
||||
public string? Metadata { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -148,3 +149,22 @@ public class TagMetadata
|
||||
[Column("node_id")] public string? NodeId { get; set; }
|
||||
[Column("loaded_at")] public DateTime LoadedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>event_history_table — 디지털 포인트 상태 변경 이벤트</summary>
|
||||
[Table("event_history_table")]
|
||||
public class EventHistoryRecord
|
||||
{
|
||||
[Key]
|
||||
[Column("id")] public long Id { get; set; }
|
||||
[Column("tagname")] public string TagName { get; set; } = string.Empty;
|
||||
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
|
||||
[Column("prev_value")] public string? PrevValue { get; set; }
|
||||
[Column("curr_value")] public string CurrValue { get; set; } = string.Empty;
|
||||
[Column("event_type")] public string EventType { get; set; } = string.Empty;
|
||||
[Column("event_time")] public DateTime EventTime { get; set; } = DateTime.UtcNow;
|
||||
[Column("area")] public string? Area { get; set; }
|
||||
[Column("section")] public string? Section { get; set; }
|
||||
[Column("duration_seconds")] public int? DurationSeconds { get; set; }
|
||||
[Column("metadata")] public string? Metadata { get; set; }
|
||||
[Column("created_at")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ public class ExperionDbContext : DbContext
|
||||
public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>();
|
||||
public DbSet<PidAuditLog> PidAuditLog => Set<PidAuditLog>();
|
||||
public DbSet<PidGraphStatus> PidGraphStatuses => Set<PidGraphStatus>();
|
||||
public DbSet<EventHistoryRecord> EventHistoryRecords => Set<EventHistoryRecord>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -166,6 +167,19 @@ public class ExperionDbContext : DbContext
|
||||
|
||||
entity.HasIndex(e => e.UpdatedAt);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<EventHistoryRecord>(entity =>
|
||||
{
|
||||
entity.ToTable("event_history_table");
|
||||
entity.HasKey(e => e.Id);
|
||||
|
||||
entity.Property(e => e.Metadata)
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
entity.HasIndex(e => new { e.TagName, e.EventTime });
|
||||
entity.HasIndex(e => new { e.Area, e.EventTime });
|
||||
entity.HasIndex(e => new { e.EventType, e.EventTime });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +190,12 @@ public class ExperionDbService : IExperionDbService
|
||||
private readonly ExperionDbContext _ctx;
|
||||
private readonly ILogger<ExperionDbService> _logger;
|
||||
|
||||
private static HashSet<string> _digitalTagCache = new();
|
||||
private static DateTime _digitalTagCacheTime = DateTime.MinValue;
|
||||
private static Task? _cacheRefreshTask = null;
|
||||
private static readonly object _digitalCacheLock = new();
|
||||
private const int DigitalTagCacheTtlSeconds = 300;
|
||||
|
||||
public ExperionDbService(ExperionDbContext ctx, ILogger<ExperionDbService> logger)
|
||||
{
|
||||
_ctx = ctx;
|
||||
@@ -325,6 +345,37 @@ public class ExperionDbService : IExperionDbService
|
||||
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
|
||||
""");
|
||||
|
||||
// event_history_table 생성 (디지털 포인트 상태 변경 이벤트)
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS event_history_table (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tagname TEXT NOT NULL,
|
||||
node_id TEXT NOT NULL,
|
||||
prev_value TEXT,
|
||||
curr_value TEXT NOT NULL DEFAULT '',
|
||||
event_type TEXT NOT NULL,
|
||||
event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
area TEXT,
|
||||
section TEXT,
|
||||
duration_seconds INT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""");
|
||||
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
CREATE INDEX IF NOT EXISTS idx_event_history_tagname_time
|
||||
ON event_history_table(tagname, event_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_history_area_time
|
||||
ON event_history_table(area, event_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_history_section_time
|
||||
ON event_history_table(section, event_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_history_event_type
|
||||
ON event_history_table(event_type, event_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_history_tagname_event_type
|
||||
ON event_history_table(tagname, event_type, event_time DESC);
|
||||
""");
|
||||
|
||||
// history 테이블은 수동으로 하이퍼테이블 생성 필요
|
||||
// CreateHypertableAsync() 메서드를 사용하여 수동 생성 가능
|
||||
// 참고: 하이퍼테이블 생성 후 보존 정책, 압축 정책, 연속 집계 설정은
|
||||
@@ -653,6 +704,35 @@ public class ExperionDbService : IExperionDbService
|
||||
return points.Count;
|
||||
}
|
||||
|
||||
public async Task<int> AppendPointsAsync(IEnumerable<string> nodeIds)
|
||||
{
|
||||
var nodeIdsList = nodeIds.Where(n => !string.IsNullOrEmpty(n)).ToList();
|
||||
if (nodeIdsList.Count == 0) return 0;
|
||||
|
||||
var existingNodeIds = await _ctx.RealtimePoints
|
||||
.Where(p => nodeIdsList.Contains(p.NodeId))
|
||||
.Select(p => p.NodeId)
|
||||
.ToListAsync();
|
||||
|
||||
var newPoints = nodeIdsList
|
||||
.Where(n => !existingNodeIds.Contains(n))
|
||||
.Select(nodeId => new RealtimePoint
|
||||
{
|
||||
TagName = ExtractTagName(nodeId),
|
||||
NodeId = nodeId,
|
||||
LiveValue = null,
|
||||
Timestamp = DateTime.UtcNow
|
||||
}).ToList();
|
||||
|
||||
if (newPoints.Count == 0) return 0;
|
||||
|
||||
await _ctx.RealtimePoints.AddRangeAsync(newPoints);
|
||||
await _ctx.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("[ExperionDb] realtime_table 추가: {Count}건 (중복 제외)", newPoints.Count);
|
||||
return newPoints.Count;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RealtimePoint>> GetRealtimePointsAsync()
|
||||
{
|
||||
try
|
||||
@@ -727,10 +807,21 @@ public class ExperionDbService : IExperionDbService
|
||||
|
||||
// ── HistoryTable ──────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<int> SnapshotToHistoryAsync()
|
||||
public async Task<int> SnapshotToHistoryAsync(bool includeDigital = false)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var points = await _ctx.RealtimePoints.ToListAsync();
|
||||
var now = DateTime.UtcNow;
|
||||
var query = _ctx.RealtimePoints.AsQueryable();
|
||||
|
||||
if (!includeDigital)
|
||||
{
|
||||
var digitalTagNames = await GetDigitalTagNamesCachedAsync();
|
||||
if (digitalTagNames.Count > 0)
|
||||
{
|
||||
query = query.Where(p => !digitalTagNames.Contains(p.TagName));
|
||||
}
|
||||
}
|
||||
|
||||
var points = await query.ToListAsync();
|
||||
if (points.Count == 0) return 0;
|
||||
|
||||
var rows = points.Select(p => new HistoryRecord
|
||||
@@ -985,7 +1076,7 @@ public class ExperionDbService : IExperionDbService
|
||||
.Where(x => tags.Contains(x.TagName))
|
||||
.ToListAsync();
|
||||
|
||||
_logger.LogInformation("[Realtime] 태그 {Count}개의 라이브 데이터 조회 완료", tags.Count);
|
||||
_logger.LogDebug("[Realtime] 태그 {Count}개의 라이브 데이터 조회 완료", tags.Count);
|
||||
return records;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1131,6 +1222,160 @@ public class ExperionDbService : IExperionDbService
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
// ── Digital Event History ──────────────────────────────────────────────────
|
||||
|
||||
public async Task<IEnumerable<string>> GetDigitalTagNamesAsync()
|
||||
{
|
||||
var fromMetadata = await _ctx.TagMetadata
|
||||
.Where(m => m.Value == "i=7594")
|
||||
.Select(m => m.BaseTag)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
if (fromMetadata.Any())
|
||||
return fromMetadata;
|
||||
|
||||
return await _ctx.RealtimePoints
|
||||
.Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
|
||||
.Select(p => p.TagName)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync()
|
||||
{
|
||||
var digitalTagNames = await GetDigitalTagNamesAsync();
|
||||
var tagSet = new HashSet<string>(digitalTagNames);
|
||||
|
||||
if (tagSet.Count == 0)
|
||||
return Enumerable.Empty<RealtimePoint>();
|
||||
|
||||
return await _ctx.RealtimePoints
|
||||
.Where(p => tagSet.Contains(p.TagName))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<string?> GetAreaByTagNameAsync(string tagName)
|
||||
{
|
||||
var baseTag = tagName.Contains('.') ? tagName[..tagName.LastIndexOf('.')] : tagName;
|
||||
var meta = await _ctx.TagMetadata
|
||||
.Where(m => m.BaseTag == baseTag && m.Attribute == "area")
|
||||
.Select(m => m.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (string.IsNullOrEmpty(meta)) return null;
|
||||
|
||||
var match = System.Text.RegularExpressions.Regex.Match(meta, @"{\s*\d+\s*\|\s*(\w+)\s*\|");
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
public async Task<int> RecordDigitalEventAsync(DigitalEventRecord record)
|
||||
{
|
||||
var row = new EventHistoryRecord
|
||||
{
|
||||
TagName = record.TagName,
|
||||
NodeId = record.NodeId,
|
||||
PrevValue = record.PrevValue,
|
||||
CurrValue = record.CurrValue,
|
||||
EventType = record.EventType,
|
||||
EventTime = record.EventTime,
|
||||
DurationSeconds = record.DurationSeconds,
|
||||
Area = record.Area,
|
||||
Section = record.Section,
|
||||
Metadata = record.Metadata
|
||||
};
|
||||
|
||||
await _ctx.EventHistoryRecords.AddAsync(row);
|
||||
return await _ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<int> BatchRecordDigitalEventsAsync(IEnumerable<DigitalEventRecord> records)
|
||||
{
|
||||
var rows = records.Select(r => new EventHistoryRecord
|
||||
{
|
||||
TagName = r.TagName,
|
||||
NodeId = r.NodeId,
|
||||
PrevValue = r.PrevValue,
|
||||
CurrValue = r.CurrValue,
|
||||
EventType = r.EventType,
|
||||
EventTime = r.EventTime,
|
||||
DurationSeconds = r.DurationSeconds,
|
||||
Area = r.Area,
|
||||
Section = r.Section,
|
||||
Metadata = r.Metadata
|
||||
}).ToList();
|
||||
|
||||
await _ctx.EventHistoryRecords.AddRangeAsync(rows);
|
||||
return await _ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<EventHistoryRow>> QueryEventHistoryAsync(
|
||||
string? tagName, string? area, string? section,
|
||||
string? eventType, DateTime from, DateTime to, int limit = 500)
|
||||
{
|
||||
var query = _ctx.EventHistoryRecords
|
||||
.Where(r => r.EventTime >= from && r.EventTime <= to);
|
||||
|
||||
if (!string.IsNullOrEmpty(tagName))
|
||||
query = query.Where(r => r.TagName == tagName);
|
||||
if (!string.IsNullOrEmpty(area))
|
||||
query = query.Where(r => r.Area == area);
|
||||
if (!string.IsNullOrEmpty(section))
|
||||
query = query.Where(r => r.Section == section);
|
||||
if (!string.IsNullOrEmpty(eventType))
|
||||
query = query.Where(r => r.EventType == eventType);
|
||||
|
||||
var records = await query
|
||||
.OrderByDescending(r => r.EventTime)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
|
||||
return records.Select(r => new EventHistoryRow
|
||||
{
|
||||
Id = r.Id,
|
||||
TagName = r.TagName,
|
||||
NodeId = r.NodeId,
|
||||
PrevValue = r.PrevValue,
|
||||
CurrValue = r.CurrValue,
|
||||
EventType = r.EventType,
|
||||
EventTime = r.EventTime,
|
||||
Area = r.Area,
|
||||
Section = r.Section,
|
||||
DurationSeconds = r.DurationSeconds,
|
||||
Metadata = r.Metadata
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> GetDigitalTagNamesCachedAsync()
|
||||
{
|
||||
if ((DateTime.UtcNow - _digitalTagCacheTime).TotalSeconds < DigitalTagCacheTtlSeconds)
|
||||
return _digitalTagCache;
|
||||
|
||||
Task refreshTask;
|
||||
lock (_digitalCacheLock)
|
||||
{
|
||||
if (_cacheRefreshTask == null)
|
||||
_cacheRefreshTask = RefreshDigitalTagCacheAsync();
|
||||
refreshTask = _cacheRefreshTask;
|
||||
}
|
||||
|
||||
await refreshTask;
|
||||
|
||||
lock (_digitalCacheLock)
|
||||
{
|
||||
_cacheRefreshTask = null;
|
||||
}
|
||||
|
||||
return _digitalTagCache;
|
||||
}
|
||||
|
||||
private async Task RefreshDigitalTagCacheAsync()
|
||||
{
|
||||
var tags = await GetDigitalTagNamesAsync();
|
||||
_digitalTagCache = new HashSet<string>(tags);
|
||||
_digitalTagCacheTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 하이퍼테이블 상태 조회합니다.
|
||||
/// 하이퍼테이블인지 여부, 레코드 수, 보존 정책, 압축, 연속 집계 설정 등을 확인합니다.
|
||||
|
||||
226
src/Infrastructure/OpcUa/DigitalEventDetectorService.cs
Normal file
226
src/Infrastructure/OpcUa/DigitalEventDetectorService.cs
Normal file
@@ -0,0 +1,226 @@
|
||||
using ExperionCrawler.Core.Application.Interfaces;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// 디지털 포인트의 상태 변경을 감지하여 event_history_table에 기록하는 BackgroundService.
|
||||
/// 1초 간격으로 realtime_table을 검사하여 변경 사항을 기록.
|
||||
/// </summary>
|
||||
public class DigitalEventDetectorService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<DigitalEventDetectorService> _logger;
|
||||
private readonly ConcurrentDictionary<string, DigitalPointState> _previousStates = new();
|
||||
private readonly ConcurrentDictionary<string, string> _areaCache = new();
|
||||
private readonly int _checkIntervalMs = 1000;
|
||||
private readonly int _debounceSeconds = 5;
|
||||
private HashSet<string> _knownDigitalTags = new();
|
||||
private DateTime _lastTagRefresh = DateTime.MinValue;
|
||||
private const int TagRefreshIntervalMinutes = 5;
|
||||
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null
|
||||
};
|
||||
|
||||
private record DigitalPointState(string Value, DateTime Timestamp, string? EventType);
|
||||
|
||||
public DigitalEventDetectorService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<DigitalEventDetectorService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("[DigitalEventDetector] 시작 — 감지 간격: {Interval}ms", _checkIntervalMs);
|
||||
|
||||
try
|
||||
{
|
||||
await LoadDigitalTagNamesAsync(stoppingToken);
|
||||
await LoadCurrentStatesAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[DigitalEventDetector] 초기화 실패 — 서비스 계속 실행 (감지 루프에서 재시도)");
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_checkIntervalMs, stoppingToken);
|
||||
await DetectAndRecordChangesAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[DigitalEventDetector] 감지 루프 오류");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("[DigitalEventDetector] 종료");
|
||||
}
|
||||
|
||||
private async Task LoadDigitalTagNamesAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||||
|
||||
var digitalTags = await db.GetDigitalTagNamesAsync();
|
||||
foreach (var tag in digitalTags)
|
||||
{
|
||||
if (!_previousStates.ContainsKey(tag))
|
||||
{
|
||||
_previousStates.TryAdd(tag, null!);
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("[DigitalEventDetector] 디지털 태그 {Count}개 로드됨", digitalTags.Count());
|
||||
}
|
||||
|
||||
private async Task LoadCurrentStatesAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||||
|
||||
var points = await db.GetDigitalPointsAsync();
|
||||
foreach (var p in points)
|
||||
{
|
||||
_previousStates[p.TagName] = new DigitalPointState(p.LiveValue ?? "", DateTime.UtcNow, null);
|
||||
}
|
||||
_logger.LogInformation("[DigitalEventDetector] 현재 상태 {Count}개 로드", _previousStates.Count);
|
||||
}
|
||||
|
||||
private async Task DetectAndRecordChangesAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||||
|
||||
// 5분마다 디지털 태그 목록 갱신 (신규 태그 감지용)
|
||||
if ((DateTime.UtcNow - _lastTagRefresh).TotalMinutes >= TagRefreshIntervalMinutes)
|
||||
{
|
||||
var tags = await db.GetDigitalTagNamesAsync();
|
||||
_knownDigitalTags = new HashSet<string>(tags);
|
||||
_lastTagRefresh = DateTime.UtcNow;
|
||||
foreach (var tag in _knownDigitalTags)
|
||||
_previousStates.TryAdd(tag, null!);
|
||||
}
|
||||
|
||||
// tag_metadata 재조회 없이 realtime_table 직접 쿼리
|
||||
var queryTags = _knownDigitalTags.Count > 0 ? _knownDigitalTags : _previousStates.Keys.ToHashSet();
|
||||
var currentPoints = queryTags.Count > 0
|
||||
? await db.GetRealtimeRecordsByTagNamesAsync(queryTags)
|
||||
: Enumerable.Empty<ExperionCrawler.Core.Domain.Entities.RealtimePoint>();
|
||||
var events = new List<DigitalEventRecord>();
|
||||
|
||||
foreach (var point in currentPoints)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
var tagName = point.TagName;
|
||||
var currValue = point.LiveValue ?? "";
|
||||
|
||||
var prevState = _previousStates.GetValueOrDefault(tagName);
|
||||
|
||||
if (prevState == null)
|
||||
{
|
||||
_previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (prevState.Value != currValue)
|
||||
{
|
||||
var eventType = DetermineEventType(prevState.Value, currValue);
|
||||
var now = DateTime.UtcNow;
|
||||
var elapsed = (now - prevState.Timestamp).TotalSeconds;
|
||||
|
||||
if (prevState.EventType == eventType && elapsed < _debounceSeconds)
|
||||
{
|
||||
_previousStates[tagName] = new DigitalPointState(currValue, now, eventType);
|
||||
continue;
|
||||
}
|
||||
|
||||
var duration = (int)elapsed;
|
||||
var area = await GetAreaAsync(db, tagName);
|
||||
var section = ExtractSection(tagName);
|
||||
|
||||
events.Add(new DigitalEventRecord
|
||||
{
|
||||
TagName = tagName,
|
||||
NodeId = point.NodeId,
|
||||
PrevValue = prevState.Value,
|
||||
CurrValue = currValue,
|
||||
EventType = eventType,
|
||||
EventTime = now,
|
||||
DurationSeconds = duration,
|
||||
Area = area,
|
||||
Section = section,
|
||||
Metadata = BuildMetadata(tagName, eventType, currValue)
|
||||
});
|
||||
|
||||
_logger.LogDebug("[DigitalEventDetector] {Tag}: {Event} ({Prev} → {Curr}, {Duration}s)",
|
||||
tagName, eventType, prevState.Value, currValue, duration);
|
||||
|
||||
_previousStates[tagName] = new DigitalPointState(currValue, now, eventType);
|
||||
}
|
||||
}
|
||||
|
||||
if (events.Count > 0)
|
||||
{
|
||||
await db.BatchRecordDigitalEventsAsync(events);
|
||||
}
|
||||
}
|
||||
|
||||
private string DetermineEventType(string prevValue, string currValue)
|
||||
{
|
||||
if (currValue.Contains("FAULT") || currValue.Contains("TRIP"))
|
||||
return "TRIP";
|
||||
if (currValue.Contains("ALARM"))
|
||||
return "ALARM";
|
||||
if (prevValue.Contains("ALARM") && !currValue.Contains("ALARM"))
|
||||
return "NORMAL";
|
||||
if (currValue.Contains("RUN") || currValue.Contains("START"))
|
||||
return "RUN";
|
||||
return "CHANGE";
|
||||
}
|
||||
|
||||
private async Task<string?> GetAreaAsync(IExperionDbService db, string tagName)
|
||||
{
|
||||
if (_areaCache.TryGetValue(tagName, out var cached)) return cached;
|
||||
|
||||
var area = await db.GetAreaByTagNameAsync(tagName);
|
||||
if (area != null)
|
||||
_areaCache[tagName] = area;
|
||||
return area;
|
||||
}
|
||||
|
||||
private string? ExtractSection(string tagName)
|
||||
{
|
||||
var match = Regex.Match(tagName, @"-(\d)(\d)\d{2}");
|
||||
if (match.Success) return $"{match.Groups[1]}-{match.Groups[2]}차";
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? BuildMetadata(string tagName, string eventType, string currValue)
|
||||
{
|
||||
if (tagName.Contains("-il-", StringComparison.OrdinalIgnoreCase) || tagName.Contains("-trip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
interlock_tag = tagName,
|
||||
event_type = eventType,
|
||||
raw_value = currValue
|
||||
}, _jsonOptions);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -380,6 +380,29 @@ public class ExperionPointBuilderController : ControllerBase
|
||||
return Ok(new { success = true, count, metaCount, message = $"{count}개 포인트 적용 완료 (메타데이터: {metaCount}개)" });
|
||||
}
|
||||
|
||||
/// <summary>선택된 포인트만 realtime_table에 추가 (기존 데이터 유지, 중복 제외)</summary>
|
||||
[HttpPost("append")]
|
||||
public async Task<IActionResult> Append([FromBody] PointBuilderApplyDto dto)
|
||||
{
|
||||
if (dto.SelectedNodeIds == null || dto.SelectedNodeIds.Count == 0)
|
||||
return BadRequest(new { success = false, message = "선택된 포인트가 없습니다." });
|
||||
|
||||
var count = await _dbSvc.AppendPointsAsync(dto.SelectedNodeIds);
|
||||
|
||||
var metaCount = 0;
|
||||
try
|
||||
{
|
||||
var cfg = GetServerConfig();
|
||||
metaCount = await _metaSvc.ReloadMetadataAsync(cfg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[PointBuilder] 메타데이터 자동 로드 실패 (포인트 추가는 완료됨)");
|
||||
}
|
||||
|
||||
return Ok(new { success = true, count, metaCount, message = $"{count}개 포인트 추가 완료 (메타데이터: {metaCount}개)" });
|
||||
}
|
||||
|
||||
/// <summary>realtime_table 전체 조회</summary>
|
||||
[HttpGet("points")]
|
||||
public async Task<IActionResult> GetPoints()
|
||||
@@ -1156,3 +1179,186 @@ public class ExperionPidController : ControllerBase
|
||||
}
|
||||
|
||||
public record PinRequest(bool Pinned);
|
||||
|
||||
// ── Event History Controller ────────────────────────────────────────────────────
|
||||
|
||||
[ApiController]
|
||||
[Route("api/event-history")]
|
||||
public class EventHistoryController : ControllerBase
|
||||
{
|
||||
private readonly IExperionDbService _db;
|
||||
private readonly ILogger<EventHistoryController> _logger;
|
||||
|
||||
public EventHistoryController(
|
||||
IExperionDbService db,
|
||||
ILogger<EventHistoryController> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Query(
|
||||
[FromQuery] string? tagName,
|
||||
[FromQuery] string? area,
|
||||
[FromQuery] string? section,
|
||||
[FromQuery] string? eventType,
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int limit = 500)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fromDt = from ?? DateTime.UtcNow.AddDays(-1);
|
||||
var toDt = to ?? DateTime.UtcNow;
|
||||
var rows = await _db.QueryEventHistoryAsync(tagName, area, section, eventType, fromDt, toDt, limit);
|
||||
var list = rows.Select(r => new
|
||||
{
|
||||
id = r.Id,
|
||||
tagName = r.TagName,
|
||||
nodeId = r.NodeId,
|
||||
prevValue = r.PrevValue,
|
||||
currValue = r.CurrValue,
|
||||
eventType = r.EventType,
|
||||
eventTime = r.EventTime,
|
||||
area = r.Area,
|
||||
section = r.Section,
|
||||
durationSeconds = r.DurationSeconds,
|
||||
metadata = r.Metadata
|
||||
}).ToList();
|
||||
|
||||
return Ok(new { success = true, count = list.Count, data = list });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[EventHistoryController] 조회 실패");
|
||||
return Ok(new { success = false, error = ex.Message, data = (List<object>)new List<object>() });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("summary")]
|
||||
public async Task<IActionResult> GetSummary(
|
||||
[FromQuery] string? area,
|
||||
[FromQuery] string? section,
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fromDt = from ?? DateTime.UtcNow.AddDays(-1);
|
||||
var toDt = to ?? DateTime.UtcNow;
|
||||
var rows = await _db.QueryEventHistoryAsync(null, area, section, null, fromDt, toDt, 10000);
|
||||
|
||||
var summary = rows.GroupBy(r => r.Section ?? "기타")
|
||||
.Select(g => new
|
||||
{
|
||||
section = g.Key,
|
||||
totalEvents = g.Count(),
|
||||
tripCount = g.Count(r => r.EventType == "TRIP"),
|
||||
runCount = g.Count(r => r.EventType == "RUN"),
|
||||
alarmCount = g.Count(r => r.EventType == "ALARM"),
|
||||
changeCount = g.Count(r => r.EventType == "CHANGE")
|
||||
}).ToList();
|
||||
|
||||
return Ok(new { success = true, count = summary.Count, data = summary });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[EventHistoryController] 요약 조회 실패");
|
||||
return Ok(new { success = false, error = ex.Message, data = (List<object>)new List<object>() });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("digital-tags")]
|
||||
public async Task<IActionResult> GetDigitalTags()
|
||||
{
|
||||
try
|
||||
{
|
||||
var tags = await _db.GetDigitalTagNamesAsync();
|
||||
var list = tags.Select(t => new { tagName = t }).ToList();
|
||||
return Ok(new { success = true, count = list.Count, data = list });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[EventHistoryController] 디지털 태그 조회 실패");
|
||||
return Ok(new { success = false, error = ex.Message, data = (List<object>)new List<object>() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── LLM 설정 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
[ApiController]
|
||||
[Route("api/llm")]
|
||||
public class LlmConfigController : ControllerBase
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<LlmConfigController> _logger;
|
||||
|
||||
public LlmConfigController(
|
||||
IConfiguration config,
|
||||
ILogger<LlmConfigController> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
string LlmModelPath
|
||||
{
|
||||
get
|
||||
{
|
||||
var mcpDir = _config["McpServer:WorkingDirectory"] ?? "../../mcp-server";
|
||||
if (!Path.IsPathRooted(mcpDir))
|
||||
mcpDir = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), mcpDir));
|
||||
return Path.Combine(mcpDir, "llm-model.json");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("config")]
|
||||
public IActionResult GetConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = LlmModelPath;
|
||||
if (!System.IO.File.Exists(path))
|
||||
{
|
||||
return Ok(new { success = false, error = "llm-model.json not found", vllmModel = "Qwen3.6-27B-FP8" });
|
||||
}
|
||||
var json = System.IO.File.ReadAllText(path);
|
||||
var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
var model = doc.RootElement.TryGetProperty("vllm_model", out var v)
|
||||
? v.GetString() ?? "Qwen3.6-27B-FP8"
|
||||
: "Qwen3.6-27B-FP8";
|
||||
return Ok(new { success = true, vllmModel = model });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[LlmConfigController] 설정 조회 실패");
|
||||
return Ok(new { success = false, error = ex.Message, vllmModel = (string?)null });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("config")]
|
||||
public IActionResult SetConfig([FromBody] System.Text.Json.JsonElement body)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = LlmModelPath;
|
||||
var model = body.TryGetProperty("vllm_model", out var v) ? v.GetString() : null;
|
||||
if (string.IsNullOrEmpty(model))
|
||||
{
|
||||
return Ok(new { success = false, error = "vllm_model is required" });
|
||||
}
|
||||
|
||||
var json = $"{{\"vllm_model\": \"{model}\"}}";
|
||||
System.IO.File.WriteAllText(path, json);
|
||||
_logger.LogInformation("[LlmConfigController] 모델 변경: {Model}", model);
|
||||
return Ok(new { success = true, vllmModel = model });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[LlmConfigController] 설정 저장 실패");
|
||||
return Ok(new { success = false, error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ builder.Services.AddSingleton<IExperionRealtimeService>(
|
||||
builder.Services.AddHostedService(
|
||||
sp => sp.GetRequiredService<ExperionRealtimeService>());
|
||||
builder.Services.AddHostedService<ExperionHistoryService>();
|
||||
builder.Services.AddHostedService<DigitalEventDetectorService>();
|
||||
|
||||
// ── MCP Service ───────────────────────────────────────────────────────────────
|
||||
// Python MCP 서버 (localhost:5001)와 통신
|
||||
|
||||
@@ -1487,3 +1487,54 @@ tr:last-child td { border-bottom: none; }
|
||||
.pb-preview-header { flex-direction: column; gap: 8px; align-items: flex-start; }
|
||||
.pb-preview-actions { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
/* ── Event History ─────────────────────────────────────────── */
|
||||
.evt-badge {
|
||||
display: inline-block;
|
||||
font-family: var(--fm); font-size: 10px; font-weight: 700;
|
||||
letter-spacing: .06em; padding: 2px 8px; border-radius: 3px;
|
||||
text-transform: uppercase; white-space: nowrap;
|
||||
}
|
||||
.evt-badge.trip { background: rgba(239,68,68,.18); color: #f87171; }
|
||||
.evt-badge.run { background: rgba(16,185,129,.18); color: #34d399; }
|
||||
.evt-badge.alarm { background: rgba(245,158,11,.18); color: #fbbf24; }
|
||||
.evt-badge.normal { background: rgba(148,163,184,.18); color: #94a3b8; }
|
||||
.evt-badge.change { background: rgba(96,165,250,.18); color: #60a5fa; }
|
||||
|
||||
.evt-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.evt-summary-item {
|
||||
background: var(--s2);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.evt-summary-section {
|
||||
font-family: var(--fm); font-size: 13px; font-weight: 700;
|
||||
color: var(--t0); margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.evt-summary-counts {
|
||||
display: flex; gap: 10px; flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.evt-count {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-family: var(--fm); font-size: 11px; color: var(--t2);
|
||||
}
|
||||
|
||||
.evt-count strong { font-size: 14px; color: var(--t0); }
|
||||
|
||||
.evt-total {
|
||||
font-family: var(--fm); font-size: 11px; color: var(--t2);
|
||||
margin-top: 8px; padding-top: 8px;
|
||||
border-top: 1px solid var(--bd);
|
||||
}
|
||||
|
||||
.evt-total strong { color: var(--t0); }
|
||||
|
||||
@@ -76,6 +76,10 @@
|
||||
<span class="ni">11</span>
|
||||
<span class="nl">P&ID 추출</span>
|
||||
</li>
|
||||
<li class="nav-item" data-tab="evt">
|
||||
<span class="ni">12</span>
|
||||
<span class="nl">이벤트 히스토리</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sb-foot">
|
||||
@@ -172,6 +176,16 @@
|
||||
<div id="tag-box" class="tag-box hidden"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-cap">LLM 설정</div>
|
||||
<div class="row-inp">
|
||||
<input id="llm-model" class="inp flex1" placeholder="모델명" />
|
||||
<button class="btn-b" onclick="llmLoadConfig()">🔄 불러오기</button>
|
||||
<button class="btn-a" onclick="llmSaveConfig()">💾 저장</button>
|
||||
</div>
|
||||
<div id="llm-status" class="kv-box" style="margin-top:8px"></div>
|
||||
</div>
|
||||
|
||||
<div id="conn-log" class="logbox hidden"></div>
|
||||
<div id="browse-wrap" class="bwrap hidden"></div>
|
||||
</section>
|
||||
@@ -620,6 +634,7 @@
|
||||
<div class="btn-row" style="margin-top:10px;margin-bottom:0">
|
||||
<button class="btn-b" onclick="pbCancelPreview()">취소</button>
|
||||
<button class="btn-a" id="pb-apply-btn" onclick="pbApplySelected()">✓ 선택된 포인트 적용하기</button>
|
||||
<button class="btn-a" onclick="pbAppendSelected()">+ 기존 데이터에 추가하기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1116,6 +1131,90 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
12 이벤트 히스토리
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="pane" id="pane-evt">
|
||||
<header class="pane-hdr">
|
||||
<div>
|
||||
<h1>이벤트 히스토리</h1>
|
||||
<p>디지털 포인트 상태 변경 이벤트를 조회합니다. (event_history_table)</p>
|
||||
</div>
|
||||
<div class="pane-tag">EVENT / DIGITAL</div>
|
||||
</header>
|
||||
|
||||
<!-- 조회 조건 카드 -->
|
||||
<div class="card">
|
||||
<div class="card-cap">조회 조건</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:13px">
|
||||
<span>태그 필터</span>
|
||||
<button class="btn-b btn-sm" onclick="evtLoadTags()">▼ 태그 목록 불러오기</button>
|
||||
<span id="evt-tag-status" class="hist-status"></span>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<select id="ef-tag" class="inp">
|
||||
<option value="">— 전체 태그 —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cols-4">
|
||||
<div class="fg">
|
||||
<label>이벤트 타입</label>
|
||||
<select id="ef-event-type" class="inp">
|
||||
<option value="">전체</option>
|
||||
<option value="TRIP">TRIP</option>
|
||||
<option value="RUN">RUN</option>
|
||||
<option value="ALARM">ALARM</option>
|
||||
<option value="NORMAL">NORMAL</option>
|
||||
<option value="CHANGE">CHANGE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>Area <em>(예: P6)</em></label>
|
||||
<input id="ef-area" class="inp" type="text" placeholder="비워두면 전체"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>Section <em>(예: 1-2차)</em></label>
|
||||
<input id="ef-section" class="inp" type="text" placeholder="비워두면 전체"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>최대 행 수</label>
|
||||
<input id="ef-limit" class="inp" type="number" value="500" min="10" max="5000"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cols-2">
|
||||
<div class="fg">
|
||||
<label>시작 시간</label>
|
||||
<input type="hidden" id="hf-evt-from"/>
|
||||
<div class="dt-display inp" id="dtp-evt-from-display" onclick="dtOpen('evt-from')">— 선택 안 함 —</div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>종료 시간</label>
|
||||
<input type="hidden" id="hf-evt-to"/>
|
||||
<div class="dt-display inp" id="dtp-evt-to-display" onclick="dtOpen('evt-to')">— 선택 안 함 —</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn-a" onclick="evtQuery()">🔍 이벤트 조회</button>
|
||||
<button class="btn-b" onclick="evtSummary()">📊 구간 요약</button>
|
||||
<button class="btn-b" onclick="evtReset()">초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 요약 결과 카드 -->
|
||||
<div id="evt-summary-card" class="card hidden">
|
||||
<div class="card-cap">구간별 이벤트 요약</div>
|
||||
<div id="evt-summary-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- 조회 결과 -->
|
||||
<div id="evt-result-info" class="nm-result-info hidden" style="margin:8px 0"></div>
|
||||
<div id="evt-table" class="tbl-wrap hidden"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -156,18 +156,55 @@ async function connRead() {
|
||||
}
|
||||
}
|
||||
|
||||
async function llmLoadConfig() {
|
||||
const statusEl = document.getElementById('llm-status');
|
||||
const input = document.getElementById('llm-model');
|
||||
statusEl.innerHTML = '<span class="placeholder">불러오는 중...</span>';
|
||||
try {
|
||||
const d = await api('GET', '/api/llm/config');
|
||||
if (d.success) {
|
||||
input.value = d.vllmModel || '';
|
||||
statusEl.innerHTML = `<span style="color:var(--color-success)">✓ 현재 모델: ${esc(d.vllmModel)}</span>`;
|
||||
} else {
|
||||
statusEl.innerHTML = `<span style="color:var(--color-danger)">✗ ${esc(d.error || '조회 실패')}</span>`;
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.innerHTML = `<span style="color:var(--color-danger)">✗ ${esc(e.message)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function llmSaveConfig() {
|
||||
const statusEl = document.getElementById('llm-status');
|
||||
const model = document.getElementById('llm-model').value.trim();
|
||||
if (!model) {
|
||||
statusEl.innerHTML = '<span style="color:var(--color-danger)">모델명을 입력하세요</span>';
|
||||
return;
|
||||
}
|
||||
statusEl.innerHTML = '<span class="placeholder">저장 중...</span>';
|
||||
try {
|
||||
const d = await api('POST', '/api/llm/config', { vllm_model: model });
|
||||
if (d.success) {
|
||||
statusEl.innerHTML = `<span style="color:var(--color-success)">✓ 모델 변경 완료: ${esc(d.vllmModel)} (다음 LLM 요청 시 반영)</span>`;
|
||||
} else {
|
||||
statusEl.innerHTML = `<span style="color:var(--color-danger)">✗ ${esc(d.error || '저장 실패')}</span>`;
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.innerHTML = `<span style="color:var(--color-danger)">✗ ${esc(e.message)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function connBrowse() {
|
||||
const wrap = document.getElementById('browse-wrap');
|
||||
const logEl = document.getElementById('conn-log');
|
||||
setGlobal('busy', '노드 탐색');
|
||||
|
||||
|
||||
// 로그 초기화
|
||||
logEl.classList.remove('hidden');
|
||||
logEl.innerHTML = '<div class="ll inf">[진행] 노드 탐색 시작...</div>';
|
||||
|
||||
|
||||
try {
|
||||
logEl.innerHTML += '<div class="ll inf">[진행] 서버: ' + getServerCfg('x').serverHostName + ':' + getServerCfg('x').port + '</div>';
|
||||
|
||||
|
||||
const d = await api('POST', '/api/connection/browse', {
|
||||
serverConfig: getServerCfg('x'), startNodeId: null
|
||||
});
|
||||
@@ -780,6 +817,26 @@ async function pbApplySelected() {
|
||||
}
|
||||
}
|
||||
|
||||
async function pbAppendSelected() {
|
||||
const selected = pbPreviewData.filter(p => p.selected).map(p => p.nodeId);
|
||||
if (selected.length === 0) {
|
||||
setGlobal('err', '추가할 포인트를 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setGlobal('busy', `${selected.length}개 포인트 추가 중`);
|
||||
try {
|
||||
const d = await api('POST', '/api/pointbuilder/append', { selectedNodeIds: selected });
|
||||
setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트 추가 완료` : '추가 실패');
|
||||
if (d.success) {
|
||||
pbCancelPreview();
|
||||
await pbRefresh();
|
||||
}
|
||||
} catch (e) {
|
||||
setGlobal('err', '추가 오류');
|
||||
}
|
||||
}
|
||||
|
||||
async function pbRefresh() {
|
||||
try {
|
||||
const d = await api('GET', '/api/pointbuilder/points');
|
||||
@@ -1168,6 +1225,159 @@ function histReset() {
|
||||
histShowStatus('pending', '⏸', '대기 중', '조회 조건을 설정하고 조회 버튼을 눌러주세요.');
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
12 이벤트 히스토리
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
async function evtLoadTags() {
|
||||
const statusEl = document.getElementById('evt-tag-status');
|
||||
statusEl.textContent = '⏳ 조회 중...';
|
||||
try {
|
||||
const d = await api('GET', '/api/event-history/digital-tags');
|
||||
const tags = d.data || [];
|
||||
const sel = document.getElementById('ef-tag');
|
||||
sel.innerHTML = '<option value="">— 전체 태그 —</option>' +
|
||||
tags.map(t => `<option value="${esc(t.tagName)}">${esc(t.tagName)}</option>`).join('');
|
||||
statusEl.textContent = `✅ ${tags.length}개`;
|
||||
} catch (e) {
|
||||
statusEl.textContent = `❌ ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function evtQuery() {
|
||||
const tag = document.getElementById('ef-tag').value;
|
||||
const eventType = document.getElementById('ef-event-type').value;
|
||||
const area = document.getElementById('ef-area').value.trim();
|
||||
const section = document.getElementById('ef-section').value.trim();
|
||||
const limit = document.getElementById('ef-limit').value || 500;
|
||||
const fromRaw = document.getElementById('hf-evt-from').value;
|
||||
const toRaw = document.getElementById('hf-evt-to').value;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (tag) params.set('tagName', tag);
|
||||
if (eventType) params.set('eventType', eventType);
|
||||
if (area) params.set('area', area);
|
||||
if (section) params.set('section', section);
|
||||
params.set('limit', limit);
|
||||
if (fromRaw) params.set('from', new Date(fromRaw).toISOString());
|
||||
if (toRaw) params.set('to', new Date(toRaw).toISOString());
|
||||
|
||||
const infoEl = document.getElementById('evt-result-info');
|
||||
const tableEl = document.getElementById('evt-table');
|
||||
infoEl.textContent = '⏳ 조회 중...';
|
||||
infoEl.classList.remove('hidden');
|
||||
tableEl.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const d = await api('GET', `/api/event-history?${params}`);
|
||||
if (!d.success) throw new Error(d.error || '조회 실패');
|
||||
infoEl.textContent = `총 ${d.count}건`;
|
||||
tableEl.innerHTML = _evtBuildTable(d.data);
|
||||
tableEl.classList.remove('hidden');
|
||||
} catch (e) {
|
||||
infoEl.textContent = `❌ ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function evtSummary() {
|
||||
const area = document.getElementById('ef-area').value.trim();
|
||||
const section = document.getElementById('ef-section').value.trim();
|
||||
const fromRaw = document.getElementById('hf-evt-from').value;
|
||||
const toRaw = document.getElementById('hf-evt-to').value;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (area) params.set('area', area);
|
||||
if (section) params.set('section', section);
|
||||
if (fromRaw) params.set('from', new Date(fromRaw).toISOString());
|
||||
if (toRaw) params.set('to', new Date(toRaw).toISOString());
|
||||
|
||||
const card = document.getElementById('evt-summary-card');
|
||||
const content = document.getElementById('evt-summary-content');
|
||||
content.textContent = '⏳ 집계 중...';
|
||||
card.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const d = await api('GET', `/api/event-history/summary?${params}`);
|
||||
if (!d.success) throw new Error(d.error || '조회 실패');
|
||||
content.innerHTML = _evtBuildSummary(d.data);
|
||||
} catch (e) {
|
||||
content.textContent = `❌ ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function _evtBadge(t) {
|
||||
const cls = { TRIP:'trip', RUN:'run', ALARM:'alarm', NORMAL:'normal', CHANGE:'change' }[t] || 'change';
|
||||
return `<span class="evt-badge ${cls}">${esc(t)}</span>`;
|
||||
}
|
||||
|
||||
function _evtFmtTime(dt) {
|
||||
if (!dt) return '—';
|
||||
return new Date(dt).toLocaleString('ko-KR', {
|
||||
year:'numeric', month:'2-digit', day:'2-digit',
|
||||
hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false
|
||||
});
|
||||
}
|
||||
|
||||
function _evtBuildTable(rows) {
|
||||
if (!rows || !rows.length)
|
||||
return '<div style="padding:24px;text-align:center;color:var(--t2)">데이터 없음</div>';
|
||||
const html = rows.map(r => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap;color:var(--t2);font-family:var(--fm);font-size:11px">${_evtFmtTime(r.eventTime)}</td>
|
||||
<td><code style="font-size:11px;color:var(--blu)">${esc(r.tagName)}</code></td>
|
||||
<td>${_evtBadge(r.eventType)}</td>
|
||||
<td style="color:var(--t2)">${esc(r.prevValue ?? '—')}</td>
|
||||
<td style="color:var(--t0);font-weight:600">${esc(r.currValue)}</td>
|
||||
<td>${r.area ? `<span class="nm-cls">${esc(r.area)}</span>` : '—'}</td>
|
||||
<td>${r.section ? `<span class="nm-cls">${esc(r.section)}</span>` : '—'}</td>
|
||||
<td style="font-family:var(--fm);font-size:11px;color:var(--t2)">${r.durationSeconds != null ? r.durationSeconds + 's' : '—'}</td>
|
||||
</tr>`).join('');
|
||||
return `
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>시간</th>
|
||||
<th>태그명</th>
|
||||
<th>이벤트</th>
|
||||
<th>이전값</th>
|
||||
<th>현재값</th>
|
||||
<th>Area</th>
|
||||
<th>Section</th>
|
||||
<th>지속(초)</th>
|
||||
</tr></thead>
|
||||
<tbody>${html}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function _evtBuildSummary(data) {
|
||||
if (!data || !data.length)
|
||||
return '<div style="padding:12px;color:var(--t2)">데이터 없음</div>';
|
||||
return `<div class="evt-summary-grid">${data.map(s => `
|
||||
<div class="evt-summary-item">
|
||||
<div class="evt-summary-section">${esc(s.section)}</div>
|
||||
<div class="evt-summary-counts">
|
||||
<div class="evt-count">${_evtBadge('TRIP')} <strong>${s.tripCount}</strong></div>
|
||||
<div class="evt-count">${_evtBadge('RUN')} <strong>${s.runCount}</strong></div>
|
||||
<div class="evt-count">${_evtBadge('ALARM')} <strong>${s.alarmCount}</strong></div>
|
||||
<div class="evt-count">${_evtBadge('CHANGE')} <strong>${s.changeCount}</strong></div>
|
||||
</div>
|
||||
<div class="evt-total">합계 <strong>${s.totalEvents}</strong>건</div>
|
||||
</div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function evtReset() {
|
||||
document.getElementById('ef-tag').value = '';
|
||||
document.getElementById('ef-event-type').value = '';
|
||||
document.getElementById('ef-area').value = '';
|
||||
document.getElementById('ef-section').value = '';
|
||||
document.getElementById('ef-limit').value = '500';
|
||||
dtClearField('evt-from');
|
||||
dtClearField('evt-to');
|
||||
document.getElementById('evt-result-info').classList.add('hidden');
|
||||
document.getElementById('evt-table').classList.add('hidden');
|
||||
document.getElementById('evt-summary-card').classList.add('hidden');
|
||||
document.getElementById('evt-tag-status').textContent = '';
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
07-2 하이퍼테이블 관리
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
260
프로젝트진단-MiniMax-M2.5-Free.md
Normal file
260
프로젝트진단-MiniMax-M2.5-Free.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# ExperionCrawler 프로젝트 문제점 진단 보고서
|
||||
|
||||
> 분석일: 2026-05-10 | 도구: MiniMax-M2.5-Free | 프로젝트: ExperionCrawler
|
||||
|
||||
---
|
||||
|
||||
## 🔴 심각 (Critical) — 즉시 수정 필요
|
||||
|
||||
### 1. P&ID 컨트롤러 중복 충돌
|
||||
|
||||
- **위치**: `src/Web/Controllers/ExperionControllers.cs` (줄 981~1158) + `src/Web/Controllers/PidController.cs` (226줄)
|
||||
- **문제**: 두 개의 컨트롤러가 동일한 라우트 `/api/pid`를 공유
|
||||
- **영향**: 의도치 않은 동작,,哪个 컨트롤러가 처리될지 불확실
|
||||
- **해결**: `ExperionPidController` 또는 `PidController` 중 하나 제거, Program.cs의 `ExcludedControllersFeatureProvider` 로직 재검토
|
||||
|
||||
### 2. SQL 인젝션 취약점 (EF1002)
|
||||
|
||||
- **위치**: `src/Infrastructure/Database/ExperionDbContext.cs`
|
||||
- 줄 1309, 1313, 1316, 1323, 1332, 1335, 1342, 1345, 1377, 1397, 1407
|
||||
- **문제**: `ExecuteSqlRawAsync` + 문자열 보간법 조합
|
||||
```csharp
|
||||
var createHypertableSql = $"SELECT create_hypertable('{request.TableName}'::regclass, ...";
|
||||
```
|
||||
- **현황**: `#pragma warning disable EF1002`로 경고 억제 → 위험성 은폐
|
||||
- **평가**: `IsValidSqlIdentifier()` 검증이 있긴 하지만, 컬럼명 일부 검증 안함
|
||||
- **해결**: 모든 동적 식별자를 `NpgsqlParameter`로 전환, 또는 Raw SQL 대신 EF Core LINQ 사용
|
||||
|
||||
### 3. AssetLoader에 DbContext 이중 관리
|
||||
|
||||
- **위치**: `src/Infrastructure/Csv/AssetLoader.cs:14~17`
|
||||
- **문제**:
|
||||
- NpgsqlConnection을 직접 생성 (`configuration.GetConnectionString("DefaultConnection")`)
|
||||
- ExperionDbService와 동시 접속 시 DB 연결 수 증가
|
||||
- DbContext 기반 로깅/트랜잭션 무시
|
||||
- **해결**: AssetLoader를 DbContext 기반으로 리팩토링
|
||||
|
||||
---
|
||||
|
||||
## 🟠 높음 (High) —尽快修正
|
||||
|
||||
### 4. ExperionControllers.cs 단일 파일 1158줄
|
||||
|
||||
- **위치**: `src/Web/Controllers/ExperionControllers.cs`
|
||||
- **문제**: 9개 이상의 컨트롤러가 하나의 파일에 집중 (ExperionCertificate, Connection, Crawl, Database, PointBuilder, TagMetadata, Realtime, History, OpcServer, NodeMap, Hypertable, Fast, ExperionPid)
|
||||
- **영향**: 유지보수困难, Merge 충돌 빈번, 코드 검색 어려움
|
||||
- **해결**: 각 컨트롤러를 별도 파일로 분리
|
||||
|
||||
### 5. ExperionDbContext.cs 단일 파일 1493줄
|
||||
|
||||
- **위치**: `src/Infrastructure/Database/ExperionDbContext.cs`
|
||||
- **문제**: 서비스 + DbContext + 엔티티 config + DTO 레코드 + SQL 헬퍼가 모두 하나의 파일
|
||||
- **해결**: DbContext/Service 분리, DTO 레코드 → Core/Application/DTOs/ 이동
|
||||
|
||||
### 6. Console.WriteLine 디버그 코드 잔존
|
||||
|
||||
- **위치**: `src/Infrastructure/Database/ExperionDbContext.cs` 약 15개 이상
|
||||
```csharp
|
||||
Console.WriteLine($"[DEBUG] 하이퍼테이블 생성 SQL: {createHypertableSql}");
|
||||
Console.WriteLine($"[ERROR] {errorDetails}");
|
||||
```
|
||||
- **문제**: 프로덕션에서 Console 출력이 로그로 출력됨, ILogger 미사용
|
||||
- **해결**: 모든 Console.WriteLine → _logger.LogInformation/Warning/Error로 교체
|
||||
|
||||
### 7. ExperionFastService MonitorLoop에서 매 반복마다 Scope 생성
|
||||
|
||||
- **위치**: `src/Infrastructure/OpcUa/ExperionFastService.cs:204~220`
|
||||
- **문제**:
|
||||
```csharp
|
||||
foreach (var kvp in _sessions.ToList())
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope(); // 매 세션마다 new scope
|
||||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||||
}
|
||||
```
|
||||
- **영향**: 3개 세션 × 1초 간격 = 초당 3개 scope + 3개 connection
|
||||
- **해결**: 모니터 루프 수준에서 scoped service를 재사용, 또는 배치 처리
|
||||
|
||||
### 8. McpClient HttpClient Timeout 30분
|
||||
|
||||
- **위치**: `src/Infrastructure/Mcp/McpClient.cs:26`
|
||||
```csharp
|
||||
Timeout = TimeSpan.FromSeconds(1800) // 30분!
|
||||
```
|
||||
- **문제**: Python MCP 서버 응답 지연 시 최대 30분 대기
|
||||
- **해결**: Timeout合理化 (예: 60초), 요청 취소 시 HttpClient 정리 개선
|
||||
|
||||
### 9. ExperionRealtimeService 재진입 방지 로직 불완전
|
||||
|
||||
- **위치**: `src/Infrastructure/OpcUa/ExperionRealtimeService.cs:198~217`
|
||||
- **문제**:
|
||||
```csharp
|
||||
_restarting = true;
|
||||
try { if (_running) { await StopAsync(); } } // StopAsync 안에서 _restarting=false
|
||||
finally { _restarting = false; } // race condition 가능
|
||||
```
|
||||
- **해결**: `_restarting` 플래그 관리 개선, CancellationToken 기반 제어
|
||||
|
||||
### 10. history_table 하이퍼테이블 자동 생성 안 됨
|
||||
|
||||
- **위치**: `src/Infrastructure/Database/ExperionDbContext.cs:328`
|
||||
```csharp
|
||||
// history 테이블은 수동으로 하이퍼테이블 생성 필요
|
||||
```
|
||||
- **문제**: InitializeAsync()에서 fast_record만 hypertable 생성, history_table은 일반 테이블로 존재
|
||||
- **영향**: TimescaleDB 시계열 최적화 미적용, 연속 집계/압축/보존 정책 미적용
|
||||
- **해결**: InitializeAsync()에 history_table hypertable 생성 로직 추가
|
||||
|
||||
---
|
||||
|
||||
## 🟡 중간 (Medium) — 개선 권장
|
||||
|
||||
### 11. ExperionServerConfig.ApplicationUri 미설정
|
||||
|
||||
- **위치**: `src/Web/Controllers/ExperionControllers.cs:111~118` (MapConfig)
|
||||
- **문제**: `MapConfig()`에서 `ApplicationUri` 매핑 안 함 → 인증서 생성 시에만 수동 조성
|
||||
- **해결**: MapConfig에 ApplicationUri 포함, 또는 ExperionServerConfig 생성자에서 자동 설정
|
||||
|
||||
### 12. Singleton 서비스의 mutable 상태 (Thread-unsafe)
|
||||
|
||||
- **위치**: `ExperionRealtimeService`, `ExperionOpcServerService`
|
||||
- **문제**:
|
||||
- `_running`, `_statusMsg`, `_subscribedCount`, `_currentCfg` → thread-unsafe
|
||||
- `_startedAt`, `_endpointUrl` → thread-unsafe
|
||||
- **해결**: `volatile` 키워드 또는 `lock` 사용, 또는 Stateless 서비스 설계
|
||||
|
||||
### 13. P&ID 서비스가 MCP에 강결합
|
||||
|
||||
- **위치**: `PidExtractorService`, `PidGraphService` — `McpClient` 직접 의존
|
||||
- **문제**: MCP 서버 죽으면 P&ID 추출 전체 실패, Fallback mechanism 없음
|
||||
- **해결**: 추출 실패 시 로컬 LLM (vLLM 직접 호출) 또는 파일 기반 백업
|
||||
|
||||
### 14. TextToSqlService 자연어 파싱 불안정
|
||||
|
||||
- **위치**: `src/Core/Application/Services/TextToSqlService.cs:226~349`
|
||||
- **문제**: 350줄에 걸친 한국어 태그명 추출 로직, 테스트 케이스 없음, Edge case 시 null/빈 SQL 반환
|
||||
- **해결**: 단위 테스트 추가, 파싱 로직 분리 (TagExtractor 서비스)
|
||||
|
||||
### 15. HistoryIntervalQuery에서 NpgsqlCommand 직접 사용
|
||||
|
||||
- **위치**: `ExperionDbContext.cs:810`
|
||||
```csharp
|
||||
using var cmd = new NpgsqlCommand(sql, _ctx.Database.GetDbConnection() as NpgsqlConnection);
|
||||
```
|
||||
- **문제**: DbContext connection 직접 cast, EF Core 파이프라인 우회, transaction handling 없음
|
||||
- **해결**: FromSqlRaw 또는 Dapper 사용 고려
|
||||
|
||||
### 16. FastRecord PK 마이그레이션 로직 복잡
|
||||
|
||||
- **위치**: `ExperionDbContext.cs:219~234` — EF Core + Raw SQL 혼용
|
||||
- **문제**: EF Core 마이그레이션 정책과 충돌 가능, EF1002 경고
|
||||
- **해결**: EF Core 마이그레이션으로 통합, 또는 Raw SQL 제거
|
||||
|
||||
### 17. ExperionOpcServerService Dispose 불완전
|
||||
|
||||
- **위치**: `src/Infrastructure/OpcUa/ExperionOpcServerService.cs:299~315`
|
||||
- **문제**:
|
||||
- `IHostedService` + `IDisposable` 동시 구현
|
||||
- DisposeAsync()와 IDisposable.Dispose() 중복 가능
|
||||
- deprecated Stop() API 사용 (#pragma warning disable CS0618)
|
||||
- **해결**: IAsyncDisposable 구현, deprecated API 제거
|
||||
|
||||
### 18. McpServerHostedService 프로세스 관리 미흡
|
||||
|
||||
- **위치**: `src/Infrastructure/Mcp/McpServerHostedService.cs`
|
||||
- **문제**:
|
||||
- 프로세스 crash 후 자동 재시작 없음
|
||||
- Ping 실패 시 warning만 로그, 복구 시도 안 함
|
||||
- Health check 1초 × 30회 = 30초 후放棄
|
||||
- **해결**: Watchdog 패턴 적용, 재시작 로직 추가
|
||||
|
||||
---
|
||||
|
||||
## 🟢 낮음 (Low) — 리팩토링 시 고려
|
||||
|
||||
### 19. CORS AllowAnyOrigin
|
||||
|
||||
- **위치**: `Program.cs:121`
|
||||
- **문제**: 개발용으로는 OK, 프로덕션에서는 제한 필요
|
||||
- **해결**: AllowedOrigins 명시적 설정
|
||||
|
||||
### 20. appsettings.json에 Password 평문
|
||||
|
||||
- **위치**: `appsettings.json:20`
|
||||
- **문제**: 연결 문자열에 평문 비밀번호
|
||||
- **해결**: 환경변수 또는 .NET Secret Manager 사용
|
||||
|
||||
### 21. JsonSerializerOptions 불일치
|
||||
|
||||
- **문제**:
|
||||
- RealtimeService: 기본 JsonSerializer 옵션
|
||||
- McpClient: `PropertyNameCaseInsensitive = true`
|
||||
- McpService: `[JsonPropertyName]` 미적용
|
||||
- **해결**: 공통 JsonSerializerOptions 정의, 또는 PascalCase 통일
|
||||
|
||||
### 22. v_tag_summary VIEW 재생성 매번
|
||||
|
||||
- **위치**: `ExperionDbContext.cs:304` — 앱 시작마다 DROP+CREATE
|
||||
- **해결**: Materialized View + REFRESH USING 또는 조건부 CREATE
|
||||
|
||||
### 23. ExperionHistoryService 재연결 로직 없음
|
||||
|
||||
- **문제**: RealtimeService 재연결하지만 HistoryService는 없음
|
||||
- **해결**: 연결 lost 감지 후 재연결 로직 추가
|
||||
|
||||
### 24. TagMetadata base_tag 길이 제한 없음
|
||||
|
||||
- **문제**: 매우 긴 태그명 삽입 시 DB 오버플로우 가능
|
||||
- **해결**: entity 정의에 .HasMaxLength() 추가
|
||||
|
||||
### 25. Logging 수준 불균형
|
||||
|
||||
- **문제**: HistoryService는 Warning, 나머지는 Information
|
||||
- **해결**: appsettings.json 통일
|
||||
|
||||
---
|
||||
|
||||
## 📊 요약
|
||||
|
||||
| Severity | Count | 주요 카테고리 |
|
||||
|---|---|---|
|
||||
| 🔴 Critical | 3 | SQL 인젝션, 컨트롤러 중복, DbContext 이중 관리 |
|
||||
| 🟠 High | 7 | 파일 크기, 디버그 코드 잔존, 리소스 관리, 유실된 설정 |
|
||||
| 🟡 Medium | 8 | 결합도, threading, transaction, disposal |
|
||||
| 🟢 Low | 6 | CORS, JSON, logging, naming |
|
||||
|
||||
## 🔥 즉시 수정 우선순위
|
||||
|
||||
1. **#2 (SQL 인젝션)** — 보안 취약점, 즉시 패치
|
||||
2. **#1 (컨트롤러 충돌)** — API 동작 불확실성
|
||||
3. **#6 (Console.WriteLine)** — 프로덕션 로그 오염
|
||||
4. **#10 (history_table hypertable)** — 성능/스토리지 최적화 미적용
|
||||
|
||||
## 관련 파일 인덱스
|
||||
|
||||
```
|
||||
src/Web/
|
||||
├── Controllers/
|
||||
│ ├── ExperionControllers.cs ← 1158줄, 다중 컨트롤러 (#4)
|
||||
│ ├── PidController.cs ← 중복 컨트롤러 (#1)
|
||||
│ ├── TextToSqlController.cs ← 375줄
|
||||
│ └── PidGraphController.cs ← 191줄
|
||||
├── Program.cs ← CORS (#19), DI 注册
|
||||
└── appsettings.json ← 평문 비밀번호 (#20)
|
||||
src/Infrastructure/
|
||||
├── Database/
|
||||
│ └── ExperionDbContext.cs ← 1493줄, SQL 인젝션 (#2), Console (#6)
|
||||
├── OpcUa/
|
||||
│ ├── ExperionRealtimeService.cs ← threading (#9, #12)
|
||||
│ ├── ExperionOpcServerService.cs ← dispose (#17), threading (#12)
|
||||
│ └── ExperionFastService.cs ← scope leak (#7)
|
||||
├── Mcp/
|
||||
│ ├── McpClient.cs ← timeout (#8)
|
||||
│ └── McpServerHostedService.cs ← watchdog 없음 (#18)
|
||||
└── Csv/
|
||||
└── AssetLoader.cs ← Npgsql 직접 (#3)
|
||||
src/Core/Application/Services/
|
||||
├── TextToSqlService.cs ← 파싱 불안정 (#14)
|
||||
├── PidExtractorService.cs ← MCP 강결합 (#13)
|
||||
└── PidGraphService.cs ← MCP 강결합 (#13)
|
||||
```
|
||||
Reference in New Issue
Block a user