feat: 디지털 이벤트 히스토리 기능 추가 (event_history_table, DigitalEventDetectorService, API, UI)
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
/// 하이퍼테이블 상태 조회합니다.
|
||||
/// 하이퍼테이블인지 여부, 레코드 수, 보존 정책, 압축, 연속 집계 설정 등을 확인합니다.
|
||||
|
||||
226
src/Infrastructure/OpcUa/DigitalEventDetectorService.cs
Normal file
226
src/Infrastructure/OpcUa/DigitalEventDetectorService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)와 통신
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 하이퍼테이블 관리
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user