feat: 디지털 이벤트 히스토리 기능 추가 (event_history_table, DigitalEventDetectorService, API, UI)

This commit is contained in:
windpacer
2026-05-11 15:48:00 +09:00
parent 7330711499
commit c6e284404c
9 changed files with 918 additions and 5 deletions

View File

@@ -81,7 +81,7 @@ public interface IExperionDbService
Task<int> BatchUpdateLiveValuesAsync(IEnumerable<LiveValueUpdate> updates);
// ── HistoryTable ──────────────────────────────────────────────────────────
Task<int> SnapshotToHistoryAsync();
Task<int> SnapshotToHistoryAsync(bool includeDigital = false);
Task<IEnumerable<string>> GetTagNamesAsync();
Task<HistoryQueryResult> QueryHistoryAsync(
IEnumerable<string> tagNames, DateTime? from, DateTime? to, int limit);
@@ -119,6 +119,27 @@ public interface IExperionDbService
// ── 공통 (이미 없는 경우만) ──────────────────────────────────────────────────
Task<string?> GetNodeIdByTagNameAsync(string tagName);
// ── Digital Event History ──────────────────────────────────────────────────
/// <summary>디지털 태그 이름 목록 조회 (value 패턴 또는 tag_metadata 기반)</summary>
Task<IEnumerable<string>> GetDigitalTagNamesAsync();
/// <summary>디지털 포인트 현재 값 조회</summary>
Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync();
/// <summary>디지털 이벤트 기록</summary>
Task<int> RecordDigitalEventAsync(DigitalEventRecord record);
/// <summary>디지털 이벤트 배치 기록</summary>
Task<int> BatchRecordDigitalEventsAsync(IEnumerable<DigitalEventRecord> records);
/// <summary>태그명으로 area 조회 (tag_metadata 기반)</summary>
Task<string?> GetAreaByTagNameAsync(string tagName);
/// <summary>이벤트 히스토리 조회</summary>
Task<IEnumerable<EventHistoryRow>> QueryEventHistoryAsync(
string? tagName, string? area, string? section,
string? eventType, DateTime from, DateTime to, int limit = 500);
}
// ── Realtime Service ─────────────────────────────────────────────────────────
@@ -320,3 +341,34 @@ public interface IMetadataLoaderService
/// </summary>
Task<int> ReloadMetadataAsync(ExperionServerConfig cfg, IEnumerable<string>? baseTags = null);
}
/// <summary>디지털 이벤트 기록용 서비스 계층 DTO</summary>
public class DigitalEventRecord
{
public string TagName { get; set; } = "";
public string NodeId { get; set; } = "";
public string? PrevValue { get; set; }
public string CurrValue { get; set; } = "";
public string EventType { get; set; } = "";
public DateTime EventTime { get; set; }
public int? DurationSeconds { get; set; }
public string? Area { get; set; }
public string? Section { get; set; }
public string? Metadata { get; set; }
}
/// <summary>이벤트 히스토리 조회 결과 행</summary>
public class EventHistoryRow
{
public long Id { get; set; }
public string TagName { get; set; } = "";
public string NodeId { get; set; } = "";
public string? PrevValue { get; set; }
public string CurrValue { get; set; } = "";
public string EventType { get; set; } = "";
public DateTime EventTime { get; set; }
public string? Area { get; set; }
public string? Section { get; set; }
public int? DurationSeconds { get; set; }
public string? Metadata { get; set; }
}

View File

@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
@@ -148,3 +149,22 @@ public class TagMetadata
[Column("node_id")] public string? NodeId { get; set; }
[Column("loaded_at")] public DateTime LoadedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>event_history_table — 디지털 포인트 상태 변경 이벤트</summary>
[Table("event_history_table")]
public class EventHistoryRecord
{
[Key]
[Column("id")] public long Id { get; set; }
[Column("tagname")] public string TagName { get; set; } = string.Empty;
[Column("node_id")] public string NodeId { get; set; } = string.Empty;
[Column("prev_value")] public string? PrevValue { get; set; }
[Column("curr_value")] public string CurrValue { get; set; } = string.Empty;
[Column("event_type")] public string EventType { get; set; } = string.Empty;
[Column("event_time")] public DateTime EventTime { get; set; } = DateTime.UtcNow;
[Column("area")] public string? Area { get; set; }
[Column("section")] public string? Section { get; set; }
[Column("duration_seconds")] public int? DurationSeconds { get; set; }
[Column("metadata")] public string? Metadata { get; set; }
[Column("created_at")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -28,6 +28,7 @@ public class ExperionDbContext : DbContext
public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>();
public DbSet<PidAuditLog> PidAuditLog => Set<PidAuditLog>();
public DbSet<PidGraphStatus> PidGraphStatuses => Set<PidGraphStatus>();
public DbSet<EventHistoryRecord> EventHistoryRecords => Set<EventHistoryRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -166,6 +167,19 @@ public class ExperionDbContext : DbContext
entity.HasIndex(e => e.UpdatedAt);
});
modelBuilder.Entity<EventHistoryRecord>(entity =>
{
entity.ToTable("event_history_table");
entity.HasKey(e => e.Id);
entity.Property(e => e.Metadata)
.HasColumnType("jsonb");
entity.HasIndex(e => new { e.TagName, e.EventTime });
entity.HasIndex(e => new { e.Area, e.EventTime });
entity.HasIndex(e => new { e.EventType, e.EventTime });
});
}
}
@@ -176,6 +190,12 @@ public class ExperionDbService : IExperionDbService
private readonly ExperionDbContext _ctx;
private readonly ILogger<ExperionDbService> _logger;
private static HashSet<string> _digitalTagCache = new();
private static DateTime _digitalTagCacheTime = DateTime.MinValue;
private static Task? _cacheRefreshTask = null;
private static readonly object _digitalCacheLock = new();
private const int DigitalTagCacheTtlSeconds = 300;
public ExperionDbService(ExperionDbContext ctx, ILogger<ExperionDbService> logger)
{
_ctx = ctx;
@@ -325,6 +345,37 @@ public class ExperionDbService : IExperionDbService
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
""");
// event_history_table 생성 (디지털 포인트 상태 변경 이벤트)
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS event_history_table (
id BIGSERIAL PRIMARY KEY,
tagname TEXT NOT NULL,
node_id TEXT NOT NULL,
prev_value TEXT,
curr_value TEXT NOT NULL DEFAULT '',
event_type TEXT NOT NULL,
event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
area TEXT,
section TEXT,
duration_seconds INT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""");
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS idx_event_history_tagname_time
ON event_history_table(tagname, event_time DESC);
CREATE INDEX IF NOT EXISTS idx_event_history_area_time
ON event_history_table(area, event_time DESC);
CREATE INDEX IF NOT EXISTS idx_event_history_section_time
ON event_history_table(section, event_time DESC);
CREATE INDEX IF NOT EXISTS idx_event_history_event_type
ON event_history_table(event_type, event_time DESC);
CREATE INDEX IF NOT EXISTS idx_event_history_tagname_event_type
ON event_history_table(tagname, event_type, event_time DESC);
""");
// history 테이블은 수동으로 하이퍼테이블 생성 필요
// CreateHypertableAsync() 메서드를 사용하여 수동 생성 가능
// 참고: 하이퍼테이블 생성 후 보존 정책, 압축 정책, 연속 집계 설정은
@@ -727,10 +778,21 @@ public class ExperionDbService : IExperionDbService
// ── HistoryTable ──────────────────────────────────────────────────────────
public async Task<int> SnapshotToHistoryAsync()
public async Task<int> SnapshotToHistoryAsync(bool includeDigital = false)
{
var now = DateTime.UtcNow;
var points = await _ctx.RealtimePoints.ToListAsync();
var now = DateTime.UtcNow;
var query = _ctx.RealtimePoints.AsQueryable();
if (!includeDigital)
{
var digitalTagNames = await GetDigitalTagNamesCachedAsync();
if (digitalTagNames.Count > 0)
{
query = query.Where(p => !digitalTagNames.Contains(p.TagName));
}
}
var points = await query.ToListAsync();
if (points.Count == 0) return 0;
var rows = points.Select(p => new HistoryRecord
@@ -985,7 +1047,7 @@ public class ExperionDbService : IExperionDbService
.Where(x => tags.Contains(x.TagName))
.ToListAsync();
_logger.LogInformation("[Realtime] 태그 {Count}개의 라이브 데이터 조회 완료", tags.Count);
_logger.LogDebug("[Realtime] 태그 {Count}개의 라이브 데이터 조회 완료", tags.Count);
return records;
}
catch (Exception ex)
@@ -1131,6 +1193,160 @@ public class ExperionDbService : IExperionDbService
.FirstOrDefaultAsync();
}
// ── Digital Event History ──────────────────────────────────────────────────
public async Task<IEnumerable<string>> GetDigitalTagNamesAsync()
{
var fromMetadata = await _ctx.TagMetadata
.Where(m => m.Value == "i=7594")
.Select(m => m.BaseTag)
.Distinct()
.ToListAsync();
if (fromMetadata.Any())
return fromMetadata;
return await _ctx.RealtimePoints
.Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
.Select(p => p.TagName)
.Distinct()
.ToListAsync();
}
public async Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync()
{
var digitalTagNames = await GetDigitalTagNamesAsync();
var tagSet = new HashSet<string>(digitalTagNames);
if (tagSet.Count == 0)
return Enumerable.Empty<RealtimePoint>();
return await _ctx.RealtimePoints
.Where(p => tagSet.Contains(p.TagName))
.ToListAsync();
}
public async Task<string?> GetAreaByTagNameAsync(string tagName)
{
var baseTag = tagName.Contains('.') ? tagName[..tagName.LastIndexOf('.')] : tagName;
var meta = await _ctx.TagMetadata
.Where(m => m.BaseTag == baseTag && m.Attribute == "area")
.Select(m => m.Value)
.FirstOrDefaultAsync();
if (string.IsNullOrEmpty(meta)) return null;
var match = System.Text.RegularExpressions.Regex.Match(meta, @"{\s*\d+\s*\|\s*(\w+)\s*\|");
return match.Success ? match.Groups[1].Value : null;
}
public async Task<int> RecordDigitalEventAsync(DigitalEventRecord record)
{
var row = new EventHistoryRecord
{
TagName = record.TagName,
NodeId = record.NodeId,
PrevValue = record.PrevValue,
CurrValue = record.CurrValue,
EventType = record.EventType,
EventTime = record.EventTime,
DurationSeconds = record.DurationSeconds,
Area = record.Area,
Section = record.Section,
Metadata = record.Metadata
};
await _ctx.EventHistoryRecords.AddAsync(row);
return await _ctx.SaveChangesAsync();
}
public async Task<int> BatchRecordDigitalEventsAsync(IEnumerable<DigitalEventRecord> records)
{
var rows = records.Select(r => new EventHistoryRecord
{
TagName = r.TagName,
NodeId = r.NodeId,
PrevValue = r.PrevValue,
CurrValue = r.CurrValue,
EventType = r.EventType,
EventTime = r.EventTime,
DurationSeconds = r.DurationSeconds,
Area = r.Area,
Section = r.Section,
Metadata = r.Metadata
}).ToList();
await _ctx.EventHistoryRecords.AddRangeAsync(rows);
return await _ctx.SaveChangesAsync();
}
public async Task<IEnumerable<EventHistoryRow>> QueryEventHistoryAsync(
string? tagName, string? area, string? section,
string? eventType, DateTime from, DateTime to, int limit = 500)
{
var query = _ctx.EventHistoryRecords
.Where(r => r.EventTime >= from && r.EventTime <= to);
if (!string.IsNullOrEmpty(tagName))
query = query.Where(r => r.TagName == tagName);
if (!string.IsNullOrEmpty(area))
query = query.Where(r => r.Area == area);
if (!string.IsNullOrEmpty(section))
query = query.Where(r => r.Section == section);
if (!string.IsNullOrEmpty(eventType))
query = query.Where(r => r.EventType == eventType);
var records = await query
.OrderByDescending(r => r.EventTime)
.Take(limit)
.ToListAsync();
return records.Select(r => new EventHistoryRow
{
Id = r.Id,
TagName = r.TagName,
NodeId = r.NodeId,
PrevValue = r.PrevValue,
CurrValue = r.CurrValue,
EventType = r.EventType,
EventTime = r.EventTime,
Area = r.Area,
Section = r.Section,
DurationSeconds = r.DurationSeconds,
Metadata = r.Metadata
});
}
private async Task<HashSet<string>> GetDigitalTagNamesCachedAsync()
{
if ((DateTime.UtcNow - _digitalTagCacheTime).TotalSeconds < DigitalTagCacheTtlSeconds)
return _digitalTagCache;
Task refreshTask;
lock (_digitalCacheLock)
{
if (_cacheRefreshTask == null)
_cacheRefreshTask = RefreshDigitalTagCacheAsync();
refreshTask = _cacheRefreshTask;
}
await refreshTask;
lock (_digitalCacheLock)
{
_cacheRefreshTask = null;
}
return _digitalTagCache;
}
private async Task RefreshDigitalTagCacheAsync()
{
var tags = await GetDigitalTagNamesAsync();
_digitalTagCache = new HashSet<string>(tags);
_digitalTagCacheTime = DateTime.UtcNow;
}
/// <summary>
/// 하이퍼테이블 상태 조회합니다.
/// 하이퍼테이블인지 여부, 레코드 수, 보존 정책, 압축, 연속 집계 설정 등을 확인합니다.

View File

@@ -0,0 +1,226 @@
using ExperionCrawler.Core.Application.Interfaces;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// 디지털 포인트의 상태 변경을 감지하여 event_history_table에 기록하는 BackgroundService.
/// 1초 간격으로 realtime_table을 검사하여 변경 사항을 기록.
/// </summary>
public class DigitalEventDetectorService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<DigitalEventDetectorService> _logger;
private readonly ConcurrentDictionary<string, DigitalPointState> _previousStates = new();
private readonly ConcurrentDictionary<string, string> _areaCache = new();
private readonly int _checkIntervalMs = 1000;
private readonly int _debounceSeconds = 5;
private HashSet<string> _knownDigitalTags = new();
private DateTime _lastTagRefresh = DateTime.MinValue;
private const int TagRefreshIntervalMinutes = 5;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = null
};
private record DigitalPointState(string Value, DateTime Timestamp, string? EventType);
public DigitalEventDetectorService(
IServiceScopeFactory scopeFactory,
ILogger<DigitalEventDetectorService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("[DigitalEventDetector] 시작 — 감지 간격: {Interval}ms", _checkIntervalMs);
try
{
await LoadDigitalTagNamesAsync(stoppingToken);
await LoadCurrentStatesAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "[DigitalEventDetector] 초기화 실패 — 서비스 계속 실행 (감지 루프에서 재시도)");
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(_checkIntervalMs, stoppingToken);
await DetectAndRecordChangesAsync(stoppingToken);
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_logger.LogError(ex, "[DigitalEventDetector] 감지 루프 오류");
}
}
_logger.LogInformation("[DigitalEventDetector] 종료");
}
private async Task LoadDigitalTagNamesAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var digitalTags = await db.GetDigitalTagNamesAsync();
foreach (var tag in digitalTags)
{
if (!_previousStates.ContainsKey(tag))
{
_previousStates.TryAdd(tag, null!);
}
}
_logger.LogInformation("[DigitalEventDetector] 디지털 태그 {Count}개 로드됨", digitalTags.Count());
}
private async Task LoadCurrentStatesAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var points = await db.GetDigitalPointsAsync();
foreach (var p in points)
{
_previousStates[p.TagName] = new DigitalPointState(p.LiveValue ?? "", DateTime.UtcNow, null);
}
_logger.LogInformation("[DigitalEventDetector] 현재 상태 {Count}개 로드", _previousStates.Count);
}
private async Task DetectAndRecordChangesAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
// 5분마다 디지털 태그 목록 갱신 (신규 태그 감지용)
if ((DateTime.UtcNow - _lastTagRefresh).TotalMinutes >= TagRefreshIntervalMinutes)
{
var tags = await db.GetDigitalTagNamesAsync();
_knownDigitalTags = new HashSet<string>(tags);
_lastTagRefresh = DateTime.UtcNow;
foreach (var tag in _knownDigitalTags)
_previousStates.TryAdd(tag, null!);
}
// tag_metadata 재조회 없이 realtime_table 직접 쿼리
var queryTags = _knownDigitalTags.Count > 0 ? _knownDigitalTags : _previousStates.Keys.ToHashSet();
var currentPoints = queryTags.Count > 0
? await db.GetRealtimeRecordsByTagNamesAsync(queryTags)
: Enumerable.Empty<ExperionCrawler.Core.Domain.Entities.RealtimePoint>();
var events = new List<DigitalEventRecord>();
foreach (var point in currentPoints)
{
if (ct.IsCancellationRequested) break;
var tagName = point.TagName;
var currValue = point.LiveValue ?? "";
var prevState = _previousStates.GetValueOrDefault(tagName);
if (prevState == null)
{
_previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, null);
continue;
}
if (prevState.Value != currValue)
{
var eventType = DetermineEventType(prevState.Value, currValue);
var now = DateTime.UtcNow;
var elapsed = (now - prevState.Timestamp).TotalSeconds;
if (prevState.EventType == eventType && elapsed < _debounceSeconds)
{
_previousStates[tagName] = new DigitalPointState(currValue, now, eventType);
continue;
}
var duration = (int)elapsed;
var area = await GetAreaAsync(db, tagName);
var section = ExtractSection(tagName);
events.Add(new DigitalEventRecord
{
TagName = tagName,
NodeId = point.NodeId,
PrevValue = prevState.Value,
CurrValue = currValue,
EventType = eventType,
EventTime = now,
DurationSeconds = duration,
Area = area,
Section = section,
Metadata = BuildMetadata(tagName, eventType, currValue)
});
_logger.LogDebug("[DigitalEventDetector] {Tag}: {Event} ({Prev} → {Curr}, {Duration}s)",
tagName, eventType, prevState.Value, currValue, duration);
_previousStates[tagName] = new DigitalPointState(currValue, now, eventType);
}
}
if (events.Count > 0)
{
await db.BatchRecordDigitalEventsAsync(events);
}
}
private string DetermineEventType(string prevValue, string currValue)
{
if (currValue.Contains("FAULT") || currValue.Contains("TRIP"))
return "TRIP";
if (currValue.Contains("ALARM"))
return "ALARM";
if (prevValue.Contains("ALARM") && !currValue.Contains("ALARM"))
return "NORMAL";
if (currValue.Contains("RUN") || currValue.Contains("START"))
return "RUN";
return "CHANGE";
}
private async Task<string?> GetAreaAsync(IExperionDbService db, string tagName)
{
if (_areaCache.TryGetValue(tagName, out var cached)) return cached;
var area = await db.GetAreaByTagNameAsync(tagName);
if (area != null)
_areaCache[tagName] = area;
return area;
}
private string? ExtractSection(string tagName)
{
var match = Regex.Match(tagName, @"-(\d)(\d)\d{2}");
if (match.Success) return $"{match.Groups[1]}-{match.Groups[2]}차";
return null;
}
private string? BuildMetadata(string tagName, string eventType, string currValue)
{
if (tagName.Contains("-il-", StringComparison.OrdinalIgnoreCase) || tagName.Contains("-trip", StringComparison.OrdinalIgnoreCase))
{
return JsonSerializer.Serialize(new
{
interlock_tag = tagName,
event_type = eventType,
raw_value = currValue
}, _jsonOptions);
}
return null;
}
}

View File

@@ -1156,3 +1156,109 @@ public class ExperionPidController : ControllerBase
}
public record PinRequest(bool Pinned);
// ── Event History Controller ────────────────────────────────────────────────────
[ApiController]
[Route("api/event-history")]
public class EventHistoryController : ControllerBase
{
private readonly IExperionDbService _db;
private readonly ILogger<EventHistoryController> _logger;
public EventHistoryController(
IExperionDbService db,
ILogger<EventHistoryController> logger)
{
_db = db;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> Query(
[FromQuery] string? tagName,
[FromQuery] string? area,
[FromQuery] string? section,
[FromQuery] string? eventType,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int limit = 500)
{
try
{
var fromDt = from ?? DateTime.UtcNow.AddDays(-1);
var toDt = to ?? DateTime.UtcNow;
var rows = await _db.QueryEventHistoryAsync(tagName, area, section, eventType, fromDt, toDt, limit);
var list = rows.Select(r => new
{
id = r.Id,
tagName = r.TagName,
nodeId = r.NodeId,
prevValue = r.PrevValue,
currValue = r.CurrValue,
eventType = r.EventType,
eventTime = r.EventTime,
area = r.Area,
section = r.Section,
durationSeconds = r.DurationSeconds,
metadata = r.Metadata
}).ToList();
return Ok(new { success = true, count = list.Count, data = list });
}
catch (Exception ex)
{
_logger.LogError(ex, "[EventHistoryController] 조회 실패");
return Ok(new { success = false, error = ex.Message, data = (List<object>)new List<object>() });
}
}
[HttpGet("summary")]
public async Task<IActionResult> GetSummary(
[FromQuery] string? area,
[FromQuery] string? section,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to)
{
try
{
var fromDt = from ?? DateTime.UtcNow.AddDays(-1);
var toDt = to ?? DateTime.UtcNow;
var rows = await _db.QueryEventHistoryAsync(null, area, section, null, fromDt, toDt, 10000);
var summary = rows.GroupBy(r => r.Section ?? "기타")
.Select(g => new
{
section = g.Key,
totalEvents = g.Count(),
tripCount = g.Count(r => r.EventType == "TRIP"),
runCount = g.Count(r => r.EventType == "RUN"),
alarmCount = g.Count(r => r.EventType == "ALARM"),
changeCount = g.Count(r => r.EventType == "CHANGE")
}).ToList();
return Ok(new { success = true, count = summary.Count, data = summary });
}
catch (Exception ex)
{
_logger.LogError(ex, "[EventHistoryController] 요약 조회 실패");
return Ok(new { success = false, error = ex.Message, data = (List<object>)new List<object>() });
}
}
[HttpGet("digital-tags")]
public async Task<IActionResult> GetDigitalTags()
{
try
{
var tags = await _db.GetDigitalTagNamesAsync();
var list = tags.Select(t => new { tagName = t }).ToList();
return Ok(new { success = true, count = list.Count, data = list });
}
catch (Exception ex)
{
_logger.LogError(ex, "[EventHistoryController] 디지털 태그 조회 실패");
return Ok(new { success = false, error = ex.Message, data = (List<object>)new List<object>() });
}
}
}

View File

@@ -81,6 +81,7 @@ builder.Services.AddSingleton<IExperionRealtimeService>(
builder.Services.AddHostedService(
sp => sp.GetRequiredService<ExperionRealtimeService>());
builder.Services.AddHostedService<ExperionHistoryService>();
builder.Services.AddHostedService<DigitalEventDetectorService>();
// ── MCP Service ───────────────────────────────────────────────────────────────
// Python MCP 서버 (localhost:5001)와 통신

View File

@@ -1487,3 +1487,54 @@ tr:last-child td { border-bottom: none; }
.pb-preview-header { flex-direction: column; gap: 8px; align-items: flex-start; }
.pb-preview-actions { flex-wrap: wrap; }
}
/* ── Event History ─────────────────────────────────────────── */
.evt-badge {
display: inline-block;
font-family: var(--fm); font-size: 10px; font-weight: 700;
letter-spacing: .06em; padding: 2px 8px; border-radius: 3px;
text-transform: uppercase; white-space: nowrap;
}
.evt-badge.trip { background: rgba(239,68,68,.18); color: #f87171; }
.evt-badge.run { background: rgba(16,185,129,.18); color: #34d399; }
.evt-badge.alarm { background: rgba(245,158,11,.18); color: #fbbf24; }
.evt-badge.normal { background: rgba(148,163,184,.18); color: #94a3b8; }
.evt-badge.change { background: rgba(96,165,250,.18); color: #60a5fa; }
.evt-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-top: 12px;
}
.evt-summary-item {
background: var(--s2);
border: 1px solid var(--bd);
border-radius: var(--r);
padding: 14px 16px;
}
.evt-summary-section {
font-family: var(--fm); font-size: 13px; font-weight: 700;
color: var(--t0); margin-bottom: 10px;
}
.evt-summary-counts {
display: flex; gap: 10px; flex-wrap: wrap;
}
.evt-count {
display: flex; align-items: center; gap: 5px;
font-family: var(--fm); font-size: 11px; color: var(--t2);
}
.evt-count strong { font-size: 14px; color: var(--t0); }
.evt-total {
font-family: var(--fm); font-size: 11px; color: var(--t2);
margin-top: 8px; padding-top: 8px;
border-top: 1px solid var(--bd);
}
.evt-total strong { color: var(--t0); }

View File

@@ -76,6 +76,10 @@
<span class="ni">11</span>
<span class="nl">P&ID 추출</span>
</li>
<li class="nav-item" data-tab="evt">
<span class="ni">12</span>
<span class="nl">이벤트 히스토리</span>
</li>
</ul>
<div class="sb-foot">
@@ -1116,6 +1120,90 @@
</div>
</section>
<!-- ══════════════════════════════════════════════════════
12 이벤트 히스토리
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-evt">
<header class="pane-hdr">
<div>
<h1>이벤트 히스토리</h1>
<p>디지털 포인트 상태 변경 이벤트를 조회합니다. (event_history_table)</p>
</div>
<div class="pane-tag">EVENT / DIGITAL</div>
</header>
<!-- 조회 조건 카드 -->
<div class="card">
<div class="card-cap">조회 조건</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:13px">
<span>태그 필터</span>
<button class="btn-b btn-sm" onclick="evtLoadTags()">▼ 태그 목록 불러오기</button>
<span id="evt-tag-status" class="hist-status"></span>
</div>
<div class="fg">
<select id="ef-tag" class="inp">
<option value="">— 전체 태그 —</option>
</select>
</div>
<div class="cols-4">
<div class="fg">
<label>이벤트 타입</label>
<select id="ef-event-type" class="inp">
<option value="">전체</option>
<option value="TRIP">TRIP</option>
<option value="RUN">RUN</option>
<option value="ALARM">ALARM</option>
<option value="NORMAL">NORMAL</option>
<option value="CHANGE">CHANGE</option>
</select>
</div>
<div class="fg">
<label>Area <em>(예: P6)</em></label>
<input id="ef-area" class="inp" type="text" placeholder="비워두면 전체"/>
</div>
<div class="fg">
<label>Section <em>(예: 1-2차)</em></label>
<input id="ef-section" class="inp" type="text" placeholder="비워두면 전체"/>
</div>
<div class="fg">
<label>최대 행 수</label>
<input id="ef-limit" class="inp" type="number" value="500" min="10" max="5000"/>
</div>
</div>
<div class="cols-2">
<div class="fg">
<label>시작 시간</label>
<input type="hidden" id="hf-evt-from"/>
<div class="dt-display inp" id="dtp-evt-from-display" onclick="dtOpen('evt-from')">— 선택 안 함 —</div>
</div>
<div class="fg">
<label>종료 시간</label>
<input type="hidden" id="hf-evt-to"/>
<div class="dt-display inp" id="dtp-evt-to-display" onclick="dtOpen('evt-to')">— 선택 안 함 —</div>
</div>
</div>
<div class="btn-row">
<button class="btn-a" onclick="evtQuery()">🔍 이벤트 조회</button>
<button class="btn-b" onclick="evtSummary()">📊 구간 요약</button>
<button class="btn-b" onclick="evtReset()">초기화</button>
</div>
</div>
<!-- 요약 결과 카드 -->
<div id="evt-summary-card" class="card hidden">
<div class="card-cap">구간별 이벤트 요약</div>
<div id="evt-summary-content"></div>
</div>
<!-- 조회 결과 -->
<div id="evt-result-info" class="nm-result-info hidden" style="margin:8px 0"></div>
<div id="evt-table" class="tbl-wrap hidden"></div>
</section>
</main>
</div>
</div>

View File

@@ -1168,6 +1168,159 @@ function histReset() {
histShowStatus('pending', '⏸', '대기 중', '조회 조건을 설정하고 조회 버튼을 눌러주세요.');
}
/* ─────────────────────────────────────────────────────────────
12 이벤트 히스토리
───────────────────────────────────────────────────────────── */
async function evtLoadTags() {
const statusEl = document.getElementById('evt-tag-status');
statusEl.textContent = '⏳ 조회 중...';
try {
const d = await api('GET', '/api/event-history/digital-tags');
const tags = d.data || [];
const sel = document.getElementById('ef-tag');
sel.innerHTML = '<option value="">— 전체 태그 —</option>' +
tags.map(t => `<option value="${esc(t.tagName)}">${esc(t.tagName)}</option>`).join('');
statusEl.textContent = `${tags.length}`;
} catch (e) {
statusEl.textContent = `${e.message}`;
}
}
async function evtQuery() {
const tag = document.getElementById('ef-tag').value;
const eventType = document.getElementById('ef-event-type').value;
const area = document.getElementById('ef-area').value.trim();
const section = document.getElementById('ef-section').value.trim();
const limit = document.getElementById('ef-limit').value || 500;
const fromRaw = document.getElementById('hf-evt-from').value;
const toRaw = document.getElementById('hf-evt-to').value;
const params = new URLSearchParams();
if (tag) params.set('tagName', tag);
if (eventType) params.set('eventType', eventType);
if (area) params.set('area', area);
if (section) params.set('section', section);
params.set('limit', limit);
if (fromRaw) params.set('from', new Date(fromRaw).toISOString());
if (toRaw) params.set('to', new Date(toRaw).toISOString());
const infoEl = document.getElementById('evt-result-info');
const tableEl = document.getElementById('evt-table');
infoEl.textContent = '⏳ 조회 중...';
infoEl.classList.remove('hidden');
tableEl.classList.add('hidden');
try {
const d = await api('GET', `/api/event-history?${params}`);
if (!d.success) throw new Error(d.error || '조회 실패');
infoEl.textContent = `${d.count}`;
tableEl.innerHTML = _evtBuildTable(d.data);
tableEl.classList.remove('hidden');
} catch (e) {
infoEl.textContent = `${e.message}`;
}
}
async function evtSummary() {
const area = document.getElementById('ef-area').value.trim();
const section = document.getElementById('ef-section').value.trim();
const fromRaw = document.getElementById('hf-evt-from').value;
const toRaw = document.getElementById('hf-evt-to').value;
const params = new URLSearchParams();
if (area) params.set('area', area);
if (section) params.set('section', section);
if (fromRaw) params.set('from', new Date(fromRaw).toISOString());
if (toRaw) params.set('to', new Date(toRaw).toISOString());
const card = document.getElementById('evt-summary-card');
const content = document.getElementById('evt-summary-content');
content.textContent = '⏳ 집계 중...';
card.classList.remove('hidden');
try {
const d = await api('GET', `/api/event-history/summary?${params}`);
if (!d.success) throw new Error(d.error || '조회 실패');
content.innerHTML = _evtBuildSummary(d.data);
} catch (e) {
content.textContent = `${e.message}`;
}
}
function _evtBadge(t) {
const cls = { TRIP:'trip', RUN:'run', ALARM:'alarm', NORMAL:'normal', CHANGE:'change' }[t] || 'change';
return `<span class="evt-badge ${cls}">${esc(t)}</span>`;
}
function _evtFmtTime(dt) {
if (!dt) return '—';
return new Date(dt).toLocaleString('ko-KR', {
year:'numeric', month:'2-digit', day:'2-digit',
hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false
});
}
function _evtBuildTable(rows) {
if (!rows || !rows.length)
return '<div style="padding:24px;text-align:center;color:var(--t2)">데이터 없음</div>';
const html = rows.map(r => `
<tr>
<td style="white-space:nowrap;color:var(--t2);font-family:var(--fm);font-size:11px">${_evtFmtTime(r.eventTime)}</td>
<td><code style="font-size:11px;color:var(--blu)">${esc(r.tagName)}</code></td>
<td>${_evtBadge(r.eventType)}</td>
<td style="color:var(--t2)">${esc(r.prevValue ?? '—')}</td>
<td style="color:var(--t0);font-weight:600">${esc(r.currValue)}</td>
<td>${r.area ? `<span class="nm-cls">${esc(r.area)}</span>` : '—'}</td>
<td>${r.section ? `<span class="nm-cls">${esc(r.section)}</span>` : '—'}</td>
<td style="font-family:var(--fm);font-size:11px;color:var(--t2)">${r.durationSeconds != null ? r.durationSeconds + 's' : '—'}</td>
</tr>`).join('');
return `
<table>
<thead><tr>
<th>시간</th>
<th>태그명</th>
<th>이벤트</th>
<th>이전값</th>
<th>현재값</th>
<th>Area</th>
<th>Section</th>
<th>지속(초)</th>
</tr></thead>
<tbody>${html}</tbody>
</table>`;
}
function _evtBuildSummary(data) {
if (!data || !data.length)
return '<div style="padding:12px;color:var(--t2)">데이터 없음</div>';
return `<div class="evt-summary-grid">${data.map(s => `
<div class="evt-summary-item">
<div class="evt-summary-section">${esc(s.section)}</div>
<div class="evt-summary-counts">
<div class="evt-count">${_evtBadge('TRIP')} <strong>${s.tripCount}</strong></div>
<div class="evt-count">${_evtBadge('RUN')} <strong>${s.runCount}</strong></div>
<div class="evt-count">${_evtBadge('ALARM')} <strong>${s.alarmCount}</strong></div>
<div class="evt-count">${_evtBadge('CHANGE')} <strong>${s.changeCount}</strong></div>
</div>
<div class="evt-total">합계 <strong>${s.totalEvents}</strong>건</div>
</div>`).join('')}</div>`;
}
function evtReset() {
document.getElementById('ef-tag').value = '';
document.getElementById('ef-event-type').value = '';
document.getElementById('ef-area').value = '';
document.getElementById('ef-section').value = '';
document.getElementById('ef-limit').value = '500';
dtClearField('evt-from');
dtClearField('evt-to');
document.getElementById('evt-result-info').classList.add('hidden');
document.getElementById('evt-table').classList.add('hidden');
document.getElementById('evt-summary-card').classList.add('hidden');
document.getElementById('evt-tag-status').textContent = '';
}
/* ─────────────────────────────────────────────────────────────
07-2 하이퍼테이블 관리
───────────────────────────────────────────────────────────── */