삽질하다 도저히 문제 파악이 안돼서 opcUaManager로 분리 테스트 중
This commit is contained in:
83
opcUaManager/Controllers/CertController.cs
Normal file
83
opcUaManager/Controllers/CertController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
75
opcUaManager/Controllers/CrawlerController.cs
Normal file
75
opcUaManager/Controllers/CrawlerController.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
98
opcUaManager/Controllers/DatabaseController.cs
Normal file
98
opcUaManager/Controllers/DatabaseController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
74
opcUaManager/Controllers/SessionController.cs
Normal file
74
opcUaManager/Controllers/SessionController.cs
Normal 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
|
||||
});
|
||||
}
|
||||
172
opcUaManager/Models/Models.cs
Normal file
172
opcUaManager/Models/Models.cs
Normal 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;
|
||||
}
|
||||
16
opcUaManager/OpcUaManager.csproj
Normal file
16
opcUaManager/OpcUaManager.csproj
Normal 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
90
opcUaManager/Program.cs
Normal 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
99
opcUaManager/README.md
Normal 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
|
||||
```
|
||||
158
opcUaManager/Services/CertService.cs
Normal file
158
opcUaManager/Services/CertService.cs
Normal 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)!;
|
||||
}
|
||||
}
|
||||
223
opcUaManager/Services/DatabaseService.cs
Normal file
223
opcUaManager/Services/DatabaseService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
161
opcUaManager/Services/OpcCrawlerService.cs
Normal file
161
opcUaManager/Services/OpcCrawlerService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
180
opcUaManager/Services/OpcSessionService.cs
Normal file
180
opcUaManager/Services/OpcSessionService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
BIN
opcUaManager/bin/Debug/net8.0/Microsoft.OpenApi.dll
Executable file
BIN
opcUaManager/bin/Debug/net8.0/Microsoft.OpenApi.dll
Executable file
Binary file not shown.
BIN
opcUaManager/bin/Debug/net8.0/Newtonsoft.Json.dll
Executable file
BIN
opcUaManager/bin/Debug/net8.0/Newtonsoft.Json.dll
Executable file
Binary file not shown.
BIN
opcUaManager/bin/Debug/net8.0/Npgsql.dll
Executable file
BIN
opcUaManager/bin/Debug/net8.0/Npgsql.dll
Executable file
Binary file not shown.
BIN
opcUaManager/bin/Debug/net8.0/Opc.Ua.Client.dll
Executable file
BIN
opcUaManager/bin/Debug/net8.0/Opc.Ua.Client.dll
Executable file
Binary file not shown.
BIN
opcUaManager/bin/Debug/net8.0/Opc.Ua.Configuration.dll
Executable file
BIN
opcUaManager/bin/Debug/net8.0/Opc.Ua.Configuration.dll
Executable file
Binary file not shown.
BIN
opcUaManager/bin/Debug/net8.0/Opc.Ua.Core.dll
Executable file
BIN
opcUaManager/bin/Debug/net8.0/Opc.Ua.Core.dll
Executable file
Binary file not shown.
BIN
opcUaManager/bin/Debug/net8.0/Opc.Ua.Security.Certificates.dll
Executable file
BIN
opcUaManager/bin/Debug/net8.0/Opc.Ua.Security.Certificates.dll
Executable file
Binary file not shown.
BIN
opcUaManager/bin/Debug/net8.0/OpcUaManager
Executable file
BIN
opcUaManager/bin/Debug/net8.0/OpcUaManager
Executable file
Binary file not shown.
266
opcUaManager/bin/Debug/net8.0/OpcUaManager.deps.json
Normal file
266
opcUaManager/bin/Debug/net8.0/OpcUaManager.deps.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
opcUaManager/bin/Debug/net8.0/OpcUaManager.dll
Normal file
BIN
opcUaManager/bin/Debug/net8.0/OpcUaManager.dll
Normal file
Binary file not shown.
BIN
opcUaManager/bin/Debug/net8.0/OpcUaManager.pdb
Normal file
BIN
opcUaManager/bin/Debug/net8.0/OpcUaManager.pdb
Normal file
Binary file not shown.
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}]}}
|
||||
BIN
opcUaManager/bin/Debug/net8.0/Swashbuckle.AspNetCore.Swagger.dll
Executable file
BIN
opcUaManager/bin/Debug/net8.0/Swashbuckle.AspNetCore.Swagger.dll
Executable file
Binary file not shown.
BIN
opcUaManager/bin/Debug/net8.0/Swashbuckle.AspNetCore.SwaggerGen.dll
Executable file
BIN
opcUaManager/bin/Debug/net8.0/Swashbuckle.AspNetCore.SwaggerGen.dll
Executable file
Binary file not shown.
BIN
opcUaManager/bin/Debug/net8.0/Swashbuckle.AspNetCore.SwaggerUI.dll
Executable file
BIN
opcUaManager/bin/Debug/net8.0/Swashbuckle.AspNetCore.SwaggerUI.dll
Executable file
Binary file not shown.
23
opcUaManager/init_db.sql
Normal file
23
opcUaManager/init_db.sql
Normal 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;
|
||||
@@ -0,0 +1,4 @@
|
||||
// <autogenerated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")]
|
||||
22
opcUaManager/obj/Debug/net8.0/OpcUaManager.AssemblyInfo.cs
Normal file
22
opcUaManager/obj/Debug/net8.0/OpcUaManager.AssemblyInfo.cs
Normal 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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
4d3435e515b1133eaf4a3bc25612c8110ded80ed47219a5a2561401d2d703539
|
||||
@@ -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 =
|
||||
17
opcUaManager/obj/Debug/net8.0/OpcUaManager.GlobalUsings.g.cs
Normal file
17
opcUaManager/obj/Debug/net8.0/OpcUaManager.GlobalUsings.g.cs
Normal 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;
|
||||
@@ -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.
|
||||
|
||||
BIN
opcUaManager/obj/Debug/net8.0/OpcUaManager.assets.cache
Normal file
BIN
opcUaManager/obj/Debug/net8.0/OpcUaManager.assets.cache
Normal file
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
a38ac13601df264e3305daaab2701d061db79abfe74d745f8cb1549021254d3b
|
||||
@@ -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
|
||||
BIN
opcUaManager/obj/Debug/net8.0/OpcUaManager.dll
Normal file
BIN
opcUaManager/obj/Debug/net8.0/OpcUaManager.dll
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
c2d6000231adf119ed097595a4eae7c659e8a4df56c6f9e58afe708f9abd8740
|
||||
BIN
opcUaManager/obj/Debug/net8.0/OpcUaManager.pdb
Normal file
BIN
opcUaManager/obj/Debug/net8.0/OpcUaManager.pdb
Normal file
Binary file not shown.
BIN
opcUaManager/obj/Debug/net8.0/apphost
Executable file
BIN
opcUaManager/obj/Debug/net8.0/apphost
Executable file
Binary file not shown.
BIN
opcUaManager/obj/Debug/net8.0/ref/OpcUaManager.dll
Normal file
BIN
opcUaManager/obj/Debug/net8.0/ref/OpcUaManager.dll
Normal file
Binary file not shown.
BIN
opcUaManager/obj/Debug/net8.0/refint/OpcUaManager.dll
Normal file
BIN
opcUaManager/obj/Debug/net8.0/refint/OpcUaManager.dll
Normal file
Binary file not shown.
39
opcUaManager/obj/Debug/net8.0/staticwebassets.build.json
Normal file
39
opcUaManager/obj/Debug/net8.0/staticwebassets.build.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}]}}
|
||||
25
opcUaManager/obj/Debug/net8.0/staticwebassets.pack.json
Normal file
25
opcUaManager/obj/Debug/net8.0/staticwebassets.pack.json
Normal 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": []
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<Project>
|
||||
<Import Project="Microsoft.AspNetCore.StaticWebAssets.props" />
|
||||
</Project>
|
||||
@@ -0,0 +1,3 @@
|
||||
<Project>
|
||||
<Import Project="../build/OpcUaManager.props" />
|
||||
</Project>
|
||||
@@ -0,0 +1,3 @@
|
||||
<Project>
|
||||
<Import Project="../buildMultiTargeting/OpcUaManager.props" />
|
||||
</Project>
|
||||
78
opcUaManager/obj/OpcUaManager.csproj.nuget.dgspec.json
Normal file
78
opcUaManager/obj/OpcUaManager.csproj.nuget.dgspec.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
opcUaManager/obj/OpcUaManager.csproj.nuget.g.props
Normal file
22
opcUaManager/obj/OpcUaManager.csproj.nuget.g.props
Normal 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>
|
||||
7
opcUaManager/obj/OpcUaManager.csproj.nuget.g.targets
Normal file
7
opcUaManager/obj/OpcUaManager.csproj.nuget.g.targets
Normal 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>
|
||||
1054
opcUaManager/obj/project.assets.json
Normal file
1054
opcUaManager/obj/project.assets.json
Normal file
File diff suppressed because it is too large
Load Diff
25
opcUaManager/obj/project.nuget.cache
Normal file
25
opcUaManager/obj/project.nuget.cache
Normal 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": []
|
||||
}
|
||||
BIN
opcUaManager/pki/own/certs/opcUaManager.pfx
Normal file
BIN
opcUaManager/pki/own/certs/opcUaManager.pfx
Normal file
Binary file not shown.
Binary file not shown.
828
opcUaManager/wwwroot/index.html
Normal file
828
opcUaManager/wwwroot/index.html
Normal 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 & 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
/* ═══════════════════════════════
|
||||
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>
|
||||
Reference in New Issue
Block a user