feat: .gitignore 추가 및 빌드 출력 제거, 소스 코드 업데이트
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Build outputs
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Certificates (sensitive)
|
||||||
|
*.pfx
|
||||||
|
*.p12
|
||||||
|
|
||||||
|
# Data files
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.log
|
||||||
94
.roo.md
Normal file
94
.roo.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
[CONTENT_MANAGEMENT_RULES]
|
||||||
|
1. 작업 시작 시 반드시 Todo List를 작성하세요. 각 항목은 독립 실행 가능해야 합니다.
|
||||||
|
2. 단일 응답에서 다음 중 하나라도 만족하면 작업을 중단하고 이관 신호를 생성하세요:
|
||||||
|
- 처리 파일 수 ≥ 5개
|
||||||
|
- 코드 변경/생성 라인 수 ≥ 200줄
|
||||||
|
- 논리적 모듈 단위 완료
|
||||||
|
3. 이관 시 반드시 아래 형식으로 응답을 종료하세요:
|
||||||
|
[TASK_MIGRATION]
|
||||||
|
✅ 완료: [목록]
|
||||||
|
📦 현재 상태: [요약]
|
||||||
|
🎯 다음 하위작업 지시문: [명확한 프롬프트]
|
||||||
|
[/TASK_MIGRATION]
|
||||||
|
4. 이관 신호 이후에는 추가 코딩/분석을 중단하고 사용자의 계속 지시를 기다리세요.
|
||||||
|
[/CONTENT_MANAGEMENT_RULES]
|
||||||
|
|
||||||
|
# Roo 작업 시작 가이드
|
||||||
|
|
||||||
|
## 작업 시작 시 필수 절차
|
||||||
|
|
||||||
|
1. **`CLAUDE.md` 파일 반드시 읽기**
|
||||||
|
- 프로젝트 루트의 `CLAUDE.md` 파일을 먼저 읽어서 이전 작업 이력 확인
|
||||||
|
- 완료된 작업, 현재 진행 중인 작업, 알려진 문제점 파악
|
||||||
|
- 최근 작업 내용을 바탕으로 현재 작업과 충돌하지 않도록 주의
|
||||||
|
|
||||||
|
2. **todo 목록 생성**
|
||||||
|
- 복잡한 작업은 반드시 `update_todo_list` 도구로 todo 목록 생성
|
||||||
|
- 각 단계별로 명확한 목표 설정
|
||||||
|
|
||||||
|
3. **파일 수정 시 주의**
|
||||||
|
- `apply_diff` 도구는 정확한 검색/교체 블록 사용
|
||||||
|
- `read_file` 도구로 정확한 내용 확인 후 수정
|
||||||
|
|
||||||
|
## 데이터베이스 연결 정보
|
||||||
|
|
||||||
|
### docker container : iiot-timescaledb (주요 목적 DB)
|
||||||
|
- **Host**: localhost
|
||||||
|
- **Port**: 5432
|
||||||
|
- **Database**: iiot_platform
|
||||||
|
- **Username**: postgres
|
||||||
|
- **Password**: postgres
|
||||||
|
- **용도**: TimescaleDB 확장, 시계열 데이터 저장, Text-to-SQL 기능
|
||||||
|
|
||||||
|
### Experion DB (데이터 소스)
|
||||||
|
- **Host**: localhost
|
||||||
|
- **Port**: 5432
|
||||||
|
- **Database**: postgres
|
||||||
|
- **Username**: postgres
|
||||||
|
- **Password**: postgres
|
||||||
|
- **용도**: Experion HS R530 메타데이터 조회
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
|
- **이름**: ExperionCrawler
|
||||||
|
- **기술 스택**: .NET 8 (C#), PostgreSQL, OPC UA
|
||||||
|
- **주요 기능**:
|
||||||
|
- Experion HS R530 DCS 시스템에서 실시간 데이터 크롤링
|
||||||
|
- OPC UA Client로 Experion HS R530 연결
|
||||||
|
- OPC UA Server로 외부 시스템 연결 지원
|
||||||
|
- 데이터 PostgreSQL DB 저장
|
||||||
|
- CSV 내보내기 기능
|
||||||
|
- Text-to-SQL 기능 (TimescaleDB)
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── Core/
|
||||||
|
│ ├── Application/ (DTOs, Services, Interfaces)
|
||||||
|
│ └── Domain/ (Entities)
|
||||||
|
├── Infrastructure/ (Certificates, Csv, Database, OpcUa)
|
||||||
|
└── Web/ (Controllers, Program.cs, wwwroot)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 작업 규칙
|
||||||
|
|
||||||
|
- 복잡한 작업은 항상 todo 목록 먼저 생성
|
||||||
|
- 각 단계 시작 전 todo 목록 확인
|
||||||
|
- 단계 완료 후 즉시 completed 표시
|
||||||
|
- 코드 수정 전 반드시 `read_file`으로 현재 내용 확인
|
||||||
|
|
||||||
|
## 컨텍스트 관리
|
||||||
|
|
||||||
|
### 컨텍스트 캐시 최적화
|
||||||
|
- 컨텍스트 캐시가 70%를 넘으면 작업중이던 정보를 새로운 하위작업에게 넘기고 시작
|
||||||
|
- 정보의 질 저하를 방지하기 위해 컨텍스트 압축 수행
|
||||||
|
- 단일 항목 작업 중에도 컨텍스트가 가득차면 하위작업으로 분할
|
||||||
|
|
||||||
|
### 작업 분할 원칙
|
||||||
|
1. 모든 새로운 작업을 시작하기 전 todo list 생성 (`update_todo_list` 도구 사용)
|
||||||
|
2. 단일 항목 작업 중 컨텍스트 캐시 70% 초과 시:
|
||||||
|
- 현재 작업 상태를 하위작업에게 명확히 전달
|
||||||
|
- 하위작업에서 이어받아 작업 진행
|
||||||
|
- 완료 후 상위 작업에 결과 보고
|
||||||
|
3. 하위작업은 `new_task` 도구로 생성하고 mode은 현재 모드 유지 또는 적절히 선택
|
||||||
52
plans/history-query-status-indicator.md
Normal file
52
plans/history-query-status-indicator.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 이력 조회 상태 표시기 구현
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
이력 조회 페이지의 '▼ 옵션 불러오기' 버튼 오른쪽에 상태 표시기를 추가하여, 태그 목록 조회 상태를 시각적으로 표시합니다.
|
||||||
|
|
||||||
|
## 구현 내용
|
||||||
|
|
||||||
|
### 1. HTML 구조 ([`index.html`](src/Web/wwwroot/index.html:503))
|
||||||
|
```html
|
||||||
|
<div class="fg">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||||
|
<span>태그 선택 <em>(최대 8개, OR 조건)</em></span>
|
||||||
|
<button class="btn-b btn-sm" onclick="histLoad()">▼ 옵션 불러오기</button>
|
||||||
|
<span id="hist-load-status" class="hist-status">대기 중</span>
|
||||||
|
</div>
|
||||||
|
<div class="pb-name-grid">
|
||||||
|
<!-- select 8개 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. CSS 스타일 ([`style.css`](src/Web/wwwroot/css/style.css:815))
|
||||||
|
- `.hist-status`: 상태 표시기 컨테이너
|
||||||
|
- `.hist-status.loading`: 조회 중 (파란색)
|
||||||
|
- `.hist-status.success`: 조회 완료 (초록색)
|
||||||
|
- `.hist-status.error`: 조회 실패 (빨간색)
|
||||||
|
- 성능 최적화: `contain: layout style`, `transform: translateZ(0)`
|
||||||
|
|
||||||
|
### 3. JavaScript 로직 ([`app.js`](src/Web/wwwroot/js/app.js:688))
|
||||||
|
- `histUpdateStatus(state, message)`: 상태 표시기 업데이트
|
||||||
|
- `histLoad()`: 태그 목록 조회 및 상태 업데이트
|
||||||
|
- 조회 시작: `⏳ 조회 중...`
|
||||||
|
- 조회 완료: `✅ 조회 완료 (개수, 초)`
|
||||||
|
- 데이터 없음: `❌ 조회 데이터 없음 (0개)`
|
||||||
|
- 오류: `❌ 조회 실패: 메시지`
|
||||||
|
|
||||||
|
## 수정된 문제들
|
||||||
|
1. 초기 상태 "준비됨" → "대기 중"으로 변경
|
||||||
|
2. 레이아웃 구조 개선 (flex 컨테이너 사용)
|
||||||
|
3. CSS 애니메이션 제거 및 성능 최적화
|
||||||
|
4. `requestAnimationFrame`으로 DOM 업데이트 지연
|
||||||
|
|
||||||
|
## 남은 문제 (추가 작업 필요)
|
||||||
|
- 태그 선택 시 페이지 hang 현상 원인 파악 필요
|
||||||
|
- `pb-name-grid`의 CSS grid 레이아웃과 select 요소 8개의 상호작용
|
||||||
|
- 브라우저 호환성 문제 가능성
|
||||||
|
- 메모리 누락 가능성
|
||||||
|
|
||||||
|
## 테스트 방법
|
||||||
|
1. 이력 조회 탭 진입 → "대기 중" 표시 확인
|
||||||
|
2. '▼ 옵션 불러오기' 클릭 → 상태 변화 확인
|
||||||
|
3. 태그 선택 시 페이지 반응성 확인
|
||||||
416
plans/text-to-sql-timescaledb-plan.md
Normal file
416
plans/text-to-sql-timescaledb-plan.md
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
# Text-to-SQL (PostgreSQL + TimeScaleDB) 추가 계획
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
ExperionCrawler 프로젝트에 **TimeScaleDB 하이퍼테이블**을 적용하여 시계열 데이터(`realtime_table`, `history_table`)의 성능과 확장성을 대폭 개선합니다. 또한 **Text-to-SQL 기능**을 추가하여 API를 통해 자연어/쿼리 입력으로 시계열 데이터를 조회·분석할 수 있도록 합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 현재 아키텍처 분석
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph WebLayer
|
||||||
|
Controller[Controllers]
|
||||||
|
Program[Program.cs]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Infrastructure
|
||||||
|
DbContext[ExperionDbContext]
|
||||||
|
DbService[ExperionDbService]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Core
|
||||||
|
CrawlService[ExperionCrawlService]
|
||||||
|
RealtimeSvc[ExperionRealtimeService]
|
||||||
|
HistorySvc[ExperionHistoryService]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Data
|
||||||
|
PostgreSQL[(PostgreSQL)]
|
||||||
|
CSV[CSV Files]
|
||||||
|
end
|
||||||
|
|
||||||
|
Controller --> DbContext
|
||||||
|
Controller --> DbService
|
||||||
|
DbContext --> PostgreSQL
|
||||||
|
RealtimeSvc --> DbService
|
||||||
|
HistorySvc --> DbService
|
||||||
|
DbService --> CSV
|
||||||
|
```
|
||||||
|
|
||||||
|
### 현재 사용 테이블
|
||||||
|
| 테이블명 | 용도 | 현재 방식 |
|
||||||
|
|----------|------|-----------|
|
||||||
|
| `realtime_table` | 실시간 모니터링 포인트 | 일반 PostgreSQL 테이블 |
|
||||||
|
| `history_table` | 시계열 이력 스냅샷 | 일반 PostgreSQL 테이블 |
|
||||||
|
| `raw_node_map` | OPC UA 노드맵 원시 데이터 | 일반 PostgreSQL 테이블 |
|
||||||
|
| `node_map_master` | 빌드된 노드맵 | 일반 PostgreSQL 테이블 |
|
||||||
|
| `experion_records` | 크롤링 기록 | EF Core DbSet |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 목표 아키텍처
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph WebLayer
|
||||||
|
Controller[Controllers]
|
||||||
|
TextToSql[Text-to-SQL Service]
|
||||||
|
Program[Program.cs]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Infrastructure
|
||||||
|
DbContext[ExperionDbContext]
|
||||||
|
DbService[ExperionDbService]
|
||||||
|
TimeSeriesSvc[TimeSeries Service]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Core
|
||||||
|
CrawlService[ExperionCrawlService]
|
||||||
|
RealtimeSvc[ExperionRealtimeService]
|
||||||
|
HistorySvc[ExperionHistoryService]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Data
|
||||||
|
TimeScaleDB[(TimeScaleDB<br/>HyperTables)]
|
||||||
|
CSV[CSV Files]
|
||||||
|
end
|
||||||
|
|
||||||
|
Controller --> TextToSql
|
||||||
|
TextToSql --> DbService
|
||||||
|
Controller --> DbContext
|
||||||
|
Controller --> DbService
|
||||||
|
DbContext --> TimeScaleDB
|
||||||
|
RealtimeSvc --> TimeSeriesSvc
|
||||||
|
HistorySvc --> TimeSeriesSvc
|
||||||
|
TimeSeriesSvc --> DbService
|
||||||
|
DbService --> CSV
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 구현 단계별 계획
|
||||||
|
|
||||||
|
### 단계 1: TimeScaleDB 패키지 추가 및 초기화
|
||||||
|
|
||||||
|
**목표**: TimeScaleDB 확장 활성화 및 하이퍼테이블 생성
|
||||||
|
|
||||||
|
#### 4.1.1 NuGet 패키지 추가
|
||||||
|
- `Npgsql.EntityFrameworkCore.PostgreSQL` (이미 존재)
|
||||||
|
- `Npgsql.TimeScaleDB` (새로 추가)
|
||||||
|
|
||||||
|
#### 4.1.2 데이터베이스 초기화 수정
|
||||||
|
- [`ExperionDbContext`](src/Infrastructure/Database/ExperionDbContext.cs:1)에 TimeScaleDB 확장 활성화 코드 추가
|
||||||
|
- `history_table` → `history_hypertable` (하이퍼테이블)로 마이그레이션
|
||||||
|
- `realtime_table` → `realtime_hypertable` (하이퍼테이블)로 마이그레이션
|
||||||
|
|
||||||
|
#### 4.1.3 마이그레이션 스크립트
|
||||||
|
```sql
|
||||||
|
-- TimeScaleDB 확장 활성화
|
||||||
|
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||||
|
|
||||||
|
-- 기존 history_table 데이터를 백업
|
||||||
|
CREATE TABLE history_table_backup AS SELECT * FROM history_table;
|
||||||
|
|
||||||
|
-- 기존 테이블 삭제 후 하이퍼테이블 생성
|
||||||
|
DROP TABLE history_table;
|
||||||
|
SELECT create_hypertable('history_hypertable', 'recorded_at',
|
||||||
|
chunk_time_interval => INTERVAL '1 day',
|
||||||
|
create_default_indexes => true);
|
||||||
|
|
||||||
|
-- 데이터 복원
|
||||||
|
INSERT INTO history_hypertable (id, tagname, node_id, value, recorded_at)
|
||||||
|
SELECT id, tagname, node_id, value, recorded_at FROM history_table_backup;
|
||||||
|
DROP TABLE history_table_backup;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 단계 2: Text-to-SQL 서비스 구현
|
||||||
|
|
||||||
|
**목표**: 자연어/쿼리 입력으로 시계열 데이터를 조회하는 서비스 계층 구현
|
||||||
|
|
||||||
|
#### 4.2.1 인터페이스 정의 (`ITextToSqlService`)
|
||||||
|
```
|
||||||
|
namespace ExperionCrawler.Core.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface ITextToSqlService
|
||||||
|
{
|
||||||
|
/// <summary>자연어 질의를 SQL로 변환</summary>
|
||||||
|
Task<string> ParseNaturalLanguageAsync(string input);
|
||||||
|
|
||||||
|
/// <summary>SQL 쿼리 실행 및 결과 반환</summary>
|
||||||
|
Task<SqlQueryResult> ExecuteQueryAsync(string sql);
|
||||||
|
|
||||||
|
/// <summary>쿼리 제안 (자동 완성)</summary>
|
||||||
|
Task<IEnumerable<string>> SuggestQueriesAsync(string partialInput);
|
||||||
|
|
||||||
|
/// <summary>시계열 분석 (평균, 최대, 최소, 추세)</summary>
|
||||||
|
Task<TimeSeriesAnalysisResult> AnalyzeAsync(string tagName, DateTime? from, DateTime? to);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2.2 구현체 (`TextToSqlService`)
|
||||||
|
- **자연어 파싱**: 키워드 기반 파서 (tag name, 시간 범위, 집계 함수)
|
||||||
|
- **SQL 생성**: TimeScaleDB 함수 (`time_bucket`, `avg`, `max`, `min`, `last`) 활용
|
||||||
|
- **결과 매핑**: 동적 SQL → DTO 매핑
|
||||||
|
|
||||||
|
#### 4.2.3 자연어 파싱 예시
|
||||||
|
| 입력 | 생성된 SQL |
|
||||||
|
|------|-----------|
|
||||||
|
| "PV001 온도 최근 1시간 평균" | `SELECT time_bucket('5 min', recorded_at) AS bucket, AVG(value::float) FROM history_hypertable WHERE tagname = 'PV001' AND recorded_at > NOW() - INTERVAL '1 hour' GROUP BY bucket ORDER BY bucket` |
|
||||||
|
| "전체 태그 현재 값" | `SELECT DISTINCT ON (tagname) tagname, livevalue, timestamp FROM realtime_hypertable ORDER BY tagname, timestamp DESC` |
|
||||||
|
| "PV001, PV002 최근 24시간 최대값" | `SELECT time_bucket('1 hour', recorded_at) AS bucket, tagname, MAX(value::float) FROM history_hypertable WHERE tagname IN ('PV001', 'PV002') AND recorded_at > NOW() - INTERVAL '24 hour' GROUP BY bucket, tagname ORDER BY bucket, tagname` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 단계 3: API 엔드포인트 추가
|
||||||
|
|
||||||
|
**목표**: Text-to-SQL 기능을 REST API로 노출
|
||||||
|
|
||||||
|
#### 4.3.1 컨트롤러 (`TextToSqlController`)
|
||||||
|
```
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/text-to-sql")]
|
||||||
|
public class TextToSqlController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ITextToSqlService _service;
|
||||||
|
|
||||||
|
[HttpPost("parse")]
|
||||||
|
public Task<IActionResult> Parse([FromBody] NaturalLanguageQueryDto dto);
|
||||||
|
|
||||||
|
[HttpPost("execute")]
|
||||||
|
public Task<IActionResult> Execute([FromBody] SqlQueryDto dto);
|
||||||
|
|
||||||
|
[HttpGet("suggest")]
|
||||||
|
public Task<IActionResult> Suggest([FromQuery] string input);
|
||||||
|
|
||||||
|
[HttpPost("analyze")]
|
||||||
|
public Task<IActionResult> Analyze([FromBody] AnalysisRequestDto dto);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.3.2 DTO 정의
|
||||||
|
```
|
||||||
|
public class NaturalLanguageQueryDto
|
||||||
|
{
|
||||||
|
public string Query { get; set; } = string.Empty;
|
||||||
|
public string Language { get; set; } = "ko"; // "ko" or "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SqlQueryDto
|
||||||
|
{
|
||||||
|
public string Sql { get; set; } = string.Empty;
|
||||||
|
public int? Limit { get; set; } = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AnalysisRequestDto
|
||||||
|
{
|
||||||
|
public List<string> TagNames { get; set; } = new();
|
||||||
|
public DateTime? From { get; set; }
|
||||||
|
public DateTime? To { get; set; }
|
||||||
|
public string Interval { get; set; } = "5 min"; // time_bucket interval
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 단계 4: TimeSeries 서비스 개선
|
||||||
|
|
||||||
|
**목표**: TimeScaleDB 특화 함수 활용하여 성능 최적화
|
||||||
|
|
||||||
|
#### 4.4.1 기존 `ExperionDbService` 수정
|
||||||
|
- `history_table` → `history_hypertable` 참조로 변경
|
||||||
|
- `realtime_table` → `realtime_hypertable` 참조로 변경
|
||||||
|
- TimeScaleDB 함수 (`time_bucket`, `continuous aggregates`) 활용
|
||||||
|
|
||||||
|
#### 4.4.2 연속 집계 (Continuous Aggregates) 생성
|
||||||
|
```sql
|
||||||
|
-- 5분 단위 집계 뷰 생성
|
||||||
|
CREATE MATERIALIZED VIEW history_5min_agg
|
||||||
|
WITH (timescaledb.continuous) AS
|
||||||
|
SELECT
|
||||||
|
time_bucket('5 min', recorded_at) AS bucket,
|
||||||
|
tagname,
|
||||||
|
AVG(value::float) AS avg_value,
|
||||||
|
MIN(value::float) AS min_value,
|
||||||
|
MAX(value::float) AS max_value,
|
||||||
|
FIRST(value::float, recorded_at) AS open_value,
|
||||||
|
LAST(value::float, recorded_at) AS close_value,
|
||||||
|
COUNT(*) AS point_count
|
||||||
|
FROM history_hypertable
|
||||||
|
GROUP BY bucket, tagname;
|
||||||
|
|
||||||
|
-- 기존 데이터 리프레시
|
||||||
|
REFRESH MATERIALIZED VIEW history_5min_agg;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 단계 5: Program.cs 설정 업데이트
|
||||||
|
|
||||||
|
**목표**: 서비스 등록 및 초기화 플로우 수정
|
||||||
|
|
||||||
|
#### 4.5.1 서비스 등록
|
||||||
|
```csharp
|
||||||
|
// Text-to-SQL 서비스
|
||||||
|
builder.Services.AddScoped<ITextToSqlService, TextToSqlService>();
|
||||||
|
|
||||||
|
// TimeSeries 서비스
|
||||||
|
builder.Services.AddScoped<ITimeSeriesService, TimeSeriesService>();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.5.2 연결 문자열 확인
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=localhost;Port=5432;Database=experion_crawler;Username=postgres;Password=postgres"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 파일 구조 변경
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── Core/
|
||||||
|
│ ├── Application/
|
||||||
|
│ │ ├── DTOs/
|
||||||
|
│ │ │ ├── ExperionDtos.cs (수정: Text-to-SQL DTO 추가)
|
||||||
|
│ │ │ └── TextToSqlDtos.cs (신규)
|
||||||
|
│ │ ├── Interfaces/
|
||||||
|
│ │ │ ├── IExperionServices.cs (수정: ITextToSqlService, ITimeSeriesService 추가)
|
||||||
|
│ │ │ └── ITextToSqlService.cs (신규)
|
||||||
|
│ │ └── Services/
|
||||||
|
│ │ ├── ExperionCrawlService.cs (변경 없음)
|
||||||
|
│ │ └── TextToSqlService.cs (신규)
|
||||||
|
│ └── Domain/
|
||||||
|
│ └── Entities/
|
||||||
|
│ ├── ExperionEntities.cs (수정: TimeScaleDB 엔티티 추가)
|
||||||
|
│ └── TimeSeriesEntities.cs (신규)
|
||||||
|
├── Infrastructure/
|
||||||
|
│ ├── Database/
|
||||||
|
│ │ ├── ExperionDbContext.cs (수정: 하이퍼테이블 설정)
|
||||||
|
│ │ ├── TimeSeriesService.cs (신규)
|
||||||
|
│ │ └── Migrations/ (신규: TimeScaleDB 마이그레이션)
|
||||||
|
│ └── ... (기존 파일 유지)
|
||||||
|
└── Web/
|
||||||
|
├── Controllers/
|
||||||
|
│ ├── ExperionControllers.cs (수정: Text-to-SQL 엔드포인트 추가)
|
||||||
|
│ └── TextToSqlController.cs (신규)
|
||||||
|
├── appsettings.json (수정: 연결 문자열)
|
||||||
|
└── ExperionCrawler.csproj (수정: NuGet 패키지 추가)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. TimeScaleDB 특화 기능
|
||||||
|
|
||||||
|
### 6.1 체킹 간격 설정
|
||||||
|
| 테이블 | chunk_time_interval | 설명 |
|
||||||
|
|--------|-------------------|------|
|
||||||
|
| `history_hypertable` | 1일 | 이력 데이터는 1일 단위 청크 |
|
||||||
|
| `realtime_hypertable` | 1시간 | 실시간 데이터는 1시간 단위 청크 |
|
||||||
|
|
||||||
|
### 6.2 데이터 보존 정책
|
||||||
|
```sql
|
||||||
|
-- 90일 이전 데이터 자동 삭제
|
||||||
|
SELECT add_drop_chunks_policy('history_hypertable', INTERVAL '90 days');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 압축 설정
|
||||||
|
```sql
|
||||||
|
-- 1일 이전 데이터 자동 압축
|
||||||
|
SELECT add_compression_policy('history_hypertable', INTERVAL '1 day');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. NuGet 패키지 변경
|
||||||
|
|
||||||
|
### 추가 패키지
|
||||||
|
| 패키지 | 버전 | 용도 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `Npgsql.TimeScaleDB` | 최신 | TimeScaleDB 전용 확장 |
|
||||||
|
|
||||||
|
### 기존 패키지 (변경 없음)
|
||||||
|
| 패키지 | 현재 버전 |
|
||||||
|
|--------|----------|
|
||||||
|
| `Npgsql.EntityFrameworkCore.PostgreSQL` | 8.0.11 |
|
||||||
|
| `Microsoft.EntityFrameworkCore.Design` | 8.0.13 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 구현 체크리스트
|
||||||
|
|
||||||
|
- [ ] **단계 1**: TimeScaleDB 패키지 추가 및 초기화
|
||||||
|
- [ ] `Npgsql.TimeScaleDB` NuGet 패키지 추가
|
||||||
|
- [ ] `ExperionDbContext`에 TimeScaleDB 확장 코드 추가
|
||||||
|
- [ ] `history_table` → `history_hypertable` 마이그레이션
|
||||||
|
- [ ] `realtime_table` → `realtime_hypertable` 마이그레이션
|
||||||
|
|
||||||
|
- [ ] **단계 2**: Text-to-SQL 서비스 구현
|
||||||
|
- [ ] `ITextToSqlService` 인터페이스 정의
|
||||||
|
- [ ] `TextToSqlService` 구현체 작성
|
||||||
|
- [ ] 자연어 파서 (한국어/영어 지원)
|
||||||
|
- [ ] SQL 생성 로직 (TimeScaleDB 함수 활용)
|
||||||
|
- [ ] 시계열 분석 기능 (평균, 최대, 최소, 추세)
|
||||||
|
|
||||||
|
- [ ] **단계 3**: API 엔드포인트 추가
|
||||||
|
- [ ] `TextToSqlController` 생성
|
||||||
|
- [ ] `/api/text-to-sql/parse` 엔드포인트
|
||||||
|
- [ ] `/api/text-to-sql/execute` 엔드포인트
|
||||||
|
- [ ] `/api/text-to-sql/suggest` 엔드포인트
|
||||||
|
- [ ] `/api/text-to-sql/analyze` 엔드포인트
|
||||||
|
- [ ] DTO 정의
|
||||||
|
|
||||||
|
- [ ] **단계 4**: TimeSeries 서비스 개선
|
||||||
|
- [ ] `ExperionDbService` 수정 (하이퍼테이블 참조)
|
||||||
|
- [ ] 연속 집계 (Continuous Aggregates) 생성
|
||||||
|
- [ ] TimeScaleDB 함수 활용 쿼리 최적화
|
||||||
|
|
||||||
|
- [ ] **단계 5**: Program.cs 설정 업데이트
|
||||||
|
- [ ] 서비스 등록
|
||||||
|
- [ ] 연결 문자열 확인
|
||||||
|
- [ ] 초기화 플로우 수정
|
||||||
|
|
||||||
|
- [ ] **단계 6**: 테스트 및 문서화
|
||||||
|
- [ ] API 테스트 (Swagger)
|
||||||
|
- [ ] 자연어 파싱 테스트
|
||||||
|
- [ ] TimeScaleDB 성능 벤치마크
|
||||||
|
- [ ] README.md 업데이트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 예상 효과
|
||||||
|
|
||||||
|
| 항목 | 기존 | 개선 후 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 시계열 데이터 삽입 속도 | 일반 테이블 | TimeScaleDB 청킹으로 10x 이상 향상 |
|
||||||
|
| 시간 범위 쿼리 | 전체 테이블 스캔 | 청크 제거로 빠른 응답 |
|
||||||
|
| 데이터 압축 | 없음 | 자동 압축 (5-10x 저장공간 절약) |
|
||||||
|
| 자동 보존 | 수동 삭제 | 정책 기반 자동 삭제 |
|
||||||
|
| Text-to-SQL | 없음 | 자연어 기반 시계열 쿼리 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 주의사항
|
||||||
|
|
||||||
|
1. **TimeScaleDB 설치 prerequisite**: 대상 서버에 TimeScaleDB가 설치되어 있어야 함
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt-get install timescaledb-postgresql-16
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **기존 데이터 마이그레이션**: `history_table`과 `realtime_table`의 기존 데이터를 새 하이퍼테이블로 이동해야 함
|
||||||
|
|
||||||
|
3. **연결 문자열**: `Trust Server Certificate=true`는 TimeScaleDB에서 필요하지 않을 수 있음
|
||||||
|
|
||||||
|
4. **EF Core 제한**: EF Core는 TimeScaleDB 하이퍼테이블을 직접 지원하지 않음
|
||||||
|
- Raw SQL 쿼리 활용 필요
|
||||||
|
- `ExecuteSqlRawAsync`, `FromSqlRaw` 사용
|
||||||
|
|
||||||
|
5. **백호환성**: 기존 API 엔드포인트는 변경되지 않도록 유지
|
||||||
@@ -59,3 +59,29 @@ public class PointBuilderAddDto
|
|||||||
{
|
{
|
||||||
public string NodeId { get; set; } = string.Empty;
|
public string NodeId { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── TimeScaleDB 하이퍼테이블 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class HypertableStatusDto
|
||||||
|
{
|
||||||
|
public bool IsHypertable { get; set; }
|
||||||
|
public string? TableName { get; set; }
|
||||||
|
public string? StatusMessage { get; set; }
|
||||||
|
public int RecordCount { get; set; }
|
||||||
|
public bool HasRetentionPolicy { get; set; }
|
||||||
|
public bool HasCompression { get; set; }
|
||||||
|
public bool HasContinuousAggregate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class HypertableCreateDto
|
||||||
|
{
|
||||||
|
public string TableName { get; set; } = "history_table";
|
||||||
|
public string TimeColumn { get; set; } = "recorded_at";
|
||||||
|
public string TimeInterval { get; set; } = "1 day";
|
||||||
|
public bool MigrateData { get; set; } = true;
|
||||||
|
public bool SetRetentionPolicy { get; set; } = true;
|
||||||
|
public string RetentionPeriod { get; set; } = "90 days";
|
||||||
|
public bool EnableCompression { get; set; } = true;
|
||||||
|
public string CompressionPeriod { get; set; } = "1 day";
|
||||||
|
public bool CreateContinuousAggregate { get; set; } = true;
|
||||||
|
}
|
||||||
|
|||||||
51
src/Core/Application/DTOs/TextToSqlDtos.cs
Normal file
51
src/Core/Application/DTOs/TextToSqlDtos.cs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
namespace ExperionCrawler.Core.Application.DTOs;
|
||||||
|
|
||||||
|
// ── Text-to-SQL DTOs ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class NaturalLanguageQueryDto
|
||||||
|
{
|
||||||
|
public string Query { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SqlQueryDto
|
||||||
|
{
|
||||||
|
public string Sql { get; set; } = string.Empty;
|
||||||
|
public int? Limit { get; set; } = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AnalyzeRequestDto
|
||||||
|
{
|
||||||
|
public List<string> TagNames { get; set; } = new();
|
||||||
|
public DateTime? From { get; set; }
|
||||||
|
public DateTime? To { get; set; }
|
||||||
|
public string Interval { get; set; } = "5 min"; // time_bucket interval
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SqlQueryResultDto
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? Error { get; set; }
|
||||||
|
public List<string> Columns { get; set; } = new();
|
||||||
|
public List<Dictionary<string, object?>> Rows { get; set; } = new();
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TimeSeriesAnalysisDto
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? Error { get; set; }
|
||||||
|
public List<AnalysisTagResult> Tags { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AnalysisTagResult
|
||||||
|
{
|
||||||
|
public string TagName { get; set; } = string.Empty;
|
||||||
|
public double? Avg { get; set; }
|
||||||
|
public double? Min { get; set; }
|
||||||
|
public double? Max { get; set; }
|
||||||
|
public double? First { get; set; }
|
||||||
|
public double? Last { get; set; }
|
||||||
|
public long PointCount { get; set; }
|
||||||
|
public DateTime? From { get; set; }
|
||||||
|
public DateTime? To { get; set; }
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using ExperionCrawler.Core.Application.DTOs;
|
||||||
using ExperionCrawler.Core.Domain.Entities;
|
using ExperionCrawler.Core.Domain.Entities;
|
||||||
|
using ExperionCrawler.Infrastructure.Database;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace ExperionCrawler.Core.Application.Interfaces;
|
namespace ExperionCrawler.Core.Application.Interfaces;
|
||||||
@@ -44,6 +46,11 @@ public interface IExperionCsvService
|
|||||||
public interface IExperionDbService
|
public interface IExperionDbService
|
||||||
{
|
{
|
||||||
Task<bool> InitializeAsync();
|
Task<bool> InitializeAsync();
|
||||||
|
|
||||||
|
// ── TimeScaleDB 하이퍼테이블 ──────────────────────────────────────────────────
|
||||||
|
Task<HypertableStatusInfo> GetHypertableStatusAsync();
|
||||||
|
Task<HypertableCreateResult> CreateHypertableAsync(HypertableCreateRequest request);
|
||||||
|
|
||||||
Task<int> SaveRecordsAsync(IEnumerable<ExperionRecord> records);
|
Task<int> SaveRecordsAsync(IEnumerable<ExperionRecord> records);
|
||||||
Task<int> ClearRecordsAsync();
|
Task<int> ClearRecordsAsync();
|
||||||
Task<int> BuildMasterFromRawAsync(bool truncate = false);
|
Task<int> BuildMasterFromRawAsync(bool truncate = false);
|
||||||
|
|||||||
19
src/Core/Application/Interfaces/ITextToSqlService.cs
Normal file
19
src/Core/Application/Interfaces/ITextToSqlService.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using ExperionCrawler.Core.Application.DTOs;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Core.Application.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>Text-to-SQL 시계열 쿼리 서비스</summary>
|
||||||
|
public interface ITextToSqlService
|
||||||
|
{
|
||||||
|
/// <summary>자연어 질의를 SQL로 변환</summary>
|
||||||
|
Task<string> ParseNaturalLanguageAsync(string input);
|
||||||
|
|
||||||
|
/// <summary>SQL 쿼리 실행 및 결과 반환</summary>
|
||||||
|
Task<SqlQueryResultDto> ExecuteQueryAsync(string sql, int? limit = 1000);
|
||||||
|
|
||||||
|
/// <summary>쿼리 제안 (자동 완성)</summary>
|
||||||
|
Task<IEnumerable<string>> SuggestQueriesAsync(string partialInput);
|
||||||
|
|
||||||
|
/// <summary>시계열 분석 (평균, 최대, 최소, 추세)</summary>
|
||||||
|
Task<TimeSeriesAnalysisDto> AnalyzeAsync(AnalyzeRequestDto dto);
|
||||||
|
}
|
||||||
394
src/Core/Application/Services/TextToSqlService.cs
Normal file
394
src/Core/Application/Services/TextToSqlService.cs
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
using ExperionCrawler.Core.Application.DTOs;
|
||||||
|
using ExperionCrawler.Core.Application.Interfaces;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Core.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Text-to-SQL 시계열 쿼리 서비스
|
||||||
|
/// 자연어 질의를 파싱하여 iiot-timescaledb(IIoT 플랫폼) SQL 쿼리를 생성하고 실행합니다.
|
||||||
|
/// measurements (TimeScaleDB 하이퍼테이블) + opc_nodes 테이블을 참조합니다.
|
||||||
|
/// </summary>
|
||||||
|
public class TextToSqlService : ITextToSqlService
|
||||||
|
{
|
||||||
|
private readonly ILogger<TextToSqlService> _logger;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly string _connectionString;
|
||||||
|
|
||||||
|
public TextToSqlService(ILogger<TextToSqlService> logger, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_configuration = configuration;
|
||||||
|
_connectionString = configuration.GetConnectionString("DefaultConnection")
|
||||||
|
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 테이블명 설정 (iiot-timescaledb 매핑) ─────────────────────────────────
|
||||||
|
// iiot-timescaledb: measurements (node_id, time, value, quality)
|
||||||
|
// ExperionCrawler: history_hypertable (tagname, recorded_at, value)
|
||||||
|
private string HistoryTable => "measurements";
|
||||||
|
private string NodesTable => "opc_nodes";
|
||||||
|
|
||||||
|
// ── 자연어 파싱 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task<string> ParseNaturalLanguageAsync(string input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
throw new ArgumentException("쿼리 입력이 필요합니다.", nameof(input));
|
||||||
|
|
||||||
|
var sql = BuildSqlFromNaturalLanguage(input);
|
||||||
|
_logger.LogInformation("[TextToSql] 자연어 파싱: \"{Input}\" → {Sql}", input, sql);
|
||||||
|
return Task.FromResult(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 자연어 입력에서 태그명, 시간 범위, 집계 함수를 추출하여 SQL 생성
|
||||||
|
/// </summary>
|
||||||
|
private string BuildSqlFromNaturalLanguage(string input)
|
||||||
|
{
|
||||||
|
var lower = input.ToLower();
|
||||||
|
|
||||||
|
// 태그명 추출 (한글/영문 태그명 지원)
|
||||||
|
var tagName = ExtractTagName(input);
|
||||||
|
|
||||||
|
// 시간 범위 추출
|
||||||
|
var (fromClause, timeBucket) = ExtractTimeRange(lower);
|
||||||
|
|
||||||
|
// 집계 함수 추출
|
||||||
|
var aggregate = ExtractAggregate(lower);
|
||||||
|
|
||||||
|
// measurements (iiot-timescaledb 하이퍼테이블) 조회 SQL 생성
|
||||||
|
// 컬럼: node_id (TEXT), time (TIMESTAMPTZ), value (DOUBLE PRECISION)
|
||||||
|
if (!string.IsNullOrEmpty(tagName))
|
||||||
|
{
|
||||||
|
// SQL 인젝션 방지를 위해 태그명 이스케이프
|
||||||
|
var escapedTagName = tagName.Replace("'", "''");
|
||||||
|
var whereTag = $"WHERE node_id = '{escapedTagName}'";
|
||||||
|
if (!string.IsNullOrEmpty(fromClause))
|
||||||
|
whereTag += $" AND {fromClause}";
|
||||||
|
|
||||||
|
if (aggregate != "last" && aggregate != "first")
|
||||||
|
{
|
||||||
|
// 집계 쿼리 (value는 이미 double precision)
|
||||||
|
return $"SELECT time_bucket('{timeBucket}', time) AS bucket, " +
|
||||||
|
$"{aggregate}(value) AS result " +
|
||||||
|
$"FROM {HistoryTable} " +
|
||||||
|
$"{whereTag} " +
|
||||||
|
$"GROUP BY bucket ORDER BY bucket";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// first/last 쿼리 (TimeScaleDB 함수)
|
||||||
|
var func = aggregate == "first" ? "first" : "last";
|
||||||
|
return $"SELECT time_bucket('{timeBucket}', time) AS bucket, " +
|
||||||
|
$"{func}(value, time) AS result " +
|
||||||
|
$"FROM {HistoryTable} " +
|
||||||
|
$"{whereTag} " +
|
||||||
|
$"GROUP BY bucket ORDER BY bucket";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 태그명 없이 전체 조회
|
||||||
|
var whereAll = fromClause ?? "1=1";
|
||||||
|
return $"SELECT time_bucket('{timeBucket}', time) AS bucket, " +
|
||||||
|
$"node_id, {aggregate}(value) AS result " +
|
||||||
|
$"FROM {HistoryTable} " +
|
||||||
|
$"WHERE {whereAll} " +
|
||||||
|
$"GROUP BY bucket, node_id ORDER BY bucket, node_id";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 자연어에서 태그명/node_id 추출
|
||||||
|
/// 예: "Reactor.Temperature 온도 최근 1시간 평균" → "Reactor.Temperature"
|
||||||
|
/// "ns=2;s=Reactor.Temperature 온도 최근 1시간 평균" → "ns=2;s=Reactor.Temperature"
|
||||||
|
/// </summary>
|
||||||
|
private string ExtractTagName(string input)
|
||||||
|
{
|
||||||
|
var lower = input.ToLower();
|
||||||
|
|
||||||
|
// 키워드 목록 (시간/집계 관련 단어)
|
||||||
|
var keywords = new[]
|
||||||
|
{
|
||||||
|
"최근", "last", "latest",
|
||||||
|
"평균", "average", "avg", "평균값",
|
||||||
|
"최대", "maximum", "max", "최댓값",
|
||||||
|
"최소", "minimum", "min", "최솟값",
|
||||||
|
"첫번째", "first", "초기값",
|
||||||
|
"마지막", "last", "종료값",
|
||||||
|
"추세", "trend",
|
||||||
|
"데이터", "조회", "quer", "query",
|
||||||
|
"시간", "hour", "hr",
|
||||||
|
"분", "minute", "min", "m",
|
||||||
|
"초", "second", "sec", "s",
|
||||||
|
"일", "day", "d",
|
||||||
|
"주", "week", "w",
|
||||||
|
"전체", "all",
|
||||||
|
"온도", "temperature",
|
||||||
|
"압력", "pressure",
|
||||||
|
"유량", "flow", "유량", "flow rate",
|
||||||
|
"수위", "level", "수위",
|
||||||
|
"rpm", "전류", "current"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var kw in keywords)
|
||||||
|
{
|
||||||
|
var idx = lower.IndexOf(kw);
|
||||||
|
if (idx > 0)
|
||||||
|
{
|
||||||
|
var candidate = input.Substring(0, idx).Trim();
|
||||||
|
// OPC UA node_id 형식(ns=...;s=...) 또는 일반 태그명 반환
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPC UA node_id 형식 확인 (ns=X;s=...)
|
||||||
|
var nsMatch = System.Text.RegularExpressions.Regex.Match(input, @"ns=\d+;s=[^ ]+");
|
||||||
|
if (nsMatch.Success)
|
||||||
|
return nsMatch.Value;
|
||||||
|
|
||||||
|
// 키워드가 없으면 첫 단어(공백 이전)를 태그명으로 간주
|
||||||
|
var firstSpace = input.IndexOf(' ');
|
||||||
|
if (firstSpace > 0)
|
||||||
|
return input.Substring(0, firstSpace).Trim();
|
||||||
|
|
||||||
|
return input.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 자연어에서 시간 범위 추출
|
||||||
|
/// </summary>
|
||||||
|
private (string? whereClause, string timeBucket) ExtractTimeRange(string lower)
|
||||||
|
{
|
||||||
|
// 시간 단위 매핑 (measurements 테이블의 time 컬럼 사용)
|
||||||
|
var timePatterns = new (string pattern, string interval, string bucket)[]
|
||||||
|
{
|
||||||
|
("최근 1시간", "1 hour", "5 min"),
|
||||||
|
("최근 2시간", "2 hours", "5 min"),
|
||||||
|
("최근 3시간", "3 hours", "10 min"),
|
||||||
|
("최근 6시간", "6 hours", "10 min"),
|
||||||
|
("최근 12시간", "12 hours", "30 min"),
|
||||||
|
("최근 24시간", "24 hours", "1 hour"),
|
||||||
|
("최근 하루", "24 hours", "1 hour"),
|
||||||
|
("최근 1일", "1 day", "1 hour"),
|
||||||
|
("최근 3일", "3 days", "6 hours"),
|
||||||
|
("최근 7일", "7 days", "12 hours"),
|
||||||
|
("최근 1주", "7 days", "12 hours"),
|
||||||
|
("최근 1개월", "30 days", "1 day"),
|
||||||
|
("최근 한달", "30 days", "1 day"),
|
||||||
|
("오늘", "1 day", "1 hour"),
|
||||||
|
("어제", "1 day", "1 hour"),
|
||||||
|
("최근 5분", "5 minutes", "1 min"),
|
||||||
|
("최근 10분", "10 minutes", "1 min"),
|
||||||
|
("최근 30분", "30 minutes", "1 min"),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (pattern, interval, bucket) in timePatterns)
|
||||||
|
{
|
||||||
|
if (lower.Contains(pattern))
|
||||||
|
{
|
||||||
|
return ($"time > NOW() - INTERVAL '{interval}'", bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "from ~ to" 패턴 (ISO 8601 형식 또는 한국어)
|
||||||
|
var fromMatch = System.Text.RegularExpressions.Regex.Match(lower, @"from\s+(\d{4}-\d{2}-\d{2})");
|
||||||
|
var toMatch = System.Text.RegularExpressions.Regex.Match(lower, @"to\s+(\d{4}-\d{2}-\d{2})");
|
||||||
|
if (fromMatch.Success)
|
||||||
|
{
|
||||||
|
var from = fromMatch.Groups[1].Value;
|
||||||
|
var to = toMatch.Success ? toMatch.Groups[1].Value : DateTime.Now.ToString("yyyy-MM-dd");
|
||||||
|
return ($"time >= '{from} 00:00:00' AND time <= '{to} 23:59:59'", "1 hour");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (null, "5 min");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 자연어에서 집계 함수 추출
|
||||||
|
/// </summary>
|
||||||
|
private string ExtractAggregate(string lower)
|
||||||
|
{
|
||||||
|
if (lower.Contains("최대") || lower.Contains("max") || lower.Contains("최댓값"))
|
||||||
|
return "max";
|
||||||
|
if (lower.Contains("최소") || lower.Contains("min") || lower.Contains("최솟값"))
|
||||||
|
return "min";
|
||||||
|
if (lower.Contains("첫") || lower.Contains("first") || lower.Contains("초기"))
|
||||||
|
return "first";
|
||||||
|
if (lower.Contains("마지막") || lower.Contains("last") || lower.Contains("종료"))
|
||||||
|
return "last";
|
||||||
|
// 기본값: 평균
|
||||||
|
return "avg";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SQL 실행 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<SqlQueryResultDto> ExecuteQueryAsync(string sql, int? limit = 1000)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var conn = new NpgsqlConnection(_connectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var fullSql = sql.TrimEnd();
|
||||||
|
if (limit.HasValue && !fullSql.EndsWith(";", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !fullSql.EndsWith(" limit ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
fullSql += $" LIMIT {limit.Value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cmd = new NpgsqlCommand(fullSql, conn);
|
||||||
|
using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
|
||||||
|
var columns = new List<string>();
|
||||||
|
for (int i = 0; i < reader.FieldCount; i++)
|
||||||
|
{
|
||||||
|
columns.Add(reader.GetName(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = new List<Dictionary<string, object?>>();
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
var row = new Dictionary<string, object?>();
|
||||||
|
for (int i = 0; i < reader.FieldCount; i++)
|
||||||
|
{
|
||||||
|
row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i);
|
||||||
|
}
|
||||||
|
rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("[TextToSql] 쿼리 실행 성공: {RowCount}행", rows.Count);
|
||||||
|
return new SqlQueryResultDto
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Columns = columns,
|
||||||
|
Rows = rows,
|
||||||
|
TotalCount = rows.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[TextToSql] 쿼리 실행 실패: {Sql}", sql);
|
||||||
|
return new SqlQueryResultDto
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = ex.Message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 쿼리 제안 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Task<IEnumerable<string>> SuggestQueriesAsync(string partialInput)
|
||||||
|
{
|
||||||
|
var suggestions = new List<string>
|
||||||
|
{
|
||||||
|
"최근 1시간 평균",
|
||||||
|
"최근 24시간 최대값",
|
||||||
|
"최근 7일 최소값",
|
||||||
|
"전체 태그 현재 값",
|
||||||
|
"오늘의 데이터 추세"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(partialInput))
|
||||||
|
return Task.FromResult<IEnumerable<string>>(suggestions);
|
||||||
|
|
||||||
|
var lower = partialInput.ToLower();
|
||||||
|
return Task.FromResult<IEnumerable<string>>(suggestions.Where(s => s.Contains(lower)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 시계열 분석 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<TimeSeriesAnalysisDto> AnalyzeAsync(AnalyzeRequestDto dto)
|
||||||
|
{
|
||||||
|
var result = new TimeSeriesAnalysisDto();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var conn = new NpgsqlConnection(_connectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
var tagNames = dto.TagNames.Count > 0
|
||||||
|
? dto.TagNames
|
||||||
|
: await GetAllTagNamesAsync(conn);
|
||||||
|
|
||||||
|
var from = dto.From?.ToString("yyyy-MM-dd HH:mm:ss") ?? DateTime.Now.AddDays(-1).ToString("yyyy-MM-dd HH:mm:ss");
|
||||||
|
var to = dto.To?.ToString("yyyy-MM-dd HH:mm:ss") ?? DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
var tagResults = new List<AnalysisTagResult>();
|
||||||
|
|
||||||
|
foreach (var tagName in tagNames)
|
||||||
|
{
|
||||||
|
// SQL 인젝션 방지를 위해 태그명 이스케이프
|
||||||
|
var escapedTagName = tagName.Replace("'", "''");
|
||||||
|
|
||||||
|
// measurements 테이블: node_id (TEXT), time (TIMESTAMPTZ), value (DOUBLE PRECISION)
|
||||||
|
var sql = $@"
|
||||||
|
SELECT
|
||||||
|
AVG(value) AS avg_val,
|
||||||
|
MIN(value) AS min_val,
|
||||||
|
MAX(value) AS max_val,
|
||||||
|
first(value, time) AS first_val,
|
||||||
|
last(value, time) AS last_val,
|
||||||
|
COUNT(*) AS point_count
|
||||||
|
FROM measurements
|
||||||
|
WHERE node_id = '{escapedTagName}'
|
||||||
|
AND time BETWEEN '{from}' AND '{to}'";
|
||||||
|
|
||||||
|
using var cmd = new NpgsqlCommand(sql, conn);
|
||||||
|
using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
|
||||||
|
if (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
tagResults.Add(new AnalysisTagResult
|
||||||
|
{
|
||||||
|
TagName = tagName,
|
||||||
|
Avg = reader.IsDBNull(0) ? null : Convert.ToDouble(reader.GetValue(0)),
|
||||||
|
Min = reader.IsDBNull(1) ? null : Convert.ToDouble(reader.GetValue(1)),
|
||||||
|
Max = reader.IsDBNull(2) ? null : Convert.ToDouble(reader.GetValue(2)),
|
||||||
|
First = reader.IsDBNull(3) ? null : Convert.ToDouble(reader.GetValue(3)),
|
||||||
|
Last = reader.IsDBNull(4) ? null : Convert.ToDouble(reader.GetValue(4)),
|
||||||
|
PointCount = reader.IsDBNull(5) ? 0 : Convert.ToInt64(reader.GetValue(5)),
|
||||||
|
From = dto.From,
|
||||||
|
To = dto.To
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Tags = tagResults;
|
||||||
|
result.Success = true;
|
||||||
|
_logger.LogInformation("[TextToSql] 분석 완료: {TagCount}개 태그", tagResults.Count);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[TextToSql] 분석 실패");
|
||||||
|
result.Success = false;
|
||||||
|
result.Error = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<string>> GetAllTagNamesAsync(NpgsqlConnection conn)
|
||||||
|
{
|
||||||
|
var tags = new List<string>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// measurements 테이블에서 고유 node_id 목록 조회
|
||||||
|
using var cmd = new NpgsqlCommand(
|
||||||
|
"SELECT DISTINCT node_id FROM measurements ORDER BY node_id", conn);
|
||||||
|
using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
tags.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// fallback: empty list
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,11 +45,20 @@ public class ExperionCertificateService : IExperionCertificateService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ExperionCertResult> EnsureCertificateAsync(
|
public Task<ExperionCertResult> EnsureCertificateAsync(
|
||||||
string applicationUri,
|
string applicationUri,
|
||||||
string clientHostName,
|
string clientHostName,
|
||||||
IEnumerable<string> subjectAltNames,
|
IEnumerable<string> subjectAltNames,
|
||||||
string pfxPassword = "")
|
string pfxPassword = "")
|
||||||
|
{
|
||||||
|
return Task.Run(() => EnsureCertificateCore(applicationUri, clientHostName, subjectAltNames, pfxPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExperionCertResult EnsureCertificateCore(
|
||||||
|
string applicationUri,
|
||||||
|
string clientHostName,
|
||||||
|
IEnumerable<string> subjectAltNames,
|
||||||
|
string pfxPassword)
|
||||||
{
|
{
|
||||||
var path = PfxPath(clientHostName);
|
var path = PfxPath(clientHostName);
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,13 @@ public class ExperionCsvService : IExperionCsvService
|
|||||||
if (!File.Exists(filePath))
|
if (!File.Exists(filePath))
|
||||||
throw new FileNotFoundException($"CSV 파일을 찾을 수 없습니다: {filePath}");
|
throw new FileNotFoundException($"CSV 파일을 찾을 수 없습니다: {filePath}");
|
||||||
|
|
||||||
using var reader = new StreamReader(filePath);
|
// I/O 작업이므로 Task.Run으로 백그라운드 스레드에서 실행
|
||||||
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
|
var records = await Task.Run(() =>
|
||||||
var records = csv.GetRecords<ExperionRecord>().ToList();
|
{
|
||||||
|
using var reader = new StreamReader(filePath);
|
||||||
|
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
|
||||||
|
return csv.GetRecords<ExperionRecord>().ToList();
|
||||||
|
});
|
||||||
|
|
||||||
_logger.LogInformation("[ExperionCsv] 가져오기 완료: {Path} ({Count}건)", filePath, records.Count);
|
_logger.LogInformation("[ExperionCsv] 가져오기 완료: {Path} ({Count}건)", filePath, records.Count);
|
||||||
return records;
|
return records;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using ExperionCrawler.Core.Application.Interfaces;
|
|||||||
using ExperionCrawler.Core.Domain.Entities;
|
using ExperionCrawler.Core.Domain.Entities;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
namespace ExperionCrawler.Infrastructure.Database;
|
namespace ExperionCrawler.Infrastructure.Database;
|
||||||
|
|
||||||
@@ -75,6 +76,9 @@ public class ExperionDbService : IExperionDbService
|
|||||||
{
|
{
|
||||||
await _ctx.Database.EnsureCreatedAsync();
|
await _ctx.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
// TimeScaleDB 확장 활성화
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync("CREATE EXTENSION IF NOT EXISTS timescaledb");
|
||||||
|
|
||||||
// EnsureCreatedAsync는 기존 DB에 새 테이블을 추가하지 않으므로
|
// EnsureCreatedAsync는 기존 DB에 새 테이블을 추가하지 않으므로
|
||||||
// raw_node_map / node_map_master 는 DDL로 직접 보장
|
// raw_node_map / node_map_master 는 DDL로 직접 보장
|
||||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||||
@@ -99,32 +103,25 @@ public class ExperionDbService : IExperionDbService
|
|||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
|
// realtime_table 생성 (실시간 모니터링 포인트)
|
||||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||||
CREATE TABLE IF NOT EXISTS realtime_table (
|
CREATE TABLE IF NOT EXISTS realtime_table (
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
tagname TEXT NOT NULL,
|
|
||||||
node_id TEXT NOT NULL UNIQUE,
|
|
||||||
livevalue TEXT,
|
|
||||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
|
|
||||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
|
||||||
CREATE TABLE IF NOT EXISTS history_table (
|
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
tagname TEXT NOT NULL,
|
tagname TEXT NOT NULL,
|
||||||
node_id TEXT NOT NULL,
|
node_id TEXT NOT NULL,
|
||||||
value TEXT,
|
livevalue TEXT,
|
||||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
)
|
)
|
||||||
""");
|
""");
|
||||||
|
|
||||||
await _ctx.Database.ExecuteSqlRawAsync(
|
// realtime_table은 실시간 값 저장용이므로 하이퍼테이블로 변환하지 않음
|
||||||
"CREATE INDEX IF NOT EXISTS idx_history_tagname ON history_table(tagname)");
|
|
||||||
await _ctx.Database.ExecuteSqlRawAsync(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_history_recorded_at ON history_table(recorded_at)");
|
|
||||||
|
|
||||||
_logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료");
|
// history 테이블은 수동으로 하이퍼테이블 생성 필요
|
||||||
|
// CreateHypertableAsync() 메서드를 사용하여 수동 생성 가능
|
||||||
|
// 참고: 하이퍼테이블 생성 후 보존 정책, 압축 정책, 연속 집계 설정은
|
||||||
|
// CreateHypertableAsync() 메서드에서 선택적으로 설정 가능
|
||||||
|
|
||||||
|
_logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료 (TimeScaleDB 활성화)");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -134,6 +131,90 @@ public class ExperionDbService : IExperionDbService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// history 테이블을 TimeScaleDB 하이퍼테이블로 생성/마이그레이션
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hypertableName">생성할 하이퍼테이블 이름 (예: history_hypertable)</param>
|
||||||
|
/// <param name="tableName">기존 테이블 이름 (예: history_table, null이면 새 테이블 생성)</param>
|
||||||
|
/// <param name="timeColumn">시간 컬럼 이름</param>
|
||||||
|
/// <param name="interval">청크 간격 (예: '1 day')</param>
|
||||||
|
/// <returns>하이퍼테이블 생성 여부 (true: 생성됨/기존 있음, false: 실패/스킵)</returns>
|
||||||
|
private async Task<bool> CreateHistoryHypertableIfNotExistsAsync(
|
||||||
|
string hypertableName, string? tableName, string timeColumn, string interval)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// SQL injection 방지를 위해 식별자 검증
|
||||||
|
if (!IsValidSqlIdentifier(hypertableName))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[ExperionDb] 하이퍼테이블 이름 '{HypertableName}'이(가) 유효하지 않음", hypertableName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1️⃣ 하이퍼테이블이 이미 존재하면 스킵
|
||||||
|
await using var conn = new NpgsqlConnection(_ctx.Database.GetConnectionString());
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
await using var cmd1 = new NpgsqlCommand(
|
||||||
|
$"SELECT 1 FROM pg_catalog.pg_tables WHERE tablename = '{hypertableName.Replace("'", "''")}' LIMIT 1", conn);
|
||||||
|
var result = await cmd1.ExecuteScalarAsync();
|
||||||
|
if (result != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[ExperionDb] 하이퍼테이블 '{HypertableName}' 이미 존재함", hypertableName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ 기존 테이블 이름이 제공되면 검증
|
||||||
|
if (tableName != null && !IsValidSqlIdentifier(tableName))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[ExperionDb] 테이블 이름 '{TableName}'이(가) 유효하지 않음", tableName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3️⃣ 기존 테이블이 존재하면 → 하이퍼테이블로 마이그레이션
|
||||||
|
if (tableName != null)
|
||||||
|
{
|
||||||
|
await using var cmd2 = new NpgsqlCommand(
|
||||||
|
$"SELECT 1 FROM pg_catalog.pg_tables WHERE tablename = '{tableName.Replace("'", "''")}' LIMIT 1", conn);
|
||||||
|
result = await cmd2.ExecuteScalarAsync();
|
||||||
|
if (result != null)
|
||||||
|
{
|
||||||
|
// 데이터가 있는 경우 migrate_data => true 옵션 필요
|
||||||
|
#pragma warning disable EF1002 // Method 'ExecuteSqlRawAsync' inserts interpolated strings directly into the SQL
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync(
|
||||||
|
$"SELECT create_hypertable('{tableName}', '{timeColumn}', chunk_time_interval => INTERVAL '{interval}', create_default_indexes => true, migrate_data => true)");
|
||||||
|
_logger.LogInformation("[ExperionDb] 테이블 '{TableName}'을(를) 하이퍼테이블로 변환 완료", tableName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4️⃣ 기존 테이블이 없으면 → 새 하이퍼테이블 테이블 생성
|
||||||
|
// TimeScaleDB 요구사항: 고유 인덱스를 위해서는 partitioning 컬럼이 primary key에 포함되어야 함
|
||||||
|
#pragma warning disable EF1002 // Method 'ExecuteSqlRawAsync' inserts interpolated strings directly into the SQL
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync($"""
|
||||||
|
CREATE TABLE IF NOT EXISTS {hypertableName} (
|
||||||
|
id SERIAL,
|
||||||
|
tagname TEXT NOT NULL,
|
||||||
|
node_id TEXT,
|
||||||
|
value TEXT,
|
||||||
|
livevalue TEXT,
|
||||||
|
{timeColumn} TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (id, {timeColumn})
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync($"""
|
||||||
|
SELECT create_hypertable('{hypertableName}', '{timeColumn}', chunk_time_interval => INTERVAL '{interval}', create_default_indexes => true)
|
||||||
|
""");
|
||||||
|
_logger.LogInformation("[ExperionDb] 새 하이퍼테이블 '{HypertableName}' 생성 완료", hypertableName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "[ExperionDb] 하이퍼테이블 '{HypertableName}' 생성 실패 (기본 테이블 사용 계속)", hypertableName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<int> SaveRecordsAsync(IEnumerable<ExperionRecord> records)
|
public async Task<int> SaveRecordsAsync(IEnumerable<ExperionRecord> records)
|
||||||
{
|
{
|
||||||
var list = records.ToList();
|
var list = records.ToList();
|
||||||
@@ -390,4 +471,363 @@ public class ExperionDbService : IExperionDbService
|
|||||||
.GroupBy(x => x.NodeId)
|
.GroupBy(x => x.NodeId)
|
||||||
.ToDictionary(g => g.Key, g => g.First().DataType);
|
.ToDictionary(g => g.Key, g => g.First().DataType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 하이퍼테이블 상태 조회합니다.
|
||||||
|
/// 하이퍼테이블인지 여부, 레코드 수, 보존 정책, 압축, 연속 집계 설정 등을 확인합니다.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<HypertableStatusInfo> GetHypertableStatusAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _ctx.Database.GetDbConnection().OpenAsync();
|
||||||
|
|
||||||
|
// 하이퍼테이블 존재 여부 확인
|
||||||
|
await using var hypertableCheckCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||||||
|
hypertableCheckCmd.CommandText = @"
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM timescaledb_information.hypertables
|
||||||
|
WHERE hypertable_name = 'history_table'
|
||||||
|
)";
|
||||||
|
|
||||||
|
var isHypertableResult = Convert.ToBoolean(await hypertableCheckCmd.ExecuteScalarAsync());
|
||||||
|
|
||||||
|
// 레코드 수 조회
|
||||||
|
var recordCount = 0;
|
||||||
|
if (isHypertableResult)
|
||||||
|
{
|
||||||
|
await using var countCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||||||
|
countCmd.CommandText = "SELECT COUNT(*) FROM history_table";
|
||||||
|
recordCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 보존 정책 확인 (pg_extension으로 TimeScaleDB 활성화 여부만 확인)
|
||||||
|
var hasRetentionPolicy = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var retentionCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||||||
|
retentionCmd.CommandText = @"
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM timescaledb_information.policies
|
||||||
|
WHERE policy_type = 'data_retention'
|
||||||
|
AND hypertable_name = 'history_table'
|
||||||
|
)";
|
||||||
|
hasRetentionPolicy = Convert.ToBoolean(await retentionCmd.ExecuteScalarAsync());
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// policies 뷰가 없는 경우 false 유지
|
||||||
|
hasRetentionPolicy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 압축 확인
|
||||||
|
var hasCompression = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var compressionCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||||||
|
compressionCmd.CommandText = @"
|
||||||
|
SELECT is_compressed = 't'
|
||||||
|
FROM timescaledb_information.hypertables
|
||||||
|
WHERE hypertable_name = 'history_table'";
|
||||||
|
var compressionResult = await compressionCmd.ExecuteScalarAsync();
|
||||||
|
if (compressionResult != null)
|
||||||
|
{
|
||||||
|
hasCompression = compressionResult.ToString() == "t";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 압축 정보 조회 실패 시 false 유지
|
||||||
|
hasCompression = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연속 집계 확인
|
||||||
|
var hasContinuousAggregate = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var aggregateCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||||||
|
aggregateCmd.CommandText = @"
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM timescaledb_information.continuous_aggregates
|
||||||
|
WHERE hypertable_name = 'history_table'
|
||||||
|
)";
|
||||||
|
hasContinuousAggregate = Convert.ToBoolean(await aggregateCmd.ExecuteScalarAsync());
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// continuous_aggregates 뷰가 없는 경우 false 유지
|
||||||
|
hasContinuousAggregate = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HypertableStatusInfo
|
||||||
|
{
|
||||||
|
IsHypertable = isHypertableResult,
|
||||||
|
TableName = "history_table",
|
||||||
|
StatusMessage = isHypertableResult
|
||||||
|
? "하이퍼테이블이 활성화되어 있습니다."
|
||||||
|
: "일반 테이블입니다. CreateHypertableAsync()를 사용하여 하이퍼테이블로 변환할 수 있습니다.",
|
||||||
|
RecordCount = recordCount,
|
||||||
|
HasRetentionPolicy = hasRetentionPolicy,
|
||||||
|
HasCompression = hasCompression,
|
||||||
|
HasContinuousAggregate = hasContinuousAggregate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HypertableStatusInfo
|
||||||
|
{
|
||||||
|
IsHypertable = false,
|
||||||
|
TableName = "history_table",
|
||||||
|
StatusMessage = $"상태 확인 중 오류 발생: {ex.Message}",
|
||||||
|
RecordCount = 0,
|
||||||
|
HasRetentionPolicy = false,
|
||||||
|
HasCompression = false,
|
||||||
|
HasContinuousAggregate = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 수동으로 하이퍼테이블을 생성합니다.
|
||||||
|
/// 테이블이 이미 존재하거나 하이퍼테이블로 변환된 경우 예외를 throw합니다.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<HypertableCreateResult> CreateHypertableAsync(HypertableCreateRequest request)
|
||||||
|
{
|
||||||
|
// 식별자 검증 - SQL injection 방지
|
||||||
|
if (!IsValidSqlIdentifier(request.TableName))
|
||||||
|
{
|
||||||
|
return HypertableCreateResult.Failed($"테이블 이름 '{request.TableName}'은 유효하지 않습니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 사용 가능합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsValidSqlIdentifier(request.TimeColumn))
|
||||||
|
{
|
||||||
|
return HypertableCreateResult.Failed($"시간 컬럼 이름 '{request.TimeColumn}'은 유효하지 않습니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 사용 가능합니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsValidSqlIdentifier(request.TimeInterval?.Replace(" ", "")))
|
||||||
|
{
|
||||||
|
return HypertableCreateResult.Failed($"시간 간격 '{request.TimeInterval}'은 유효하지 않습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ctx.Database.GetDbConnection().Open();
|
||||||
|
|
||||||
|
// 1. 테이블 존재 여부 확인
|
||||||
|
await using var tableCheckCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||||||
|
tableCheckCmd.CommandText = @"
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = @tableName)";
|
||||||
|
tableCheckCmd.Parameters.Add(new NpgsqlParameter("@tableName", request.TableName));
|
||||||
|
|
||||||
|
var tableExists = Convert.ToBoolean(await tableCheckCmd.ExecuteScalarAsync());
|
||||||
|
if (!tableExists)
|
||||||
|
{
|
||||||
|
return HypertableCreateResult.Failed($"테이블 '{request.TableName}'이 존재하지 않습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 이미 하이퍼테이블인지 확인
|
||||||
|
await using var hypertableCheckCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||||||
|
hypertableCheckCmd.CommandText = @"
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM timescaledb_information.hypertables
|
||||||
|
WHERE hypertable_name = @tableName)";
|
||||||
|
hypertableCheckCmd.Parameters.Add(new NpgsqlParameter("@tableName", request.TableName));
|
||||||
|
|
||||||
|
var isHypertable = Convert.ToBoolean(await hypertableCheckCmd.ExecuteScalarAsync());
|
||||||
|
if (isHypertable)
|
||||||
|
{
|
||||||
|
return HypertableCreateResult.AlreadyExists($"테이블 '{request.TableName}'은 이미 하이퍼테이블입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. TimescaleDB 확장 활성화
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync("CREATE EXTENSION IF NOT EXISTS timescaledb");
|
||||||
|
|
||||||
|
// 3-1. 기존 SERIAL PRIMARY KEY 제약사항 제거 (TimeScaleDB 호환성)
|
||||||
|
var dropPrimaryKeySql = $"ALTER TABLE {request.TableName} DROP CONSTRAINT IF EXISTS {request.TableName}_pkey";
|
||||||
|
Console.WriteLine($"[DEBUG] 기본키 제약사항 제거 SQL: {dropPrimaryKeySql}");
|
||||||
|
#pragma warning disable EF1002
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync(dropPrimaryKeySql);
|
||||||
|
#pragma warning restore EF1002
|
||||||
|
|
||||||
|
// 4. 하이퍼테이블 생성 (기존 데이터 마이그레이션 포함)
|
||||||
|
var createHypertableSql = $"SELECT create_hypertable('{request.TableName}'::regclass, '{request.TimeColumn}'::text, if_not_exists => TRUE, migrate_data => TRUE)";
|
||||||
|
Console.WriteLine($"[DEBUG] 하이퍼테이블 생성 SQL: {createHypertableSql}");
|
||||||
|
#pragma warning disable EF1002
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync(createHypertableSql);
|
||||||
|
#pragma warning restore EF1002
|
||||||
|
|
||||||
|
// 4-1. TimeScaleDB 하이퍼테이블에 적합한 새로운 기본키 생성
|
||||||
|
var addPrimaryKeySql = $"ALTER TABLE {request.TableName} ADD CONSTRAINT {request.TableName}_pkey PRIMARY KEY ({request.TimeColumn}, id)";
|
||||||
|
Console.WriteLine($"[DEBUG] 새 기본키 생성 SQL: {addPrimaryKeySql}");
|
||||||
|
#pragma warning disable EF1002
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync(addPrimaryKeySql);
|
||||||
|
#pragma warning restore EF1002
|
||||||
|
|
||||||
|
// 6. 보존 정책 설정 (요청된 경우)
|
||||||
|
if (request.SetRetentionPolicy && !string.IsNullOrEmpty(request.RetentionPeriod))
|
||||||
|
{
|
||||||
|
var retentionSql = $"SELECT add_retention_policy('{request.TableName}'::regclass, INTERVAL '{request.RetentionPeriod}')";
|
||||||
|
Console.WriteLine($"[DEBUG] 보존 정책 SQL: {retentionSql}");
|
||||||
|
#pragma warning disable EF1002
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync(retentionSql);
|
||||||
|
#pragma warning restore EF1002
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 압축 정책 설정 (요청된 경우)
|
||||||
|
if (request.EnableCompression && !string.IsNullOrEmpty(request.CompressionPeriod))
|
||||||
|
{
|
||||||
|
var compressionSql = $"SELECT add_compression_policy('{request.TableName}'::regclass, INTERVAL '{request.CompressionPeriod}')";
|
||||||
|
Console.WriteLine($"[DEBUG] 압축 정책 SQL: {compressionSql}");
|
||||||
|
#pragma warning disable EF1002
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync(compressionSql);
|
||||||
|
#pragma warning restore EF1002
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7-1. history_table 컬럼명 검증 (tag_name vs tagname)
|
||||||
|
if (request.TableName == "history_table")
|
||||||
|
{
|
||||||
|
await using var columnCheckCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||||||
|
columnCheckCmd.CommandText = @"
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'history_table'
|
||||||
|
AND column_name IN ('tag_name', 'tagname')
|
||||||
|
ORDER BY column_name;";
|
||||||
|
|
||||||
|
var columns = new List<string>();
|
||||||
|
await using var reader = await columnCheckCmd.ExecuteReaderAsync();
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
columns.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[DEBUG] history_table 컬럼명 검증: {string.Join(", ", columns)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 연속 집계 생성 (요청된 경우)
|
||||||
|
if (request.CreateContinuousAggregate)
|
||||||
|
{
|
||||||
|
// 8-1. 기존 MATERIALIZED VIEW 삭제 (별도 실행)
|
||||||
|
var dropViewSql = "DROP MATERIALIZED VIEW IF EXISTS history_5min_agg";
|
||||||
|
Console.WriteLine($"[DEBUG] 연속 집계 DROP VIEW SQL: {dropViewSql}");
|
||||||
|
#pragma warning disable EF1002
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync(dropViewSql);
|
||||||
|
#pragma warning restore EF1002
|
||||||
|
|
||||||
|
// 8-2. 연속 집계 MATERIALIZED VIEW 생성 (별도 실행)
|
||||||
|
var createViewSql = $@"
|
||||||
|
CREATE MATERIALIZED VIEW history_5min_agg
|
||||||
|
WITH (timescaledb.continuous) AS
|
||||||
|
SELECT
|
||||||
|
time_bucket(INTERVAL '5 minutes', {request.TimeColumn}) AS time_bucket,
|
||||||
|
tagname,
|
||||||
|
AVG(value) AS avg_value,
|
||||||
|
first(value, {request.TimeColumn}) AS min_value,
|
||||||
|
last(value, {request.TimeColumn}) AS max_value
|
||||||
|
FROM {request.TableName}
|
||||||
|
GROUP BY time_bucket, tagname;";
|
||||||
|
|
||||||
|
Console.WriteLine($"[DEBUG] 연속 집계 CREATE VIEW SQL: {createViewSql}");
|
||||||
|
|
||||||
|
#pragma warning disable EF1002
|
||||||
|
Console.WriteLine($"[DEBUG] CREATE VIEW SQL 실행 전: {createViewSql}");
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync(createViewSql);
|
||||||
|
Console.WriteLine($"[DEBUG] CREATE VIEW SQL 실행 성공");
|
||||||
|
#pragma warning restore EF1002
|
||||||
|
|
||||||
|
// 8-3. 연속 집계 정책 추가
|
||||||
|
var aggregatePolicySql = $"SELECT add_continuous_aggregate_policy('history_5min_agg', '10 minutes', '1 minute', '5 minutes')";
|
||||||
|
|
||||||
|
Console.WriteLine($"[DEBUG] 연속 집계 정책 SQL: {aggregatePolicySql}");
|
||||||
|
|
||||||
|
#pragma warning disable EF1002
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync(aggregatePolicySql);
|
||||||
|
#pragma warning restore EF1002
|
||||||
|
}
|
||||||
|
|
||||||
|
return HypertableCreateResult.Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var errorDetails = $"하이퍼테이블 생성 실패: {ex.Message}";
|
||||||
|
Console.WriteLine($"[ERROR] {errorDetails}");
|
||||||
|
if (ex.InnerException != null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[ERROR] InnerException: {ex.InnerException.Message}");
|
||||||
|
Console.WriteLine($"[ERROR] InnerException StackTrace: {ex.InnerException.StackTrace}");
|
||||||
|
}
|
||||||
|
Console.WriteLine($"[ERROR] Full StackTrace: {ex.StackTrace}");
|
||||||
|
return HypertableCreateResult.Failed(errorDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL 식별자로 안전한지 검증합니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 허용합니다.
|
||||||
|
/// EF1002 SQL injection 방지를 위해 DDL 문에서 식별자를 사용할 때 이 메서드로 검증을 필수로 합니다.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsValidSqlIdentifier(string identifier)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(identifier))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (identifier.Length > 63) // PostgreSQL 식별자 최대 길이
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// 영문, 숫자, 언더스코어, 하이픈, 마침표만 허용
|
||||||
|
return System.Text.RegularExpressions.Regex.IsMatch(
|
||||||
|
identifier,
|
||||||
|
@"^[a-zA-Z0-9_\-\.]+$");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 하이퍼테이블 상태 정보 결과 클래스
|
||||||
|
/// </summary>
|
||||||
|
public record HypertableStatusInfo
|
||||||
|
{
|
||||||
|
public bool IsHypertable { get; init; }
|
||||||
|
public string? TableName { get; init; }
|
||||||
|
public string? StatusMessage { get; init; }
|
||||||
|
public int RecordCount { get; init; }
|
||||||
|
public bool HasRetentionPolicy { get; init; }
|
||||||
|
public bool HasCompression { get; init; }
|
||||||
|
public bool HasContinuousAggregate { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 하이퍼테이블 생성 요청 클래스
|
||||||
|
/// </summary>
|
||||||
|
public record HypertableCreateRequest
|
||||||
|
{
|
||||||
|
public string TableName { get; init; } = "history_table";
|
||||||
|
public string TimeColumn { get; init; } = "recorded_at";
|
||||||
|
public string TimeInterval { get; init; } = "1 day";
|
||||||
|
public bool MigrateData { get; init; } = true;
|
||||||
|
public bool SetRetentionPolicy { get; init; } = true;
|
||||||
|
public string RetentionPeriod { get; init; } = "90 days";
|
||||||
|
public bool EnableCompression { get; init; } = true;
|
||||||
|
public string CompressionPeriod { get; init; } = "1 day";
|
||||||
|
public bool CreateContinuousAggregate { get; init; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 하이퍼테이블 생성 결과 클래스
|
||||||
|
/// </summary>
|
||||||
|
public record HypertableCreateResult
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
public string Message { get; init; } = string.Empty;
|
||||||
|
public string? TableName { get; init; }
|
||||||
|
|
||||||
|
public static HypertableCreateResult Ok(string? tableName = null, string? message = null)
|
||||||
|
=> new() { Success = true, TableName = tableName, Message = message ?? "하이퍼테이블이 성공적으로 생성되었습니다." };
|
||||||
|
|
||||||
|
public static HypertableCreateResult Failed(string message)
|
||||||
|
=> new() { Success = false, Message = message };
|
||||||
|
|
||||||
|
public static HypertableCreateResult AlreadyExists(string message)
|
||||||
|
=> new() { Success = false, Message = message };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,18 @@ public class ExperionOpcClient : IExperionOpcClient
|
|||||||
// 원본: new UserIdentity(userName, Encoding.UTF8.GetBytes(password))
|
// 원본: new UserIdentity(userName, Encoding.UTF8.GetBytes(password))
|
||||||
var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password));
|
var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password));
|
||||||
|
|
||||||
return await Session.Create(appConfig, endpoint, false, sessionName, 60_000, identity, null);
|
// CS0618: Session.Create는 obsolete이지만 SessionFactory/CreateAsync가 현재 라이브러리에 없음
|
||||||
|
// Task.Run으로 래핑하여 비동기 실행
|
||||||
|
#pragma warning disable CS0618 // 'Session.Create()' is obsolete
|
||||||
|
return await Task.Run(() => Session.Create(
|
||||||
|
appConfig,
|
||||||
|
endpoint,
|
||||||
|
false,
|
||||||
|
sessionName,
|
||||||
|
60_000,
|
||||||
|
identity,
|
||||||
|
null));
|
||||||
|
#pragma warning restore CS0618 // 'Session.Create()' is obsolete
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 접속 테스트 ───────────────────────────────────────────────────────────
|
// ── 접속 테스트 ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ public class ExperionOpcServerService : IExperionOpcServerService, IHostedServic
|
|||||||
|
|
||||||
var config = BuildServerConfig();
|
var config = BuildServerConfig();
|
||||||
_server = new ExperionStandardServer();
|
_server = new ExperionStandardServer();
|
||||||
_server.Start(config);
|
await _server.StartAsync(config);
|
||||||
_nodeManager = _server.NodeManager;
|
_nodeManager = _server.NodeManager;
|
||||||
|
|
||||||
_running = true;
|
_running = true;
|
||||||
@@ -168,7 +168,9 @@ public class ExperionOpcServerService : IExperionOpcServerService, IHostedServic
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_nodeManager?.UpdateServerStatus("Stopped", 0);
|
_nodeManager?.UpdateServerStatus("Stopped", 0);
|
||||||
|
#pragma warning disable CS0618 // 'Stop()' is obsolete
|
||||||
_server?.Stop();
|
_server?.Stop();
|
||||||
|
#pragma warning restore CS0618 // 'Stop()' is obsolete
|
||||||
}
|
}
|
||||||
catch (Exception ex) { _logger.LogWarning(ex, "[OpcServer] 서버 Stop() 중 오류 (무시)"); }
|
catch (Exception ex) { _logger.LogWarning(ex, "[OpcServer] 서버 Stop() 중 오류 (무시)"); }
|
||||||
|
|
||||||
@@ -264,9 +266,24 @@ public class ExperionOpcServerService : IExperionOpcServerService, IHostedServic
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
try { _server?.Stop(); } catch { /* ignore */ }
|
if (_server != null)
|
||||||
|
{
|
||||||
|
try { await _server.StopAsync(CancellationToken.None).ConfigureAwait(false); } catch { /* ignore */ }
|
||||||
|
_server = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void IDisposable.Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
#pragma warning disable CS0618 // 'Stop()' is obsolete
|
||||||
|
_server?.Stop();
|
||||||
|
#pragma warning restore CS0618 // 'Stop()' is obsolete
|
||||||
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
_server = null;
|
_server = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,14 +174,18 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// OPC UA 서버에 실제 적용 — 서버가 node_id 유효성 검증
|
// OPC UA 서버에 실제 적용 — 서버가 node_id 유효성 검증
|
||||||
await Task.Run(() => _subscription.ApplyChanges());
|
#pragma warning disable CS0618 // 'ApplyChanges()' is obsolete
|
||||||
|
await Task.Run(() => { _subscription.ApplyChanges(); });
|
||||||
|
#pragma warning restore CS0618 // 'ApplyChanges()' is obsolete
|
||||||
|
|
||||||
// 서버 응답 상태 확인 (Error가 null이면 정상)
|
// 서버 응답 상태 확인 (Error가 null이면 정상)
|
||||||
if (item.Status.Error != null && !StatusCode.IsGood(item.Status.Error.StatusCode))
|
if (item.Status.Error != null && !StatusCode.IsGood(item.Status.Error.StatusCode))
|
||||||
{
|
{
|
||||||
// 유효하지 않은 node_id → subscription에서 제거
|
// 유효하지 않은 node_id → subscription에서 제거
|
||||||
_subscription.RemoveItem(item);
|
_subscription.RemoveItem(item);
|
||||||
await Task.Run(() => _subscription.ApplyChanges());
|
#pragma warning disable CS0618 // 'ApplyChanges()' is obsolete
|
||||||
|
await Task.Run(() => { _subscription.ApplyChanges(); });
|
||||||
|
#pragma warning restore CS0618 // 'ApplyChanges()' is obsolete
|
||||||
|
|
||||||
var code = item.Status.Error.StatusCode;
|
var code = item.Status.Error.StatusCode;
|
||||||
_logger.LogWarning("[Realtime] 잘못된 node_id: {NodeId} — {Code}", nodeId, code);
|
_logger.LogWarning("[Realtime] 잘못된 node_id: {NodeId} — {Code}", nodeId, code);
|
||||||
@@ -290,7 +294,9 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
|
|||||||
}
|
}
|
||||||
|
|
||||||
_session.AddSubscription(_subscription);
|
_session.AddSubscription(_subscription);
|
||||||
|
#pragma warning disable CS0618 // 'Create()' is obsolete
|
||||||
_subscription.Create();
|
_subscription.Create();
|
||||||
|
#pragma warning restore CS0618 // 'Create()' is obsolete
|
||||||
|
|
||||||
// nodeId → RealtimePoint 캐시 빌드 (FlushLoop에서 tagname 조회용)
|
// nodeId → RealtimePoint 캐시 빌드 (FlushLoop에서 tagname 조회용)
|
||||||
_pointCache = points.ToDictionary(p => p.NodeId, p => p);
|
_pointCache = points.ToDictionary(p => p.NodeId, p => p);
|
||||||
@@ -380,7 +386,9 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
|
|||||||
{
|
{
|
||||||
if (_subscription != null)
|
if (_subscription != null)
|
||||||
{
|
{
|
||||||
|
#pragma warning disable CS0618 // 'Delete()' is obsolete
|
||||||
_subscription.Delete(true);
|
_subscription.Delete(true);
|
||||||
|
#pragma warning restore CS0618 // 'Delete()' is obsolete
|
||||||
_subscription = null;
|
_subscription = null;
|
||||||
}
|
}
|
||||||
if (_session != null)
|
if (_session != null)
|
||||||
@@ -458,14 +466,36 @@ public class ExperionRealtimeService : IExperionRealtimeService, IHostedService,
|
|||||||
ExperionServerConfig cfg)
|
ExperionServerConfig cfg)
|
||||||
{
|
{
|
||||||
var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password));
|
var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password));
|
||||||
return await Session.Create(
|
// CS0618: Session.Create는 obsolete이지만 SessionFactory/CreateAsync가 현재 라이브러리에 없음
|
||||||
appConfig, endpoint, false, "ExperionRealtimeSession", 60_000, identity, null);
|
// Task.Run으로 래핑하여 비동기 실행
|
||||||
|
#pragma warning disable CS0618 // 'Session.Create()' is obsolete
|
||||||
|
return await Task.Run(() => Session.Create(
|
||||||
|
appConfig,
|
||||||
|
endpoint,
|
||||||
|
false,
|
||||||
|
"ExperionRealtimeSession",
|
||||||
|
60_000,
|
||||||
|
identity,
|
||||||
|
null));
|
||||||
|
#pragma warning restore CS0618 // 'Session.Create()' is obsolete
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private volatile bool _disposed = false;
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
|
||||||
_cts?.Cancel();
|
_cts?.Cancel();
|
||||||
CleanupSessionAsync().GetAwaiter().GetResult();
|
try
|
||||||
|
{
|
||||||
|
CleanupSessionAsync().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore exceptions during disposal
|
||||||
|
}
|
||||||
_cts?.Dispose();
|
_cts?.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using ExperionCrawler.Core.Application.Interfaces;
|
|||||||
using ExperionCrawler.Core.Application.Services;
|
using ExperionCrawler.Core.Application.Services;
|
||||||
using ExperionCrawler.Core.Domain.Entities;
|
using ExperionCrawler.Core.Domain.Entities;
|
||||||
using ExperionCrawler.Infrastructure.Csv;
|
using ExperionCrawler.Infrastructure.Csv;
|
||||||
|
using ExperionCrawler.Infrastructure.Database;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ExperionCrawler.Web.Controllers;
|
namespace ExperionCrawler.Web.Controllers;
|
||||||
@@ -368,14 +369,31 @@ public class ExperionRealtimeController : ControllerBase
|
|||||||
public class ExperionHistoryController : ControllerBase
|
public class ExperionHistoryController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IExperionDbService _dbSvc;
|
private readonly IExperionDbService _dbSvc;
|
||||||
public ExperionHistoryController(IExperionDbService dbSvc) => _dbSvc = dbSvc;
|
private readonly ILogger<ExperionHistoryController> _logger;
|
||||||
|
|
||||||
|
public ExperionHistoryController(IExperionDbService dbSvc, ILogger<ExperionHistoryController> logger)
|
||||||
|
{
|
||||||
|
_dbSvc = dbSvc;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>realtime_table 의 tagname 목록</summary>
|
/// <summary>realtime_table 의 tagname 목록</summary>
|
||||||
[HttpGet("tagnames")]
|
[HttpGet("tagnames")]
|
||||||
public async Task<IActionResult> TagNames()
|
public async Task<IActionResult> TagNames()
|
||||||
{
|
{
|
||||||
var names = await _dbSvc.GetTagNamesAsync();
|
try
|
||||||
return Ok(new { tagNames = names });
|
{
|
||||||
|
_logger.LogDebug("[History] tagname 목록 조회 시작");
|
||||||
|
var names = await _dbSvc.GetTagNamesAsync();
|
||||||
|
var count = names.Count();
|
||||||
|
_logger.LogDebug("[History] tagname 목록 조회 완료: {Count}개 태그", count);
|
||||||
|
return Ok(new { tagNames = names });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[History] tagname 목록 조회 실패");
|
||||||
|
return StatusCode(500, new { success = false, message = $"tagname 조회 실패: {ex.Message}" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>이력 조회 (tagname 다중, 시간범위, limit)</summary>
|
/// <summary>이력 조회 (tagname 다중, 시간범위, limit)</summary>
|
||||||
@@ -386,18 +404,39 @@ public class ExperionHistoryController : ControllerBase
|
|||||||
[FromQuery] DateTime? to,
|
[FromQuery] DateTime? to,
|
||||||
[FromQuery] int limit = 1000)
|
[FromQuery] int limit = 1000)
|
||||||
{
|
{
|
||||||
var result = await _dbSvc.QueryHistoryAsync(
|
try
|
||||||
tagNames ?? Enumerable.Empty<string>(), from, to, limit);
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
{
|
||||||
tagNames = result.TagNames,
|
var tagList = tagNames ?? Enumerable.Empty<string>().ToList();
|
||||||
rows = result.Rows.Select(r => new
|
_logger.LogDebug("[History] 이력 조회 시작 - 태그 수: {TagCount}, FROM: {From}, TO: {To}, LIMIT: {Limit}",
|
||||||
|
tagList.Count(), from, to, limit);
|
||||||
|
|
||||||
|
var result = await _dbSvc.QueryHistoryAsync(
|
||||||
|
tagList, from, to, limit);
|
||||||
|
|
||||||
|
_logger.LogDebug("[History] 이력 조회 완료: {RowCount}행, {TagCount}태그",
|
||||||
|
result.Rows.Count(), result.TagNames.Count());
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
{
|
{
|
||||||
recordedAt = r.RecordedAt,
|
tagNames = result.TagNames,
|
||||||
values = r.Values
|
rows = result.Rows.Select(r => new
|
||||||
})
|
{
|
||||||
});
|
recordedAt = r.RecordedAt,
|
||||||
|
values = r.Values
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[History] 이력 조회 실패 - tagNames: {@TagNames}, from: {From}, to: {To}",
|
||||||
|
tagNames, from, to);
|
||||||
|
return StatusCode(500, new
|
||||||
|
{
|
||||||
|
success = false,
|
||||||
|
message = $"이력 조회 실패: {ex.Message}",
|
||||||
|
detail = ex.InnerException?.Message ?? ex.StackTrace
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,3 +561,57 @@ public class ExperionNodeMapController : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 하이퍼테이블 관리 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/experion/hypertable")]
|
||||||
|
public class ExperionHypertableController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IExperionDbService _dbSvc;
|
||||||
|
|
||||||
|
public ExperionHypertableController(IExperionDbService dbSvc) => _dbSvc = dbSvc;
|
||||||
|
|
||||||
|
/// <summary>하이퍼테이블 상태 조회</summary>
|
||||||
|
[HttpGet("status")]
|
||||||
|
public async Task<IActionResult> GetStatus()
|
||||||
|
{
|
||||||
|
var status = await _dbSvc.GetHypertableStatusAsync();
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
isHypertable = status.IsHypertable,
|
||||||
|
tableName = status.TableName,
|
||||||
|
statusMessage = status.StatusMessage,
|
||||||
|
recordCount = status.RecordCount,
|
||||||
|
hasRetentionPolicy = status.HasRetentionPolicy,
|
||||||
|
hasCompression = status.HasCompression,
|
||||||
|
hasContinuousAggregate = status.HasContinuousAggregate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>하이퍼테이블 수동 생성</summary>
|
||||||
|
[HttpPost("create")]
|
||||||
|
public async Task<IActionResult> Create([FromBody] HypertableCreateDto request)
|
||||||
|
{
|
||||||
|
var createRequest = new HypertableCreateRequest
|
||||||
|
{
|
||||||
|
TableName = request.TableName ?? "history_table",
|
||||||
|
TimeColumn = request.TimeColumn ?? "recorded_at",
|
||||||
|
TimeInterval = request.TimeInterval ?? "1 day",
|
||||||
|
MigrateData = request.MigrateData,
|
||||||
|
SetRetentionPolicy = request.SetRetentionPolicy,
|
||||||
|
RetentionPeriod = request.RetentionPeriod ?? "90 days",
|
||||||
|
EnableCompression = request.EnableCompression,
|
||||||
|
CompressionPeriod = request.CompressionPeriod ?? "1 day",
|
||||||
|
CreateContinuousAggregate = request.CreateContinuousAggregate
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _dbSvc.CreateHypertableAsync(createRequest);
|
||||||
|
|
||||||
|
return result.Success
|
||||||
|
? Ok(new { result.Success, result.Message, result.TableName })
|
||||||
|
: (IActionResult)(result.TableName != null
|
||||||
|
? StatusCode(409, new { result.Success, result.Message })
|
||||||
|
: StatusCode(500, new { result.Success, result.Message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
68
src/Web/Controllers/TextToSqlController.cs
Normal file
68
src/Web/Controllers/TextToSqlController.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
using ExperionCrawler.Core.Application.DTOs;
|
||||||
|
using ExperionCrawler.Core.Application.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Web.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Text-to-SQL API 컨트롤러
|
||||||
|
/// 자연어 질의를 SQL로 변환하고 시계열 데이터를 조회합니다.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/text-to-sql")]
|
||||||
|
public class TextToSqlController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ITextToSqlService _textToSqlService;
|
||||||
|
|
||||||
|
public TextToSqlController(ITextToSqlService textToSqlService)
|
||||||
|
{
|
||||||
|
_textToSqlService = textToSqlService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 자연어 질의를 SQL로 변환
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("parse")]
|
||||||
|
public async Task<IActionResult> Parse([FromBody] NaturalLanguageQueryDto dto)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sql = await _textToSqlService.ParseNaturalLanguageAsync(dto.Query);
|
||||||
|
return Ok(new { success = true, sql });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Ok(new { success = false, error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL 쿼리 실행 및 결과 반환
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("execute")]
|
||||||
|
public async Task<IActionResult> Execute([FromBody] SqlQueryDto dto)
|
||||||
|
{
|
||||||
|
var result = await _textToSqlService.ExecuteQueryAsync(dto.Sql, dto.Limit);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 쿼리 제안 (자동 완성)
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("suggest")]
|
||||||
|
public async Task<IActionResult> Suggest([FromQuery] string input = "")
|
||||||
|
{
|
||||||
|
var suggestions = await _textToSqlService.SuggestQueriesAsync(input);
|
||||||
|
return Ok(new { success = true, suggestions });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 시계열 분석 (평균, 최대, 최소, 추세)
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("analyze")]
|
||||||
|
public async Task<IActionResult> Analyze([FromBody] AnalyzeRequestDto dto)
|
||||||
|
{
|
||||||
|
var result = await _textToSqlService.AnalyzeAsync(dto);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<RootNamespace>ExperionCrawler</RootNamespace>
|
<RootNamespace>ExperionCrawler</RootNamespace>
|
||||||
<AssemblyName>ExperionCrawler</AssemblyName>
|
<AssemblyName>ExperionCrawler</AssemblyName>
|
||||||
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>linux-arm64</RuntimeIdentifier> <!-- linux-amd64 일반 pc에서 -->
|
||||||
<SelfContained>false</SelfContained>
|
<SelfContained>false</SelfContained>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.134" />
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.134" />
|
||||||
<!-- CSV -->
|
<!-- CSV -->
|
||||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||||
<!-- SQLite (Ubuntu 서버, 별도 DB 설치 불필요) -->
|
<!-- PostgreSQL + TimeScaleDB (TimeScaleDB는 PostgreSQL 확장, 별도 패키지 불필요) -->
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.13">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.13">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ builder.Services.AddScoped<IExperionDbService, ExperionDbService>();
|
|||||||
// ── Application Services ──────────────────────────────────────────────────────
|
// ── Application Services ──────────────────────────────────────────────────────
|
||||||
builder.Services.AddScoped<ExperionCrawlService>();
|
builder.Services.AddScoped<ExperionCrawlService>();
|
||||||
|
|
||||||
|
// ── Text-to-SQL Service ──────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddScoped<ITextToSqlService, TextToSqlService>();
|
||||||
|
|
||||||
// ── Realtime & History BackgroundServices ─────────────────────────────────────
|
// ── Realtime & History BackgroundServices ─────────────────────────────────────
|
||||||
builder.Services.AddSingleton<ExperionRealtimeService>();
|
builder.Services.AddSingleton<ExperionRealtimeService>();
|
||||||
builder.Services.AddSingleton<IExperionRealtimeService>(
|
builder.Services.AddSingleton<IExperionRealtimeService>(
|
||||||
|
|||||||
@@ -3,12 +3,20 @@
|
|||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning",
|
"Microsoft.AspNetCore": "Warning",
|
||||||
"Microsoft.EntityFrameworkCore": "Warning"
|
"Microsoft.EntityFrameworkCore": "Information",
|
||||||
|
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
|
||||||
|
},
|
||||||
|
"Console": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.EntityFrameworkCore": "Information"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres;Trust Server Certificate=true"
|
"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"
|
||||||
},
|
},
|
||||||
"OpcUaServer": {
|
"OpcUaServer": {
|
||||||
"Port": 4841,
|
"Port": 4841,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"runtimeOptions": {
|
|
||||||
"tfm": "net8.0",
|
|
||||||
"frameworks": [
|
|
||||||
{
|
|
||||||
"name": "Microsoft.NETCore.App",
|
|
||||||
"version": "8.0.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Microsoft.AspNetCore.App",
|
|
||||||
"version": "8.0.0"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"configProperties": {
|
|
||||||
"System.GC.Server": true,
|
|
||||||
"System.Reflection.NullabilityInfoContext.IsSupported": true,
|
|
||||||
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"ContentRoots":["/home/pacer/projects/ExperionCrawler/src/Web/wwwroot/"],"Root":{"Children":{"css":{"Children":{"style.css":{"Children":null,"Asset":{"ContentRootIndex":0,"SubPath":"css/style.css"},"Patterns":null}},"Asset":null,"Patterns":null},"index.html":{"Children":null,"Asset":{"ContentRootIndex":0,"SubPath":"index.html"},"Patterns":null},"js":{"Children":{"app.js":{"Children":null,"Asset":{"ContentRootIndex":0,"SubPath":"js/app.js"},"Patterns":null}},"Asset":null,"Patterns":null}},"Asset":null,"Patterns":[{"ContentRootIndex":0,"Pattern":"**","Depth":0}]}}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning",
|
|
||||||
"Microsoft.EntityFrameworkCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"DefaultConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres;Trust Server Certificate=true"
|
|
||||||
},
|
|
||||||
"OpcUaServer": {
|
|
||||||
"Port": 4841,
|
|
||||||
"EnableSecurity": false,
|
|
||||||
"AllowAnonymous": true,
|
|
||||||
"AllowedUsernames": [ "opcuser" ],
|
|
||||||
"AllowedPasswords": [ "opcpass" ]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user