삽질하다 도저히 문제 파악이 안돼서 opcUaManager로 분리 테스트 중

This commit is contained in:
2026-02-25 08:52:03 +09:00
parent 4ea351946a
commit e88ab87771
138 changed files with 1051971 additions and 351 deletions

View File

@@ -0,0 +1,83 @@
using Microsoft.AspNetCore.Mvc;
using OpcUaManager.Models;
using OpcUaManager.Services;
namespace OpcUaManager.Controllers;
[ApiController]
[Route("api/cert")]
[Produces("application/json")]
public class CertController : ControllerBase
{
private readonly CertService _certService;
private readonly ILogger<CertController> _logger;
public CertController(CertService certService, ILogger<CertController> logger)
{
_certService = certService;
_logger = logger;
}
/// <summary>
/// X.509 클라이언트 인증서를 생성하고 pki/own/certs/ 에 PFX로 저장합니다.
/// </summary>
/// <remarks>
/// 원본 Program.cs 의 인증서 체계(pki/ 폴더, Exportable|MachineKeySet 플래그)를 유지합니다.
///
/// Sample request:
///
/// POST /api/cert/generate
/// {
/// "clientHostName": "dbsvr",
/// "applicationName": "OpcTestClient",
/// "serverHostName": "opc-server-01",
/// "serverIp": "192.168.0.20",
/// "pfxPassword": "",
/// "validDays": 365
/// }
/// </remarks>
[HttpPost("generate")]
[ProducesResponseType(typeof(CertCreateResult), 200)]
[ProducesResponseType(typeof(ApiError), 400)]
public async Task<IActionResult> Generate([FromBody] CertCreateRequest req)
{
if (string.IsNullOrWhiteSpace(req.ClientHostName) ||
string.IsNullOrWhiteSpace(req.ApplicationName))
return BadRequest(new ApiError
{
Error = "필수값 누락",
Detail = "ClientHostName, ApplicationName 은 필수입니다."
});
_logger.LogInformation("인증서 생성 요청: {Host}/{App}", req.ClientHostName, req.ApplicationName);
var result = await _certService.GenerateAsync(req);
if (!result.Success)
return BadRequest(new ApiError { Error = "인증서 생성 실패", Detail = result.Message });
return Ok(result);
}
/// <summary>pki/own/certs/ 폴더의 PFX 파일 목록을 반환합니다.</summary>
[HttpGet("list")]
[ProducesResponseType(typeof(IEnumerable<string>), 200)]
public IActionResult List()
=> Ok(_certService.ListCertificates());
/// <summary>PFX 파일을 다운로드합니다.</summary>
[HttpGet("download/{fileName}")]
public IActionResult Download(string fileName)
{
// 경로 traversal 방지
if (fileName.Contains("..") || fileName.Contains('/') || fileName.Contains('\\'))
return BadRequest(new ApiError { Error = "잘못된 파일 이름" });
string path = Path.Combine("pki", "own", "certs", fileName);
if (!System.IO.File.Exists(path))
return NotFound(new ApiError { Error = "파일 없음", Detail = path });
byte[] bytes = System.IO.File.ReadAllBytes(path);
return File(bytes, "application/x-pkcs12", fileName);
}
}

View File

@@ -0,0 +1,75 @@
using Microsoft.AspNetCore.Mvc;
using OpcUaManager.Models;
using OpcUaManager.Services;
namespace OpcUaManager.Controllers;
[ApiController]
[Route("api/crawler")]
[Produces("application/json")]
public class CrawlerController : ControllerBase
{
private readonly OpcCrawlerService _crawlerSvc;
private readonly OpcSessionService _sessionSvc;
private readonly ILogger<CrawlerController> _logger;
public CrawlerController(
OpcCrawlerService crawlerSvc,
OpcSessionService sessionSvc,
ILogger<CrawlerController> logger)
{
_crawlerSvc = crawlerSvc;
_sessionSvc = sessionSvc;
_logger = logger;
}
/// <summary>
/// 지정한 시작 노드부터 OPC UA 노드 트리를 재귀 탐색합니다.
/// </summary>
/// <remarks>
/// Sample request:
///
/// POST /api/crawler/start
/// {
/// "startNodeId": "ns=1;s=$assetmodel",
/// "maxDepth": 5
/// }
///
/// 탐색 결과는 응답 JSON 과 함께 서버 로컬에 Honeywell_FullMap.csv 로 저장됩니다.
/// </remarks>
[HttpPost("start")]
[ProducesResponseType(typeof(CrawlResult), 200)]
[ProducesResponseType(typeof(ApiError), 400)]
public async Task<IActionResult> Start([FromBody] CrawlRequest req)
{
if (!_sessionSvc.IsConnected)
return BadRequest(new ApiError
{
Error = "세션 없음",
Detail = "먼저 /api/session/connect 로 OPC 서버에 연결하세요."
});
_logger.LogInformation("Crawler 시작: {NodeId} (depth={Depth})",
req.StartNodeId, req.MaxDepth);
// 대규모 탐사는 시간이 오래 걸릴 수 있으므로 타임아웃을 늘려줍니다
HttpContext.RequestAborted.ThrowIfCancellationRequested();
var result = await _crawlerSvc.CrawlAsync(req);
return result.Success ? Ok(result)
: BadRequest(new ApiError { Error = "탐사 실패", Detail = result.Message });
}
/// <summary>마지막 탐사로 생성된 CSV 파일을 다운로드합니다.</summary>
[HttpGet("csv")]
public IActionResult DownloadCsv()
{
string path = Path.GetFullPath("Honeywell_FullMap.csv");
if (!System.IO.File.Exists(path))
return NotFound(new ApiError { Error = "CSV 없음", Detail = "탐사를 먼저 실행하세요." });
byte[] bytes = System.IO.File.ReadAllBytes(path);
return File(bytes, "text/csv", "Honeywell_FullMap.csv");
}
}

View File

@@ -0,0 +1,98 @@
using Microsoft.AspNetCore.Mvc;
using OpcUaManager.Models;
using OpcUaManager.Services;
namespace OpcUaManager.Controllers;
[ApiController]
[Route("api/database")]
[Produces("application/json")]
public class DatabaseController : ControllerBase
{
private readonly DatabaseService _dbSvc;
private readonly ILogger<DatabaseController> _logger;
public DatabaseController(DatabaseService dbSvc, ILogger<DatabaseController> logger)
{
_dbSvc = dbSvc;
_logger = logger;
}
/// <summary>
/// DB 연결을 테스트합니다.
/// </summary>
[HttpPost("test")]
[ProducesResponseType(200)]
[ProducesResponseType(typeof(ApiError), 400)]
public async Task<IActionResult> Test([FromBody] DbWriteRequest req)
{
var (ok, msg) = await _dbSvc.TestConnectionAsync(req);
return ok ? Ok(new { Message = msg })
: BadRequest(new ApiError { Error = "연결 실패", Detail = msg });
}
/// <summary>
/// opc_history 테이블을 없으면 생성합니다.
/// </summary>
[HttpPost("init")]
[ProducesResponseType(200)]
[ProducesResponseType(typeof(ApiError), 400)]
public async Task<IActionResult> Init([FromBody] DbWriteRequest req)
{
var (ok, msg) = await _dbSvc.EnsureTableAsync(req);
return ok ? Ok(new { Message = msg })
: BadRequest(new ApiError { Error = "테이블 초기화 실패", Detail = msg });
}
/// <summary>
/// OPC 태그를 지정 횟수만큼 읽어 DB에 저장합니다 (원본 5회 루프).
/// </summary>
/// <remarks>
/// Sample request:
///
/// POST /api/database/write
/// {
/// "tagNodeId": "ns=1;s=shinam:p-6102.hzset.fieldvalue",
/// "tagName": "p-6102",
/// "count": 5,
/// "intervalMs": 2000,
/// "dbHost": "localhost",
/// "dbName": "opcdb",
/// "dbUser": "postgres",
/// "dbPassword": "postgres"
/// }
/// </remarks>
[HttpPost("write")]
[ProducesResponseType(typeof(DbWriteResult), 200)]
[ProducesResponseType(typeof(ApiError), 400)]
public async Task<IActionResult> Write([FromBody] DbWriteRequest req)
{
if (string.IsNullOrWhiteSpace(req.TagNodeId) || string.IsNullOrWhiteSpace(req.TagName))
return BadRequest(new ApiError
{
Error = "필수값 누락",
Detail = "TagNodeId, TagName 은 필수입니다."
});
req.Count = Math.Clamp(req.Count, 1, 100);
_logger.LogInformation("DB 저장 시작: {Tag} × {Count}회", req.TagName, req.Count);
var result = await _dbSvc.WriteLoopAsync(req);
return result.Success ? Ok(result)
: BadRequest(new ApiError { Error = "저장 실패", Detail = result.Message });
}
/// <summary>
/// opc_history 테이블의 최근 레코드를 조회합니다.
/// </summary>
[HttpPost("query")]
[ProducesResponseType(typeof(DbQueryResult), 200)]
[ProducesResponseType(typeof(ApiError), 400)]
public async Task<IActionResult> Query([FromBody] DbWriteRequest req, [FromQuery] int limit = 100)
{
var result = await _dbSvc.QueryRecentAsync(req, limit);
return result.Success ? Ok(result)
: BadRequest(new ApiError { Error = "조회 실패", Detail = result.Message });
}
}

View File

@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Mvc;
using OpcUaManager.Models;
using OpcUaManager.Services;
namespace OpcUaManager.Controllers;
[ApiController]
[Route("api/session")]
[Produces("application/json")]
public class SessionController : ControllerBase
{
private readonly OpcSessionService _sessionSvc;
private readonly ILogger<SessionController> _logger;
public SessionController(OpcSessionService sessionSvc, ILogger<SessionController> logger)
{
_sessionSvc = sessionSvc;
_logger = logger;
}
/// <summary>
/// OPC UA 서버에 연결합니다.
/// </summary>
/// <remarks>
/// Sample request:
///
/// POST /api/session/connect
/// {
/// "serverIp": "192.168.0.20",
/// "port": 4840,
/// "userName": "mngr",
/// "password": "mngr",
/// "securityPolicy": "Basic256Sha256",
/// "sessionTimeoutMs": 60000,
/// "pfxPath": "pki/own/certs/OpcTestClient.pfx",
/// "pfxPassword": "",
/// "applicationName": "OpcTestClient",
/// "applicationUri": "urn:dbsvr:OpcTestClient"
/// }
/// </remarks>
[HttpPost("connect")]
[ProducesResponseType(typeof(ConnectResult), 200)]
[ProducesResponseType(typeof(ApiError), 400)]
public async Task<IActionResult> Connect([FromBody] ConnectRequest req)
{
if (string.IsNullOrWhiteSpace(req.ServerIp))
return BadRequest(new ApiError { Error = "ServerIp 는 필수입니다." });
_logger.LogInformation("연결 요청: {Ip}:{Port}", req.ServerIp, req.Port);
var result = await _sessionSvc.ConnectAsync(req);
return result.Success ? Ok(result)
: BadRequest(new ApiError { Error = "연결 실패", Detail = result.Message });
}
/// <summary>현재 OPC UA 세션을 종료합니다.</summary>
[HttpPost("disconnect")]
[ProducesResponseType(200)]
public async Task<IActionResult> Disconnect()
{
await _sessionSvc.DisconnectAsync();
return Ok(new { Message = "세션 종료 완료" });
}
/// <summary>현재 세션 상태를 반환합니다.</summary>
[HttpGet("status")]
[ProducesResponseType(200)]
public IActionResult Status()
=> Ok(new
{
IsConnected = _sessionSvc.IsConnected,
SessionId = _sessionSvc.SessionId
});
}

View File

@@ -0,0 +1,172 @@
namespace OpcUaManager.Models;
// ── 인증서 관련 ────────────────────────────────────────────────
/// <summary>프론트엔드에서 전달받는 인증서 생성 요청</summary>
public class CertCreateRequest
{
/// <summary>내 컴퓨터(클라이언트) 호스트명 e.g. "dbsvr"</summary>
public string ClientHostName { get; set; } = string.Empty;
/// <summary>OPC Application 이름 e.g. "OpcTestClient"</summary>
public string ApplicationName { get; set; } = string.Empty;
/// <summary>상대 OPC 서버 호스트명 (SAN DNS) e.g. "opc-server-01"</summary>
public string ServerHostName { get; set; } = string.Empty;
/// <summary>상대 OPC 서버 IP (SAN IP) e.g. "192.168.0.20"</summary>
public string ServerIp { get; set; } = string.Empty;
/// <summary>PFX 비밀번호 (없으면 빈 문자열)</summary>
public string PfxPassword { get; set; } = string.Empty;
/// <summary>인증서 유효 기간(일)</summary>
public int ValidDays { get; set; } = 365;
}
/// <summary>인증서 생성 결과</summary>
public class CertCreateResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
/// <summary>생성된 PFX 파일의 서버 내 절대 경로</summary>
public string PfxPath { get; set; } = string.Empty;
/// <summary>적용된 Application URI</summary>
public string ApplicationUri { get; set; } = string.Empty;
/// <summary>썸프린트(SHA-1 hex)</summary>
public string Thumbprint { get; set; } = string.Empty;
public string SerialNumber { get; set; } = string.Empty;
public string NotBefore { get; set; } = string.Empty;
public string NotAfter { get; set; } = string.Empty;
}
// ── OPC 세션 관련 ────────────────────────────────────────────
public class ConnectRequest
{
public string ServerIp { get; set; } = string.Empty;
public int Port { get; set; } = 4840;
public string UserName { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string SecurityPolicy { get; set; } = "Basic256Sha256";
public int SessionTimeoutMs { get; set; } = 60000;
/// <summary>사용할 PFX 경로 (cert 생성 후 전달)</summary>
public string PfxPath { get; set; } = string.Empty;
public string PfxPassword { get; set; } = string.Empty;
public string ApplicationName { get; set; } = "OpcTestClient";
public string ApplicationUri { get; set; } = string.Empty;
}
public class ConnectResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public string SessionId { get; set; } = string.Empty;
public string EndpointUrl { get; set; } = string.Empty;
public string SecurityMode { get; set; } = string.Empty;
}
// ── Crawler 관련 ─────────────────────────────────────────────
public class CrawlRequest
{
public string StartNodeId { get; set; } = "ns=1;s=$assetmodel";
public int MaxDepth { get; set; } = 5;
}
public class TagMaster
{
public string TagName { get; set; } = string.Empty;
public string FullNodeId { get; set; } = string.Empty;
public string NodeClass { get; set; } = string.Empty;
public string DataType { get; set; } = string.Empty;
public int Level { get; set; }
}
public class CrawlResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public int TotalNodes { get; set; }
public List<TagMaster> Tags { get; set; } = [];
public string CsvPath { get; set; } = string.Empty;
}
// ── Database 관련 ─────────────────────────────────────────────
public class DbWriteRequest
{
/// <summary>읽을 OPC 태그 Node ID</summary>
public string TagNodeId { get; set; } = string.Empty;
/// <summary>DB에 저장할 태그 이름</summary>
public string TagName { get; set; } = string.Empty;
/// <summary>반복 횟수</summary>
public int Count { get; set; } = 5;
/// <summary>읽기 간격 (ms)</summary>
public int IntervalMs { get; set; } = 2000;
// DB 접속 정보
public string DbHost { get; set; } = "localhost";
public string DbName { get; set; } = "opcdb";
public string DbUser { get; set; } = "postgres";
public string DbPassword { get; set; } = "postgres";
}
public class DbWriteRecord
{
public int Seq { get; set; }
public DateTime Timestamp { get; set; }
public string TagName { get; set; } = string.Empty;
public double Value { get; set; }
public string StatusCode { get; set; } = string.Empty;
public bool DbSaved { get; set; }
}
public class DbWriteResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public int SavedCount { get; set; }
public List<DbWriteRecord> Records { get; set; } = [];
}
public class DbQueryResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public int TotalCount { get; set; }
public List<OpcHistoryRow> Rows { get; set; } = [];
}
public class OpcHistoryRow
{
public long Id { get; set; }
public string TagName { get; set; } = string.Empty;
public double TagValue { get; set; }
public string StatusCode { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}
// ── 공통 ─────────────────────────────────────────────────────
public class StatusCodeInfo
{
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;
}
public class ApiError
{
public string Error { get; set; } = string.Empty;
public string Detail { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>OpcUaManager</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.374.70" />
<PackageReference Include="Npgsql" Version="8.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
</Project>

90
opcUaManager/Program.cs Normal file
View File

@@ -0,0 +1,90 @@
using OpcUaManager.Services;
var builder = WebApplication.CreateBuilder(args);
// ── DI 등록 ───────────────────────────────────────────────────────────
builder.Services.AddSingleton<OpcSessionService>();
builder.Services.AddSingleton<CertService>();
builder.Services.AddSingleton<OpcCrawlerService>();
builder.Services.AddSingleton<DatabaseService>();
builder.Services.AddControllers()
.AddJsonOptions(opt =>
{
opt.JsonSerializerOptions.Converters.Add(
new System.Text.Json.Serialization.JsonStringEnumConverter());
opt.JsonSerializerOptions.PropertyNamingPolicy =
System.Text.Json.JsonNamingPolicy.CamelCase;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(opt =>
{
opt.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
{
Title = "OPC UA Manager API",
Version = "v1",
Description = "OPC UA 클라이언트 인증서 생성 · 세션 관리 · 노드 탐색 · DB 저장"
});
});
builder.Services.AddCors(opt =>
{
opt.AddDefaultPolicy(policy =>
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod());
});
// ── 앱 빌드 ───────────────────────────────────────────────────────────
var app = builder.Build();
// ── 정적 파일: wwwroot 경로를 명시적으로 지정 ─────────────────────────
// dotnet run 실행 위치와 무관하게 프로젝트 루트의 wwwroot 를 서빙
var wwwrootPath = Path.Combine(
builder.Environment.ContentRootPath, "wwwroot");
if (!Directory.Exists(wwwrootPath))
{
// dotnet run 이 프로젝트 루트가 아닌 곳에서 실행될 경우 대비
wwwrootPath = Path.Combine(
AppContext.BaseDirectory, "wwwroot");
}
app.Logger.LogInformation("wwwroot 경로: {Path}", wwwrootPath);
app.UseDefaultFiles(new DefaultFilesOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(wwwrootPath),
RequestPath = ""
});
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(wwwrootPath),
RequestPath = ""
});
app.UseSwagger();
app.UseSwaggerUI(opt =>
{
opt.SwaggerEndpoint("/swagger/v1/swagger.json", "OPC UA Manager v1");
opt.RoutePrefix = "swagger";
});
app.UseCors();
app.UseAuthorization();
app.MapControllers();
// ── PKI 디렉토리 초기화 ───────────────────────────────────────────────
foreach (var dir in new[]
{
"pki/own/certs", "pki/trusted/certs",
"pki/issuers/certs", "pki/rejected/certs"
})
Directory.CreateDirectory(dir);
app.Logger.LogInformation("=== OPC UA Manager 시작 ===");
app.Logger.LogInformation("Frontend : http://localhost:5000/");
app.Logger.LogInformation("Swagger : http://localhost:5000/swagger");
app.Run();

99
opcUaManager/README.md Normal file
View File

@@ -0,0 +1,99 @@
# OPC UA Manager
원본 `Program.cs` (단일 파일)를 **ASP.NET Core Web API + 프론트엔드** 프로젝트로 분리한 버전입니다.
## 프로젝트 구조
```
OpcUaManager/
├── OpcUaManager.csproj
├── Program.cs ← ASP.NET Core 진입점, DI 등록
├── appsettings.json
├── init_db.sql ← PostgreSQL 테이블 초기화
├── Models/
│ └── Models.cs ← DTO / 도메인 모델 전체
├── Services/
│ ├── CertService.cs ← X.509 인증서 생성 (pki/ 폴더 체계 유지)
│ ├── OpcSessionService.cs ← OPC UA 세션 관리 (Singleton)
│ ├── OpcCrawlerService.cs ← HoneywellCrawler 로직 → Service 분리
│ └── DatabaseService.cs ← PostgreSQL 저장/조회 (SaveToDatabase 패턴 유지)
├── Controllers/
│ ├── CertController.cs ← POST /api/cert/generate
│ ├── SessionController.cs ← POST /api/session/connect|disconnect
│ ├── CrawlerController.cs ← POST /api/crawler/start
│ └── DatabaseController.cs ← POST /api/database/write|query|test
└── wwwroot/
└── index.html ← 프론트엔드 (단일 HTML, 백엔드 API 연결)
```
## 원본 코드와의 대응 관계
| 원본 코드 위치 | 분리된 위치 |
|---|---|
| `Directory.CreateDirectory("pki/...")` | `CertService.EnsurePkiDirectories()` |
| `new X509Certificate2(pfxPath, ...)` | `CertService.LoadPfx()` |
| `ApplicationConfiguration { ... }` | `OpcSessionService.ConnectAsync()` |
| `DiscoveryClient → Session.Create` | `OpcSessionService.ConnectAsync()` |
| `HoneywellCrawler.BrowseRecursiveAsync` | `OpcCrawlerService.CrawlAsync()` |
| `HoneywellCrawler.SaveToCsv` | `OpcCrawlerService.SaveToCsvAsync()` |
| `SaveToDatabase(tagName, val, status)` | `DatabaseService.SaveToDatabaseAsync()` |
| `for (int i = 0; i < 5; i++) { ... }` | `DatabaseService.WriteLoopAsync()` |
**원본 인증서 체계 유지 항목:**
- `pki/own/certs/`, `pki/trusted/certs/`, `pki/issuers/certs/`, `pki/rejected/certs/` 폴더 구조
- `X509KeyStorageFlags.Exportable | MachineKeySet`
- `AutoAcceptUntrustedCertificates = true`, `AddAppCertToTrustedStore = true`
- OPC UA SAN(Subject Alternative Name) 에 `ApplicationUri` 포함 (필수 요구사항)
## 실행 방법
### 1. PostgreSQL 초기화
```bash
createdb opcdb
psql -U postgres -d opcdb -f init_db.sql
```
### 2. 백엔드 실행
```bash
cd OpcUaManager
dotnet restore
dotnet run
# → http://localhost:5000
# → Swagger: http://localhost:5000/swagger
```
### 3. 프론트엔드 접속
브라우저에서 `http://localhost:5000` 접속
(또는 `wwwroot/index.html` 을 별도 Live Server로 열고 API URL을 `http://localhost:5000` 으로 설정)
## API 엔드포인트 요약
```
POST /api/cert/generate 인증서 생성
GET /api/cert/list PFX 목록 조회
GET /api/cert/download/{name} PFX 다운로드
POST /api/session/connect OPC 연결
POST /api/session/disconnect OPC 연결 해제
GET /api/session/status 세션 상태
POST /api/crawler/start 노드 탐사 시작
GET /api/crawler/csv CSV 다운로드
POST /api/database/test DB 연결 테스트
POST /api/database/init 테이블 초기화
POST /api/database/write OPC 읽기 + DB 저장 (N회)
POST /api/database/query 최근 레코드 조회
```
## 의존 패키지
```xml
OPCFoundation.NetStandard.Opc.Ua.Client 1.5.x
Npgsql 8.0.x
Swashbuckle.AspNetCore 6.6.x
```

View File

@@ -0,0 +1,158 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using OpcUaManager.Models;
namespace OpcUaManager.Services;
/// <summary>
/// OPC UA 클라이언트용 X.509 인증서를 생성하고 PFX로 저장합니다.
/// 원본 Program.cs 의 인증서 체계(pki/ 폴더 구조, X509KeyStorageFlags)를 유지합니다.
/// </summary>
public class CertService
{
private readonly ILogger<CertService> _logger;
private readonly string _pkiRoot;
public CertService(ILogger<CertService> logger, IWebHostEnvironment env)
{
_logger = logger;
_pkiRoot = Path.Combine(env.ContentRootPath, "pki");
}
private void EnsurePkiDirectories()
{
Directory.CreateDirectory(Path.Combine(_pkiRoot, "own", "certs"));
Directory.CreateDirectory(Path.Combine(_pkiRoot, "trusted", "certs"));
Directory.CreateDirectory(Path.Combine(_pkiRoot, "issuers", "certs"));
Directory.CreateDirectory(Path.Combine(_pkiRoot, "rejected", "certs"));
}
public Task<CertCreateResult> GenerateAsync(CertCreateRequest req)
{
try
{
EnsurePkiDirectories();
string appUri = $"urn:{req.ClientHostName}:{req.ApplicationName}";
// ── 1. RSA 키 생성 ────────────────────────────────────────
using var rsa = RSA.Create(2048);
// ── 2. Distinguished Name ──────────────────────────────────
var dn = new X500DistinguishedName(
$"CN={req.ApplicationName}, O={req.ClientHostName}, C=KR");
// ── 3. Certificate Request ─────────────────────────────────
var certReq = new CertificateRequest(
dn, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// ── 4. Extensions ──────────────────────────────────────────
certReq.CertificateExtensions.Add(
new X509BasicConstraintsExtension(false, false, 0, true));
certReq.CertificateExtensions.Add(
new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature |
X509KeyUsageFlags.NonRepudiation |
X509KeyUsageFlags.KeyEncipherment |
X509KeyUsageFlags.DataEncipherment,
critical: true));
certReq.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection
{
new Oid("1.3.6.1.5.5.7.3.2"), // clientAuth
new Oid("1.3.6.1.5.5.7.3.1") // serverAuth
}, false));
certReq.CertificateExtensions.Add(
new X509SubjectKeyIdentifierExtension(certReq.PublicKey, false));
// ── 5. Subject Alternative Names ──────────────────────────
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddUri(new Uri(appUri));
if (!string.IsNullOrWhiteSpace(req.ServerIp) &&
System.Net.IPAddress.TryParse(req.ServerIp, out var ip))
sanBuilder.AddIpAddress(ip);
if (!string.IsNullOrWhiteSpace(req.ServerHostName))
sanBuilder.AddDnsName(req.ServerHostName);
sanBuilder.AddDnsName(req.ClientHostName);
sanBuilder.AddDnsName("localhost");
certReq.CertificateExtensions.Add(sanBuilder.Build());
// ── 6. 자체 서명 인증서 생성 ──────────────────────────────
var notBefore = DateTimeOffset.UtcNow.AddMinutes(-5);
var notAfter = notBefore.AddDays(req.ValidDays);
// FIX: CreateSelfSigned 는 이미 RSA 키가 연결된 X509Certificate2 를 반환함
// → CopyWithPrivateKey() 를 추가로 호출하면
// "The certificate already has an associated private key" 에러 발생
// → CreateSelfSigned 결과를 직접 Export(Pfx) 하면 개인키 포함됨
using var certWithKey = certReq.CreateSelfSigned(notBefore, notAfter);
// ── 7. PFX 내보내기 ───────────────────────────────────────
string pfxPassword = string.IsNullOrEmpty(req.PfxPassword) ? "" : req.PfxPassword;
// .NET 9 이상: X509CertificateLoader 권장이지만 net8 에서는 Export 사용
byte[] pfxBytes = certWithKey.Export(X509ContentType.Pfx, pfxPassword);
string pfxFileName = $"{req.ApplicationName}.pfx";
string pfxPath = Path.Combine(_pkiRoot, "own", "certs", pfxFileName);
File.WriteAllBytes(pfxPath, pfxBytes);
// ── 8. Trusted 폴더에 DER(공개키) 복사 ───────────────────
byte[] derBytes = certWithKey.Export(X509ContentType.Cert);
string derPath = Path.Combine(_pkiRoot, "trusted", "certs",
$"{req.ApplicationName}[{certWithKey.Thumbprint}].der");
File.WriteAllBytes(derPath, derBytes);
_logger.LogInformation(
"인증서 생성 완료 → {PfxPath} | Thumbprint: {Thumbprint}",
pfxPath, certWithKey.Thumbprint);
return Task.FromResult(new CertCreateResult
{
Success = true,
Message = $"인증서 생성 완료: {pfxFileName}",
PfxPath = pfxPath,
ApplicationUri = appUri,
Thumbprint = certWithKey.Thumbprint ?? string.Empty,
SerialNumber = certWithKey.SerialNumber ?? string.Empty,
NotBefore = notBefore.ToString("yyyy-MM-dd HH:mm:ss UTC"),
NotAfter = notAfter.ToString("yyyy-MM-dd HH:mm:ss UTC"),
});
}
catch (Exception ex)
{
_logger.LogError(ex, "인증서 생성 실패");
return Task.FromResult(new CertCreateResult
{
Success = false,
Message = $"인증서 생성 실패: {ex.Message}"
});
}
}
/// <summary>
/// 기존 PFX를 로드합니다 (원본 코드와 동일한 플래그).
/// </summary>
public X509Certificate2 LoadPfx(string pfxPath, string pfxPassword)
{
return new X509Certificate2(
pfxPath,
pfxPassword,
X509KeyStorageFlags.Exportable | X509KeyStorageFlags.MachineKeySet);
}
public IEnumerable<string> ListCertificates()
{
string dir = Path.Combine(_pkiRoot, "own", "certs");
if (!Directory.Exists(dir)) return [];
return Directory.GetFiles(dir, "*.pfx").Select(Path.GetFileName)!;
}
}

View File

@@ -0,0 +1,223 @@
using Npgsql;
using OpcUaManager.Models;
namespace OpcUaManager.Services;
/// <summary>
/// PostgreSQL opc_history 테이블에 대한 읽기/쓰기를 담당합니다.
/// 원본 SaveToDatabase 함수의 INSERT 패턴을 그대로 유지하며,
/// OPC 태그 읽기(OpcSessionService)와 DB 저장을 조율합니다.
/// </summary>
public class DatabaseService
{
private readonly ILogger<DatabaseService> _logger;
private readonly OpcSessionService _sessionSvc;
public DatabaseService(ILogger<DatabaseService> logger, OpcSessionService sessionSvc)
{
_logger = logger;
_sessionSvc = sessionSvc;
}
// ── 연결 문자열 빌더 ──────────────────────────────────────────────
private static string BuildConnString(DbWriteRequest req)
=> $"Host={req.DbHost};Username={req.DbUser};Password={req.DbPassword};Database={req.DbName}";
// ── DB/테이블 초기화 (최초 한 번) ────────────────────────────────
/// <summary>
/// opc_history 테이블이 없으면 자동 생성합니다.
/// 원본 코드에는 없었지만, 프로젝트화 시 처음 실행 환경을 위해 추가합니다.
/// </summary>
public async Task<(bool Ok, string Msg)> EnsureTableAsync(DbWriteRequest req)
{
const string ddl = """
CREATE TABLE IF NOT EXISTS opc_history (
id BIGSERIAL PRIMARY KEY,
tag_name TEXT NOT NULL,
tag_value DOUBLE PRECISION NOT NULL,
status_code TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
""";
try
{
await using var conn = new NpgsqlConnection(BuildConnString(req));
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand(ddl, conn);
await cmd.ExecuteNonQueryAsync();
return (true, "테이블 확인/생성 완료");
}
catch (Exception ex)
{
_logger.LogError(ex, "테이블 초기화 실패");
return (false, ex.Message);
}
}
// ── OPC 읽기 + DB 저장 루프 ──────────────────────────────────────
/// <summary>
/// OPC 노드를 req.Count 회 읽고 각 결과를 DB에 저장합니다.
/// 원본 for 루프 + SaveToDatabase 패턴을 그대로 유지합니다.
/// </summary>
public async Task<DbWriteResult> WriteLoopAsync(DbWriteRequest req)
{
if (!_sessionSvc.IsConnected)
return new DbWriteResult { Success = false, Message = "OPC 세션이 연결되어 있지 않습니다." };
var (tableOk, tableMsg) = await EnsureTableAsync(req);
if (!tableOk)
return new DbWriteResult { Success = false, Message = $"테이블 오류: {tableMsg}" };
var records = new List<DbWriteRecord>();
int saved = 0;
for (int i = 1; i <= req.Count; i++)
{
// ── 1. OPC 태그 읽기 ────────────────────────────────────
double val = 0;
string status = "Unknown";
bool dbSaved = false;
try
{
var (rawVal, sc) = await _sessionSvc.ReadValueAsync(req.TagNodeId);
val = Convert.ToDouble(rawVal);
status = sc;
_logger.LogInformation("[{I}/{N}] {Tag} = {Val} ({Status})",
i, req.Count, req.TagName, val, status);
}
catch (Exception ex)
{
status = $"ReadError: {ex.Message}";
_logger.LogWarning("[{I}/{N}] OPC 읽기 실패: {Msg}", i, req.Count, ex.Message);
}
// ── 2. DB 저장 (원본 SaveToDatabase 로직) ───────────────
try
{
await SaveToDatabaseAsync(req, val, status);
dbSaved = true;
saved++;
_logger.LogInformation("[{I}/{N}] DB 저장 완료.", i, req.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "[{I}/{N}] DB 저장 실패", i, req.Count);
}
records.Add(new DbWriteRecord
{
Seq = i,
Timestamp = DateTime.UtcNow,
TagName = req.TagName,
Value = val,
StatusCode = status,
DbSaved = dbSaved
});
// ── 3. 인터벌 대기 (마지막 회차는 생략) ─────────────────
if (i < req.Count && req.IntervalMs > 0)
await Task.Delay(req.IntervalMs);
}
return new DbWriteResult
{
Success = saved > 0,
Message = $"{saved}/{req.Count}회 저장 완료",
SavedCount = saved,
Records = records
};
}
// ── 원본 SaveToDatabase (INSERT 패턴 동일) ────────────────────────
private async Task SaveToDatabaseAsync(DbWriteRequest req, double val, string status)
{
await using var conn = new NpgsqlConnection(BuildConnString(req));
await conn.OpenAsync();
// 원본 동일 INSERT
const string sql =
"INSERT INTO opc_history (tag_name, tag_value, status_code) " +
"VALUES (@tag, @val, @status)";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tag", req.TagName);
cmd.Parameters.AddWithValue("val", val);
cmd.Parameters.AddWithValue("status", status);
await cmd.ExecuteNonQueryAsync();
}
// ── DB 조회 ───────────────────────────────────────────────────────
public async Task<DbQueryResult> QueryRecentAsync(DbWriteRequest req, int limit = 100)
{
try
{
await using var conn = new NpgsqlConnection(BuildConnString(req));
await conn.OpenAsync();
string sql = $"""
SELECT id, tag_name, tag_value, status_code, created_at
FROM opc_history
ORDER BY id DESC
LIMIT {limit}
""";
await using var cmd = new NpgsqlCommand(sql, conn);
await using var reader = await cmd.ExecuteReaderAsync();
var rows = new List<OpcHistoryRow>();
while (await reader.ReadAsync())
{
rows.Add(new OpcHistoryRow
{
Id = reader.GetInt64(0),
TagName = reader.GetString(1),
TagValue = reader.GetDouble(2),
StatusCode = reader.GetString(3),
CreatedAt = reader.GetDateTime(4)
});
}
// 전체 카운트
await reader.CloseAsync();
await using var cntCmd = new NpgsqlCommand("SELECT COUNT(*) FROM opc_history", conn);
long total = (long)(await cntCmd.ExecuteScalarAsync() ?? 0L);
return new DbQueryResult
{
Success = true,
TotalCount = (int)total,
Rows = rows
};
}
catch (Exception ex)
{
_logger.LogError(ex, "DB 조회 실패");
return new DbQueryResult { Success = false, Message = ex.Message };
}
}
// ── 연결 테스트 ───────────────────────────────────────────────────
public async Task<(bool Ok, string Msg)> TestConnectionAsync(DbWriteRequest req)
{
try
{
await using var conn = new NpgsqlConnection(BuildConnString(req));
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand("SELECT version()", conn);
var ver = await cmd.ExecuteScalarAsync();
return (true, $"연결 성공: {ver}");
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
}

View File

@@ -0,0 +1,161 @@
using Opc.Ua;
using Opc.Ua.Client;
using OpcUaManager.Models;
namespace OpcUaManager.Services;
/// <summary>
/// 원본 HoneywellCrawler 를 Service 로 분리.
/// v1.5.374.70: BrowseNextAsync 가 튜플 반환이 아닌 BrowseNextResponse 객체 반환으로 변경됨.
/// </summary>
public class OpcCrawlerService
{
private readonly ILogger<OpcCrawlerService> _logger;
private readonly OpcSessionService _sessionSvc;
public OpcCrawlerService(ILogger<OpcCrawlerService> logger, OpcSessionService sessionSvc)
{
_logger = logger;
_sessionSvc = sessionSvc;
}
public async Task<CrawlResult> CrawlAsync(CrawlRequest req)
{
if (!_sessionSvc.IsConnected)
return new CrawlResult { Success = false, Message = "세션이 연결되어 있지 않습니다." };
var session = _sessionSvc.GetRawSession();
if (session == null)
return new CrawlResult { Success = false, Message = "Raw 세션을 가져올 수 없습니다." };
var tags = new List<TagMaster>();
try
{
_logger.LogInformation("저인망 탐사 시작: {NodeId}", req.StartNodeId);
NodeId rootNode = NodeId.Parse(req.StartNodeId);
await BrowseRecursiveAsync(session, rootNode, 0, req.MaxDepth, tags);
string csvPath = await SaveToCsvAsync(tags);
_logger.LogInformation("탐사 완료: {Count}개 노드", tags.Count);
return new CrawlResult
{
Success = true,
Message = $"탐사 완료: {tags.Count}개 노드 발견",
TotalNodes = tags.Count,
Tags = tags,
CsvPath = csvPath
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Crawler 실행 오류");
return new CrawlResult { Success = false, Message = ex.Message, Tags = tags };
}
}
private async Task BrowseRecursiveAsync(
Session session, NodeId nodeId, int level, int maxDepth, List<TagMaster> tags)
{
try
{
// 원본 동일: BrowseAsync 객체 방식
BrowseDescription description = new()
{
NodeId = nodeId,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)(NodeClass.Variable | NodeClass.Object),
ResultMask = (uint)BrowseResultMask.All
};
BrowseResponse response = await session.BrowseAsync(
null, null, 0, [description], default);
if (response?.Results == null || response.Results.Count == 0) return;
foreach (var result in response.Results)
{
await ProcessReferencesAsync(session, result.References, level, maxDepth, tags);
byte[] cp = result.ContinuationPoint;
while (cp != null && cp.Length > 0)
{
// FIX CS8130/CS1503: BrowseNextAsync 튜플 방식 → BrowseNextResponse 객체 방식
// v1.5.374.70 에서 반환 타입이 Task<BrowseNextResponse> 로 변경됨
BrowseNextResponse nextResponse = await session.BrowseNextAsync(
null,
false,
new ByteStringCollection { cp },
default);
if (nextResponse?.Results != null && nextResponse.Results.Count > 0)
{
var nextResult = nextResponse.Results[0];
await ProcessReferencesAsync(session, nextResult.References, level, maxDepth, tags);
cp = nextResult.ContinuationPoint;
}
else
{
break;
}
}
}
}
catch (Exception ex)
{
// 원본과 동일: 특정 노드 권한 에러 무시
_logger.LogDebug("노드 탐색 건너뜀 [{Level}] {NodeId}: {Msg}", level, nodeId, ex.Message);
}
}
private async Task ProcessReferencesAsync(
Session session,
ReferenceDescriptionCollection? references,
int level,
int maxDepth,
List<TagMaster> tags)
{
if (references == null || references.Count == 0) return;
foreach (var rd in references)
{
NodeId childId = ExpandedNodeId.ToNodeId(rd.NodeId, session.NamespaceUris);
tags.Add(new TagMaster
{
TagName = rd.BrowseName.Name ?? "Unknown",
FullNodeId = childId.ToString(),
NodeClass = rd.NodeClass.ToString(),
Level = level
});
_logger.LogDebug("{Indent}[{Class}] {Name} ({Id})",
new string(' ', level * 2), rd.NodeClass, rd.BrowseName.Name, childId);
if (rd.NodeClass == NodeClass.Object && level < maxDepth)
await BrowseRecursiveAsync(session, childId, level + 1, maxDepth, tags);
}
}
private async Task<string> SaveToCsvAsync(List<TagMaster> tags)
{
string path = Path.GetFullPath("Honeywell_FullMap.csv");
try
{
await using var sw = new StreamWriter(path);
await sw.WriteLineAsync("Level,Class,Name,NodeId");
foreach (var tag in tags)
await sw.WriteLineAsync($"{tag.Level},{tag.NodeClass},{tag.TagName},{tag.FullNodeId}");
_logger.LogInformation("CSV 저장 완료: {Path}", path);
}
catch (Exception ex)
{
_logger.LogError(ex, "CSV 저장 실패");
}
return path;
}
}

View File

@@ -0,0 +1,180 @@
using System.Text;
using Opc.Ua;
using Opc.Ua.Client;
using OpcUaManager.Models;
// Microsoft.AspNetCore.Http.ISession 과 Opc.Ua.Client.ISession 충돌 해소
using OpcUaISession = Opc.Ua.Client.ISession;
// Microsoft.AspNetCore.Http.StatusCodes 와 Opc.Ua.StatusCodes 충돌 해소
using OpcStatusCodes = Opc.Ua.StatusCodes;
namespace OpcUaManager.Services;
/// <summary>
/// OPC UA 세션을 싱글톤으로 관리합니다.
/// v1.5.374.70 API 기준으로 수정되었습니다.
/// </summary>
public class OpcSessionService : IAsyncDisposable
{
private readonly ILogger<OpcSessionService> _logger;
private readonly CertService _certService;
private OpcUaISession? _session;
private ApplicationConfiguration? _appConfig;
private readonly SemaphoreSlim _lock = new(1, 1);
public bool IsConnected => _session?.Connected == true;
public string SessionId => _session?.SessionId?.ToString() ?? string.Empty;
public OpcSessionService(ILogger<OpcSessionService> logger, CertService certService)
{
_logger = logger;
_certService = certService;
}
public async Task<ConnectResult> ConnectAsync(ConnectRequest req)
{
await _lock.WaitAsync();
try
{
if (IsConnected)
await InternalCloseAsync();
string endpointUrl = $"opc.tcp://{req.ServerIp}:{req.Port}";
// ── 1. 클라이언트 인증서 로드 ────────────────────────────
System.Security.Cryptography.X509Certificates.X509Certificate2? clientCert = null;
if (!string.IsNullOrWhiteSpace(req.PfxPath) && File.Exists(req.PfxPath))
clientCert = _certService.LoadPfx(req.PfxPath, req.PfxPassword);
string appUri = string.IsNullOrWhiteSpace(req.ApplicationUri)
? $"urn:{System.Net.Dns.GetHostName()}:{req.ApplicationName}"
: req.ApplicationUri;
// ── 2. ApplicationConfiguration ───────────────────────────
_appConfig = new ApplicationConfiguration
{
ApplicationName = req.ApplicationName,
ApplicationType = ApplicationType.Client,
ApplicationUri = appUri,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = clientCert != null
? new CertificateIdentifier { Certificate = clientCert }
: new CertificateIdentifier(),
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 = 15000 },
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = req.SessionTimeoutMs }
};
// FIX CS1061: ValidateAsync → Validate (v1.5.374.70에서 동기 메서드)
_appConfig.Validate(ApplicationType.Client);
// FIX CS0104: StatusCodes → OpcStatusCodes 별칭 사용
_appConfig.CertificateValidator.CertificateValidation += (_, e) =>
{
if (e.Error.StatusCode != OpcStatusCodes.Good) e.Accept = true;
};
// ── 3. Endpoint Discovery ─────────────────────────────────
_logger.LogInformation("Endpoint Discovery: {Url}", endpointUrl);
var endpointConfig = EndpointConfiguration.Create(_appConfig);
// FIX CS0117: DiscoveryClient.CreateAsync 없음 → 동기 Create 사용
EndpointDescriptionCollection endpoints;
using (var discovery = DiscoveryClient.Create(new Uri(endpointUrl), endpointConfig))
{
endpoints = discovery.GetEndpoints(null);
}
var selected = endpoints
.OrderByDescending(e => e.SecurityLevel)
.FirstOrDefault(e => e.SecurityPolicyUri.Contains(req.SecurityPolicy))
?? endpoints.OrderByDescending(e => e.SecurityLevel).First();
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
// FIX CS1503: UserIdentity(user, byte[]) → UserIdentity(user, string)
// v1.5.374.70 에서 password 파라미터가 string 으로 변경됨
var identity = new UserIdentity(req.UserName, req.Password);
// ── 5. Session.Create ─────────────────────────────────────
#pragma warning disable CS0618
_session = await Session.Create(
_appConfig,
endpoint,
false,
"OpcUaManagerSession",
(uint)req.SessionTimeoutMs,
identity,
null);
#pragma warning restore CS0618
string secMode = selected.SecurityMode.ToString();
_logger.LogInformation("OPC UA 연결 완료 | SessionId={Id} | Security={Sec}",
_session.SessionId, secMode);
return new ConnectResult
{
Success = true,
Message = "연결 성공",
SessionId = _session.SessionId.ToString(),
EndpointUrl = endpointUrl,
SecurityMode = secMode
};
}
catch (Exception ex)
{
_logger.LogError(ex, "OPC UA 연결 실패");
return new ConnectResult { Success = false, Message = ex.Message };
}
finally
{
_lock.Release();
}
}
public async Task<bool> DisconnectAsync()
{
await _lock.WaitAsync();
try { await InternalCloseAsync(); return true; }
finally { _lock.Release(); }
}
private async Task InternalCloseAsync()
{
if (_session != null)
{
try { await _session.CloseAsync(); } catch { /* ignore */ }
_session.Dispose();
_session = null;
}
}
public async Task<(object? Value, string StatusCode)> ReadValueAsync(string nodeId)
{
if (!IsConnected || _session == null)
throw new InvalidOperationException("세션이 연결되어 있지 않습니다.");
var result = await _session.ReadValueAsync(nodeId);
return (result.Value, result.StatusCode.ToString());
}
public Session? GetRawSession() => _session as Session;
public async ValueTask DisposeAsync()
{
await InternalCloseAsync();
_lock.Dispose();
GC.SuppressFinalize(this);
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,266 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v8.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v8.0": {
"OpcUaManager/1.0.0": {
"dependencies": {
"Npgsql": "8.0.3",
"OPCFoundation.NetStandard.Opc.Ua.Client": "1.5.374.70",
"Swashbuckle.AspNetCore": "6.6.2"
},
"runtime": {
"OpcUaManager.dll": {}
}
},
"Microsoft.Extensions.ApiDescription.Server/6.0.5": {},
"Microsoft.Extensions.DependencyInjection.Abstractions/8.0.0": {},
"Microsoft.Extensions.Logging.Abstractions/8.0.0": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
}
},
"Microsoft.OpenApi/1.6.14": {
"runtime": {
"lib/netstandard2.0/Microsoft.OpenApi.dll": {
"assemblyVersion": "1.6.14.0",
"fileVersion": "1.6.14.0"
}
}
},
"Newtonsoft.Json/13.0.3": {
"runtime": {
"lib/net6.0/Newtonsoft.Json.dll": {
"assemblyVersion": "13.0.0.0",
"fileVersion": "13.0.3.27908"
}
}
},
"Npgsql/8.0.3": {
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
},
"runtime": {
"lib/net8.0/Npgsql.dll": {
"assemblyVersion": "8.0.3.0",
"fileVersion": "8.0.3.0"
}
}
},
"OPCFoundation.NetStandard.Opc.Ua.Client/1.5.374.70": {
"dependencies": {
"OPCFoundation.NetStandard.Opc.Ua.Configuration": "1.5.374.70",
"OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.374.70"
},
"runtime": {
"lib/net8.0/Opc.Ua.Client.dll": {
"assemblyVersion": "1.5.374.0",
"fileVersion": "1.5.374.70"
}
}
},
"OPCFoundation.NetStandard.Opc.Ua.Configuration/1.5.374.70": {
"dependencies": {
"OPCFoundation.NetStandard.Opc.Ua.Core": "1.5.374.70"
},
"runtime": {
"lib/net8.0/Opc.Ua.Configuration.dll": {
"assemblyVersion": "1.5.374.0",
"fileVersion": "1.5.374.70"
}
}
},
"OPCFoundation.NetStandard.Opc.Ua.Core/1.5.374.70": {
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Newtonsoft.Json": "13.0.3",
"OPCFoundation.NetStandard.Opc.Ua.Security.Certificates": "1.5.374.70"
},
"runtime": {
"lib/net8.0/Opc.Ua.Core.dll": {
"assemblyVersion": "1.5.374.0",
"fileVersion": "1.5.374.70"
}
}
},
"OPCFoundation.NetStandard.Opc.Ua.Security.Certificates/1.5.374.70": {
"dependencies": {
"System.Formats.Asn1": "8.0.0",
"System.Security.Cryptography.Cng": "5.0.0"
},
"runtime": {
"lib/net8.0/Opc.Ua.Security.Certificates.dll": {
"assemblyVersion": "1.5.374.0",
"fileVersion": "1.5.374.70"
}
}
},
"Swashbuckle.AspNetCore/6.6.2": {
"dependencies": {
"Microsoft.Extensions.ApiDescription.Server": "6.0.5",
"Swashbuckle.AspNetCore.Swagger": "6.6.2",
"Swashbuckle.AspNetCore.SwaggerGen": "6.6.2",
"Swashbuckle.AspNetCore.SwaggerUI": "6.6.2"
}
},
"Swashbuckle.AspNetCore.Swagger/6.6.2": {
"dependencies": {
"Microsoft.OpenApi": "1.6.14"
},
"runtime": {
"lib/net8.0/Swashbuckle.AspNetCore.Swagger.dll": {
"assemblyVersion": "6.6.2.0",
"fileVersion": "6.6.2.401"
}
}
},
"Swashbuckle.AspNetCore.SwaggerGen/6.6.2": {
"dependencies": {
"Swashbuckle.AspNetCore.Swagger": "6.6.2"
},
"runtime": {
"lib/net8.0/Swashbuckle.AspNetCore.SwaggerGen.dll": {
"assemblyVersion": "6.6.2.0",
"fileVersion": "6.6.2.401"
}
}
},
"Swashbuckle.AspNetCore.SwaggerUI/6.6.2": {
"runtime": {
"lib/net8.0/Swashbuckle.AspNetCore.SwaggerUI.dll": {
"assemblyVersion": "6.6.2.0",
"fileVersion": "6.6.2.401"
}
}
},
"System.Formats.Asn1/8.0.0": {},
"System.Security.Cryptography.Cng/5.0.0": {
"dependencies": {
"System.Formats.Asn1": "8.0.0"
}
}
}
},
"libraries": {
"OpcUaManager/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Microsoft.Extensions.ApiDescription.Server/6.0.5": {
"type": "package",
"serviceable": true,
"sha512": "sha512-Ckb5EDBUNJdFWyajfXzUIMRkhf52fHZOQuuZg/oiu8y7zDCVwD0iHhew6MnThjHmevanpxL3f5ci2TtHQEN6bw==",
"path": "microsoft.extensions.apidescription.server/6.0.5",
"hashPath": "microsoft.extensions.apidescription.server.6.0.5.nupkg.sha512"
},
"Microsoft.Extensions.DependencyInjection.Abstractions/8.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==",
"path": "microsoft.extensions.dependencyinjection.abstractions/8.0.0",
"hashPath": "microsoft.extensions.dependencyinjection.abstractions.8.0.0.nupkg.sha512"
},
"Microsoft.Extensions.Logging.Abstractions/8.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==",
"path": "microsoft.extensions.logging.abstractions/8.0.0",
"hashPath": "microsoft.extensions.logging.abstractions.8.0.0.nupkg.sha512"
},
"Microsoft.OpenApi/1.6.14": {
"type": "package",
"serviceable": true,
"sha512": "sha512-tTaBT8qjk3xINfESyOPE2rIellPvB7qpVqiWiyA/lACVvz+xOGiXhFUfohcx82NLbi5avzLW0lx+s6oAqQijfw==",
"path": "microsoft.openapi/1.6.14",
"hashPath": "microsoft.openapi.1.6.14.nupkg.sha512"
},
"Newtonsoft.Json/13.0.3": {
"type": "package",
"serviceable": true,
"sha512": "sha512-HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==",
"path": "newtonsoft.json/13.0.3",
"hashPath": "newtonsoft.json.13.0.3.nupkg.sha512"
},
"Npgsql/8.0.3": {
"type": "package",
"serviceable": true,
"sha512": "sha512-6WEmzsQJCZAlUG1pThKg/RmeF6V+I0DmBBBE/8YzpRtEzhyZzKcK7ulMANDm5CkxrALBEC8H+5plxHWtIL7xnA==",
"path": "npgsql/8.0.3",
"hashPath": "npgsql.8.0.3.nupkg.sha512"
},
"OPCFoundation.NetStandard.Opc.Ua.Client/1.5.374.70": {
"type": "package",
"serviceable": true,
"sha512": "sha512-TiguVbV6kANLKqV8KUPzf89re/ng53ebq3xaLUJJTfg0NAYxZTmUGYGAULR8c/C9aVi6xM5MGAuq+ggf1jEl+A==",
"path": "opcfoundation.netstandard.opc.ua.client/1.5.374.70",
"hashPath": "opcfoundation.netstandard.opc.ua.client.1.5.374.70.nupkg.sha512"
},
"OPCFoundation.NetStandard.Opc.Ua.Configuration/1.5.374.70": {
"type": "package",
"serviceable": true,
"sha512": "sha512-Qai1Ieo+laEO6ARW/YNuAcS1W34KJYqcbg4gUEYMBvHUNvl1ACgp8lYbXkj34vJ9BlqHlT9nYM0W6ioynUbaAw==",
"path": "opcfoundation.netstandard.opc.ua.configuration/1.5.374.70",
"hashPath": "opcfoundation.netstandard.opc.ua.configuration.1.5.374.70.nupkg.sha512"
},
"OPCFoundation.NetStandard.Opc.Ua.Core/1.5.374.70": {
"type": "package",
"serviceable": true,
"sha512": "sha512-zOrqHMkHm5Pf9oFbRgscr5smU0Jx54l3ARGJcBQugbb2r+O53PPR8C/y1a+8FtI4Lx/feDB2qgjwZ8xqzSfuXQ==",
"path": "opcfoundation.netstandard.opc.ua.core/1.5.374.70",
"hashPath": "opcfoundation.netstandard.opc.ua.core.1.5.374.70.nupkg.sha512"
},
"OPCFoundation.NetStandard.Opc.Ua.Security.Certificates/1.5.374.70": {
"type": "package",
"serviceable": true,
"sha512": "sha512-hBCW2MAOX+BJmzVOekOCBLiSYNGz5hDJM/JEvGyaCqJ4dTv3D5/Fgi4y9NgquuY2pOd9bd9mOWjGgdW9ZQdJOg==",
"path": "opcfoundation.netstandard.opc.ua.security.certificates/1.5.374.70",
"hashPath": "opcfoundation.netstandard.opc.ua.security.certificates.1.5.374.70.nupkg.sha512"
},
"Swashbuckle.AspNetCore/6.6.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-+NB4UYVYN6AhDSjW0IJAd1AGD8V33gemFNLPaxKTtPkHB+HaKAKf9MGAEUPivEWvqeQfcKIw8lJaHq6LHljRuw==",
"path": "swashbuckle.aspnetcore/6.6.2",
"hashPath": "swashbuckle.aspnetcore.6.6.2.nupkg.sha512"
},
"Swashbuckle.AspNetCore.Swagger/6.6.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-ovgPTSYX83UrQUWiS5vzDcJ8TEX1MAxBgDFMK45rC24MorHEPQlZAHlaXj/yth4Zf6xcktpUgTEBvffRQVwDKA==",
"path": "swashbuckle.aspnetcore.swagger/6.6.2",
"hashPath": "swashbuckle.aspnetcore.swagger.6.6.2.nupkg.sha512"
},
"Swashbuckle.AspNetCore.SwaggerGen/6.6.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-zv4ikn4AT1VYuOsDCpktLq4QDq08e7Utzbir86M5/ZkRaLXbCPF11E1/vTmOiDzRTl0zTZINQU2qLKwTcHgfrA==",
"path": "swashbuckle.aspnetcore.swaggergen/6.6.2",
"hashPath": "swashbuckle.aspnetcore.swaggergen.6.6.2.nupkg.sha512"
},
"Swashbuckle.AspNetCore.SwaggerUI/6.6.2": {
"type": "package",
"serviceable": true,
"sha512": "sha512-mBBb+/8Hm2Q3Wygag+hu2jj69tZW5psuv0vMRXY07Wy+Rrj40vRP8ZTbKBhs91r45/HXT4aY4z0iSBYx1h6JvA==",
"path": "swashbuckle.aspnetcore.swaggerui/6.6.2",
"hashPath": "swashbuckle.aspnetcore.swaggerui.6.6.2.nupkg.sha512"
},
"System.Formats.Asn1/8.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-AJukBuLoe3QeAF+mfaRKQb2dgyrvt340iMBHYv+VdBzCUM06IxGlvl0o/uPOS7lHnXPN6u8fFRHSHudx5aTi8w==",
"path": "system.formats.asn1/8.0.0",
"hashPath": "system.formats.asn1.8.0.0.nupkg.sha512"
},
"System.Security.Cryptography.Cng/5.0.0": {
"type": "package",
"serviceable": true,
"sha512": "sha512-jIMXsKn94T9JY7PvPq/tMfqa6GAaHpElRDpmG+SuL+D3+sTw2M8VhnibKnN8Tq+4JqbPJ/f+BwtLeDMEnzAvRg==",
"path": "system.security.cryptography.cng/5.0.0",
"hashPath": "system.security.cryptography.cng.5.0.0.nupkg.sha512"
}
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,19 @@
{
"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.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

View File

@@ -0,0 +1 @@
{"ContentRoots":["/home/pacer/projects/opcUaManager/wwwroot/"],"Root":{"Children":{"index.html":{"Children":null,"Asset":{"ContentRootIndex":0,"SubPath":"index.html"},"Patterns":null}},"Asset":null,"Patterns":[{"ContentRootIndex":0,"Pattern":"**","Depth":0}]}}

23
opcUaManager/init_db.sql Normal file
View File

@@ -0,0 +1,23 @@
-- ============================================================
-- OPC UA Manager — PostgreSQL 초기화 스크립트
-- 실행: psql -U postgres -d opcdb -f init_db.sql
-- ============================================================
-- 데이터베이스 생성 (psql 에서 직접 실행 시)
-- CREATE DATABASE opcdb;
-- opc_history 테이블
CREATE TABLE IF NOT EXISTS opc_history (
id BIGSERIAL PRIMARY KEY,
tag_name TEXT NOT NULL,
tag_value DOUBLE PRECISION NOT NULL,
status_code TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 인덱스 (태그명 + 시간 기준 조회 최적화)
CREATE INDEX IF NOT EXISTS idx_opc_history_tag_name ON opc_history (tag_name);
CREATE INDEX IF NOT EXISTS idx_opc_history_created_at ON opc_history (created_at DESC);
-- 확인
SELECT 'opc_history 테이블 준비 완료' AS result;

View File

@@ -0,0 +1,4 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")]

View File

@@ -0,0 +1,22 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("OpcUaManager")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+4ea351946aa5ffb76c8c693b768cb7f460f0cb79")]
[assembly: System.Reflection.AssemblyProductAttribute("OpcUaManager")]
[assembly: System.Reflection.AssemblyTitleAttribute("OpcUaManager")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// Generated by the MSBuild WriteCodeFragment class.

View File

@@ -0,0 +1 @@
4d3435e515b1133eaf4a3bc25612c8110ded80ed47219a5a2561401d2d703539

View File

@@ -0,0 +1,19 @@
is_global = true
build_property.TargetFramework = net8.0
build_property.TargetPlatformMinVersion =
build_property.UsingMicrosoftNETSdkWeb = true
build_property.ProjectTypeGuids =
build_property.InvariantGlobalization =
build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = OpcUaManager
build_property.RootNamespace = OpcUaManager
build_property.ProjectDir = /home/pacer/projects/opcUaManager/
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.RazorLangVersion = 8.0
build_property.SupportLocalizedComponentNames =
build_property.GenerateRazorMetadataSourceChecksumAttributes =
build_property.MSBuildProjectDirectory = /home/pacer/projects/opcUaManager
build_property._RazorSourceGeneratorDebug =

View File

@@ -0,0 +1,17 @@
// <auto-generated/>
global using global::Microsoft.AspNetCore.Builder;
global using global::Microsoft.AspNetCore.Hosting;
global using global::Microsoft.AspNetCore.Http;
global using global::Microsoft.AspNetCore.Routing;
global using global::Microsoft.Extensions.Configuration;
global using global::Microsoft.Extensions.DependencyInjection;
global using global::Microsoft.Extensions.Hosting;
global using global::Microsoft.Extensions.Logging;
global using global::System;
global using global::System.Collections.Generic;
global using global::System.IO;
global using global::System.Linq;
global using global::System.Net.Http;
global using global::System.Net.Http.Json;
global using global::System.Threading;
global using global::System.Threading.Tasks;

View File

@@ -0,0 +1,16 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: Microsoft.AspNetCore.Mvc.ApplicationParts.ApplicationPartAttribute("Swashbuckle.AspNetCore.SwaggerGen")]
// Generated by the MSBuild WriteCodeFragment class.

View File

@@ -0,0 +1 @@
a38ac13601df264e3305daaab2701d061db79abfe74d745f8cb1549021254d3b

View File

@@ -0,0 +1,37 @@
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/OpcUaManager.csproj.AssemblyReference.cache
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/OpcUaManager.GeneratedMSBuildEditorConfig.editorconfig
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/OpcUaManager.AssemblyInfoInputs.cache
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/OpcUaManager.AssemblyInfo.cs
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/OpcUaManager.csproj.CoreCompileInputs.cache
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/OpcUaManager.MvcApplicationPartsAssemblyInfo.cs
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/OpcUaManager.MvcApplicationPartsAssemblyInfo.cache
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/OpcUaManager.staticwebassets.runtime.json
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/OpcUaManager
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/OpcUaManager.deps.json
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/OpcUaManager.runtimeconfig.json
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/OpcUaManager.dll
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/OpcUaManager.pdb
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/Microsoft.OpenApi.dll
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/Newtonsoft.Json.dll
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/Npgsql.dll
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/Opc.Ua.Client.dll
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/Opc.Ua.Configuration.dll
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/Opc.Ua.Core.dll
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/Opc.Ua.Security.Certificates.dll
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/Swashbuckle.AspNetCore.Swagger.dll
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/Swashbuckle.AspNetCore.SwaggerGen.dll
/home/pacer/projects/opcUaManager/bin/Debug/net8.0/Swashbuckle.AspNetCore.SwaggerUI.dll
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/staticwebassets.build.json
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/staticwebassets.development.json
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/staticwebassets/msbuild.OpcUaManager.Microsoft.AspNetCore.StaticWebAssets.props
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/staticwebassets/msbuild.build.OpcUaManager.props
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/staticwebassets/msbuild.buildMultiTargeting.OpcUaManager.props
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/staticwebassets/msbuild.buildTransitive.OpcUaManager.props
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/staticwebassets.pack.json
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/scopedcss/bundle/OpcUaManager.styles.css
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/OpcUaManager.csproj.CopyComplete
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/OpcUaManager.dll
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/refint/OpcUaManager.dll
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/OpcUaManager.pdb
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/OpcUaManager.genruntimeconfig.cache
/home/pacer/projects/opcUaManager/obj/Debug/net8.0/ref/OpcUaManager.dll

Binary file not shown.

View File

@@ -0,0 +1 @@
c2d6000231adf119ed097595a4eae7c659e8a4df56c6f9e58afe708f9abd8740

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,39 @@
{
"Version": 1,
"Hash": "s4uFx+nwU80AcCRA99ifmwH5T5X85dmfqujG/UwPRww=",
"Source": "OpcUaManager",
"BasePath": "_content/OpcUaManager",
"Mode": "Default",
"ManifestType": "Build",
"ReferencedProjectsConfiguration": [],
"DiscoveryPatterns": [
{
"Name": "OpcUaManager/wwwroot",
"Source": "OpcUaManager",
"ContentRoot": "/home/pacer/projects/opcUaManager/wwwroot/",
"BasePath": "_content/OpcUaManager",
"Pattern": "**"
}
],
"Assets": [
{
"Identity": "/home/pacer/projects/opcUaManager/wwwroot/index.html",
"SourceId": "OpcUaManager",
"SourceType": "Discovered",
"ContentRoot": "/home/pacer/projects/opcUaManager/wwwroot/",
"BasePath": "_content/OpcUaManager",
"RelativePath": "index.html",
"AssetKind": "All",
"AssetMode": "All",
"AssetRole": "Primary",
"AssetMergeBehavior": "PreferTarget",
"AssetMergeSource": "",
"RelatedAsset": "",
"AssetTraitName": "",
"AssetTraitValue": "",
"CopyToOutputDirectory": "Never",
"CopyToPublishDirectory": "PreserveNewest",
"OriginalItemSpec": "wwwroot/index.html"
}
]
}

View File

@@ -0,0 +1 @@
{"ContentRoots":["/home/pacer/projects/opcUaManager/wwwroot/"],"Root":{"Children":{"index.html":{"Children":null,"Asset":{"ContentRootIndex":0,"SubPath":"index.html"},"Patterns":null}},"Asset":null,"Patterns":[{"ContentRootIndex":0,"Pattern":"**","Depth":0}]}}

View File

@@ -0,0 +1,25 @@
{
"Files": [
{
"Id": "/home/pacer/projects/opcUaManager/wwwroot/index.html",
"PackagePath": "staticwebassets/index.html"
},
{
"Id": "obj/Debug/net8.0/staticwebassets/msbuild.OpcUaManager.Microsoft.AspNetCore.StaticWebAssets.props",
"PackagePath": "build\\Microsoft.AspNetCore.StaticWebAssets.props"
},
{
"Id": "obj/Debug/net8.0/staticwebassets/msbuild.build.OpcUaManager.props",
"PackagePath": "build\\OpcUaManager.props"
},
{
"Id": "obj/Debug/net8.0/staticwebassets/msbuild.buildMultiTargeting.OpcUaManager.props",
"PackagePath": "buildMultiTargeting\\OpcUaManager.props"
},
{
"Id": "obj/Debug/net8.0/staticwebassets/msbuild.buildTransitive.OpcUaManager.props",
"PackagePath": "buildTransitive\\OpcUaManager.props"
}
],
"ElementsToRemove": []
}

View File

@@ -0,0 +1,20 @@
<Project>
<ItemGroup>
<StaticWebAsset Include="$([System.IO.Path]::GetFullPath($(MSBuildThisFileDirectory)..\staticwebassets\index.html))">
<SourceType>Package</SourceType>
<SourceId>OpcUaManager</SourceId>
<ContentRoot>$(MSBuildThisFileDirectory)..\staticwebassets\</ContentRoot>
<BasePath>_content/OpcUaManager</BasePath>
<RelativePath>index.html</RelativePath>
<AssetKind>All</AssetKind>
<AssetMode>All</AssetMode>
<AssetRole>Primary</AssetRole>
<RelatedAsset></RelatedAsset>
<AssetTraitName></AssetTraitName>
<AssetTraitValue></AssetTraitValue>
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<OriginalItemSpec>$([System.IO.Path]::GetFullPath($(MSBuildThisFileDirectory)..\staticwebassets\index.html))</OriginalItemSpec>
</StaticWebAsset>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,3 @@
<Project>
<Import Project="Microsoft.AspNetCore.StaticWebAssets.props" />
</Project>

View File

@@ -0,0 +1,3 @@
<Project>
<Import Project="../build/OpcUaManager.props" />
</Project>

View File

@@ -0,0 +1,3 @@
<Project>
<Import Project="../buildMultiTargeting/OpcUaManager.props" />
</Project>

View File

@@ -0,0 +1,78 @@
{
"format": 1,
"restore": {
"/home/pacer/projects/opcUaManager/OpcUaManager.csproj": {}
},
"projects": {
"/home/pacer/projects/opcUaManager/OpcUaManager.csproj": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "/home/pacer/projects/opcUaManager/OpcUaManager.csproj",
"projectName": "OpcUaManager",
"projectPath": "/home/pacer/projects/opcUaManager/OpcUaManager.csproj",
"packagesPath": "/home/pacer/.nuget/packages/",
"outputPath": "/home/pacer/projects/opcUaManager/obj/",
"projectStyle": "PackageReference",
"configFilePaths": [
"/home/pacer/.nuget/NuGet/NuGet.Config"
],
"originalTargetFrameworks": [
"net8.0"
],
"sources": {
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
"net8.0": {
"targetAlias": "net8.0",
"projectReferences": {}
}
},
"warningProperties": {
"warnAsError": [
"NU1605"
]
}
},
"frameworks": {
"net8.0": {
"targetAlias": "net8.0",
"dependencies": {
"Npgsql": {
"target": "Package",
"version": "[8.0.3, )"
},
"OPCFoundation.NetStandard.Opc.Ua.Client": {
"target": "Package",
"version": "[1.5.374.70, )"
},
"Swashbuckle.AspNetCore": {
"target": "Package",
"version": "[6.6.2, )"
}
},
"imports": [
"net461",
"net462",
"net47",
"net471",
"net472",
"net48",
"net481"
],
"assetTargetFallback": true,
"warn": true,
"frameworkReferences": {
"Microsoft.AspNetCore.App": {
"privateAssets": "none"
},
"Microsoft.NETCore.App": {
"privateAssets": "all"
}
},
"runtimeIdentifierGraphPath": "/usr/lib/dotnet/sdk/8.0.124/PortableRuntimeIdentifierGraph.json"
}
}
}
}
}

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">/home/pacer/.nuget/packages/</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">/home/pacer/.nuget/packages/</NuGetPackageFolders>
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">6.8.1</NuGetToolVersion>
</PropertyGroup>
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<SourceRoot Include="/home/pacer/.nuget/packages/" />
</ItemGroup>
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<Import Project="$(NuGetPackageRoot)microsoft.extensions.apidescription.server/6.0.5/build/Microsoft.Extensions.ApiDescription.Server.props" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.apidescription.server/6.0.5/build/Microsoft.Extensions.ApiDescription.Server.props')" />
<Import Project="$(NuGetPackageRoot)swashbuckle.aspnetcore/6.6.2/build/Swashbuckle.AspNetCore.props" Condition="Exists('$(NuGetPackageRoot)swashbuckle.aspnetcore/6.6.2/build/Swashbuckle.AspNetCore.props')" />
</ImportGroup>
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<PkgMicrosoft_Extensions_ApiDescription_Server Condition=" '$(PkgMicrosoft_Extensions_ApiDescription_Server)' == '' ">/home/pacer/.nuget/packages/microsoft.extensions.apidescription.server/6.0.5</PkgMicrosoft_Extensions_ApiDescription_Server>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<Import Project="$(NuGetPackageRoot)microsoft.extensions.apidescription.server/6.0.5/build/Microsoft.Extensions.ApiDescription.Server.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.apidescription.server/6.0.5/build/Microsoft.Extensions.ApiDescription.Server.targets')" />
<Import Project="$(NuGetPackageRoot)microsoft.extensions.logging.abstractions/8.0.0/buildTransitive/net6.0/Microsoft.Extensions.Logging.Abstractions.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.extensions.logging.abstractions/8.0.0/buildTransitive/net6.0/Microsoft.Extensions.Logging.Abstractions.targets')" />
</ImportGroup>
</Project>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
{
"version": 2,
"dgSpecHash": "Ga9UHoPQpUkv8jxodDA8vo11T3mprY66/yGczOjljIyTq1sa8UveU6sxFTXdPSSJTx3ClCJ8exYfTtrnZyfYww==",
"success": true,
"projectFilePath": "/home/pacer/projects/opcUaManager/OpcUaManager.csproj",
"expectedPackageFiles": [
"/home/pacer/.nuget/packages/microsoft.extensions.apidescription.server/6.0.5/microsoft.extensions.apidescription.server.6.0.5.nupkg.sha512",
"/home/pacer/.nuget/packages/microsoft.extensions.dependencyinjection.abstractions/8.0.0/microsoft.extensions.dependencyinjection.abstractions.8.0.0.nupkg.sha512",
"/home/pacer/.nuget/packages/microsoft.extensions.logging.abstractions/8.0.0/microsoft.extensions.logging.abstractions.8.0.0.nupkg.sha512",
"/home/pacer/.nuget/packages/microsoft.openapi/1.6.14/microsoft.openapi.1.6.14.nupkg.sha512",
"/home/pacer/.nuget/packages/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg.sha512",
"/home/pacer/.nuget/packages/npgsql/8.0.3/npgsql.8.0.3.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.client/1.5.374.70/opcfoundation.netstandard.opc.ua.client.1.5.374.70.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.configuration/1.5.374.70/opcfoundation.netstandard.opc.ua.configuration.1.5.374.70.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.core/1.5.374.70/opcfoundation.netstandard.opc.ua.core.1.5.374.70.nupkg.sha512",
"/home/pacer/.nuget/packages/opcfoundation.netstandard.opc.ua.security.certificates/1.5.374.70/opcfoundation.netstandard.opc.ua.security.certificates.1.5.374.70.nupkg.sha512",
"/home/pacer/.nuget/packages/swashbuckle.aspnetcore/6.6.2/swashbuckle.aspnetcore.6.6.2.nupkg.sha512",
"/home/pacer/.nuget/packages/swashbuckle.aspnetcore.swagger/6.6.2/swashbuckle.aspnetcore.swagger.6.6.2.nupkg.sha512",
"/home/pacer/.nuget/packages/swashbuckle.aspnetcore.swaggergen/6.6.2/swashbuckle.aspnetcore.swaggergen.6.6.2.nupkg.sha512",
"/home/pacer/.nuget/packages/swashbuckle.aspnetcore.swaggerui/6.6.2/swashbuckle.aspnetcore.swaggerui.6.6.2.nupkg.sha512",
"/home/pacer/.nuget/packages/system.formats.asn1/8.0.0/system.formats.asn1.8.0.0.nupkg.sha512",
"/home/pacer/.nuget/packages/system.security.cryptography.cng/5.0.0/system.security.cryptography.cng.5.0.0.nupkg.sha512"
],
"logs": []
}

Binary file not shown.

View File

@@ -0,0 +1,828 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OPC UA Control Panel</title>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0c10;
--surface: #111520;
--border: #1e2535;
--border2: #2a3550;
--accent: #00d4ff;
--accent2: #00ff9d;
--warn: #ffb800;
--danger: #ff3c5a;
--text: #c8d8f0;
--muted: #4a5a78;
--mono: 'Share Tech Mono', monospace;
--sans: 'Rajdhani', sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg); color: var(--text);
font-family: var(--sans); font-size: 15px;
min-height: 100vh; overflow-x: hidden;
}
body::before {
content: ''; position: fixed; inset: 0;
background-image:
linear-gradient(rgba(0,212,255,.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,212,255,.03) 1px, transparent 1px);
background-size: 40px 40px; pointer-events: none; z-index: 0;
}
header {
position: relative; z-index: 10;
display: flex; align-items: center; justify-content: space-between;
padding: 14px 32px;
background: rgba(17,21,32,.95);
border-bottom: 1px solid var(--border2);
backdrop-filter: blur(12px);
}
.logo { display: flex; align-items: center; gap: 14px; }
.logo-mark {
width: 36px; height: 36px; border: 2px solid var(--accent);
transform: rotate(45deg); position: relative;
animation: spin-pulse 4s ease-in-out infinite;
}
.logo-mark::after {
content: ''; position: absolute; inset: 5px;
background: var(--accent); opacity: .6;
}
@keyframes spin-pulse {
0%,100% { box-shadow: 0 0 8px var(--accent); }
50% { box-shadow: 0 0 24px var(--accent), 0 0 48px rgba(0,212,255,.3); }
}
.logo-text { font-size: 22px; font-weight: 700; letter-spacing: 4px; color: #fff; }
.logo-sub { font-family: var(--mono); font-size: 11px; color: var(--muted); letter-spacing: 2px; }
.header-right { display: flex; align-items: center; gap: 20px; }
.api-url-wrap { display: flex; align-items: center; gap: 8px; }
.api-url-wrap label { font-family: var(--mono); font-size: 11px; color: var(--muted); }
.api-url-wrap input {
background: #080b12; border: 1px solid var(--border2); border-radius: 3px;
padding: 5px 10px; color: var(--text); font-family: var(--mono); font-size: 12px;
outline: none; width: 220px;
}
.header-status { display: flex; align-items: center; gap: 8px; font-family: var(--mono); font-size: 12px; color: var(--muted); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); transition: all .4s; }
.status-dot.online { background: var(--accent2); box-shadow: 0 0 10px var(--accent2); animation: blink 2s infinite; }
.status-dot.error { background: var(--danger); box-shadow: 0 0 10px var(--danger); }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.4} }
main {
position: relative; z-index: 1;
max-width: 1200px; margin: 0 auto; padding: 32px 24px;
display: grid; grid-template-columns: 1fr 1fr; gap: 20px;
}
.panel {
background: var(--surface); border: 1px solid var(--border);
border-radius: 4px; overflow: hidden; position: relative; transition: border-color .3s;
}
.panel:hover { border-color: var(--border2); }
.panel::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: 0; transition: opacity .3s;
}
.panel:hover::before { opacity: 1; }
.panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-bottom: 1px solid var(--border);
background: rgba(255,255,255,.02);
}
.panel-title {
font-size: 13px; font-weight: 600; letter-spacing: 3px;
color: var(--accent); text-transform: uppercase;
display: flex; align-items: center; gap: 10px;
}
.panel-num { font-family: var(--mono); font-size: 10px; color: var(--muted); padding: 2px 8px; border: 1px solid var(--border2); border-radius: 2px; }
.panel-body { padding: 20px; }
.field { margin-bottom: 16px; }
.field label { display: block; margin-bottom: 6px; font-family: var(--mono); font-size: 11px; color: var(--muted); letter-spacing: 1.5px; text-transform: uppercase; }
.field input {
width: 100%; background: #080b12; border: 1px solid var(--border2); border-radius: 3px;
padding: 10px 14px; color: var(--text); font-family: var(--mono); font-size: 13px;
outline: none; transition: border-color .2s, box-shadow .2s;
}
.field input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0,212,255,.1), inset 0 0 20px rgba(0,212,255,.03); }
.field input::placeholder { color: var(--muted); }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.btn {
position: relative; overflow: hidden;
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
padding: 11px 24px; border: 1px solid; border-radius: 3px;
font-family: var(--sans); font-size: 13px; font-weight: 600;
letter-spacing: 2px; text-transform: uppercase; cursor: pointer; transition: all .2s; outline: none;
}
.btn::before { content: ''; position: absolute; inset: 0; background: currentColor; opacity: 0; transition: opacity .2s; }
.btn:hover::before { opacity: .08; }
.btn:active { transform: scale(.98); }
.btn-primary { background: transparent; color: var(--accent); border-color: var(--accent); box-shadow: inset 0 0 20px rgba(0,212,255,.05); }
.btn-primary:hover { background: rgba(0,212,255,.1); box-shadow: 0 0 20px rgba(0,212,255,.25), inset 0 0 20px rgba(0,212,255,.05); }
.btn-success { background: transparent; color: var(--accent2); border-color: var(--accent2); }
.btn-success:hover { background: rgba(0,255,157,.08); box-shadow: 0 0 20px rgba(0,255,157,.25); }
.btn-warn { background: transparent; color: var(--warn); border-color: var(--warn); }
.btn-warn:hover { background: rgba(255,184,0,.08); box-shadow: 0 0 20px rgba(255,184,0,.2); }
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
.btn-danger:hover { background: rgba(255,60,90,.08); box-shadow: 0 0 20px rgba(255,60,90,.2); }
.btn:disabled { opacity: .35; cursor: not-allowed; pointer-events: none; }
.btn-full { width: 100%; }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.console { grid-column: 1/-1; background: #050710; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
.console-header { padding: 10px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.console-title { font-family: var(--mono); font-size: 11px; color: var(--muted); letter-spacing: 2px; }
.console-body { height: 240px; overflow-y: auto; padding: 14px 20px; font-family: var(--mono); font-size: 12px; line-height: 1.8; }
.console-body::-webkit-scrollbar { width: 4px; }
.console-body::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.log-line { display: flex; gap: 12px; }
.log-time { color: var(--muted); flex-shrink: 0; }
.log-msg.info { color: var(--text); }
.log-msg.ok { color: var(--accent2); }
.log-msg.warn { color: var(--warn); }
.log-msg.error { color: var(--danger); }
.log-msg.accent{ color: var(--accent); }
.db-table { grid-column: 1/-1; }
.data-grid { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
.data-grid th { padding: 10px 16px; text-align: left; color: var(--muted); font-size: 10px; letter-spacing: 2px; text-transform: uppercase; border-bottom: 1px solid var(--border); background: rgba(255,255,255,.02); }
.data-grid td { padding: 10px 16px; border-bottom: 1px solid rgba(255,255,255,.04); color: var(--text); }
.data-grid tr:last-child td { border-bottom: none; }
.data-grid tr:hover td { background: rgba(0,212,255,.04); }
.data-grid .val { color: var(--accent2); }
.data-grid .status-ok { color: var(--accent2); }
.data-grid .status-err { color: var(--danger); }
.row-new { animation: row-flash .6s ease; }
@keyframes row-flash { 0%{background:rgba(0,255,157,.15)} 100%{background:transparent} }
.metrics { grid-column: 1/-1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
.metric { background: var(--surface); padding: 16px 20px; display: flex; flex-direction: column; gap: 4px; }
.metric-label { font-family: var(--mono); font-size: 10px; color: var(--muted); letter-spacing: 2px; text-transform: uppercase; }
.metric-val { font-family: var(--mono); font-size: 22px; font-weight: 700; color: var(--text); transition: color .3s; }
.metric-val.accent { color: var(--accent); }
.metric-val.success { color: var(--accent2); }
.metric-val.warn { color: var(--warn); }
.progress-wrap { margin-top: 14px; }
.progress-label { display: flex; justify-content: space-between; font-family: var(--mono); font-size: 11px; color: var(--muted); margin-bottom: 6px; }
.progress-bar { height: 4px; background: var(--border2); border-radius: 2px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: linear-gradient(90deg, var(--accent), var(--accent2)); border-radius: 2px; transition: width .4s ease; box-shadow: 0 0 8px var(--accent); }
.badge { display: inline-block; padding: 2px 10px; border-radius: 2px; font-family: var(--mono); font-size: 10px; letter-spacing: 1px; }
.badge-blue { background: rgba(0,212,255,.1); color: var(--accent); border: 1px solid rgba(0,212,255,.3); }
.badge-green { background: rgba(0,255,157,.1); color: var(--accent2); border: 1px solid rgba(0,255,157,.3); }
.cert-result {
margin-top: 14px; padding: 12px 14px;
background: rgba(0,255,157,.04); border: 1px solid rgba(0,255,157,.2); border-radius: 3px;
font-family: var(--mono); font-size: 11px; line-height: 2; display: none;
}
.cert-result .cr-label { color: var(--muted); }
.cert-result .cr-val { color: var(--accent2); }
.divider { grid-column: 1/-1; display: flex; align-items: center; gap: 16px; padding: 4px 0; }
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.divider span { font-family: var(--mono); font-size: 10px; color: var(--muted); letter-spacing: 3px; white-space: nowrap; }
@media (max-width: 768px) { main { grid-template-columns: 1fr; } .metrics { grid-template-columns: repeat(2, 1fr); } }
</style>
</head>
<body>
<header>
<div class="logo">
<div class="logo-mark"></div>
<div>
<div class="logo-text">OPC·UA</div>
<div class="logo-sub">INDUSTRIAL CONTROL PANEL</div>
</div>
</div>
<div class="header-right">
<div class="api-url-wrap">
<label>API</label>
<input type="text" id="apiBase" value="http://localhost:5000">
</div>
<div class="header-status">
<div class="status-dot" id="connDot"></div>
<span id="connLabel">DISCONNECTED</span>
</div>
</div>
</header>
<main>
<!-- METRICS -->
<div class="metrics">
<div class="metric"><span class="metric-label">OPC SESSION</span><span class="metric-val" id="m-conn">OFFLINE</span></div>
<div class="metric"><span class="metric-label">NODES FOUND</span><span class="metric-val accent" id="m-nodes"></span></div>
<div class="metric"><span class="metric-label">DB RECORDS</span><span class="metric-val success" id="m-records">0</span></div>
<div class="metric"><span class="metric-label">LAST VALUE</span><span class="metric-val warn" id="m-lastval"></span></div>
</div>
<!-- ① CERT -->
<div class="panel">
<div class="panel-header">
<div class="panel-title"><span>🔐</span> Certificate Generator <span class="panel-num">01</span></div>
<span class="badge badge-blue">PKI / X.509</span>
</div>
<div class="panel-body">
<div class="field-row">
<div class="field">
<label>내 컴퓨터 호스트명</label>
<input type="text" id="clientHost" value="dbsvr" oninput="updateUri()">
</div>
<div class="field">
<label>Application Name</label>
<input type="text" id="appName" value="OpcTestClient" oninput="updateUri()">
</div>
</div>
<div class="field">
<label>Application URI (자동 생성)</label>
<input type="text" id="appUri" readonly>
</div>
<div class="field-row">
<div class="field">
<label>OPC 서버 호스트명</label>
<input type="text" id="serverHost" placeholder="opc-server-01">
</div>
<div class="field">
<label>OPC 서버 IP</label>
<input type="text" id="serverIp" value="192.168.0.20">
</div>
</div>
<div class="field-row">
<div class="field">
<label>PFX 비밀번호</label>
<input type="password" id="pfxPassword" placeholder="(없으면 공백)">
</div>
<div class="field">
<label>유효 기간 (일)</label>
<input type="number" id="certDays" value="365" min="1">
</div>
</div>
<!-- 결과 박스 -->
<div class="cert-result" id="certResult">
<div><span class="cr-label">Thumbprint : </span><span class="cr-val" id="crThumb"></span></div>
<div><span class="cr-label">Serial No : </span><span class="cr-val" id="crSerial"></span></div>
<div><span class="cr-label">Not Before : </span><span class="cr-val" id="crFrom"></span></div>
<div><span class="cr-label">Not After : </span><span class="cr-val" id="crTo"></span></div>
<div><span class="cr-label">PFX Path : </span><span class="cr-val" id="crPath"></span></div>
</div>
<div style="display:flex;gap:10px;margin-top:12px">
<button class="btn btn-primary btn-full" id="btnCert" onclick="generateCert()">
<span></span> 인증서 생성
</button>
<button class="btn btn-success" id="btnCertDownload" style="padding:11px 16px;display:none" title="PFX 다운로드" onclick="downloadPfx()"></button>
</div>
</div>
</div>
<!-- ② SESSION -->
<div class="panel">
<div class="panel-header">
<div class="panel-title"><span>🔗</span> OPC-UA Session <span class="panel-num">02</span></div>
<span class="badge badge-blue">opc.tcp://</span>
</div>
<div class="panel-body">
<div class="field-row">
<div class="field">
<label>서버 IP</label>
<input type="text" id="endpointIp" value="192.168.0.20">
</div>
<div class="field">
<label>포트</label>
<input type="number" id="endpointPort" value="4840">
</div>
</div>
<div class="field-row">
<div class="field">
<label>사용자명</label>
<input type="text" id="opcUser" value="mngr">
</div>
<div class="field">
<label>비밀번호</label>
<input type="password" id="opcPass" value="mngr">
</div>
</div>
<div class="field">
<label>Security Policy</label>
<input type="text" id="secPolicy" value="Basic256Sha256">
</div>
<div class="field-row">
<div class="field">
<label>PFX 파일명 (pki/own/certs/)</label>
<input type="text" id="pfxFile" value="OpcTestClient.pfx">
</div>
<div class="field">
<label>PFX 비밀번호</label>
<input type="password" id="sessPfxPass">
</div>
</div>
<div style="display:flex;gap:10px;margin-top:8px">
<button class="btn btn-success btn-full" id="btnConnect" onclick="connectSession()">
<span></span> 연결
</button>
<button class="btn btn-danger" id="btnDisconnect" onclick="disconnectSession()" disabled style="padding:11px 18px"></button>
</div>
</div>
</div>
<div class="divider"><span>NODE CRAWLER &amp; DATABASE</span></div>
<!-- ③ CRAWLER -->
<div class="panel">
<div class="panel-header">
<div class="panel-title"><span>🌐</span> Node Crawler <span class="panel-num">03</span></div>
<span class="badge badge-green" id="crawlBadge">IDLE</span>
</div>
<div class="panel-body">
<div class="field">
<label>시작 Node ID</label>
<input type="text" id="startNode" value="ns=1;s=$assetmodel">
</div>
<div class="field">
<label>최대 탐색 깊이</label>
<input type="number" id="crawlDepth" value="5" min="1" max="20">
</div>
<div class="progress-wrap" id="crawlProgressWrap" style="display:none">
<div class="progress-label"><span id="crawlStatus">탐색 중...</span><span id="crawlCount">0 nodes</span></div>
<div class="progress-bar"><div class="progress-fill" id="crawlFill"></div></div>
</div>
<div style="display:flex;gap:10px;margin-top:8px">
<button class="btn btn-success btn-full" id="btnCrawl" onclick="startCrawler()" disabled>
<span></span> Crawler 시작
</button>
<button class="btn btn-warn" id="btnExportCsv" onclick="downloadCsv()" disabled style="padding:11px 18px" title="CSV 다운로드"></button>
</div>
</div>
</div>
<!-- ④ DATABASE -->
<div class="panel">
<div class="panel-header">
<div class="panel-title"><span>🗄</span> Database Writer <span class="panel-num">04</span></div>
<span class="badge badge-blue">PostgreSQL</span>
</div>
<div class="panel-body">
<div class="field">
<label>Tag Node ID</label>
<input type="text" id="tagNodeId" value="ns=1;s=shinam:p-6102.hzset.fieldvalue">
</div>
<div class="field">
<label>Tag Name (DB 저장명)</label>
<input type="text" id="tagName" value="p-6102">
</div>
<div class="field-row">
<div class="field">
<label>DB Host</label>
<input type="text" id="dbHost" value="localhost">
</div>
<div class="field">
<label>Database</label>
<input type="text" id="dbName" value="opcdb">
</div>
</div>
<div class="field-row">
<div class="field">
<label>DB User</label>
<input type="text" id="dbUser" value="postgres">
</div>
<div class="field">
<label>DB Password</label>
<input type="password" id="dbPass" value="postgres">
</div>
</div>
<div class="progress-wrap" id="dbProgressWrap" style="display:none">
<div class="progress-label"><span>저장 중...</span><span id="dbPct">0/5</span></div>
<div class="progress-bar"><div class="progress-fill" id="dbFill"></div></div>
</div>
<div style="display:flex;gap:10px;margin-top:8px">
<button class="btn btn-warn" id="btnDbTest" onclick="testDb()" style="padding:11px 16px">TEST</button>
<button class="btn btn-warn btn-full" id="btnDb" onclick="startDbWrite()" disabled>
<span>💾</span> DB 5회 저장
</button>
<button class="btn btn-primary" id="btnDbQuery" onclick="queryDb()" disabled style="padding:11px 16px">조회</button>
</div>
</div>
</div>
<!-- ⑤ CONSOLE -->
<div class="console">
<div class="console-header">
<span class="console-title">// SYSTEM LOG</span>
<button class="btn btn-danger" onclick="clearLog()" style="padding:4px 14px;font-size:11px">CLEAR</button>
</div>
<div class="console-body" id="logBody">
<div class="log-line"><span class="log-time">--:--:--</span><span class="log-msg accent">OPC UA Control Panel initialized.</span></div>
</div>
</div>
<!-- ⑥ DB TABLE -->
<div class="panel db-table">
<div class="panel-header">
<div class="panel-title"><span>📊</span> Recent DB Records <span class="panel-num">05</span></div>
<button class="btn btn-primary" onclick="clearTable()" style="padding:5px 14px;font-size:11px">CLEAR</button>
</div>
<div class="panel-body" style="padding:0">
<table class="data-grid">
<thead><tr><th>#</th><th>TIMESTAMP</th><th>TAG NAME</th><th>VALUE</th><th>STATUS</th><th>DB</th></tr></thead>
<tbody id="dbTableBody">
<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:24px">No records yet.</td></tr>
</tbody>
</table>
</div>
</div>
</main>
<script>
/* ═══════════════════════════════
CONFIG
═══════════════════════════════ */
const api = () => document.getElementById('apiBase').value.replace(/\/$/, '');
let dbRowCount = 0;
let lastPfxPath = '';
let lastPfxName = '';
/* ═══════════════════════════════
LOG
═══════════════════════════════ */
function log(msg, type = 'info') {
const body = document.getElementById('logBody');
const now = new Date();
const ts = [now.getHours(), now.getMinutes(), now.getSeconds()]
.map(n => String(n).padStart(2,'0')).join(':');
const line = document.createElement('div');
line.className = 'log-line';
line.innerHTML = `<span class="log-time">${ts}</span><span class="log-msg ${type}">${escHtml(String(msg))}</span>`;
body.appendChild(line);
body.scrollTop = body.scrollHeight;
}
function clearLog() { document.getElementById('logBody').innerHTML = ''; log('Log cleared.','warn'); }
const escHtml = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
/* ═══════════════════════════════
URI 자동 생성
═══════════════════════════════ */
function updateUri() {
const h = document.getElementById('clientHost').value || '{hostname}';
const a = document.getElementById('appName').value || '{appname}';
document.getElementById('appUri').value = `urn:${h}:${a}`;
}
updateUri();
/* ═══════════════════════════════
API 공통 Fetch
═══════════════════════════════ */
async function apiFetch(path, method = 'GET', body = null) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' }
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(api() + path, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`);
return data;
}
/* ═══════════════════════════════
① CERTIFICATE
═══════════════════════════════ */
async function generateCert() {
const req = {
clientHostName: document.getElementById('clientHost').value.trim(),
applicationName: document.getElementById('appName').value.trim(),
serverHostName: document.getElementById('serverHost').value.trim(),
serverIp: document.getElementById('serverIp').value.trim(),
pfxPassword: document.getElementById('pfxPassword').value,
validDays: parseInt(document.getElementById('certDays').value) || 365
};
if (!req.clientHostName || !req.applicationName) {
log('❌ 호스트명과 Application Name을 입력하세요.', 'error'); return;
}
setBtnLoading('btnCert', true, '생성 중...');
log(`⚙ 인증서 생성 요청: ${req.applicationName} @ ${req.clientHostName}`, 'accent');
try {
const res = await apiFetch('/api/cert/generate', 'POST', req);
lastPfxPath = res.pfxPath;
lastPfxName = `${req.applicationName}.pfx`;
// 결과 표시
document.getElementById('crThumb').textContent = res.thumbprint;
document.getElementById('crSerial').textContent = res.serialNumber;
document.getElementById('crFrom').textContent = res.notBefore;
document.getElementById('crTo').textContent = res.notAfter;
document.getElementById('crPath').textContent = res.pfxPath;
document.getElementById('certResult').style.display = 'block';
document.getElementById('btnCertDownload').style.display = '';
log(`✅ 인증서 생성 완료!`, 'ok');
log(` URI: ${res.applicationUri}`, 'ok');
log(` Thumbprint: ${res.thumbprint}`, 'ok');
log(` 유효기간: ${res.notBefore} ~ ${res.notAfter}`, 'info');
log(` PFX 경로: ${res.pfxPath}`, 'info');
// 세션 패널에 pfxFile 자동 채우기
document.getElementById('pfxFile').value = lastPfxName;
document.getElementById('sessPfxPass').value = req.pfxPassword;
document.getElementById('appUri').value = res.applicationUri;
} catch (e) {
log(`❌ 인증서 생성 실패: ${e.message}`, 'error');
} finally {
setBtnLoading('btnCert', false, '⚙ 인증서 생성');
}
}
async function downloadPfx() {
if (!lastPfxName) return;
try {
const res = await fetch(`${api()}/api/cert/download/${lastPfxName}`);
if (!res.ok) throw new Error('다운로드 실패');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = lastPfxName; a.click();
URL.revokeObjectURL(url);
log(`💾 PFX 다운로드: ${lastPfxName}`, 'ok');
} catch (e) {
log(`${e.message}`, 'error');
}
}
/* ═══════════════════════════════
② SESSION
═══════════════════════════════ */
async function connectSession() {
const pfxFile = document.getElementById('pfxFile').value.trim();
const req = {
serverIp: document.getElementById('endpointIp').value.trim(),
port: parseInt(document.getElementById('endpointPort').value),
userName: document.getElementById('opcUser').value.trim(),
password: document.getElementById('opcPass').value,
securityPolicy: document.getElementById('secPolicy').value.trim(),
sessionTimeoutMs: 60000,
pfxPath: pfxFile ? `pki/own/certs/${pfxFile}` : '',
pfxPassword: document.getElementById('sessPfxPass').value,
applicationName: document.getElementById('appName').value.trim(),
applicationUri: document.getElementById('appUri').value.trim()
};
if (!req.serverIp) { log('❌ 서버 IP를 입력하세요.', 'error'); return; }
setBtnLoading('btnConnect', true, '연결 중...');
log(`🔗 연결 시도: opc.tcp://${req.serverIp}:${req.port}`, 'accent');
log(` Security: ${req.securityPolicy} | User: ${req.userName}`, 'info');
try {
const res = await apiFetch('/api/session/connect', 'POST', req);
updateConnectionState(true);
log(`✅ 연결 성공! SessionId: ${res.sessionId}`, 'ok');
log(` Security Mode: ${res.securityMode}`, 'ok');
} catch (e) {
log(`❌ 연결 실패: ${e.message}`, 'error');
setBtnLoading('btnConnect', false, '▶ 연결');
}
}
async function disconnectSession() {
log('🔌 세션 종료 중...', 'warn');
try {
await apiFetch('/api/session/disconnect', 'POST');
updateConnectionState(false);
log('✅ 세션 종료 완료.', 'ok');
} catch (e) {
log(`${e.message}`, 'error');
}
}
function updateConnectionState(connected) {
const dot = document.getElementById('connDot');
const label = document.getElementById('connLabel');
document.getElementById('m-conn').textContent = connected ? 'ONLINE' : 'OFFLINE';
document.getElementById('m-conn').className = 'metric-val ' + (connected ? 'success' : '');
dot.className = 'status-dot ' + (connected ? 'online' : 'error');
label.textContent = connected ? 'CONNECTED' : 'DISCONNECTED';
document.getElementById('btnDisconnect').disabled = !connected;
document.getElementById('btnCrawl').disabled = !connected;
document.getElementById('btnDb').disabled = !connected;
document.getElementById('btnDbQuery').disabled = !connected;
if (connected) {
document.getElementById('btnConnect').innerHTML = '<span>▶</span> 연결됨';
} else {
document.getElementById('btnConnect').disabled = false;
document.getElementById('btnConnect').innerHTML = '<span>▶</span> 연결';
}
}
// 세션 상태 폴링 (5초마다)
setInterval(async () => {
try {
const st = await apiFetch('/api/session/status');
updateConnectionState(st.isConnected);
} catch { /* API 서버 꺼진 경우 무시 */ }
}, 5000);
/* ═══════════════════════════════
③ CRAWLER
═══════════════════════════════ */
async function startCrawler() {
const req = {
startNodeId: document.getElementById('startNode').value.trim(),
maxDepth: parseInt(document.getElementById('crawlDepth').value)
};
const badge = document.getElementById('crawlBadge');
const wrap = document.getElementById('crawlProgressWrap');
const fill = document.getElementById('crawlFill');
const cnt = document.getElementById('crawlCount');
const stat = document.getElementById('crawlStatus');
setBtnLoading('btnCrawl', true, '탐색 중...');
badge.textContent = 'RUNNING';
badge.style.cssText = 'background:rgba(255,184,0,.1);color:var(--warn);border-color:rgba(255,184,0,.3)';
wrap.style.display = 'block';
stat.textContent = '서버 응답 대기 중...';
// 진행률 애니메이션 (실제 완료 후 100%)
let fakeProgress = 0;
const ticker = setInterval(() => {
if (fakeProgress < 90) { fakeProgress += 2; fill.style.width = fakeProgress + '%'; }
}, 400);
log(`🌐 Crawler 시작: ${req.startNodeId} (depth=${req.maxDepth})`, 'accent');
try {
const res = await apiFetch('/api/crawler/start', 'POST', req);
clearInterval(ticker);
fill.style.width = '100%';
cnt.textContent = `${res.totalNodes} nodes`;
badge.textContent = 'DONE';
badge.style.cssText = 'background:rgba(0,255,157,.1);color:var(--accent2);border-color:rgba(0,255,157,.3)';
document.getElementById('m-nodes').textContent = res.totalNodes;
document.getElementById('btnExportCsv').disabled = false;
log(`✅ 탐사 완료: ${res.totalNodes}개 노드`, 'ok');
log(` CSV: ${res.csvPath}`, 'info');
// 상위 20개 로그
res.tags?.slice(0, 20).forEach(t =>
log(` [Lv${t.level}][${t.nodeClass}] ${t.tagName}${t.fullNodeId}`, 'info'));
if (res.tags?.length > 20) log(` ... 그 외 ${res.tags.length - 20}`, 'muted');
} catch (e) {
clearInterval(ticker);
log(`❌ Crawler 실패: ${e.message}`, 'error');
badge.textContent = 'ERROR';
badge.style.cssText = 'background:rgba(255,60,90,.1);color:var(--danger);border-color:rgba(255,60,90,.3)';
} finally {
setBtnLoading('btnCrawl', false, '⛏ Crawler 시작');
}
}
function downloadCsv() {
const a = document.createElement('a');
a.href = `${api()}/api/crawler/csv`;
a.download = 'Honeywell_FullMap.csv';
a.click();
log('💾 CSV 다운로드 시작.', 'ok');
}
/* ═══════════════════════════════
④ DATABASE
═══════════════════════════════ */
function dbReq() {
return {
tagNodeId: document.getElementById('tagNodeId').value.trim(),
tagName: document.getElementById('tagName').value.trim(),
count: 5,
intervalMs: 2000,
dbHost: document.getElementById('dbHost').value.trim(),
dbName: document.getElementById('dbName').value.trim(),
dbUser: document.getElementById('dbUser').value.trim(),
dbPassword: document.getElementById('dbPass').value
};
}
async function testDb() {
log('🔌 DB 연결 테스트...', 'accent');
try {
const res = await apiFetch('/api/database/test', 'POST', dbReq());
log(`${res.message}`, 'ok');
} catch (e) {
log(`❌ DB 연결 실패: ${e.message}`, 'error');
}
}
async function startDbWrite() {
const req = dbReq();
if (!req.tagNodeId || !req.tagName) {
log('❌ Tag Node ID와 Tag Name을 입력하세요.', 'error'); return;
}
const wrap = document.getElementById('dbProgressWrap');
const fill = document.getElementById('dbFill');
const pct = document.getElementById('dbPct');
setBtnLoading('btnDb', true, '저장 중...');
wrap.style.display = 'block';
fill.style.width = '0%';
pct.textContent = '0/5';
log(`\n💾 DB 저장 시작 (${req.count}회) → ${req.dbUser}@${req.dbHost}/${req.dbName}`, 'accent');
log(` Tag: ${req.tagName} | Node: ${req.tagNodeId}`, 'info');
let fakeP = 0;
const ticker = setInterval(() => {
if (fakeP < 90) { fakeP += 5; fill.style.width = fakeP + '%'; }
}, 500);
try {
const res = await apiFetch('/api/database/write', 'POST', req);
clearInterval(ticker);
fill.style.width = '100%';
pct.textContent = `${res.savedCount}/${req.count}`;
const tbody = document.getElementById('dbTableBody');
if (dbRowCount === 0) tbody.innerHTML = '';
res.records?.forEach(r => {
dbRowCount++;
const tr = document.createElement('tr');
tr.className = 'row-new';
const ts = new Date(r.timestamp).toLocaleTimeString('ko-KR', { hour12: false });
const sc = r.statusCode === 'Good' ? 'status-ok' : 'status-err';
tr.innerHTML = `
<td>${dbRowCount}</td><td>${ts}</td><td>${r.tagName}</td>
<td class="val">${r.value.toFixed(4)}</td>
<td class="${sc}">${r.statusCode}</td>
<td>${r.dbSaved ? '✅' : '❌'}</td>`;
tbody.insertBefore(tr, tbody.firstChild);
document.getElementById('m-records').textContent = dbRowCount;
document.getElementById('m-lastval').textContent = r.value.toFixed(4);
log(` [${r.seq}/${req.count}] ${r.tagName} = ${r.value.toFixed(4)} (${r.statusCode}) → DB: ${r.dbSaved ? '✅' : '❌'}`, r.dbSaved ? 'ok' : 'warn');
});
log(`${res.message}`, 'ok');
} catch (e) {
clearInterval(ticker);
log(`❌ DB 저장 실패: ${e.message}`, 'error');
} finally {
setBtnLoading('btnDb', false, '💾 DB 5회 저장');
}
}
async function queryDb() {
log('📊 DB 조회 중...', 'accent');
try {
const res = await apiFetch('/api/database/query?limit=50', 'POST', dbReq());
const tbody = document.getElementById('dbTableBody');
tbody.innerHTML = '';
dbRowCount = 0;
if (!res.rows?.length) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:24px">No records found.</td></tr>';
return;
}
res.rows.forEach(r => {
dbRowCount++;
const tr = document.createElement('tr');
const ts = new Date(r.createdAt).toLocaleTimeString('ko-KR', {hour12:false});
const sc = r.statusCode === 'Good' ? 'status-ok' : 'status-err';
tr.innerHTML = `
<td>${r.id}</td><td>${ts}</td><td>${r.tagName}</td>
<td class="val">${r.tagValue.toFixed(4)}</td>
<td class="${sc}">${r.statusCode}</td><td>✅</td>`;
tbody.appendChild(tr);
});
document.getElementById('m-records').textContent = res.totalCount;
log(`✅ 조회 완료: 총 ${res.totalCount}개 중 ${res.rows.length}개 표시`, 'ok');
} catch (e) {
log(`❌ 조회 실패: ${e.message}`, 'error');
}
}
function clearTable() {
document.getElementById('dbTableBody').innerHTML =
'<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:24px">No records yet.</td></tr>';
dbRowCount = 0;
document.getElementById('m-records').textContent = '0';
document.getElementById('m-lastval').textContent = '—';
log('🗑 테이블 초기화.', 'warn');
}
/* ═══════════════════════════════
UTILITY
═══════════════════════════════ */
function setBtnLoading(id, loading, label) {
const btn = document.getElementById(id);
btn.disabled = loading;
btn.innerHTML = loading
? `<span class="spinner"></span> ${label}`
: label;
}
</script>
</body>
</html>