ExperionCrawler First Commit
This commit is contained in:
44
CLAUDE.md
Normal file
44
CLAUDE.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# ExperionCrawler — 작업 이력
|
||||||
|
|
||||||
|
## 완료된 작업
|
||||||
|
|
||||||
|
### 노드맵 대시보드 구현 (2026-04-14)
|
||||||
|
|
||||||
|
node_map_master 테이블을 조회·탐색할 수 있는 웹 대시보드를 풀스택으로 구현했다.
|
||||||
|
|
||||||
|
#### 수정된 파일
|
||||||
|
|
||||||
|
| 파일 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| `src/Core/Application/Interfaces/IExperionServices.cs` | `IExperionDbService`에 `GetMasterStatsAsync()` / `QueryMasterAsync()` 추가, `NodeMapStats` / `NodeMapQueryResult` record 추가 |
|
||||||
|
| `src/Infrastructure/Database/ExperionDbContext.cs` | `ExperionDbService`에 두 메서드 구현 (통계·필터 조회, 페이지네이션) |
|
||||||
|
| `src/Web/Controllers/ExperionControllers.cs` | `ExperionNodeMapController` 추가 (`GET /api/nodemap/stats`, `GET /api/nodemap/query`) |
|
||||||
|
| `src/Web/wwwroot/index.html` | 사이드바 05번 탭 추가, `#pane-nm-dash` 섹션 추가 (통계 카드·필터폼·페이지네이션·테이블) |
|
||||||
|
| `src/Web/wwwroot/js/app.js` | `nmLoad()` / `nmQuery()` / `nmPrev()` / `nmNext()` / `nmReset()` 구현, 탭 클릭 핸들러에 `nmLoad()` 호출 추가 |
|
||||||
|
| `src/Web/wwwroot/css/style.css` | `.nm-stat-row`, `.nm-cls`, `.nm-dtype`, `.pg`, `.btn-sm` 등 대시보드 전용 스타일 추가 |
|
||||||
|
|
||||||
|
#### 빌드 결과
|
||||||
|
- 경고 3건 (기존 경고 동일), **에러 0건** — 빌드 성공
|
||||||
|
|
||||||
|
#### 주의 사항
|
||||||
|
- 인증서 관련 코드(`ExperionCertificateService.cs`, 인증서 컨트롤러)는 일절 수정하지 않음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 이름 필터 드롭다운 OR 조건 검색 (2026-04-14)
|
||||||
|
|
||||||
|
노드맵 대시보드의 이름 검색을 텍스트 입력에서 `name` 컬럼 고유값 풀다운 메뉴 4개로 교체, OR 조건 최대 4개 동시 선택 가능하도록 확장했다.
|
||||||
|
|
||||||
|
#### 수정된 파일
|
||||||
|
|
||||||
|
| 파일 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| `src/Core/Application/Interfaces/IExperionServices.cs` | `GetNameListAsync()` 추가; `QueryMasterAsync` 파라미터 `string? name` → `IEnumerable<string>? names` |
|
||||||
|
| `src/Infrastructure/Database/ExperionDbContext.cs` | `GetNameListAsync()` 구현 (distinct + 오름차순 정렬); `QueryMasterAsync`에서 `nameList.Contains(x.Name)` → EF가 `WHERE name IN (...)` SQL 생성 |
|
||||||
|
| `src/Web/Controllers/ExperionControllers.cs` | `GET /api/nodemap/names` 엔드포인트 추가; `Query` 액션 파라미터 `string? name` → `List<string>? names` (ASP.NET Core가 `?names=A&names=B` 자동 바인딩) |
|
||||||
|
| `src/Web/wwwroot/index.html` | "이름 검색" 텍스트 입력 제거 → `nf-name-1` ~ `nf-name-4` 4개 `<select>` 드롭다운 추가 |
|
||||||
|
| `src/Web/wwwroot/js/app.js` | `nmLoad()`에서 `/api/nodemap/names` 병렬 호출 후 4개 드롭다운 채우기; `nmQuery()`에서 선택 이름들을 `params.append('names', nm)`로 OR 전송; `nmReset()`에서 4개 드롭다운 초기화 |
|
||||||
|
| `src/Web/wwwroot/css/style.css` | `.nm-name-selects` (4열 그리드, 900px 이하 2열) 추가 |
|
||||||
|
|
||||||
|
#### 빌드 결과
|
||||||
|
- 경고 3건 (기존 경고 동일), **에러 0건** — 빌드 성공
|
||||||
32
ExperionCrawler.sln
Normal file
32
ExperionCrawler.sln
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.5.2.0
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Web", "Web", "{03997797-E7F5-0643-168D-B8EA7178C2FE}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExperionCrawler", "src\Web\ExperionCrawler.csproj", "{626F01A0-96C6-C0BC-CFDE-BA3921676116}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{626F01A0-96C6-C0BC-CFDE-BA3921676116}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{626F01A0-96C6-C0BC-CFDE-BA3921676116}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{626F01A0-96C6-C0BC-CFDE-BA3921676116}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{626F01A0-96C6-C0BC-CFDE-BA3921676116}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{03997797-E7F5-0643-168D-B8EA7178C2FE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{626F01A0-96C6-C0BC-CFDE-BA3921676116} = {03997797-E7F5-0643-168D-B8EA7178C2FE}
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {64610A79-CA44-42E5-A487-C3B8B6AF7DED}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
120
README.md
Normal file
120
README.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# ExperionCrawler
|
||||||
|
|
||||||
|
Honeywell Experion OPC UA 서버를 위한 웹 기반 데이터 수집 도구.
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
ExperionCrawler/
|
||||||
|
└── src/
|
||||||
|
├── Core/
|
||||||
|
│ ├── Domain/Entities/ # ExperionTag, ExperionRecord, ExperionServerConfig ...
|
||||||
|
│ ├── Application/
|
||||||
|
│ │ ├── Interfaces/ # IExperionCertificateService, IExperionOpcClient ...
|
||||||
|
│ │ ├── Services/ # ExperionCrawlService
|
||||||
|
│ │ └── DTOs/ # ExperionServerConfigDto, ExperionCrawlRequestDto ...
|
||||||
|
│ └── (Domain 은 Infrastructure 에 의존하지 않음)
|
||||||
|
│
|
||||||
|
├── Infrastructure/
|
||||||
|
│ ├── Certificates/ # ExperionCertificateService (pki/ 폴더 관리)
|
||||||
|
│ ├── OpcUa/ # ExperionOpcClient, ExperionStatusCodeService
|
||||||
|
│ ├── Csv/ # ExperionCsvService (CsvHelper)
|
||||||
|
│ └── Database/ # ExperionDbContext + ExperionDbService (EF Core / SQLite)
|
||||||
|
│
|
||||||
|
└── Web/
|
||||||
|
├── Controllers/ # ExperionCertificateController, ConnectionController ...
|
||||||
|
├── Program.cs # DI 등록, 미들웨어
|
||||||
|
└── wwwroot/ # index.html + css/style.css + js/app.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 기능
|
||||||
|
|
||||||
|
| 메뉴 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| 01 인증서 관리 | OPC UA 클라이언트 X.509 인증서 생성 / 상태 확인 |
|
||||||
|
| 02 서버 접속 테스트 | OPC UA 서버 연결 테스트, 단일 태그 읽기, 노드 탐색 |
|
||||||
|
| 03 데이터 크롤링 | 복수 노드 주기 수집 → CSV 저장 |
|
||||||
|
| 04 DB 저장 | CSV 파일 → SQLite DB 임포트, 레코드 조회 |
|
||||||
|
|
||||||
|
## Ubuntu 서버 배포
|
||||||
|
|
||||||
|
### 사전 요구사항
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .NET 8 SDK (없으면 deploy.sh 가 자동 설치)
|
||||||
|
dotnet --version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 한 번에 배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo> ExperionCrawler
|
||||||
|
cd ExperionCrawler
|
||||||
|
sudo bash deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수동 실행 (개발/테스트)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Web
|
||||||
|
dotnet run
|
||||||
|
# → http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 서비스 관리
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status experioncrawler
|
||||||
|
sudo systemctl restart experioncrawler
|
||||||
|
sudo systemctl stop experioncrawler
|
||||||
|
sudo journalctl -u experioncrawler -f # 실시간 로그
|
||||||
|
```
|
||||||
|
|
||||||
|
## PKI 디렉토리 구조 (원본 Program.cs 준수)
|
||||||
|
|
||||||
|
```
|
||||||
|
<실행 위치>/
|
||||||
|
└── pki/
|
||||||
|
├── own/certs/{clientHostName}.pfx ← 생성된 클라이언트 인증서
|
||||||
|
├── trusted/certs/ ← 신뢰 피어 인증서
|
||||||
|
├── issuers/certs/ ← 신뢰 발급자 (필수 경로)
|
||||||
|
└── rejected/certs/ ← 거부된 인증서
|
||||||
|
```
|
||||||
|
|
||||||
|
## 데이터 저장 위치
|
||||||
|
|
||||||
|
```
|
||||||
|
<실행 위치>/
|
||||||
|
└── data/
|
||||||
|
├── experion.db ← SQLite DB
|
||||||
|
└── csv/ ← 크롤링 CSV 파일
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 엔드포인트
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/certificate/status?clientHostName=dbsvr
|
||||||
|
POST /api/certificate/create { clientHostName, subjectAltNames, pfxPassword }
|
||||||
|
|
||||||
|
POST /api/connection/test { serverHostName, port, clientHostName, userName, password }
|
||||||
|
POST /api/connection/read { serverConfig, nodeId }
|
||||||
|
POST /api/connection/browse { serverConfig, startNodeId? }
|
||||||
|
|
||||||
|
POST /api/crawl/start { serverConfig, nodeIds[], intervalSeconds, durationSeconds }
|
||||||
|
|
||||||
|
GET /api/database/files
|
||||||
|
POST /api/database/import { fileName }
|
||||||
|
GET /api/database/records?limit=100&from=&to=
|
||||||
|
```
|
||||||
|
|
||||||
|
Swagger UI: `http://<서버IP>:5000/swagger` (Development 모드)
|
||||||
|
|
||||||
|
## 패키지 버전
|
||||||
|
|
||||||
|
| 패키지 | 버전 |
|
||||||
|
|--------|------|
|
||||||
|
| OPCFoundation.NetStandard.Opc.Ua.Client | 1.5.374.85 |
|
||||||
|
| OPCFoundation.NetStandard.Opc.Ua.Core | 1.5.374.85 |
|
||||||
|
| CsvHelper | 33.0.1 |
|
||||||
|
| Microsoft.EntityFrameworkCore.Sqlite | 8.0.13 |
|
||||||
|
| Swashbuckle.AspNetCore | 6.8.1 |
|
||||||
98
deploy.sh
Normal file
98
deploy.sh
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# ExperionCrawler — Ubuntu 서버 배포 스크립트
|
||||||
|
# 사용법: sudo bash deploy.sh
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_NAME="experioncrawler"
|
||||||
|
APP_DIR="/opt/ExperionCrawler"
|
||||||
|
SERVICE_USER="www-data"
|
||||||
|
DOTNET_MIN="8.0"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════╗"
|
||||||
|
echo "║ ExperionCrawler 배포 스크립트 ║"
|
||||||
|
echo "╚══════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 1. .NET 8 설치 확인 ─────────────────────────────────────────
|
||||||
|
echo "▶ .NET SDK 확인..."
|
||||||
|
if ! command -v dotnet &> /dev/null; then
|
||||||
|
echo " .NET이 설치되어 있지 않습니다. 설치를 시작합니다..."
|
||||||
|
wget -q https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/packages-microsoft-prod.deb \
|
||||||
|
-O packages-microsoft-prod.deb
|
||||||
|
dpkg -i packages-microsoft-prod.deb
|
||||||
|
rm packages-microsoft-prod.deb
|
||||||
|
apt-get update -q
|
||||||
|
apt-get install -y dotnet-sdk-8.0
|
||||||
|
else
|
||||||
|
echo " .NET $(dotnet --version) 확인됨"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. 빌드 ─────────────────────────────────────────────────────
|
||||||
|
echo "▶ 빌드 중..."
|
||||||
|
cd "$(dirname "$0")/src/Web"
|
||||||
|
dotnet publish -c Release -o "$APP_DIR" --nologo -q
|
||||||
|
echo " 빌드 완료 → $APP_DIR"
|
||||||
|
|
||||||
|
# ── 3. 필수 디렉토리 및 권한 ─────────────────────────────────────
|
||||||
|
echo "▶ 디렉토리 설정..."
|
||||||
|
mkdir -p "$APP_DIR/pki/own/certs"
|
||||||
|
mkdir -p "$APP_DIR/pki/trusted/certs"
|
||||||
|
mkdir -p "$APP_DIR/pki/issuers/certs"
|
||||||
|
mkdir -p "$APP_DIR/pki/rejected/certs"
|
||||||
|
mkdir -p "$APP_DIR/data/csv"
|
||||||
|
|
||||||
|
chown -R "$SERVICE_USER":"$SERVICE_USER" "$APP_DIR"
|
||||||
|
chmod -R 750 "$APP_DIR"
|
||||||
|
echo " 권한 설정 완료 (소유자: $SERVICE_USER)"
|
||||||
|
|
||||||
|
# ── 4. systemd 서비스 등록 ───────────────────────────────────────
|
||||||
|
echo "▶ systemd 서비스 등록..."
|
||||||
|
cat > /etc/systemd/system/${APP_NAME}.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=ExperionCrawler OPC UA Web Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${SERVICE_USER}
|
||||||
|
WorkingDirectory=${APP_DIR}
|
||||||
|
ExecStart=/usr/bin/dotnet ${APP_DIR}/ExperionCrawler.dll
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
KillSignal=SIGINT
|
||||||
|
SyslogIdentifier=${APP_NAME}
|
||||||
|
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
||||||
|
|
||||||
|
# 리소스 제한
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable ${APP_NAME}
|
||||||
|
systemctl restart ${APP_NAME}
|
||||||
|
echo " 서비스 등록 및 시작 완료"
|
||||||
|
|
||||||
|
# ── 5. 방화벽 설정 (ufw 사용 시) ────────────────────────────────
|
||||||
|
if command -v ufw &> /dev/null; then
|
||||||
|
echo "▶ 방화벽 포트 5000 개방..."
|
||||||
|
ufw allow 5000/tcp comment 'ExperionCrawler'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 6. 상태 확인 ─────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "▶ 서비스 상태:"
|
||||||
|
systemctl status ${APP_NAME} --no-pager -l | head -20
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ 배포 완료! ║"
|
||||||
|
echo "║ 접속 주소: http://$(hostname -I | awk '{print $1}'):5000 ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════╝"
|
||||||
50
src/Core/Application/DTOs/ExperionDtos.cs
Normal file
50
src/Core/Application/DTOs/ExperionDtos.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
namespace ExperionCrawler.Core.Application.DTOs;
|
||||||
|
|
||||||
|
public class ExperionServerConfigDto
|
||||||
|
{
|
||||||
|
public string ServerHostName { get; set; } = "192.168.0.20";
|
||||||
|
public int Port { get; set; } = 4840;
|
||||||
|
public string ClientHostName { get; set; } = "dbsvr";
|
||||||
|
public string UserName { get; set; } = "mngr";
|
||||||
|
public string Password { get; set; } = "mngr";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExperionCrawlRequestDto
|
||||||
|
{
|
||||||
|
public ExperionServerConfigDto ServerConfig { get; set; } = new();
|
||||||
|
public List<string> NodeIds { get; set; } = new();
|
||||||
|
public int IntervalSeconds { get; set; } = 1;
|
||||||
|
public int DurationSeconds { get; set; } = 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExperionReadTagRequestDto
|
||||||
|
{
|
||||||
|
public ExperionServerConfigDto ServerConfig { get; set; } = new();
|
||||||
|
public string NodeId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExperionBrowseRequestDto
|
||||||
|
{
|
||||||
|
public ExperionServerConfigDto ServerConfig { get; set; } = new();
|
||||||
|
public string? StartNodeId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExperionCertCreateDto
|
||||||
|
{
|
||||||
|
public string ClientHostName { get; set; } = "dbsvr";
|
||||||
|
public List<string> SubjectAltNames { get; set; } = new() { "localhost", "192.168.0.50"};
|
||||||
|
public string PfxPassword { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExperionCsvImportDto
|
||||||
|
{
|
||||||
|
public string FileName { get; set; } = string.Empty;
|
||||||
|
public string ServerHostName { get; set; } = string.Empty;
|
||||||
|
public bool Truncate { get; set; } = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExperionNodeMapCrawlRequestDto
|
||||||
|
{
|
||||||
|
public ExperionServerConfigDto ServerConfig { get; set; } = new();
|
||||||
|
public int MaxDepth { get; set; } = 10;
|
||||||
|
}
|
||||||
80
src/Core/Application/Interfaces/IExperionServices.cs
Normal file
80
src/Core/Application/Interfaces/IExperionServices.cs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
using ExperionCrawler.Core.Domain.Entities;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Core.Application.Interfaces;
|
||||||
|
|
||||||
|
// ── Certificate ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public interface IExperionCertificateService
|
||||||
|
{
|
||||||
|
/// <summary>인증서가 없으면 생성, 있으면 기존 반환</summary>
|
||||||
|
Task<ExperionCertResult> EnsureCertificateAsync(
|
||||||
|
string applicationUri,
|
||||||
|
string clientHostName,
|
||||||
|
IEnumerable<string> subjectAltNames,
|
||||||
|
string pfxPassword = "");
|
||||||
|
|
||||||
|
bool CertificateExists(string clientHostName);
|
||||||
|
ExperionCertInfo GetCertificateInfo(string clientHostName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OPC UA Client ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public interface IExperionOpcClient
|
||||||
|
{
|
||||||
|
Task<ExperionConnectResult> TestConnectionAsync(ExperionServerConfig cfg);
|
||||||
|
Task<ExperionReadResult> ReadTagAsync(ExperionServerConfig cfg, string nodeId);
|
||||||
|
Task<IEnumerable<ExperionReadResult>> ReadTagsAsync(ExperionServerConfig cfg, IEnumerable<string> nodeIds);
|
||||||
|
Task<ExperionBrowseResult> BrowseNodesAsync(ExperionServerConfig cfg, string? startNodeId = null);
|
||||||
|
Task<ExperionNodeMapResult> BrowseAllNodesAsync(ExperionServerConfig cfg, int maxDepth = 10, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSV ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public interface IExperionCsvService
|
||||||
|
{
|
||||||
|
Task<string> ExportAsync(IEnumerable<ExperionRecord> records, string fileName);
|
||||||
|
Task<IEnumerable<ExperionRecord>> ImportAsync(string filePath);
|
||||||
|
IEnumerable<string> GetAvailableFiles();
|
||||||
|
Task<string> ExportNodeMapAsync(IEnumerable<ExperionNodeMapEntry> entries, string fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Database ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public interface IExperionDbService
|
||||||
|
{
|
||||||
|
Task<bool> InitializeAsync();
|
||||||
|
Task<int> SaveRecordsAsync(IEnumerable<ExperionRecord> records);
|
||||||
|
Task<int> ClearRecordsAsync();
|
||||||
|
Task<int> BuildMasterFromRawAsync(bool truncate = false);
|
||||||
|
Task<IEnumerable<ExperionRecord>> GetRecordsAsync(DateTime? from = null, DateTime? to = null, int limit = 1000);
|
||||||
|
Task<int> GetTotalCountAsync();
|
||||||
|
Task<IEnumerable<string>> GetNameListAsync();
|
||||||
|
Task<NodeMapStats> GetMasterStatsAsync();
|
||||||
|
Task<NodeMapQueryResult> QueryMasterAsync(
|
||||||
|
int? minLevel, int? maxLevel, string? nodeClass,
|
||||||
|
IEnumerable<string>? names, string? nodeId, string? dataType,
|
||||||
|
int limit, int offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status Code ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public interface IExperionStatusCodeService
|
||||||
|
{
|
||||||
|
ExperionStatusCodeInfo? GetByHex(string hexCode);
|
||||||
|
ExperionStatusCodeInfo? GetByUint(uint statusCode);
|
||||||
|
int LoadedCount { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Result records ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public record ExperionCertResult (bool Success, string Message, string? ThumbPrint = null);
|
||||||
|
public record ExperionCertInfo (bool Exists, string? SubjectName, DateTime? NotAfter, string? ThumbPrint, string FilePath);
|
||||||
|
public record ExperionConnectResult(bool Success, string Message, string? SessionId = null, string? PolicyUri = null);
|
||||||
|
public record ExperionReadResult (bool Success, string NodeId, object? Value, string StatusCode,
|
||||||
|
string? ErrorMessage = null, DateTime? Timestamp = null);
|
||||||
|
public record ExperionBrowseResult(bool Success, IEnumerable<ExperionNodeInfo> Nodes, string? ErrorMessage = null);
|
||||||
|
public record ExperionNodeInfo (string NodeId, string DisplayName, string NodeClass, bool HasChildren);
|
||||||
|
public record ExperionNodeMapEntry(int Level, string NodeClass, string DisplayName, string NodeId, string DataType);
|
||||||
|
public record ExperionNodeMapResult(bool Success, IEnumerable<ExperionNodeMapEntry> Nodes, int TotalCount, string? ErrorMessage = null);
|
||||||
|
public record NodeMapStats(int Total, int ObjectCount, int VariableCount, int MaxLevel, IEnumerable<string> DataTypes);
|
||||||
|
public record NodeMapQueryResult(int Total, IEnumerable<NodeMapMaster> Items);
|
||||||
125
src/Core/Application/Services/ExperionCrawlService.cs
Normal file
125
src/Core/Application/Services/ExperionCrawlService.cs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
using ExperionCrawler.Core.Application.Interfaces;
|
||||||
|
using ExperionCrawler.Core.Domain.Entities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Core.Application.Services;
|
||||||
|
|
||||||
|
public class ExperionCrawlService
|
||||||
|
{
|
||||||
|
private readonly IExperionOpcClient _opcClient;
|
||||||
|
private readonly IExperionCsvService _csvService;
|
||||||
|
private readonly IExperionDbService _dbService;
|
||||||
|
private readonly ILogger<ExperionCrawlService> _logger;
|
||||||
|
|
||||||
|
public ExperionCrawlService(
|
||||||
|
IExperionOpcClient opcClient,
|
||||||
|
IExperionCsvService csvService,
|
||||||
|
IExperionDbService dbService,
|
||||||
|
ILogger<ExperionCrawlService> logger)
|
||||||
|
{
|
||||||
|
_opcClient = opcClient;
|
||||||
|
_csvService = csvService;
|
||||||
|
_dbService = dbService;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 지정한 노드들을 intervalSeconds 간격으로 durationSeconds 동안 수집하고
|
||||||
|
/// CSV 파일로 저장한다.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ExperionCrawlResult> RunCrawlAsync(
|
||||||
|
ExperionServerConfig config,
|
||||||
|
IEnumerable<string> nodeIds,
|
||||||
|
int intervalSeconds,
|
||||||
|
int durationSeconds,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
var allRecords = new List<ExperionRecord>();
|
||||||
|
var nodeList = nodeIds.ToList();
|
||||||
|
var endAt = DateTime.UtcNow.AddSeconds(durationSeconds);
|
||||||
|
int iteration = 0;
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"[ExperionCrawl] session={Session} nodes={Nodes} duration={Duration}s interval={Interval}s",
|
||||||
|
sessionId, nodeList.Count, durationSeconds, intervalSeconds);
|
||||||
|
|
||||||
|
while (DateTime.UtcNow < endAt && !ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
iteration++;
|
||||||
|
var results = await _opcClient.ReadTagsAsync(config, nodeList);
|
||||||
|
|
||||||
|
allRecords.AddRange(results.Select(r => new ExperionRecord
|
||||||
|
{
|
||||||
|
NodeId = r.NodeId,
|
||||||
|
Value = r.Value?.ToString(),
|
||||||
|
StatusCode = r.StatusCode,
|
||||||
|
CollectedAt = r.Timestamp ?? DateTime.UtcNow,
|
||||||
|
SessionId = sessionId
|
||||||
|
}));
|
||||||
|
|
||||||
|
_logger.LogDebug("[ExperionCrawl] iter={Iter} collected={Count}", iteration, allRecords.Count);
|
||||||
|
|
||||||
|
if (DateTime.UtcNow < endAt && !ct.IsCancellationRequested)
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV 내보내기
|
||||||
|
var csvName = $"experion_{sessionId}_{DateTime.Now:yyyyMMdd_HHmmss}.csv";
|
||||||
|
var csvPath = await _csvService.ExportAsync(allRecords, csvName);
|
||||||
|
|
||||||
|
return new ExperionCrawlResult(sessionId, allRecords.Count, csvPath, allRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA 서버의 전체 노드 트리를 재귀 탐색하여 노드맵 CSV로 저장.
|
||||||
|
/// 생성된 CSV는 AssetLoader.ImportFullMapAsync로 DB에 적재 가능.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ExperionNodeMapCrawlResult> RunNodeMapCrawlAsync(
|
||||||
|
ExperionServerConfig config,
|
||||||
|
int maxDepth = 10,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[ExperionCrawl] 노드맵 크롤 시작 (maxDepth={MaxDepth})", maxDepth);
|
||||||
|
|
||||||
|
var mapResult = await _opcClient.BrowseAllNodesAsync(config, maxDepth, ct);
|
||||||
|
|
||||||
|
if (!mapResult.Success)
|
||||||
|
return new ExperionNodeMapCrawlResult(false, 0, string.Empty, mapResult.ErrorMessage ?? "탐색 실패");
|
||||||
|
|
||||||
|
var csvName = $"{config.ServerHostName}_{DateTime.Now:yyyyMMdd_HHmmss}.csv";
|
||||||
|
var csvPath = await _csvService.ExportNodeMapAsync(mapResult.Nodes, csvName);
|
||||||
|
|
||||||
|
_logger.LogInformation("[ExperionCrawl] 노드맵 크롤 완료: {Count}개 → {Path}", mapResult.TotalCount, csvPath);
|
||||||
|
return new ExperionNodeMapCrawlResult(true, mapResult.TotalCount, csvPath, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>CSV 파일 한 개를 읽어 DB에 삽입. truncate=true 이면 기존 레코드를 모두 삭제 후 삽입.</summary>
|
||||||
|
public async Task<ExperionImportResult> ImportCsvToDbAsync(string csvFileName, bool truncate = false)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (truncate)
|
||||||
|
await _dbService.ClearRecordsAsync();
|
||||||
|
|
||||||
|
var fullPath = Path.Combine("data/csv", csvFileName);
|
||||||
|
var records = (await _csvService.ImportAsync(fullPath)).ToList();
|
||||||
|
var saved = await _dbService.SaveRecordsAsync(records);
|
||||||
|
return new ExperionImportResult(true, saved, $"{saved}개 레코드가 DB에 저장되었습니다.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[ExperionCrawl] CSV import failed: {File}", csvFileName);
|
||||||
|
return new ExperionImportResult(false, 0, $"오류: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ExperionCrawlResult(
|
||||||
|
string SessionId,
|
||||||
|
int TotalRecords,
|
||||||
|
string CsvPath,
|
||||||
|
IEnumerable<ExperionRecord> Records);
|
||||||
|
|
||||||
|
public record ExperionImportResult(bool Success, int Count, string Message);
|
||||||
|
public record ExperionNodeMapCrawlResult(bool Success, int TotalCount, string CsvPath, string? ErrorMessage);
|
||||||
74
src/Core/Domain/Entities/ExperionEntities.cs
Normal file
74
src/Core/Domain/Entities/ExperionEntities.cs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Core.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>OPC UA 노드 태그 읽기 결과 (메모리)</summary>
|
||||||
|
public class ExperionTag
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string NodeId { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string? Value { get; set; }
|
||||||
|
public string? DataType { get; set; }
|
||||||
|
public string StatusCode { get; set; } = "Good";
|
||||||
|
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||||
|
public bool IsGood => StatusCode == "Good";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>크롤링으로 수집한 레코드 (DB/CSV 저장 단위)</summary>
|
||||||
|
public class ExperionRecord
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string NodeId { get; set; } = string.Empty;
|
||||||
|
public string? Value { get; set; }
|
||||||
|
public string StatusCode { get; set; } = "Good";
|
||||||
|
public DateTime CollectedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public string? SessionId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Experion 서버 접속 설정</summary>
|
||||||
|
public class ExperionServerConfig
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string ServerHostName { get; set; } = string.Empty;
|
||||||
|
public int Port { get; set; } = 4840;
|
||||||
|
public string ClientHostName { get; set; } = string.Empty;
|
||||||
|
public string UserName { get; set; } = string.Empty;
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string EndpointUrl => $"opc.tcp://{ServerHostName}:{Port}";
|
||||||
|
public string ApplicationUri => $"urn:{ClientHostName}:ExperionCrawlerClient";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>OPC UA 전체 노드맵 원시 데이터 (AssetLoader binary COPY 대상)</summary>
|
||||||
|
[Table("raw_node_map")]
|
||||||
|
public class RawNodeMap
|
||||||
|
{
|
||||||
|
[Column("id")] public int Id { get; set; }
|
||||||
|
[Column("level")] public int Level { get; set; }
|
||||||
|
[Column("class")] public string Class { get; set; } = string.Empty;
|
||||||
|
[Column("name")] public string Name { get; set; } = string.Empty;
|
||||||
|
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
|
||||||
|
[Column("data_type")]public string DataType { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>raw_node_map 에서 빌드된 master 테이블</summary>
|
||||||
|
[Table("node_map_master")]
|
||||||
|
public class NodeMapMaster
|
||||||
|
{
|
||||||
|
[Column("id")] public int Id { get; set; }
|
||||||
|
[Column("level")] public int Level { get; set; }
|
||||||
|
[Column("class")] public string Class { get; set; } = string.Empty;
|
||||||
|
[Column("name")] public string Name { get; set; } = string.Empty;
|
||||||
|
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
|
||||||
|
[Column("data_type")]public string DataType { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>statuscode.json 항목</summary>
|
||||||
|
public class ExperionStatusCodeInfo
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Hex { get; set; } = string.Empty;
|
||||||
|
public ulong Decimal { get; set; }
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
125
src/Infrastructure/Certificates/ExperionCertificateService.cs
Normal file
125
src/Infrastructure/Certificates/ExperionCertificateService.cs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using ExperionCrawler.Core.Application.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Opc.Ua;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Infrastructure.Certificates;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 기존 Program.cs 의 인증서 로직을 그대로 준수.
|
||||||
|
/// - pki/own/certs/{clientHostName}.pfx 로 저장
|
||||||
|
/// - 없으면 CertificateFactory 로 생성, 있으면 로드
|
||||||
|
/// - pki/trusted / pki/issuers / pki/rejected 폴더 보장
|
||||||
|
/// </summary>
|
||||||
|
public class ExperionCertificateService : IExperionCertificateService
|
||||||
|
{
|
||||||
|
private const string PkiBase = "pki";
|
||||||
|
private readonly ILogger<ExperionCertificateService> _logger;
|
||||||
|
|
||||||
|
public ExperionCertificateService(ILogger<ExperionCertificateService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
EnsurePkiDirectories();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 공개 메서드 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public bool CertificateExists(string clientHostName)
|
||||||
|
=> File.Exists(PfxPath(clientHostName));
|
||||||
|
|
||||||
|
public ExperionCertInfo GetCertificateInfo(string clientHostName)
|
||||||
|
{
|
||||||
|
var path = PfxPath(clientHostName);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return new ExperionCertInfo(false, null, null, null, path);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cert = LoadPfx(path, "");
|
||||||
|
return new ExperionCertInfo(true, cert.SubjectName.Name, cert.NotAfter, cert.Thumbprint, path);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "인증서 로드 실패: {Path}", path);
|
||||||
|
return new ExperionCertInfo(false, null, null, null, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExperionCertResult> EnsureCertificateAsync(
|
||||||
|
string applicationUri,
|
||||||
|
string clientHostName,
|
||||||
|
IEnumerable<string> subjectAltNames,
|
||||||
|
string pfxPassword = "")
|
||||||
|
{
|
||||||
|
var path = PfxPath(clientHostName);
|
||||||
|
|
||||||
|
// ── 기존 인증서 로드 (원본 Program.cs 동일 분기) ────────────────────
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("기존 인증서 사용: {Path}", path);
|
||||||
|
using var existing = LoadPfx(path, pfxPassword);
|
||||||
|
return new ExperionCertResult(true, $"기존 인증서 사용: {existing.Thumbprint}", existing.Thumbprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 새 인증서 생성 (원본 Program.cs 동일 로직) ──────────────────────
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("새 인증서 생성: {Uri}", applicationUri);
|
||||||
|
|
||||||
|
var altNames = new List<string>(subjectAltNames) { "localhost", clientHostName };
|
||||||
|
altNames = altNames.Distinct().ToList();
|
||||||
|
|
||||||
|
// 원본: CertificateFactory.CreateCertificate(applicationUri, "OpcTestClient", $"CN=OpcTestClient, O=MyCompany", ...)
|
||||||
|
var builder = CertificateFactory.CreateCertificate(
|
||||||
|
applicationUri,
|
||||||
|
"ExperionCrawlerClient",
|
||||||
|
$"CN=ExperionCrawlerClient, O=ExperionCrawler",
|
||||||
|
altNames
|
||||||
|
);
|
||||||
|
var cert = builder.CreateForRSA();
|
||||||
|
|
||||||
|
File.WriteAllBytes(path, cert.Export(X509ContentType.Pfx, pfxPassword));
|
||||||
|
// ── 8. Trusted 폴더에 DER(공개키) 복사 ───────────────────
|
||||||
|
byte[] derBytes = cert.Export(X509ContentType.Cert);
|
||||||
|
string derPath = Path.Combine(PkiBase, "trusted", "certs",
|
||||||
|
$"ExperionCrawlerClient [{cert.Thumbprint}].der");
|
||||||
|
File.WriteAllBytes(derPath, derBytes);
|
||||||
|
_logger.LogInformation("✨ 새 인증서 생성됨: {Uri} Thumbprint={Tp}", applicationUri, cert.Thumbprint);
|
||||||
|
|
||||||
|
return new ExperionCertResult(true, $"새 인증서 생성 완료: {cert.Thumbprint}", cert.Thumbprint);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "인증서 생성 실패");
|
||||||
|
return new ExperionCertResult(false, $"인증서 생성 실패: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 내부 헬퍼 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>OpcUaClientService 에서 인증서를 로드할 때 사용</summary>
|
||||||
|
public static X509Certificate2? TryLoadCertificate(string clientHostName, string pfxPassword = "")
|
||||||
|
{
|
||||||
|
var path = PfxPath(clientHostName);
|
||||||
|
if (!File.Exists(path)) return null;
|
||||||
|
return LoadPfx(path, pfxPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static X509Certificate2 LoadPfx(string path, string password)
|
||||||
|
=> new(path, password, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.MachineKeySet);
|
||||||
|
|
||||||
|
private static string PfxPath(string clientHostName)
|
||||||
|
=> Path.Combine(PkiBase, "own", "certs", $"{clientHostName}.pfx");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 원본 Program.cs 에서 필수라고 주석 달린 4개 폴더 생성
|
||||||
|
/// pki/own/certs, pki/trusted/certs, pki/issuers/certs, pki/rejected/certs
|
||||||
|
/// </summary>
|
||||||
|
private static void EnsurePkiDirectories()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory($"{PkiBase}/own/certs");
|
||||||
|
Directory.CreateDirectory($"{PkiBase}/trusted/certs");
|
||||||
|
Directory.CreateDirectory($"{PkiBase}/issuers/certs");
|
||||||
|
Directory.CreateDirectory($"{PkiBase}/rejected/certs");
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/Infrastructure/Csv/AssetLoader.cs
Normal file
87
src/Infrastructure/Csv/AssetLoader.cs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Infrastructure.Csv
|
||||||
|
{
|
||||||
|
public class AssetLoader
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
|
||||||
|
public AssetLoader(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_connectionString = configuration.GetConnectionString("DefaultConnection")
|
||||||
|
?? throw new InvalidOperationException("DefaultConnection 연결 문자열이 설정되지 않았습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> ImportFullMapAsync(string csvPath, bool truncate = false)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"🚀 CSV 데이터 로드 시작: {csvPath}");
|
||||||
|
|
||||||
|
if (!File.Exists(csvPath))
|
||||||
|
{
|
||||||
|
Console.WriteLine("❌ 파일을 찾을 수 없습니다.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV 파일 읽기 (첫 줄 헤더 제외)
|
||||||
|
var lines = await File.ReadAllLinesAsync(csvPath);
|
||||||
|
Console.WriteLine($"📊 총 {lines.Length - 1}개의 라인을 처리합니다...");
|
||||||
|
|
||||||
|
using var conn = new NpgsqlConnection(_connectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
if (truncate)
|
||||||
|
{
|
||||||
|
using var cmd = new NpgsqlCommand("TRUNCATE TABLE raw_node_map", conn);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
Console.WriteLine("🗑️ raw_node_map 테이블 초기화 완료");
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL Binary Copy 시작
|
||||||
|
// level, class, name, node_id, data_type 총 5개 필드
|
||||||
|
using (var writer = conn.BeginBinaryImport("COPY raw_node_map (level, class, name, node_id, data_type) FROM STDIN (FORMAT BINARY)"))
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
for (int i = 1; i < lines.Length; i++) // i=1 부터 시작하여 헤더 스킵
|
||||||
|
{
|
||||||
|
var line = lines[i];
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
|
|
||||||
|
var cols = line.Split(',');
|
||||||
|
if (cols.Length < 4) continue; // 최소 4개 필드(NodeId까지)는 있어야 함
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
writer.StartRow();
|
||||||
|
// 1. Level (int)
|
||||||
|
writer.Write(int.Parse(cols[0]), NpgsqlTypes.NpgsqlDbType.Integer);
|
||||||
|
// 2. Class (string)
|
||||||
|
writer.Write(cols[1], NpgsqlTypes.NpgsqlDbType.Text);
|
||||||
|
// 3. Name (string)
|
||||||
|
writer.Write(cols[2], NpgsqlTypes.NpgsqlDbType.Text);
|
||||||
|
// 4. NodeId (string)
|
||||||
|
writer.Write(cols[3], NpgsqlTypes.NpgsqlDbType.Text);
|
||||||
|
|
||||||
|
// 5. DataType (데이터가 없으면 "Unknown" 처리)
|
||||||
|
string dataType = cols.Length > 4 ? cols[4].Trim() : "Unknown";
|
||||||
|
writer.Write(dataType, NpgsqlTypes.NpgsqlDbType.Text);
|
||||||
|
|
||||||
|
count++;
|
||||||
|
if (count % 50000 == 0) Console.WriteLine($"... {count}개 처리 중");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"⚠️ 에러 발생 (라인 {i+1}): {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writer.CompleteAsync();
|
||||||
|
Console.WriteLine($"✅ 총 {count}개 노드 로드 완료!");
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/Infrastructure/Csv/ExperionCsvService.cs
Normal file
86
src/Infrastructure/Csv/ExperionCsvService.cs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using CsvHelper;
|
||||||
|
using CsvHelper.Configuration;
|
||||||
|
using ExperionCrawler.Core.Application.Interfaces;
|
||||||
|
using ExperionCrawler.Core.Domain.Entities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Infrastructure.Csv;
|
||||||
|
|
||||||
|
public class ExperionCsvService : IExperionCsvService
|
||||||
|
{
|
||||||
|
private const string CsvDir = "data/csv";
|
||||||
|
private readonly ILogger<ExperionCsvService> _logger;
|
||||||
|
|
||||||
|
public ExperionCsvService(ILogger<ExperionCsvService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
Directory.CreateDirectory(CsvDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> ExportAsync(IEnumerable<ExperionRecord> records, string fileName)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(CsvDir, fileName.EndsWith(".csv") ? fileName : $"{fileName}.csv");
|
||||||
|
var config = new CsvConfiguration(CultureInfo.InvariantCulture) { HasHeaderRecord = true };
|
||||||
|
|
||||||
|
await using var writer = new StreamWriter(path);
|
||||||
|
await using var csv = new CsvWriter(writer, config);
|
||||||
|
await csv.WriteRecordsAsync(records);
|
||||||
|
|
||||||
|
_logger.LogInformation("[ExperionCsv] 내보내기 완료: {Path} ({Count}건)", path, records.Count());
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<ExperionRecord>> ImportAsync(string filePath)
|
||||||
|
{
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
throw new FileNotFoundException($"CSV 파일을 찾을 수 없습니다: {filePath}");
|
||||||
|
|
||||||
|
using var reader = new StreamReader(filePath);
|
||||||
|
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
|
||||||
|
var records = csv.GetRecords<ExperionRecord>().ToList();
|
||||||
|
|
||||||
|
_logger.LogInformation("[ExperionCsv] 가져오기 완료: {Path} ({Count}건)", filePath, records.Count);
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<string> GetAvailableFiles()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(CsvDir)) return Enumerable.Empty<string>();
|
||||||
|
return Directory.GetFiles(CsvDir, "*.csv")
|
||||||
|
.Select(Path.GetFileName)
|
||||||
|
.Where(f => f != null)
|
||||||
|
.Cast<string>()
|
||||||
|
.OrderByDescending(f => f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 노드맵 엔트리를 AssetLoader가 읽을 수 있는 형식으로 CSV 저장
|
||||||
|
/// 헤더: level,class,name,node_id,data_type
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> ExportNodeMapAsync(IEnumerable<ExperionNodeMapEntry> entries, string fileName)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(CsvDir, fileName.EndsWith(".csv") ? fileName : $"{fileName}.csv");
|
||||||
|
|
||||||
|
await using var writer = new StreamWriter(path, append: false, encoding: System.Text.Encoding.UTF8);
|
||||||
|
await writer.WriteLineAsync("level,class,name,node_id,data_type");
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
foreach (var e in entries)
|
||||||
|
{
|
||||||
|
await writer.WriteLineAsync(
|
||||||
|
$"{e.Level},{EscapeCsv(e.NodeClass)},{EscapeCsv(e.DisplayName)},{EscapeCsv(e.NodeId)},{EscapeCsv(e.DataType)}");
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("[ExperionCsv] 노드맵 내보내기 완료: {Path} ({Count}건)", path, count);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapeCsv(string value)
|
||||||
|
{
|
||||||
|
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
||||||
|
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
187
src/Infrastructure/Database/ExperionDbContext.cs
Normal file
187
src/Infrastructure/Database/ExperionDbContext.cs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
using ExperionCrawler.Core.Application.Interfaces;
|
||||||
|
using ExperionCrawler.Core.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Infrastructure.Database;
|
||||||
|
|
||||||
|
// ── DbContext ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class ExperionDbContext : DbContext
|
||||||
|
{
|
||||||
|
public ExperionDbContext(DbContextOptions<ExperionDbContext> options) : base(options) { }
|
||||||
|
|
||||||
|
public DbSet<ExperionRecord> ExperionRecords => Set<ExperionRecord>();
|
||||||
|
public DbSet<RawNodeMap> RawNodeMaps => Set<RawNodeMap>();
|
||||||
|
public DbSet<NodeMapMaster> NodeMapMasters => Set<NodeMapMaster>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ExperionRecord>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.HasIndex(x => x.CollectedAt);
|
||||||
|
e.HasIndex(x => x.NodeId);
|
||||||
|
e.HasIndex(x => x.SessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<RawNodeMap>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.HasIndex(x => x.NodeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<NodeMapMaster>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.HasIndex(x => x.NodeId);
|
||||||
|
e.HasIndex(x => x.Level);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class ExperionDbService : IExperionDbService
|
||||||
|
{
|
||||||
|
private readonly ExperionDbContext _ctx;
|
||||||
|
private readonly ILogger<ExperionDbService> _logger;
|
||||||
|
|
||||||
|
public ExperionDbService(ExperionDbContext ctx, ILogger<ExperionDbService> logger)
|
||||||
|
{
|
||||||
|
_ctx = ctx;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> InitializeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _ctx.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
// EnsureCreatedAsync는 기존 DB에 새 테이블을 추가하지 않으므로
|
||||||
|
// raw_node_map / node_map_master 는 DDL로 직접 보장
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||||
|
CREATE TABLE IF NOT EXISTS raw_node_map (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
level INTEGER NOT NULL,
|
||||||
|
class TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
node_id TEXT NOT NULL,
|
||||||
|
data_type TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||||
|
CREATE TABLE IF NOT EXISTS node_map_master (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
level INTEGER NOT NULL,
|
||||||
|
class TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
node_id TEXT NOT NULL,
|
||||||
|
data_type TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
|
||||||
|
_logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[ExperionDb] 초기화 실패");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> SaveRecordsAsync(IEnumerable<ExperionRecord> records)
|
||||||
|
{
|
||||||
|
var list = records.ToList();
|
||||||
|
await _ctx.ExperionRecords.AddRangeAsync(list);
|
||||||
|
var saved = await _ctx.SaveChangesAsync();
|
||||||
|
_logger.LogInformation("[ExperionDb] {Count}건 저장", saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> ClearRecordsAsync()
|
||||||
|
{
|
||||||
|
var deleted = await _ctx.ExperionRecords.ExecuteDeleteAsync();
|
||||||
|
_logger.LogInformation("[ExperionDb] {Count}건 삭제 (초기화)", deleted);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> BuildMasterFromRawAsync(bool truncate = false)
|
||||||
|
{
|
||||||
|
if (truncate)
|
||||||
|
{
|
||||||
|
await _ctx.Database.ExecuteSqlRawAsync(
|
||||||
|
"TRUNCATE TABLE node_map_master RESTART IDENTITY");
|
||||||
|
_logger.LogInformation("[ExperionDb] node_map_master 초기화 완료");
|
||||||
|
}
|
||||||
|
|
||||||
|
var inserted = await _ctx.Database.ExecuteSqlRawAsync(
|
||||||
|
"INSERT INTO node_map_master (level, class, name, node_id, data_type) " +
|
||||||
|
"SELECT level, class, name, node_id, data_type FROM raw_node_map");
|
||||||
|
|
||||||
|
_logger.LogInformation("[ExperionDb] node_map_master 빌드 완료: {Count}건", inserted);
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<ExperionRecord>> GetRecordsAsync(
|
||||||
|
DateTime? from = null, DateTime? to = null, int limit = 1000)
|
||||||
|
{
|
||||||
|
var q = _ctx.ExperionRecords.AsQueryable();
|
||||||
|
if (from.HasValue) q = q.Where(r => r.CollectedAt >= from.Value);
|
||||||
|
if (to.HasValue) q = q.Where(r => r.CollectedAt <= to.Value);
|
||||||
|
return await q.OrderByDescending(r => r.CollectedAt).Take(limit).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetTotalCountAsync()
|
||||||
|
=> await _ctx.ExperionRecords.CountAsync();
|
||||||
|
|
||||||
|
public async Task<IEnumerable<string>> GetNameListAsync()
|
||||||
|
{
|
||||||
|
return await _ctx.NodeMapMasters
|
||||||
|
.Select(x => x.Name).Distinct()
|
||||||
|
.OrderBy(x => x).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NodeMapStats> GetMasterStatsAsync()
|
||||||
|
{
|
||||||
|
if (!await _ctx.NodeMapMasters.AnyAsync())
|
||||||
|
return new NodeMapStats(0, 0, 0, 0, Enumerable.Empty<string>());
|
||||||
|
|
||||||
|
var total = await _ctx.NodeMapMasters.CountAsync();
|
||||||
|
var objectCount = await _ctx.NodeMapMasters.CountAsync(x => x.Class == "Object");
|
||||||
|
var variableCount = await _ctx.NodeMapMasters.CountAsync(x => x.Class == "Variable");
|
||||||
|
var maxLevel = await _ctx.NodeMapMasters.MaxAsync(x => (int?)x.Level) ?? 0;
|
||||||
|
var dataTypes = await _ctx.NodeMapMasters
|
||||||
|
.Select(x => x.DataType).Distinct()
|
||||||
|
.OrderBy(x => x).ToListAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("[ExperionDb] 노드맵 통계: total={Total}", total);
|
||||||
|
return new NodeMapStats(total, objectCount, variableCount, maxLevel, dataTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NodeMapQueryResult> QueryMasterAsync(
|
||||||
|
int? minLevel, int? maxLevel, string? nodeClass,
|
||||||
|
IEnumerable<string>? names, string? nodeId, string? dataType,
|
||||||
|
int limit, int offset)
|
||||||
|
{
|
||||||
|
var q = _ctx.NodeMapMasters.AsQueryable();
|
||||||
|
|
||||||
|
if (minLevel.HasValue) q = q.Where(x => x.Level >= minLevel.Value);
|
||||||
|
if (maxLevel.HasValue) q = q.Where(x => x.Level <= maxLevel.Value);
|
||||||
|
if (!string.IsNullOrEmpty(nodeClass)) q = q.Where(x => x.Class == nodeClass);
|
||||||
|
var nameList = names?.Where(n => !string.IsNullOrEmpty(n)).ToList();
|
||||||
|
if (nameList?.Count > 0) q = q.Where(x => nameList.Contains(x.Name));
|
||||||
|
if (!string.IsNullOrEmpty(nodeId)) q = q.Where(x => x.NodeId.Contains(nodeId));
|
||||||
|
if (!string.IsNullOrEmpty(dataType)) q = q.Where(x => x.DataType == dataType);
|
||||||
|
|
||||||
|
var total = await q.CountAsync();
|
||||||
|
var items = await q.OrderBy(x => x.Level).ThenBy(x => x.Name)
|
||||||
|
.Skip(offset).Take(Math.Min(limit, 500))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return new NodeMapQueryResult(total, items);
|
||||||
|
}
|
||||||
|
}
|
||||||
392
src/Infrastructure/OpcUa/ExperionOpcClient.cs
Normal file
392
src/Infrastructure/OpcUa/ExperionOpcClient.cs
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
using System.Text;
|
||||||
|
using ExperionCrawler.Core.Application.Interfaces;
|
||||||
|
using ExperionCrawler.Core.Domain.Entities;
|
||||||
|
using ExperionCrawler.Infrastructure.Certificates;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Client;
|
||||||
|
using ISession = Opc.Ua.Client.ISession;
|
||||||
|
using StatusCodes = Opc.Ua.StatusCodes;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Infrastructure.OpcUa;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OPC UA 서버 접속·읽기·탐색.
|
||||||
|
/// ApplicationConfiguration 은 원본 Program.cs 의 SecurityConfiguration 구조를 그대로 준수.
|
||||||
|
/// </summary>
|
||||||
|
public class ExperionOpcClient : IExperionOpcClient
|
||||||
|
{
|
||||||
|
private readonly ILogger<ExperionOpcClient> _logger;
|
||||||
|
|
||||||
|
public ExperionOpcClient(ILogger<ExperionOpcClient> logger) => _logger = logger;
|
||||||
|
|
||||||
|
// ── 공통 설정 빌더 ────────────────────────────────────────────────────────
|
||||||
|
private static async Task<ApplicationConfiguration> BuildConfigAsync(ExperionServerConfig cfg)
|
||||||
|
{
|
||||||
|
var clientCert = ExperionCertificateService.TryLoadCertificate(cfg.ClientHostName);
|
||||||
|
|
||||||
|
var config = new ApplicationConfiguration
|
||||||
|
{
|
||||||
|
ApplicationName = "ExperionCrawlerClient",
|
||||||
|
ApplicationType = ApplicationType.Client,
|
||||||
|
ApplicationUri = cfg.ApplicationUri,
|
||||||
|
SecurityConfiguration = new SecurityConfiguration
|
||||||
|
{
|
||||||
|
// 원본: ApplicationCertificate = new CertificateIdentifier { Certificate = clientCert }
|
||||||
|
ApplicationCertificate = clientCert != null
|
||||||
|
? new CertificateIdentifier { Certificate = clientCert }
|
||||||
|
: new CertificateIdentifier(),
|
||||||
|
|
||||||
|
// 원본 주석: "⚠️ 에러 발생했던 지점: 아래 3개 경로가 모두 명시되어야 함"
|
||||||
|
TrustedPeerCertificates = new CertificateTrustList
|
||||||
|
{
|
||||||
|
StoreType = "Directory",
|
||||||
|
StorePath = Path.GetFullPath("pki/trusted")
|
||||||
|
},
|
||||||
|
TrustedIssuerCertificates = new CertificateTrustList
|
||||||
|
{
|
||||||
|
StoreType = "Directory",
|
||||||
|
StorePath = Path.GetFullPath("pki/issuers")
|
||||||
|
},
|
||||||
|
RejectedCertificateStore = new CertificateTrustList
|
||||||
|
{
|
||||||
|
StoreType = "Directory",
|
||||||
|
StorePath = Path.GetFullPath("pki/rejected")
|
||||||
|
},
|
||||||
|
AutoAcceptUntrustedCertificates = true,
|
||||||
|
AddAppCertToTrustedStore = true
|
||||||
|
},
|
||||||
|
TransportQuotas = new TransportQuotas { OperationTimeout = 15_000 },
|
||||||
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }
|
||||||
|
};
|
||||||
|
|
||||||
|
await config.ValidateAsync(ApplicationType.Client);
|
||||||
|
|
||||||
|
// 원본: config.CertificateValidator.CertificateValidation += (v, e) => { if (...) e.Accept = true; };
|
||||||
|
config.CertificateValidator.CertificateValidation += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Error.StatusCode != StatusCodes.Good) e.Accept = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 엔드포인트 선택 (원본 로직 동일) ────────────────────────────────────
|
||||||
|
private static async Task<ConfiguredEndpoint> SelectEndpointAsync(
|
||||||
|
ApplicationConfiguration appConfig, string endpointUrl)
|
||||||
|
{
|
||||||
|
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
||||||
|
using var discovery = await DiscoveryClient.CreateAsync(
|
||||||
|
appConfig, new Uri(endpointUrl), DiagnosticsMasks.All, CancellationToken.None);
|
||||||
|
|
||||||
|
var endpoints = await discovery.GetEndpointsAsync(null);
|
||||||
|
|
||||||
|
// 원본: OrderByDescending SecurityLevel, prefer Basic256Sha256
|
||||||
|
var selected = endpoints
|
||||||
|
.OrderByDescending(e => e.SecurityLevel)
|
||||||
|
.FirstOrDefault(e => e.SecurityPolicyUri.Contains("Basic256Sha256"))
|
||||||
|
?? endpoints[0];
|
||||||
|
|
||||||
|
return new ConfiguredEndpoint(null, selected, endpointConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 세션 생성 ─────────────────────────────────────────────────────────────
|
||||||
|
private static async Task<ISession> CreateSessionAsync(
|
||||||
|
ApplicationConfiguration appConfig,
|
||||||
|
ConfiguredEndpoint endpoint,
|
||||||
|
ExperionServerConfig cfg,
|
||||||
|
string sessionName)
|
||||||
|
{
|
||||||
|
// 원본: new UserIdentity(userName, Encoding.UTF8.GetBytes(password))
|
||||||
|
var identity = new UserIdentity(cfg.UserName, Encoding.UTF8.GetBytes(cfg.Password));
|
||||||
|
|
||||||
|
return await Session.Create(appConfig, endpoint, false, sessionName, 60_000, identity, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 접속 테스트 ───────────────────────────────────────────────────────────
|
||||||
|
public async Task<ExperionConnectResult> TestConnectionAsync(ExperionServerConfig cfg)
|
||||||
|
{
|
||||||
|
ISession? session = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[ExperionOpc] TestConnection → {Url}", cfg.EndpointUrl);
|
||||||
|
var appConfig = await BuildConfigAsync(cfg);
|
||||||
|
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl);
|
||||||
|
|
||||||
|
_logger.LogInformation("정책 선택됨: {Policy}", endpoint.Description.SecurityPolicyUri);
|
||||||
|
|
||||||
|
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerSession");
|
||||||
|
var sessionId = session.SessionId?.ToString() ?? "N/A";
|
||||||
|
|
||||||
|
return new ExperionConnectResult(true, "연결 성공", sessionId, endpoint.Description.SecurityPolicyUri);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[ExperionOpc] 연결 실패");
|
||||||
|
return new ExperionConnectResult(false, ex.Message);
|
||||||
|
}
|
||||||
|
finally { await DisposeSessionAsync(session); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 단일 태그 읽기 ────────────────────────────────────────────────────────
|
||||||
|
public async Task<ExperionReadResult> ReadTagAsync(ExperionServerConfig cfg, string nodeId)
|
||||||
|
{
|
||||||
|
var results = await ReadTagsAsync(cfg, new[] { nodeId });
|
||||||
|
return results.FirstOrDefault()
|
||||||
|
?? new ExperionReadResult(false, nodeId, null, "Error", "결과 없음");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 복수 태그 읽기 ────────────────────────────────────────────────────────
|
||||||
|
public async Task<IEnumerable<ExperionReadResult>> ReadTagsAsync(
|
||||||
|
ExperionServerConfig cfg, IEnumerable<string> nodeIds)
|
||||||
|
{
|
||||||
|
ISession? session = null;
|
||||||
|
var nodeList = nodeIds.ToList();
|
||||||
|
var results = new List<ExperionReadResult>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var appConfig = await BuildConfigAsync(cfg);
|
||||||
|
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl);
|
||||||
|
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerReadSession");
|
||||||
|
|
||||||
|
foreach (var nodeId in nodeList)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 원본: session.ReadValueAsync(nodeId)
|
||||||
|
var nodeToRead = new ReadValueId
|
||||||
|
{
|
||||||
|
NodeId = new NodeId(nodeId),
|
||||||
|
AttributeId = Attributes.Value
|
||||||
|
};
|
||||||
|
var readResponse = await session.ReadAsync(null, 0, TimestampsToReturn.Both, new ReadValueIdCollection { nodeToRead }, CancellationToken.None);
|
||||||
|
var dv = readResponse.Results[0];
|
||||||
|
|
||||||
|
var statusStr = StatusCode.IsGood(dv.StatusCode)
|
||||||
|
? "Good"
|
||||||
|
: $"0x{(uint)dv.StatusCode:X8}";
|
||||||
|
|
||||||
|
results.Add(new ExperionReadResult(
|
||||||
|
true, nodeId, dv.Value, statusStr, null, dv.SourceTimestamp));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
results.Add(new ExperionReadResult(false, nodeId, null, "Error", ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[ExperionOpc] ReadTags 실패");
|
||||||
|
foreach (var n in nodeList.Where(n => results.All(r => r.NodeId != n)))
|
||||||
|
results.Add(new ExperionReadResult(false, n, null, "Error", ex.Message));
|
||||||
|
}
|
||||||
|
finally { await DisposeSessionAsync(session); }
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 전체 노드맵 탐색 (재귀) ───────────────────────────────────────────────
|
||||||
|
public async Task<ExperionNodeMapResult> BrowseAllNodesAsync(
|
||||||
|
ExperionServerConfig cfg, int maxDepth = 10, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ISession? session = null;
|
||||||
|
var results = new List<ExperionNodeMapEntry>();
|
||||||
|
var visited = new HashSet<string>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var appConfig = await BuildConfigAsync(cfg);
|
||||||
|
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl);
|
||||||
|
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerNodeMapSession");
|
||||||
|
|
||||||
|
_logger.LogInformation("[ExperionOpc] 전체 노드맵 탐색 시작 (maxDepth={MaxDepth})", maxDepth);
|
||||||
|
|
||||||
|
await BrowseRecursiveAsync(session, ObjectIds.ObjectsFolder, 0, maxDepth, results, visited, ct);
|
||||||
|
|
||||||
|
_logger.LogInformation("[ExperionOpc] 전체 노드맵 탐색 완료: {Count}개", results.Count);
|
||||||
|
return new ExperionNodeMapResult(true, results, results.Count);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[ExperionOpc] BrowseAllNodes 실패");
|
||||||
|
return new ExperionNodeMapResult(false, results, results.Count, ex.Message);
|
||||||
|
}
|
||||||
|
finally { await DisposeSessionAsync(session); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BrowseRecursiveAsync(
|
||||||
|
ISession session,
|
||||||
|
NodeId startNode,
|
||||||
|
int currentLevel,
|
||||||
|
int maxLevel,
|
||||||
|
List<ExperionNodeMapEntry> results,
|
||||||
|
HashSet<string> visited,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (currentLevel > maxLevel || ct.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
ReferenceDescriptionCollection refs;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var browser = new Browser(session)
|
||||||
|
{
|
||||||
|
BrowseDirection = BrowseDirection.Forward,
|
||||||
|
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||||
|
IncludeSubtypes = true,
|
||||||
|
NodeClassMask = (int)(NodeClass.Object | NodeClass.Variable),
|
||||||
|
ResultMask = (uint)BrowseResultMask.All
|
||||||
|
};
|
||||||
|
refs = await browser.BrowseAsync(startNode);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[ExperionOpc] Browse 실패 NodeId={NodeId}: {Msg}", startNode, ex.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refs.Count == 0) return;
|
||||||
|
|
||||||
|
// Variable 노드들 DataType 배치 읽기
|
||||||
|
var variableIds = refs
|
||||||
|
.Where(r => r.NodeClass == NodeClass.Variable)
|
||||||
|
.Select(r => (NodeId)r.NodeId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var dataTypeMap = await BatchReadDataTypesAsync(session, variableIds);
|
||||||
|
|
||||||
|
foreach (var r in refs)
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) return;
|
||||||
|
|
||||||
|
var nodeIdStr = r.NodeId.ToString();
|
||||||
|
if (!visited.Add(nodeIdStr)) continue; // 순환 참조 방지
|
||||||
|
|
||||||
|
var dataType = r.NodeClass == NodeClass.Variable
|
||||||
|
? (dataTypeMap.TryGetValue(nodeIdStr, out var dt) ? dt : "Unknown")
|
||||||
|
: "N/A";
|
||||||
|
|
||||||
|
results.Add(new ExperionNodeMapEntry(
|
||||||
|
currentLevel,
|
||||||
|
r.NodeClass.ToString(),
|
||||||
|
r.DisplayName.Text,
|
||||||
|
nodeIdStr,
|
||||||
|
dataType));
|
||||||
|
|
||||||
|
if (results.Count % 1000 == 0)
|
||||||
|
_logger.LogInformation("[ExperionOpc] 탐색 중... {Count}개 수집", results.Count);
|
||||||
|
|
||||||
|
if (r.NodeClass == NodeClass.Object)
|
||||||
|
await BrowseRecursiveAsync(session, (NodeId)r.NodeId, currentLevel + 1, maxLevel, results, visited, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Dictionary<string, string>> BatchReadDataTypesAsync(
|
||||||
|
ISession session, List<NodeId> variableNodeIds)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>(variableNodeIds.Count);
|
||||||
|
if (variableNodeIds.Count == 0) return result;
|
||||||
|
|
||||||
|
const int chunkSize = 500;
|
||||||
|
for (int i = 0; i < variableNodeIds.Count; i += chunkSize)
|
||||||
|
{
|
||||||
|
var chunk = variableNodeIds.Skip(i).Take(chunkSize).ToList();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var readValues = new ReadValueIdCollection(
|
||||||
|
chunk.Select(n => new ReadValueId { NodeId = n, AttributeId = Attributes.DataType }));
|
||||||
|
|
||||||
|
var response = await session.ReadAsync(
|
||||||
|
null, 0, TimestampsToReturn.Neither, readValues, CancellationToken.None);
|
||||||
|
|
||||||
|
for (int j = 0; j < chunk.Count; j++)
|
||||||
|
result[chunk[j].ToString()] = ResolveDataTypeName(response.Results[j]);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[ExperionOpc] DataType 배치 읽기 실패: {Msg}", ex.Message);
|
||||||
|
foreach (var n in chunk)
|
||||||
|
result[n.ToString()] = "Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Dictionary<uint, string> _builtinTypes = new()
|
||||||
|
{
|
||||||
|
{ 1, "Boolean" }, { 2, "SByte" }, { 3, "Byte" }, { 4, "Int16" },
|
||||||
|
{ 5, "UInt16" }, { 6, "Int32" }, { 7, "UInt32" }, { 8, "Int64" },
|
||||||
|
{ 9, "UInt64" }, { 10, "Float" }, { 11, "Double" }, { 12, "String" },
|
||||||
|
{ 13, "DateTime" }, { 15, "ByteString"}, { 17, "StatusCode" }
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ResolveDataTypeName(DataValue dv)
|
||||||
|
{
|
||||||
|
if (!StatusCode.IsGood(dv.StatusCode)) return "Unknown";
|
||||||
|
|
||||||
|
NodeId? nodeId = dv.Value switch
|
||||||
|
{
|
||||||
|
NodeId nid => nid,
|
||||||
|
ExpandedNodeId eid => (NodeId)eid,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
if (nodeId is null) return "Unknown";
|
||||||
|
|
||||||
|
if (nodeId.NamespaceIndex == 0 && nodeId.IdType == IdType.Numeric)
|
||||||
|
{
|
||||||
|
var id = Convert.ToUInt32(nodeId.Identifier);
|
||||||
|
if (_builtinTypes.TryGetValue(id, out var name)) return name;
|
||||||
|
}
|
||||||
|
return nodeId.ToString() ?? "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 노드 탐색 ─────────────────────────────────────────────────────────────
|
||||||
|
public async Task<ExperionBrowseResult> BrowseNodesAsync(ExperionServerConfig cfg, string? startNodeId = null)
|
||||||
|
{
|
||||||
|
ISession? session = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var appConfig = await BuildConfigAsync(cfg);
|
||||||
|
var endpoint = await SelectEndpointAsync(appConfig, cfg.EndpointUrl);
|
||||||
|
session = await CreateSessionAsync(appConfig, endpoint, cfg, "ExperionCrawlerBrowseSession");
|
||||||
|
|
||||||
|
var startNode = startNodeId != null
|
||||||
|
? new NodeId(startNodeId)
|
||||||
|
: ObjectIds.ObjectsFolder;
|
||||||
|
|
||||||
|
var browser = new Browser(session)
|
||||||
|
{
|
||||||
|
BrowseDirection = BrowseDirection.Forward,
|
||||||
|
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
|
||||||
|
IncludeSubtypes = true,
|
||||||
|
NodeClassMask = (int)(NodeClass.Object | NodeClass.Variable),
|
||||||
|
ResultMask = (uint)BrowseResultMask.All
|
||||||
|
};
|
||||||
|
|
||||||
|
var refs = await browser.BrowseAsync(startNode);
|
||||||
|
var nodes = refs.Select(r => new ExperionNodeInfo(
|
||||||
|
r.NodeId.ToString(),
|
||||||
|
r.DisplayName.Text,
|
||||||
|
r.NodeClass.ToString(),
|
||||||
|
r.NodeClass == NodeClass.Object
|
||||||
|
)).ToList();
|
||||||
|
|
||||||
|
return new ExperionBrowseResult(true, nodes);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[ExperionOpc] BrowseNodes 실패");
|
||||||
|
return new ExperionBrowseResult(false, Enumerable.Empty<ExperionNodeInfo>(), ex.Message);
|
||||||
|
}
|
||||||
|
finally { await DisposeSessionAsync(session); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 세션 정리 ─────────────────────────────────────────────────────────────
|
||||||
|
private static async Task DisposeSessionAsync(ISession? session)
|
||||||
|
{
|
||||||
|
if (session == null) return;
|
||||||
|
try { await session.CloseAsync(); } catch { /* ignore */ }
|
||||||
|
session.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/Infrastructure/OpcUa/ExperionStatusCodeService.cs
Normal file
50
src/Infrastructure/OpcUa/ExperionStatusCodeService.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ExperionCrawler.Core.Application.Interfaces;
|
||||||
|
using ExperionCrawler.Core.Domain.Entities;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Infrastructure.OpcUa;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 원본 Program.cs 의 LoadStatusCodes() / _statusCodeMap 을 서비스로 분리.
|
||||||
|
/// statuscode.json 에서 로드하며 Hex 키(대소문자 무시)로 조회한다.
|
||||||
|
/// </summary>
|
||||||
|
public class ExperionStatusCodeService : IExperionStatusCodeService
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, ExperionStatusCodeInfo> _map
|
||||||
|
= new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly ILogger<ExperionStatusCodeService> _logger;
|
||||||
|
|
||||||
|
public int LoadedCount => _map.Count;
|
||||||
|
|
||||||
|
public ExperionStatusCodeService(ILogger<ExperionStatusCodeService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Load()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Directory.GetCurrentDirectory(), "statuscode.json");
|
||||||
|
if (!File.Exists(path)) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||||
|
var list = JsonSerializer.Deserialize<List<ExperionStatusCodeInfo>>(json, opts);
|
||||||
|
if (list == null) return;
|
||||||
|
foreach (var item in list) _map[item.Hex] = item;
|
||||||
|
_logger.LogInformation("✅ {Count}개의 에러 코드 정의 로드 완료.", _map.Count);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "statuscode.json 로드 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExperionStatusCodeInfo? GetByHex(string hexCode)
|
||||||
|
=> _map.TryGetValue(hexCode, out var info) ? info : null;
|
||||||
|
|
||||||
|
public ExperionStatusCodeInfo? GetByUint(uint statusCode)
|
||||||
|
=> GetByHex($"0x{statusCode:X8}");
|
||||||
|
}
|
||||||
306
src/Web/Controllers/ExperionControllers.cs
Normal file
306
src/Web/Controllers/ExperionControllers.cs
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
using ExperionCrawler.Core.Application.DTOs;
|
||||||
|
using ExperionCrawler.Core.Application.Interfaces;
|
||||||
|
using ExperionCrawler.Core.Application.Services;
|
||||||
|
using ExperionCrawler.Core.Domain.Entities;
|
||||||
|
using ExperionCrawler.Infrastructure.Csv;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ExperionCrawler.Web.Controllers;
|
||||||
|
|
||||||
|
// ── 인증서 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/certificate")]
|
||||||
|
public class ExperionCertificateController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IExperionCertificateService _certSvc;
|
||||||
|
|
||||||
|
public ExperionCertificateController(IExperionCertificateService certSvc)
|
||||||
|
=> _certSvc = certSvc;
|
||||||
|
|
||||||
|
/// <summary>현재 인증서 상태 조회</summary>
|
||||||
|
[HttpGet("status")]
|
||||||
|
public IActionResult GetStatus([FromQuery] string clientHostName = "dbsvr")
|
||||||
|
{
|
||||||
|
var info = _certSvc.GetCertificateInfo(clientHostName);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
exists = info.Exists,
|
||||||
|
subjectName = info.SubjectName,
|
||||||
|
notAfter = info.NotAfter,
|
||||||
|
thumbPrint = info.ThumbPrint,
|
||||||
|
filePath = info.FilePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>인증서 생성 (없으면 생성, 있으면 기존 반환)</summary>
|
||||||
|
[HttpPost("create")]
|
||||||
|
public async Task<IActionResult> Create([FromBody] ExperionCertCreateDto dto)
|
||||||
|
{
|
||||||
|
var applicationUri = $"urn:{dto.ClientHostName}:ExperionCrawlerClient";
|
||||||
|
var result = await _certSvc.EnsureCertificateAsync(
|
||||||
|
applicationUri, dto.ClientHostName, dto.SubjectAltNames, dto.PfxPassword);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
success = result.Success,
|
||||||
|
message = result.Message,
|
||||||
|
thumbPrint = result.ThumbPrint
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 서버 접속 테스트 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/connection")]
|
||||||
|
public class ExperionConnectionController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IExperionOpcClient _opcClient;
|
||||||
|
|
||||||
|
public ExperionConnectionController(IExperionOpcClient opcClient)
|
||||||
|
=> _opcClient = opcClient;
|
||||||
|
|
||||||
|
/// <summary>OPC UA 서버 접속 테스트</summary>
|
||||||
|
[HttpPost("test")]
|
||||||
|
public async Task<IActionResult> Test([FromBody] ExperionServerConfigDto dto)
|
||||||
|
{
|
||||||
|
var cfg = MapConfig(dto);
|
||||||
|
var r = await _opcClient.TestConnectionAsync(cfg);
|
||||||
|
return Ok(new { success = r.Success, message = r.Message, sessionId = r.SessionId, policyUri = r.PolicyUri });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>단일 노드 태그 읽기</summary>
|
||||||
|
[HttpPost("read")]
|
||||||
|
public async Task<IActionResult> ReadTag([FromBody] ExperionReadTagRequestDto dto)
|
||||||
|
{
|
||||||
|
var cfg = MapConfig(dto.ServerConfig);
|
||||||
|
var r = await _opcClient.ReadTagAsync(cfg, dto.NodeId);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
success = r.Success,
|
||||||
|
nodeId = r.NodeId,
|
||||||
|
value = r.Value?.ToString(),
|
||||||
|
statusCode = r.StatusCode,
|
||||||
|
timestamp = r.Timestamp,
|
||||||
|
error = r.ErrorMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>노드 탐색 (ObjectsFolder 기준)</summary>
|
||||||
|
[HttpPost("browse")]
|
||||||
|
public async Task<IActionResult> Browse([FromBody] ExperionBrowseRequestDto dto)
|
||||||
|
{
|
||||||
|
var cfg = MapConfig(dto.ServerConfig);
|
||||||
|
var r = await _opcClient.BrowseNodesAsync(cfg, dto.StartNodeId);
|
||||||
|
return Ok(new { success = r.Success, nodes = r.Nodes, error = r.ErrorMessage });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExperionServerConfig MapConfig(ExperionServerConfigDto dto) => new()
|
||||||
|
{
|
||||||
|
ServerHostName = dto.ServerHostName,
|
||||||
|
Port = dto.Port,
|
||||||
|
ClientHostName = dto.ClientHostName,
|
||||||
|
UserName = dto.UserName,
|
||||||
|
Password = dto.Password
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 크롤링 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/crawl")]
|
||||||
|
public class ExperionCrawlController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ExperionCrawlService _crawlSvc;
|
||||||
|
|
||||||
|
public ExperionCrawlController(ExperionCrawlService crawlSvc)
|
||||||
|
=> _crawlSvc = crawlSvc;
|
||||||
|
|
||||||
|
/// <summary>전체 노드맵 크롤 (재귀 탐색 → CSV 저장)</summary>
|
||||||
|
[HttpPost("nodemap")]
|
||||||
|
public async Task<IActionResult> NodeMap(
|
||||||
|
[FromBody] ExperionNodeMapCrawlRequestDto dto,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = new ExperionServerConfig
|
||||||
|
{
|
||||||
|
ServerHostName = dto.ServerConfig.ServerHostName,
|
||||||
|
Port = dto.ServerConfig.Port,
|
||||||
|
ClientHostName = dto.ServerConfig.ClientHostName,
|
||||||
|
UserName = dto.ServerConfig.UserName,
|
||||||
|
Password = dto.ServerConfig.Password
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _crawlSvc.RunNodeMapCrawlAsync(cfg, dto.MaxDepth, ct);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
success = result.Success,
|
||||||
|
totalCount = result.TotalCount,
|
||||||
|
csvPath = result.CsvPath,
|
||||||
|
error = result.ErrorMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>크롤링 시작 (동기식 – 완료 후 결과 반환)</summary>
|
||||||
|
[HttpPost("start")]
|
||||||
|
public async Task<IActionResult> Start(
|
||||||
|
[FromBody] ExperionCrawlRequestDto dto,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = new ExperionServerConfig
|
||||||
|
{
|
||||||
|
ServerHostName = dto.ServerConfig.ServerHostName,
|
||||||
|
Port = dto.ServerConfig.Port,
|
||||||
|
ClientHostName = dto.ServerConfig.ClientHostName,
|
||||||
|
UserName = dto.ServerConfig.UserName,
|
||||||
|
Password = dto.ServerConfig.Password
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _crawlSvc.RunCrawlAsync(
|
||||||
|
cfg, dto.NodeIds, dto.IntervalSeconds, dto.DurationSeconds, ct);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
sessionId = result.SessionId,
|
||||||
|
totalRecords = result.TotalRecords,
|
||||||
|
csvPath = result.CsvPath,
|
||||||
|
preview = result.Records.Take(5).Select(r => new
|
||||||
|
{
|
||||||
|
r.NodeId, r.Value, r.StatusCode,
|
||||||
|
collectedAt = r.CollectedAt
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DB 저장 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/database")]
|
||||||
|
public class ExperionDatabaseController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ExperionCrawlService _crawlSvc;
|
||||||
|
private readonly IExperionDbService _dbSvc;
|
||||||
|
private readonly IExperionCsvService _csvSvc;
|
||||||
|
private readonly AssetLoader _assetLoader;
|
||||||
|
|
||||||
|
public ExperionDatabaseController(
|
||||||
|
ExperionCrawlService crawlSvc,
|
||||||
|
IExperionDbService dbSvc,
|
||||||
|
IExperionCsvService csvSvc,
|
||||||
|
AssetLoader assetLoader)
|
||||||
|
{
|
||||||
|
_crawlSvc = crawlSvc;
|
||||||
|
_dbSvc = dbSvc;
|
||||||
|
_csvSvc = csvSvc;
|
||||||
|
_assetLoader = assetLoader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>수집된 CSV 파일 목록</summary>
|
||||||
|
[HttpGet("files")]
|
||||||
|
public IActionResult GetCsvFiles()
|
||||||
|
=> Ok(new { files = _csvSvc.GetAvailableFiles() });
|
||||||
|
|
||||||
|
/// <summary>CSV → DB 임포트 (ServerHostName 지정 시 → raw_node_map, 그 외 → ExperionRecords)</summary>
|
||||||
|
[HttpPost("import")]
|
||||||
|
public async Task<IActionResult> Import([FromBody] ExperionCsvImportDto dto)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(dto.ServerHostName) &&
|
||||||
|
dto.FileName.StartsWith(dto.ServerHostName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fullPath = Path.Combine("data/csv", dto.FileName);
|
||||||
|
var rawCount = await _assetLoader.ImportFullMapAsync(fullPath, dto.Truncate);
|
||||||
|
var masterCount = await _dbSvc.BuildMasterFromRawAsync(dto.Truncate);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
count = rawCount,
|
||||||
|
masterCount,
|
||||||
|
message = $"{rawCount}개 노드 → raw_node_map 적재, {masterCount}개 → node_map_master 빌드 완료"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Ok(new { success = false, count = 0, masterCount = 0, message = $"오류: {ex.Message}" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _crawlSvc.ImportCsvToDbAsync(dto.FileName, dto.Truncate);
|
||||||
|
return Ok(new { success = result.Success, count = result.Count, message = result.Message });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DB 레코드 조회</summary>
|
||||||
|
[HttpGet("records")]
|
||||||
|
public async Task<IActionResult> GetRecords(
|
||||||
|
[FromQuery] DateTime? from,
|
||||||
|
[FromQuery] DateTime? to,
|
||||||
|
[FromQuery] int limit = 100)
|
||||||
|
{
|
||||||
|
var records = await _dbSvc.GetRecordsAsync(from, to, limit);
|
||||||
|
var total = await _dbSvc.GetTotalCountAsync();
|
||||||
|
return Ok(new { total, records });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 노드맵 대시보드 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/nodemap")]
|
||||||
|
public class ExperionNodeMapController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IExperionDbService _dbSvc;
|
||||||
|
public ExperionNodeMapController(IExperionDbService dbSvc) => _dbSvc = dbSvc;
|
||||||
|
|
||||||
|
/// <summary>node_map_master 의 name 컬럼 고유값 목록</summary>
|
||||||
|
[HttpGet("names")]
|
||||||
|
public async Task<IActionResult> Names()
|
||||||
|
{
|
||||||
|
var names = await _dbSvc.GetNameListAsync();
|
||||||
|
return Ok(new { names });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>node_map_master 통계 (총 수, 클래스별, 최대 레벨, 데이터타입 목록)</summary>
|
||||||
|
[HttpGet("stats")]
|
||||||
|
public async Task<IActionResult> Stats()
|
||||||
|
{
|
||||||
|
var s = await _dbSvc.GetMasterStatsAsync();
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
total = s.Total,
|
||||||
|
objectCount = s.ObjectCount,
|
||||||
|
variableCount = s.VariableCount,
|
||||||
|
maxLevel = s.MaxLevel,
|
||||||
|
dataTypes = s.DataTypes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>node_map_master 필터 조회 (페이지네이션 포함, names OR 조건)</summary>
|
||||||
|
[HttpGet("query")]
|
||||||
|
public async Task<IActionResult> Query(
|
||||||
|
[FromQuery] int? minLevel,
|
||||||
|
[FromQuery] int? maxLevel,
|
||||||
|
[FromQuery] string? nodeClass,
|
||||||
|
[FromQuery] List<string>? names,
|
||||||
|
[FromQuery] string? nodeId,
|
||||||
|
[FromQuery] string? dataType,
|
||||||
|
[FromQuery] int limit = 100,
|
||||||
|
[FromQuery] int offset = 0)
|
||||||
|
{
|
||||||
|
var r = await _dbSvc.QueryMasterAsync(
|
||||||
|
minLevel, maxLevel, nodeClass, names, nodeId, dataType, limit, offset);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
total = r.Total,
|
||||||
|
items = r.Items.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id, x.Level, x.Class, x.Name, x.NodeId, x.DataType
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/Web/ExperionCrawler.csproj
Normal file
32
src/Web/ExperionCrawler.csproj
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>ExperionCrawler</RootNamespace>
|
||||||
|
<AssemblyName>ExperionCrawler</AssemblyName>
|
||||||
|
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
|
||||||
|
<SelfContained>false</SelfContained>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="../../src/Core/**/*.cs" />
|
||||||
|
<Compile Include="../../src/Infrastructure/**/*.cs" />
|
||||||
|
<!-- OPC UA : 기존 버전 준수 -->
|
||||||
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="1.5.378.134" />
|
||||||
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.134" />
|
||||||
|
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.134" />
|
||||||
|
<!-- CSV -->
|
||||||
|
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||||
|
<!-- SQLite (Ubuntu 서버, 별도 DB 설치 불필요) -->
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.13">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<!-- Swagger (개발 편의) -->
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.8.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
63
src/Web/Program.cs
Normal file
63
src/Web/Program.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
using ExperionCrawler.Core.Application.Interfaces;
|
||||||
|
using ExperionCrawler.Core.Application.Services;
|
||||||
|
using ExperionCrawler.Infrastructure.Certificates;
|
||||||
|
using ExperionCrawler.Infrastructure.Csv;
|
||||||
|
using ExperionCrawler.Infrastructure.Database;
|
||||||
|
using ExperionCrawler.Infrastructure.OpcUa;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// ── MVC / Swagger ─────────────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen(c =>
|
||||||
|
c.SwaggerDoc("v1", new() { Title = "ExperionCrawler API", Version = "v1" }));
|
||||||
|
|
||||||
|
// ── Infrastructure ────────────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddSingleton<IExperionCertificateService, ExperionCertificateService>();
|
||||||
|
builder.Services.AddSingleton<IExperionStatusCodeService, ExperionStatusCodeService>();
|
||||||
|
builder.Services.AddScoped<IExperionOpcClient, ExperionOpcClient>();
|
||||||
|
builder.Services.AddScoped<IExperionCsvService, ExperionCsvService>();
|
||||||
|
builder.Services.AddScoped<AssetLoader>();
|
||||||
|
|
||||||
|
// PostgreSQL – Ubuntu 서버에서 별도 설치 없이 동작
|
||||||
|
Directory.CreateDirectory("data");
|
||||||
|
builder.Services.AddDbContext<ExperionDbContext>(opt =>
|
||||||
|
opt.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||||
|
builder.Services.AddScoped<IExperionDbService, ExperionDbService>();
|
||||||
|
|
||||||
|
// ── Application Services ──────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddScoped<ExperionCrawlService>();
|
||||||
|
|
||||||
|
// ── CORS ──────────────────────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddCors(opt =>
|
||||||
|
opt.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
|
||||||
|
|
||||||
|
// ── 포트 설정 (Ubuntu 환경: 기본 5000) ───────────────────────────────────────
|
||||||
|
builder.WebHost.UseUrls("http://0.0.0.0:5000");
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// ── DB 초기화 ─────────────────────────────────────────────────────────────────
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||||||
|
await db.InitializeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Middleware ────────────────────────────────────────────────────────────────
|
||||||
|
app.UseCors();
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseDefaultFiles(); // index.html
|
||||||
|
app.UseStaticFiles(); // wwwroot/
|
||||||
|
app.MapControllers();
|
||||||
|
app.MapFallbackToFile("index.html");
|
||||||
|
|
||||||
|
app.Run();
|
||||||
13
src/Web/appsettings.json
Normal file
13
src/Web/appsettings.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/Web/bin/Debug/net8.0/linux-x64/BitFaster.Caching.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/BitFaster.Caching.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/CsvHelper.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/CsvHelper.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler
Executable file
Binary file not shown.
1113
src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.deps.json
Normal file
1113
src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.deps.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.dll
Normal file
BIN
src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.dll
Normal file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.pdb
Normal file
BIN
src/Web/bin/Debug/net8.0/linux-x64/ExperionCrawler.pdb
Normal file
Binary file not shown.
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"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}]}}
|
||||||
BIN
src/Web/bin/Debug/net8.0/linux-x64/Humanizer.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Humanizer.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Bcl.AsyncInterfaces.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Bcl.AsyncInterfaces.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.CodeAnalysis.CSharp.Workspaces.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.CodeAnalysis.CSharp.Workspaces.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.CodeAnalysis.CSharp.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.CodeAnalysis.CSharp.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.CodeAnalysis.Workspaces.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.CodeAnalysis.Workspaces.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.CodeAnalysis.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.CodeAnalysis.dll
Executable file
Binary file not shown.
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.EntityFrameworkCore.Design.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.EntityFrameworkCore.Design.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.EntityFrameworkCore.Relational.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.EntityFrameworkCore.Relational.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.EntityFrameworkCore.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.EntityFrameworkCore.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Extensions.Caching.Memory.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Extensions.Caching.Memory.dll
Executable file
Binary file not shown.
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Extensions.DependencyInjection.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Extensions.DependencyInjection.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Extensions.DependencyModel.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Extensions.DependencyModel.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Extensions.Logging.Abstractions.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Extensions.Logging.Abstractions.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Extensions.Logging.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Extensions.Logging.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Extensions.Options.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Extensions.Options.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Extensions.Primitives.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.Extensions.Primitives.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.OpenApi.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Microsoft.OpenApi.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Mono.TextTemplating.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Mono.TextTemplating.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Newtonsoft.Json.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Newtonsoft.Json.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Npgsql.EntityFrameworkCore.PostgreSQL.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Npgsql.EntityFrameworkCore.PostgreSQL.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Npgsql.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Npgsql.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Client.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Client.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Configuration.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Configuration.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Core.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Core.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Security.Certificates.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Security.Certificates.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Types.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Opc.Ua.Types.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Swashbuckle.AspNetCore.Swagger.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Swashbuckle.AspNetCore.Swagger.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Swashbuckle.AspNetCore.SwaggerGen.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Swashbuckle.AspNetCore.SwaggerGen.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/Swashbuckle.AspNetCore.SwaggerUI.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/Swashbuckle.AspNetCore.SwaggerUI.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.CodeDom.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.CodeDom.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Collections.Immutable.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Collections.Immutable.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Composition.AttributedModel.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Composition.AttributedModel.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Composition.Convention.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Composition.Convention.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Composition.Hosting.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Composition.Hosting.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Composition.Runtime.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Composition.Runtime.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Composition.TypedParts.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Composition.TypedParts.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Diagnostics.DiagnosticSource.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Diagnostics.DiagnosticSource.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Formats.Asn1.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Formats.Asn1.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.IO.Pipelines.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.IO.Pipelines.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Text.Encodings.Web.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Text.Encodings.Web.dll
Executable file
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Text.Json.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/System.Text.Json.dll
Executable file
Binary file not shown.
13
src/Web/bin/Debug/net8.0/linux-x64/appsettings.json
Normal file
13
src/Web/bin/Debug/net8.0/linux-x64/appsettings.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/cs/Microsoft.CodeAnalysis.resources.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/cs/Microsoft.CodeAnalysis.resources.dll
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/de/Microsoft.CodeAnalysis.resources.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/de/Microsoft.CodeAnalysis.resources.dll
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/es/Microsoft.CodeAnalysis.resources.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/es/Microsoft.CodeAnalysis.resources.dll
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/fr/Microsoft.CodeAnalysis.resources.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/fr/Microsoft.CodeAnalysis.resources.dll
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/it/Microsoft.CodeAnalysis.resources.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/it/Microsoft.CodeAnalysis.resources.dll
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/ja/Microsoft.CodeAnalysis.resources.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/ja/Microsoft.CodeAnalysis.resources.dll
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/Web/bin/Debug/net8.0/linux-x64/ko/Microsoft.CodeAnalysis.resources.dll
Executable file
BIN
src/Web/bin/Debug/net8.0/linux-x64/ko/Microsoft.CodeAnalysis.resources.dll
Executable file
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