1760 lines
62 KiB
Markdown
1760 lines
62 KiB
Markdown
# fastTable/fastRecord 코딩 플랜 (Qwen3-Coder-Next)
|
||
|
||
## 개요
|
||
|
||
`idea-fastTable.md`의 요구사항을 기반으로 ExperionCrawler 프로젝트에 fastTable/fastRecord 기능을 구현합니다.
|
||
|
||
### 핵심 개념
|
||
|
||
| 개념 | 설명 |
|
||
|------|------|
|
||
| **fastSession** | 데이터 수집 세션 (메타 정보 저장) |
|
||
| **fastRecord** | 초 단위로 수집된 실시간 데이터 (TimescaleDB hypertable) |
|
||
| **별도 Subscription** | 기존 realtime Subscription과 분리된 고해상도 구독 |
|
||
|
||
---
|
||
|
||
## Task A — DB 스키마 + 엔티티
|
||
|
||
### 1. `ExperionEntities.cs` — 엔티티 추가
|
||
|
||
**파일**: `src/Core/Domain/Entities/ExperionEntities.cs`
|
||
|
||
```csharp
|
||
/// <summary>fastSession — 데이터 수집 세션 메타</summary>
|
||
[Table("fast_session")]
|
||
public class FastSession
|
||
{
|
||
[Column("id")] public int Id { get; set; }
|
||
[Column("name")] public string Name { get; set; } = string.Empty;
|
||
[Column("started_at")] public DateTime StartedAt { get; set; }
|
||
[Column("ended_at")] public DateTime? EndedAt { get; set; }
|
||
[Column("status")] public string Status { get; set; } = "Pending"; // Pending/Running/Completed/Cancelled/Failed/RowLimitReached
|
||
[Column("sampling_ms")] public int SamplingMs { get; set; } // 100/250/500/1000
|
||
[Column("duration_sec")] public int DurationSec { get; set; }
|
||
[Column("tag_list")] public string TagList { get; set; } = "[]"; // JSON array of tagNames
|
||
[Column("row_count")] public int RowCount { get; set; }
|
||
[Column("retention_days")] public int? RetentionDays { get; set; } // null = 무한 보관
|
||
[Column("pinned")] public bool Pinned { get; set; }
|
||
}
|
||
|
||
/// <summary>fastRecord — 시계열 데이터 (Long 포맷: 태그 1행/시점)</summary>
|
||
[Table("fast_record")]
|
||
public class FastRecord
|
||
{
|
||
[Column("id")] public int Id { get; set; }
|
||
[Column("session_id")] public int SessionId { get; set; }
|
||
[Column("recorded_at")] public DateTime RecordedAt { get; set; }
|
||
[Column("tagname")] public string TagName { get; set; } = string.Empty;
|
||
[Column("value")] public string? Value { get; set; }
|
||
}
|
||
```
|
||
|
||
### 2. `ExperionDbContext.cs` — DbSet + 테이블 생성
|
||
|
||
**파일**: `src/Infrastructure/Database/ExperionDbContext.cs`
|
||
|
||
#### DbSet 추가 (20~25번째 줄 근처)
|
||
|
||
```csharp
|
||
public DbSet<FastSession> FastSessions => Set<FastSession>();
|
||
public DbSet<FastRecord> FastRecords => Set<FastRecord>();
|
||
```
|
||
|
||
#### OnModelCreating에 인덱스 추가 (57번째 줄 근처)
|
||
|
||
```csharp
|
||
modelBuilder.Entity<FastSession>(e =>
|
||
{
|
||
e.HasKey(x => x.Id);
|
||
e.HasIndex(x => x.Status);
|
||
e.HasIndex(x => x.StartedAt);
|
||
});
|
||
|
||
modelBuilder.Entity<FastRecord>(e =>
|
||
{
|
||
e.HasKey(x => x.Id);
|
||
e.HasIndex(x => x.SessionId);
|
||
e.HasIndex(x => new { x.SessionId, x.TagName, x.RecordedAt });
|
||
});
|
||
```
|
||
|
||
#### DB 초기화 시 hypertable 생성 (100번째 줄 근처)
|
||
|
||
`ExperionDbService.InitializeAsync()` 메서드에 추가:
|
||
|
||
```csharp
|
||
// fastSession / fastRecord 테이블 생성
|
||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||
CREATE TABLE IF NOT EXISTS fast_session (
|
||
id SERIAL PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
started_at TIMESTAMPTZ NOT NULL,
|
||
ended_at TIMESTAMPTZ,
|
||
status TEXT NOT NULL DEFAULT 'Pending',
|
||
sampling_ms INTEGER NOT NULL,
|
||
duration_sec INTEGER NOT NULL,
|
||
tag_list JSONB NOT NULL DEFAULT '[]',
|
||
row_count INTEGER NOT NULL DEFAULT 0,
|
||
retention_days INTEGER,
|
||
pinned BOOLEAN NOT NULL DEFAULT FALSE
|
||
)
|
||
""");
|
||
|
||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||
CREATE TABLE IF NOT EXISTS fast_record (
|
||
id SERIAL PRIMARY KEY,
|
||
session_id INTEGER NOT NULL REFERENCES fast_session(id) ON DELETE CASCADE,
|
||
recorded_at TIMESTAMPTZ NOT NULL,
|
||
tagname TEXT NOT NULL,
|
||
value TEXT
|
||
)
|
||
""");
|
||
|
||
// TimescaleDB hypertable 생성 (recorded_at 기준, chunk_interval = 1 day)
|
||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||
SELECT create_hypertable('fast_record', 'recorded_at', if_not_exists => TRUE)
|
||
""");
|
||
|
||
// chunk_time_interval 설정 (기본 1day)
|
||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||
SELECT set_chunk_time_interval('fast_record', INTERVAL '1 day')
|
||
""");
|
||
```
|
||
|
||
### 3. `IExperionServices.cs` — 인터페이스 추가
|
||
|
||
**파일**: `src/Core/Application/Interfaces/IExperionServices.cs`
|
||
|
||
#### DTOs 추가 (DTOs 파일에 별도 정의 후 import)
|
||
|
||
```csharp
|
||
// IExperionFastService 인터페이스
|
||
public interface IExperionFastService
|
||
{
|
||
Task<FastSessionInfo> StartSessionAsync(FastSessionStartRequest request);
|
||
Task StopSessionAsync(int sessionId);
|
||
Task DeleteSessionAsync(int sessionId);
|
||
Task PinSessionAsync(int sessionId, bool pinned);
|
||
Task<FastSessionInfo?> GetSessionAsync(int sessionId);
|
||
Task<IEnumerable<FastSessionInfo>> GetSessionsAsync();
|
||
Task<FastQueryResult> GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long");
|
||
Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null);
|
||
}
|
||
|
||
// FastSessionStatus enum
|
||
public enum FastSessionStatus
|
||
{
|
||
Pending, Running, Completed, Cancelled, Failed, RowLimitReached
|
||
}
|
||
|
||
// FastSessionInfo record
|
||
public record FastSessionInfo(
|
||
int Id,
|
||
string Name,
|
||
DateTime StartedAt,
|
||
DateTime? EndedAt,
|
||
string Status,
|
||
int SamplingMs,
|
||
int DurationSec,
|
||
string[] TagList,
|
||
int RowCount,
|
||
int? RetentionDays,
|
||
bool Pinned
|
||
);
|
||
|
||
// FastSessionCreateRequest record (Claude 진단 #3)
|
||
public record FastSessionCreateRequest(
|
||
string Name,
|
||
int SamplingMs,
|
||
int DurationSec,
|
||
string[] TagList,
|
||
int? RetentionDays = null
|
||
);
|
||
|
||
// FastQueryResult record
|
||
public record FastQueryResult(
|
||
int SessionId,
|
||
DateTime From,
|
||
DateTime To,
|
||
string[] TagNames,
|
||
IEnumerable<FastRecord> Items,
|
||
int TotalCount
|
||
);
|
||
|
||
// FastSessionStartRequest record
|
||
public record FastSessionStartRequest(
|
||
string Name,
|
||
int SamplingMs,
|
||
int DurationSec,
|
||
string[] TagList,
|
||
int? RetentionDays = null
|
||
);
|
||
```
|
||
|
||
---
|
||
|
||
## Task B — FastService (백그라운드 + 컨트롤러)
|
||
|
||
### 1. `ExperionFastService.cs` — 신규 파일 생성
|
||
|
||
**파일**: `src/Infrastructure/OpcUa/ExperionFastService.cs`
|
||
|
||
```csharp
|
||
using System.Collections.Concurrent;
|
||
using System.Text.Json; // ✅ 추가: JsonSerializer.Serialize/Deserialize 사용 (Claude 진단 #7)
|
||
using ExperionCrawler.Core.Application.Interfaces;
|
||
using ExperionCrawler.Core.Domain.Entities;
|
||
using Microsoft.Extensions.Hosting;
|
||
using Microsoft.Extensions.Logging;
|
||
using Opc.Ua;
|
||
using Opc.Ua.Client;
|
||
|
||
namespace ExperionCrawler.Infrastructure.OpcUa;
|
||
|
||
/// <summary>
|
||
/// fastRecord 데이터 수집 서비스.
|
||
/// 세션별 별도 OPC UA Subscription을 관리하고, 2초마다 배치 INSERT.
|
||
/// </summary>
|
||
public class ExperionFastService : IExperionFastService, IHostedService, IDisposable
|
||
{
|
||
private readonly IServiceScopeFactory _scopeFactory;
|
||
private readonly ILogger<ExperionFastService> _logger;
|
||
private readonly IServiceProvider _sp;
|
||
private readonly IOpcUaConfigProvider _configProvider;
|
||
private readonly IExperionOpcClient _opcClient;
|
||
|
||
private readonly ConcurrentDictionary<int, FastSessionContext> _sessions = new();
|
||
private readonly object _lock = new();
|
||
|
||
private CancellationTokenSource? _cts;
|
||
private Task? _monitorTask;
|
||
|
||
// 설정
|
||
private int _maxConcurrentSessions = 3;
|
||
private int _maxRowsPerSession = 5_000_000;
|
||
private int _flushIntervalMs = 2000;
|
||
|
||
public ExperionFastService(
|
||
IServiceScopeFactory scopeFactory,
|
||
ILogger<ExperionFastService> logger,
|
||
IServiceProvider sp,
|
||
IOpcUaConfigProvider configProvider,
|
||
IExperionOpcClient opcClient)
|
||
{
|
||
_scopeFactory = scopeFactory;
|
||
_logger = logger;
|
||
_sp = sp;
|
||
_configProvider = configProvider;
|
||
_opcClient = opcClient;
|
||
}
|
||
|
||
// ── IHostedService ────────────────────────────────────────────────────────
|
||
|
||
public async Task StartAsync(CancellationToken cancellationToken)
|
||
{
|
||
// 앱 시작 시 Running 상태 세션 → Failed 마킹
|
||
using var scope = _scopeFactory.CreateScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||
var sessions = await db.GetFastSessionsAsync();
|
||
var runningSessions = sessions.Where(s => s.Status == "Running").ToList();
|
||
|
||
foreach (var s in runningSessions)
|
||
{
|
||
_logger.LogWarning("[Fast] 앱 시작 시 Running 세션 {Id} → Failed 마킹", s.Id);
|
||
await db.UpdateFastSessionStatusAsync(s.Id, "Failed");
|
||
}
|
||
|
||
// 모니터링 루프 시작
|
||
_cts = new CancellationTokenSource();
|
||
_monitorTask = Task.Run(() => MonitorLoopAsync(_cts.Token), _cts.Token);
|
||
}
|
||
|
||
public async Task StopAsync(CancellationToken cancellationToken)
|
||
{
|
||
_cts?.Cancel();
|
||
if (_monitorTask != null)
|
||
await _monitorTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
||
|
||
// 모든 Running 세션 graceful 종료
|
||
foreach (var kvp in _sessions)
|
||
{
|
||
var ctx = kvp.Value;
|
||
ctx.Cancel = true;
|
||
}
|
||
|
||
await Task.Delay(2000).ConfigureAwait(false); // flush 대기
|
||
}
|
||
|
||
// ── IExperionFastService ──────────────────────────────────────────────────
|
||
|
||
public async Task<FastSessionInfo> StartSessionAsync(FastSessionStartRequest request)
|
||
{
|
||
// 유효성 검사
|
||
if (request.TagList.Length > 8)
|
||
throw new ArgumentException("태그는 최대 8개까지 가능합니다.");
|
||
|
||
if (request.SamplingMs is not (100 or 250 or 500 or 1000))
|
||
throw new ArgumentException("샘플링 간격은 100/250/500/1000ms 중 하나여야 합니다.");
|
||
|
||
// 동시 세션 수 제한
|
||
using var scope = _scopeFactory.CreateScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||
var runningCount = (await db.GetFastSessionsAsync()).Count(s => s.Status == "Running");
|
||
|
||
if (runningCount >= _maxConcurrentSessions)
|
||
throw new InvalidOperationException($"동시 실행 가능한 세션은 {_maxConcurrentSessions}개까지입니다.");
|
||
|
||
// ✅ 수정: IOpcUaConfigProvider를 통해 서버 설정 가져오기 (Claude 진단 #5)
|
||
var cfg = await _configProvider.GetConfigAsync(new ExperionServerConfig());
|
||
if (!await _opcClient.IsConnectedAsync(cfg))
|
||
throw new InvalidOperationException("OPC UA 서버에 연결되어 있지 않습니다.");
|
||
|
||
// ✅ 추가: 설정이 유효한지 확인
|
||
if (string.IsNullOrEmpty(cfg?.EndpointUrl))
|
||
throw new InvalidOperationException("서버 엔드포인트 URL이 설정되어 있지 않습니다.");
|
||
|
||
// 노드 유효성 사전 검증 (Read 1회)
|
||
foreach (var tagName in request.TagList)
|
||
{
|
||
var nodeId = await db.GetNodeIdByTagNameAsync(tagName);
|
||
if (string.IsNullOrEmpty(nodeId))
|
||
throw new ArgumentException($"태그 '{tagName}'에 대한 nodeId를 찾을 수 없습니다.");
|
||
|
||
var readResult = await _opcClient.ReadTagAsync(cfg, nodeId);
|
||
if (!readResult.Success)
|
||
throw new ArgumentException($"태그 '{tagName}' (nodeId: {nodeId}) 읽기 실패: {readResult.ErrorMessage}");
|
||
}
|
||
|
||
// DB에 세션 생성
|
||
var session = await db.CreateFastSessionAsync(new FastSessionCreateRequest
|
||
{
|
||
Name = request.Name,
|
||
SamplingMs = request.SamplingMs,
|
||
DurationSec = request.DurationSec,
|
||
TagList = request.TagList,
|
||
RetentionDays = request.RetentionDays
|
||
});
|
||
|
||
// Subscription 생성
|
||
var ctx = new FastSessionContext
|
||
{
|
||
SessionId = session.Id,
|
||
TagList = request.TagList,
|
||
SamplingMs = request.SamplingMs,
|
||
DurationSec = request.DurationSec,
|
||
StartedAt = DateTime.UtcNow,
|
||
Buffer = new ConcurrentQueue<FastRecord>()
|
||
};
|
||
|
||
_sessions[session.Id] = ctx;
|
||
|
||
// Subscription 시작
|
||
await StartSubscriptionAsync(ctx, cfg);
|
||
|
||
// 상태 업데이트
|
||
await db.UpdateFastSessionStatusAsync(session.Id, "Running");
|
||
|
||
_logger.LogInformation("[Fast] 세션 {Id} 시작 — 태그 {Count}개, 샘플링 {Ms}ms, 기간 {Sec}s",
|
||
session.Id, request.TagList.Length, request.SamplingMs, request.DurationSec);
|
||
|
||
return MapToInfo(session);
|
||
}
|
||
|
||
public async Task StopSessionAsync(int sessionId)
|
||
{
|
||
if (!_sessions.TryGetValue(sessionId, out var ctx))
|
||
throw new InvalidOperationException($"세션 {sessionId}를 찾을 수 없습니다.");
|
||
|
||
ctx.Cancel = true;
|
||
await FlushBufferAsync(ctx).ConfigureAwait(false);
|
||
await StopSubscriptionAsync(ctx).ConfigureAwait(false);
|
||
|
||
using var scope = _scopeFactory.CreateScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||
await db.UpdateFastSessionStatusAsync(sessionId, "Completed");
|
||
// ✅ 수정: 누적 RowCount 사용 (Claude 진단 #8)
|
||
await db.UpdateFastSessionRowCountAsync(sessionId, ctx.TotalRows);
|
||
|
||
_logger.LogInformation("[Fast] 세션 {Id} 중지 — 수집 {Count}행", sessionId, ctx.TotalRows);
|
||
}
|
||
|
||
public async Task DeleteSessionAsync(int sessionId)
|
||
{
|
||
using var scope = _scopeFactory.CreateScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||
await db.DeleteFastSessionAsync(sessionId);
|
||
_sessions.TryRemove(sessionId, out _);
|
||
}
|
||
|
||
public async Task PinSessionAsync(int sessionId, bool pinned)
|
||
{
|
||
using var scope = _scopeFactory.CreateScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||
await db.UpdateFastSessionPinnedAsync(sessionId, pinned);
|
||
}
|
||
|
||
public async Task<FastSessionInfo?> GetSessionAsync(int sessionId)
|
||
{
|
||
using var scope = _scopeFactory.CreateScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||
var session = await db.GetFastSessionAsync(sessionId);
|
||
return session == null ? null : MapToInfo(session);
|
||
}
|
||
|
||
public async Task<IEnumerable<FastSessionInfo>> GetSessionsAsync()
|
||
{
|
||
using var scope = _scopeFactory.CreateScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||
var sessions = await db.GetFastSessionsAsync();
|
||
return sessions.Select(MapToInfo);
|
||
}
|
||
|
||
public async Task<FastQueryResult> GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long")
|
||
{
|
||
using var scope = _scopeFactory.CreateScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||
var result = await db.GetFastRecordsAsync(sessionId, from, to);
|
||
return result;
|
||
}
|
||
|
||
public async Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null)
|
||
{
|
||
using var scope = _scopeFactory.CreateScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||
await db.ExportFastRecordsToCsvAsync(sessionId, stream, from, to);
|
||
}
|
||
|
||
// ── Private ────────────────────────────────────────────────────────────────
|
||
|
||
// ✅ 참고: CreateSessionAsync 사용을 위해 파일 상단에 using Opc.Ua; 추가 필요
|
||
private async Task StartSubscriptionAsync(FastSessionContext ctx, ApplicationConfiguration cfg)
|
||
{
|
||
var session = await _opcClient.CreateSessionAsync(cfg);
|
||
var subscription = new Subscription(session.DefaultSubscription)
|
||
{
|
||
PublishingInterval = ctx.SamplingMs,
|
||
KeepAliveCount = 10
|
||
};
|
||
|
||
foreach (var tagName in ctx.TagList)
|
||
{
|
||
var nodeId = await GetNodeIdAsync(tagName);
|
||
var item = new MonitoredItem(subscription)
|
||
{
|
||
StartNodeId = nodeId,
|
||
SamplingInterval = ctx.SamplingMs,
|
||
DisplayName = tagName
|
||
};
|
||
item.Notification += (sender, e) => OnNotification(ctx, e, tagName);
|
||
subscription.AddItem(item);
|
||
}
|
||
|
||
await session.AddSubscriptionAsync(subscription);
|
||
subscription.Create();
|
||
|
||
ctx.Subscription = subscription;
|
||
ctx.Session = session;
|
||
}
|
||
|
||
private async Task StopSubscriptionAsync(FastSessionContext ctx)
|
||
{
|
||
if (ctx.Subscription != null)
|
||
{
|
||
ctx.Subscription.Delete(false);
|
||
ctx.Subscription = null;
|
||
}
|
||
|
||
if (ctx.Session != null)
|
||
{
|
||
await ctx.Session.CloseAsync();
|
||
await ctx.Session.DisposeAsync();
|
||
ctx.Session = null;
|
||
}
|
||
}
|
||
|
||
private void OnNotification(FastSessionContext ctx, MonitoredItemNotificationEventArgs e, string tagName)
|
||
{
|
||
if (ctx.Cancel) return;
|
||
|
||
// ✅ 수정: MonitoredItemNotification 사용 (Claude 진단 #6)
|
||
if (e.NotificationValue is MonitoredItemNotification notification)
|
||
{
|
||
var dv = notification.Value;
|
||
var value = dv.Value?.ToString();
|
||
|
||
var record = new FastRecord
|
||
{
|
||
SessionId = ctx.SessionId,
|
||
RecordedAt = DateTime.UtcNow,
|
||
TagName = tagName,
|
||
Value = value
|
||
};
|
||
ctx.Buffer.Enqueue(record);
|
||
|
||
// ✅ 수정: 누적 RowCount 증가 (Claude 진단 #8, #9)
|
||
ctx.TotalRows++;
|
||
}
|
||
}
|
||
|
||
private async Task FlushBufferAsync(FastSessionContext ctx)
|
||
{
|
||
var buffer = new List<FastRecord>();
|
||
while (ctx.Buffer.TryDequeue(out var record))
|
||
{
|
||
buffer.Add(record);
|
||
}
|
||
|
||
if (buffer.Count == 0) return;
|
||
|
||
using var scope = _scopeFactory.CreateScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||
await db.BatchInsertFastRecordsAsync(buffer);
|
||
|
||
// ✅ 수정: 누적 RowCount 업데이트 (Claude 진단 #8)
|
||
await db.UpdateFastSessionRowCountAsync(ctx.SessionId, ctx.TotalRows);
|
||
|
||
// ✅ 수정: 누적 총 행 수와 비교 (Claude 진단 #9)
|
||
if (ctx.TotalRows >= _maxRowsPerSession)
|
||
{
|
||
ctx.Cancel = true;
|
||
await StopSessionAsync(ctx.SessionId);
|
||
using var s = _scopeFactory.CreateScope();
|
||
var d = s.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||
await d.UpdateFastSessionStatusAsync(ctx.SessionId, "RowLimitReached");
|
||
}
|
||
}
|
||
|
||
private async Task MonitorLoopAsync(CancellationToken ct)
|
||
{
|
||
while (!ct.IsCancellationRequested)
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(_flushIntervalMs, ct);
|
||
|
||
foreach (var kvp in _sessions)
|
||
{
|
||
var ctx = kvp.Value;
|
||
if (ctx.Cancel) continue;
|
||
|
||
// 기간 만료 체크
|
||
var elapsed = (DateTime.UtcNow - ctx.StartedAt).TotalSeconds;
|
||
if (elapsed >= ctx.DurationSec)
|
||
{
|
||
ctx.Cancel = true;
|
||
await StopSessionAsync(ctx.SessionId);
|
||
continue;
|
||
}
|
||
|
||
await FlushBufferAsync(ctx);
|
||
}
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "[Fast] 모니터링 루프 오류");
|
||
}
|
||
}
|
||
}
|
||
|
||
private FastSessionInfo MapToInfo(FastSession session)
|
||
=> new(session.Id, session.Name, session.StartedAt, session.EndedAt,
|
||
session.Status, session.SamplingMs, session.DurationSec,
|
||
// ✅ 수정: JSONB 파싱을 위해 JsonSerializer.Deserialize 사용 (Claude 진단 #7)
|
||
JsonSerializer.Deserialize<string[]>(session.TagList) ?? [],
|
||
session.RowCount, session.RetentionDays, session.Pinned);
|
||
|
||
// ✅ 참고: JsonSerializer.Deserialize 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요
|
||
private async Task<string> GetNodeIdAsync(string tagName)
|
||
{
|
||
using var scope = _scopeFactory.CreateScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||
return await db.GetNodeIdByTagNameAsync(tagName) ?? string.Empty;
|
||
}
|
||
|
||
private class FastSessionContext
|
||
{
|
||
public int SessionId { get; set; }
|
||
public string[] TagList { get; set; } = [];
|
||
public int SamplingMs { get; set; }
|
||
public int DurationSec { get; set; }
|
||
public DateTime StartedAt { get; set; }
|
||
public ConcurrentQueue<FastRecord> Buffer { get; set; } = new();
|
||
public int TotalRows { get; set; } // ✅ 수정: 누적 총 행 수 (Claude 진단 #8, #9)
|
||
public bool Cancel { get; set; }
|
||
public ISession? Session { get; set; }
|
||
public Subscription? Subscription { get; set; }
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2. `ExperionDbContext.cs` — DB 서비스 메서드 추가
|
||
|
||
**파일**: `src/Infrastructure/Database/ExperionDbContext.cs`
|
||
|
||
#### ExperionDbService에 추가할 메서드 (1000번째 줄 근처)
|
||
|
||
```csharp
|
||
// ✅ 참고: JsonSerializer 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요
|
||
|
||
// ── FastSession / FastRecord ────────────────────────────────────────────────
|
||
|
||
public async Task<FastSession> CreateFastSessionAsync(FastSessionCreateRequest request)
|
||
{
|
||
var session = new FastSession
|
||
{
|
||
Name = request.Name,
|
||
SamplingMs = request.SamplingMs,
|
||
DurationSec = request.DurationSec,
|
||
// ✅ 수정: JSONB 저장을 위해 JsonSerializer.Serialize 사용 (Claude 진단 #7)
|
||
TagList = JsonSerializer.Serialize(request.TagList),
|
||
StartedAt = DateTime.UtcNow,
|
||
Status = "Pending",
|
||
RowCount = 0,
|
||
RetentionDays = request.RetentionDays,
|
||
Pinned = false
|
||
};
|
||
|
||
_ctx.FastSessions.Add(session);
|
||
await _ctx.SaveChangesAsync();
|
||
return session;
|
||
}
|
||
|
||
public async Task UpdateFastSessionStatusAsync(int sessionId, string status)
|
||
{
|
||
var session = await _ctx.FastSessions.FindAsync(sessionId);
|
||
if (session == null) return;
|
||
|
||
session.Status = status;
|
||
if (status == "Completed" || status == "Cancelled" || status == "Failed" || status == "RowLimitReached")
|
||
session.EndedAt = DateTime.UtcNow;
|
||
|
||
await _ctx.SaveChangesAsync();
|
||
}
|
||
|
||
public async Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount)
|
||
{
|
||
var session = await _ctx.FastSessions.FindAsync(sessionId);
|
||
if (session == null) return;
|
||
|
||
session.RowCount = rowCount;
|
||
await _ctx.SaveChangesAsync();
|
||
}
|
||
|
||
public async Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned)
|
||
{
|
||
var session = await _ctx.FastSessions.FindAsync(sessionId);
|
||
if (session == null) return;
|
||
|
||
session.Pinned = pinned;
|
||
await _ctx.SaveChangesAsync();
|
||
}
|
||
|
||
public async Task<FastSession?> GetFastSessionAsync(int sessionId)
|
||
=> await _ctx.FastSessions.FindAsync(sessionId);
|
||
|
||
// ✅ 참고: JsonSerializer.Deserialize 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요
|
||
public async Task<IEnumerable<FastSession>> GetFastSessionsAsync()
|
||
=> await _ctx.FastSessions.OrderBy(x => x.StartedAt).ToListAsync();
|
||
|
||
public async Task DeleteFastSessionAsync(int sessionId)
|
||
{
|
||
var session = await _ctx.FastSessions.FindAsync(sessionId);
|
||
if (session == null) return;
|
||
|
||
_ctx.FastSessions.Remove(session);
|
||
await _ctx.SaveChangesAsync();
|
||
}
|
||
|
||
// ✅ 참고: JsonSerializer.Deserialize 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요
|
||
public async Task<FastQueryResult> GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to)
|
||
{
|
||
var query = _ctx.FastRecords.Where(x => x.SessionId == sessionId);
|
||
|
||
if (from.HasValue)
|
||
query = query.Where(x => x.RecordedAt >= from.Value);
|
||
|
||
if (to.HasValue)
|
||
query = query.Where(x => x.RecordedAt <= to.Value);
|
||
|
||
var records = await query.OrderBy(x => x.RecordedAt).ToListAsync();
|
||
var tagNames = records.Select(x => x.TagName).Distinct().OrderBy(x => x).ToArray();
|
||
|
||
return new FastQueryResult
|
||
{
|
||
SessionId = sessionId,
|
||
From = from ?? records.MinBy(x => x.RecordedAt)?.RecordedAt ?? DateTime.UtcNow,
|
||
To = to ?? records.MaxBy(x => x.RecordedAt)?.RecordedAt ?? DateTime.UtcNow,
|
||
TagNames = tagNames,
|
||
Items = records,
|
||
TotalCount = records.Count
|
||
};
|
||
}
|
||
|
||
public async Task BatchInsertFastRecordsAsync(IEnumerable<FastRecord> records)
|
||
{
|
||
if (!records.Any()) return;
|
||
|
||
_ctx.FastRecords.AddRange(records);
|
||
await _ctx.SaveChangesAsync();
|
||
}
|
||
|
||
// ✅ 참고: JsonSerializer.Deserialize 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요
|
||
public async Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null)
|
||
{
|
||
var result = await GetFastRecordsAsync(sessionId, from, to);
|
||
|
||
using var writer = new StreamWriter(stream, leaveOpen: true);
|
||
var header = "recorded_at," + string.Join(",", result.TagNames.Select(t => $"\"{t}\""));
|
||
await writer.WriteLineAsync(header);
|
||
|
||
// PIVOT: recorded_at 기준 그룹화
|
||
var grouped = result.Items.GroupBy(x => x.RecordedAt)
|
||
.OrderBy(x => x.Key)
|
||
.Select(g => new
|
||
{
|
||
Time = g.Key,
|
||
Values = g.ToDictionary(r => r.TagName, r => r.Value)
|
||
});
|
||
|
||
foreach (var g in grouped)
|
||
{
|
||
var row = g.Time.ToString("o") + "," + string.Join(",", result.TagNames.Select(t => g.Values.TryGetValue(t, out var v) ? $"\"{v}\"" : ""));
|
||
await writer.WriteLineAsync(row);
|
||
}
|
||
|
||
await writer.FlushAsync();
|
||
}
|
||
|
||
public async Task<string> GetNodeIdByTagNameAsync(string tagName)
|
||
{
|
||
return await _ctx.RealtimePoints
|
||
.Where(x => x.TagName == tagName)
|
||
.Select(x => x.NodeId)
|
||
.FirstOrDefaultAsync();
|
||
}
|
||
```
|
||
|
||
### 3. `ExperionFastController` — 컨트롤러 추가
|
||
|
||
**파일**: `src/Web/Controllers/ExperionControllers.cs`
|
||
|
||
```csharp
|
||
// ── FastTable / FastRecord ────────────────────────────────────────────────────
|
||
|
||
[ApiController]
|
||
[Route("api/fast")]
|
||
public class ExperionFastController : ControllerBase
|
||
{
|
||
private readonly IExperionFastService _fastSvc;
|
||
|
||
public ExperionFastController(IExperionFastService fastSvc)
|
||
=> _fastSvc = fastSvc;
|
||
|
||
/// <summary>새 fastSession 시작</summary>
|
||
[HttpPost("start")]
|
||
public async Task<IActionResult> Start([FromBody] FastSessionStartRequest request)
|
||
{
|
||
try
|
||
{
|
||
var session = await _fastSvc.StartSessionAsync(request);
|
||
return Ok(new
|
||
{
|
||
id = session.Id,
|
||
name = session.Name,
|
||
status = session.Status,
|
||
startedAt = session.StartedAt
|
||
});
|
||
}
|
||
catch (ArgumentException ex)
|
||
{
|
||
return BadRequest(new { error = ex.Message });
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
return Conflict(new { error = ex.Message });
|
||
}
|
||
}
|
||
|
||
/// <summary>세션 중지</summary>
|
||
[HttpPost("{id:int}/stop")]
|
||
public async Task<IActionResult> Stop(int id)
|
||
{
|
||
try
|
||
{
|
||
await _fastSvc.StopSessionAsync(id);
|
||
return Ok(new { success = true, message = "세션이 중지되었습니다." });
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
return NotFound(new { error = ex.Message });
|
||
}
|
||
}
|
||
|
||
/// <summary>세션 목록 조회</summary>
|
||
[HttpGet("sessions")]
|
||
public async Task<IActionResult> GetSessions()
|
||
{
|
||
var sessions = await _fastSvc.GetSessionsAsync();
|
||
return Ok(new
|
||
{
|
||
total = sessions.Count(),
|
||
items = sessions.Select(s => new
|
||
{
|
||
id = s.Id,
|
||
name = s.Name,
|
||
status = s.Status,
|
||
samplingMs = s.SamplingMs,
|
||
durationSec = s.DurationSec,
|
||
tagCount = s.TagList.Length,
|
||
rowCount = s.RowCount,
|
||
startedAt = s.StartedAt,
|
||
endedAt = s.EndedAt,
|
||
retentionDays = s.RetentionDays,
|
||
pinned = s.Pinned
|
||
})
|
||
});
|
||
}
|
||
|
||
/// <summary>세션 상세 정보</summary>
|
||
[HttpGet("{id:int}")]
|
||
public async Task<IActionResult> GetSession(int id)
|
||
{
|
||
var session = await _fastSvc.GetSessionAsync(id);
|
||
if (session == null)
|
||
return NotFound();
|
||
|
||
return Ok(new
|
||
{
|
||
id = session.Id,
|
||
name = session.Name,
|
||
status = session.Status,
|
||
samplingMs = session.SamplingMs,
|
||
durationSec = session.DurationSec,
|
||
tagList = session.TagList,
|
||
rowCount = session.RowCount,
|
||
startedAt = session.StartedAt,
|
||
endedAt = session.EndedAt,
|
||
retentionDays = session.RetentionDays,
|
||
pinned = session.Pinned
|
||
});
|
||
}
|
||
|
||
/// <summary>레코드 조회</summary>
|
||
[HttpGet("{id:int}/records")]
|
||
public async Task<IActionResult> GetRecords(int id,
|
||
[FromQuery] DateTime? from,
|
||
[FromQuery] DateTime? to,
|
||
[FromQuery] string format = "long")
|
||
{
|
||
var result = await _fastSvc.GetRecordsAsync(id, from, to, format);
|
||
|
||
return Ok(new
|
||
{
|
||
sessionId = result.SessionId,
|
||
from = result.From,
|
||
to = result.To,
|
||
tagNames = result.TagNames,
|
||
total = result.TotalCount,
|
||
items = result.Items.Select(r => new
|
||
{
|
||
sessionId = r.SessionId,
|
||
recordedAt = r.RecordedAt,
|
||
tagName = r.TagName,
|
||
value = r.Value
|
||
})
|
||
});
|
||
}
|
||
|
||
/// <summary>CSV Export (스트리밍)</summary>
|
||
[HttpGet("{id:int}/csv")]
|
||
public async Task<IActionResult> ExportCsv(int id,
|
||
[FromQuery] DateTime? from,
|
||
[FromQuery] DateTime? to)
|
||
{
|
||
var memoryStream = new MemoryStream();
|
||
await _fastSvc.ExportCsvAsync(id, memoryStream, from, to);
|
||
memoryStream.Position = 0;
|
||
|
||
return File(memoryStream, "text/csv", $"fast-{id}-{DateTime.Now:yyyyMMddHHmm}.csv");
|
||
}
|
||
|
||
/// <summary>세션 삭제</summary>
|
||
[HttpDelete("{id:int}")]
|
||
public async Task<IActionResult> Delete(int id)
|
||
{
|
||
try
|
||
{
|
||
await _fastSvc.DeleteSessionAsync(id);
|
||
return Ok(new { success = true, message = "세션이 삭제되었습니다." });
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
return NotFound(new { error = ex.Message });
|
||
}
|
||
}
|
||
|
||
/// <summary>세션 고정/해제</summary>
|
||
[HttpPost("{id:int}/pin")]
|
||
public async Task<IActionResult> Pin(int id, [FromBody] PinRequest request)
|
||
{
|
||
try
|
||
{
|
||
await _fastSvc.PinSessionAsync(id, request.Pinned);
|
||
return Ok(new { success = true, pinned = request.Pinned });
|
||
}
|
||
catch (InvalidOperationException ex)
|
||
{
|
||
return NotFound(new { error = ex.Message });
|
||
}
|
||
}
|
||
}
|
||
|
||
public record PinRequest(bool Pinned);
|
||
```
|
||
|
||
### 4. `Program.cs` — 서비스 등록
|
||
|
||
**파일**: `src/Web/Program.cs`
|
||
|
||
```csharp
|
||
// ── FastTable Service ────────────────────────────────────────────────────────
|
||
// ✅ 수정: 올바른 DI 등록 패턴 (Claude 진단 #4)
|
||
builder.Services.AddSingleton<ExperionFastService>();
|
||
builder.Services.AddSingleton<IExperionFastService>(sp => sp.GetRequiredService<ExperionFastService>());
|
||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionFastService>());
|
||
```
|
||
|
||
### 5. `appsettings.json` — 설정 추가
|
||
|
||
**파일**: `src/Web/appsettings.json`
|
||
|
||
```json
|
||
{
|
||
"Fast": {
|
||
"MaxConcurrentSessions": 3,
|
||
"MaxRowsPerSession": 5000000,
|
||
"FlushIntervalMs": 2000
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Task C — UI: 09 fastRecord 탭
|
||
|
||
### 1. `index.html` — HTML 구조 추가
|
||
|
||
**파일**: `src/Web/wwwroot/index.html`
|
||
|
||
```html
|
||
<!-- 사이드바 메뉴 -->
|
||
<li><a href="#pane-fast">09 fastRecord</a></li>
|
||
|
||
<!-- fastRecord 패널 -->
|
||
<div id="pane-fast" class="tab-pane fade">
|
||
<div class="tab-content">
|
||
<div class="row">
|
||
<!-- 좌측: 세션 목록 -->
|
||
<div class="col-md-3">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h5>fastSession 목록</h5>
|
||
<button id="btn-fast-new" class="btn btn-sm btn-primary float-end">신규 세션</button>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<div id="fast-session-list" class="list-group list-group-flush">
|
||
<!-- 세션 항목들 -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 우측: 그래프 및 통계 -->
|
||
<div class="col-md-9">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h5 id="fast-session-title">세션 상세</h5>
|
||
<div id="fast-session-controls" class="btn-group">
|
||
<button id="btn-fast-stop" class="btn btn-sm btn-danger">중지</button>
|
||
<button id="btn-fast-export-xlsx" class="btn btn-sm btn-success">Excel</button>
|
||
<button id="btn-fast-export-csv" class="btn btn-sm btn-info">CSV</button>
|
||
<button id="btn-fast-delete" class="btn btn-sm btn-secondary">삭제</button>
|
||
<button id="btn-fast-pin" class="btn btn-sm btn-warning">고정</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">
|
||
<!-- 진행률 -->
|
||
<div class="progress mb-2" style="height: 20px;">
|
||
<div id="fast-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
|
||
</div>
|
||
<div class="d-flex justify-content-between small text-muted mb-2">
|
||
<span id="fast-progress-text">0 / 0 (0%)</span>
|
||
<span id="fast-elapsed-time">경과: 0s</span>
|
||
</div>
|
||
|
||
<!-- 그래프 -->
|
||
<div id="fast-chart-container" style="height: 400px; width: 100%;"></div>
|
||
|
||
<!-- 통계 -->
|
||
<div id="fast-stats-panel" class="mt-3">
|
||
<h6>통계 요약</h6>
|
||
<div id="fast-stats-grid" class="row"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 모달: 신규 세션 -->
|
||
<div class="modal fade" id="modal-fast-new" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">신규 fastSession</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label class="form-label">세션 이름</label>
|
||
<input type="text" class="form-control" id="fast-session-name" placeholder="예: 공정온도_분석_20260428">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">태그 선택 (최대 8개)</label>
|
||
<select id="fast-tag-select" class="form-select" multiple size="10"></select>
|
||
</div>
|
||
<div class="row">
|
||
<div class="col-md-6 mb-3">
|
||
<label class="form-label">샘플링 간격 (ms)</label>
|
||
<select class="form-select" id="fast-sampling-ms">
|
||
<option value="100">100ms</option>
|
||
<option value="250">250ms</option>
|
||
<option value="500" selected>500ms</option>
|
||
<option value="1000">1000ms</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-md-6 mb-3">
|
||
<label class="form-label">수집 기간 (초)</label>
|
||
<select class="form-select" id="fast-duration-sec">
|
||
<option value="60">1분</option>
|
||
<option value="300">5분</option>
|
||
<option value="900">15분</option>
|
||
<option value="1800">30분</option>
|
||
<option value="3600" selected>1시간</option>
|
||
<option value="7200">2시간</option>
|
||
<option value="14400">4시간</option>
|
||
<option value="43200">12시간</option>
|
||
<option value="86400">24시간</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">보관 기간 (일, null=무한)</label>
|
||
<input type="number" class="form-control" id="fast-retention-days" placeholder="30">
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||
<button type="button" class="btn btn-primary" id="btn-fast-start">시작</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
### 2. `app.js` — JavaScript 로직 추가
|
||
|
||
**파일**: `src/Web/wwwroot/js/app.js`
|
||
|
||
```javascript
|
||
// ── fastRecord Variables ──────────────────────────────────────────────────────
|
||
let fastCurrentSessionId = null;
|
||
let fastChart = null;
|
||
let fastLivePollTimer = null;
|
||
let fastTagList = [];
|
||
|
||
// ── fastRecord Functions ──────────────────────────────────────────────────────
|
||
|
||
async function fastSessionsLoad() {
|
||
const res = await fetch('/api/fast/sessions');
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
|
||
const list = document.getElementById('fast-session-list');
|
||
list.innerHTML = '';
|
||
|
||
data.items.forEach(s => {
|
||
const item = document.createElement('a');
|
||
item.className = 'list-group-item list-group-item-action';
|
||
item.href = '#';
|
||
item.dataset.id = s.id;
|
||
item.innerHTML = `
|
||
<div class="d-flex w-100 justify-content-between">
|
||
<h6 class="mb-1">${s.name}</h6>
|
||
<small>${s.status}</small>
|
||
</div>
|
||
<p class="mb-1">${s.tagCount} tags • ${s.samplingMs}ms • ${formatDuration(s.durationSec)}</p>
|
||
<small>${formatDateTime(s.startedAt)}</small>
|
||
${s.pinned ? '<span class="badge bg-warning text-dark ms-1">📌</span>' : ''}
|
||
`;
|
||
item.onclick = () => fastSelect(s.id);
|
||
list.appendChild(item);
|
||
});
|
||
}
|
||
|
||
async function fastStart() {
|
||
const name = document.getElementById('fast-session-name').value.trim();
|
||
if (!name) { alert('세션 이름을 입력하세요.'); return; }
|
||
|
||
const select = document.getElementById('fast-tag-select');
|
||
const tags = Array.from(select.selectedOptions).map(o => o.value);
|
||
if (tags.length === 0) { alert('태그를 최소 1개 이상 선택하세요.'); return; }
|
||
if (tags.length > 8) { alert('태그는 최대 8개까지 선택 가능합니다.'); return; }
|
||
|
||
const samplingMs = parseInt(document.getElementById('fast-sampling-ms').value);
|
||
const durationSec = parseInt(document.getElementById('fast-duration-sec').value);
|
||
const retentionDays = document.getElementById('fast-retention-days').value.trim() || null;
|
||
|
||
const res = await fetch('/api/fast/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, samplingMs, durationSec, tagList: tags, retentionDays })
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.json();
|
||
alert('오류: ' + (err.error || '알 수 없는 오류'));
|
||
return;
|
||
}
|
||
|
||
const data = await res.json();
|
||
await fastSessionsLoad();
|
||
fastSelect(data.id);
|
||
document.getElementById('modal-fast-new').querySelector('.btn-close').click();
|
||
}
|
||
|
||
async function fastStop(id) {
|
||
const res = await fetch(`/api/fast/${id}/stop`, { method: 'POST' });
|
||
if (!res.ok) { alert('중지 실패'); return; }
|
||
await fastSessionsLoad();
|
||
if (fastCurrentSessionId === id) {
|
||
fastCurrentSessionId = null;
|
||
fastClearChart();
|
||
}
|
||
}
|
||
|
||
async function fastDelete(id) {
|
||
if (!confirm('세션을 삭제하시겠습니까?')) return;
|
||
const res = await fetch(`/api/fast/${id}`, { method: 'DELETE' });
|
||
if (!res.ok) { alert('삭제 실패'); return; }
|
||
await fastSessionsLoad();
|
||
if (fastCurrentSessionId === id) {
|
||
fastCurrentSessionId = null;
|
||
fastClearChart();
|
||
}
|
||
}
|
||
|
||
async function fastPin(id) {
|
||
const res = await fetch(`/api/fast/${id}/pin`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ pinned: true })
|
||
});
|
||
if (!res.ok) { alert('고정 실패'); return; }
|
||
await fastSessionsLoad();
|
||
}
|
||
|
||
async function fastSelect(id) {
|
||
fastCurrentSessionId = id;
|
||
const res = await fetch(`/api/fast/${id}`);
|
||
if (!res.ok) { alert('세션 조회 실패'); return; }
|
||
const session = await res.json();
|
||
|
||
document.getElementById('fast-session-title').textContent = `${session.name} (${session.status})`;
|
||
document.getElementById('fast-progress-bar').style.width = '0%';
|
||
document.getElementById('fast-progress-text').textContent = '0 / 0 (0%)';
|
||
|
||
// 버튼 상태 업데이트
|
||
const isRunning = session.status === 'Running';
|
||
document.getElementById('btn-fast-stop').style.display = isRunning ? 'inline' : 'none';
|
||
document.getElementById('btn-fast-export-xlsx').style.display = isRunning ? 'none' : 'inline';
|
||
document.getElementById('btn-fast-export-csv').style.display = isRunning ? 'none' : 'inline';
|
||
document.getElementById('btn-fast-delete').style.display = 'inline';
|
||
document.getElementById('btn-fast-pin').textContent = session.pinned ? '고정 해제' : '고정';
|
||
|
||
// 태그 목록 업데이트
|
||
fastTagList = session.tagList;
|
||
|
||
// 그래프 렌더링
|
||
await fastRenderChart();
|
||
|
||
// 라이브 폴링 시작
|
||
if (isRunning) {
|
||
fastLivePollStart();
|
||
} else {
|
||
fastLivePollStop();
|
||
}
|
||
}
|
||
|
||
async function fastRenderChart() {
|
||
if (!fastCurrentSessionId) return;
|
||
|
||
const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`);
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
|
||
if (!data.items || data.items.length === 0) {
|
||
document.getElementById('fast-chart-container').innerHTML = '<div class="text-center text-muted">수집된 데이터가 없습니다.</div>';
|
||
return;
|
||
}
|
||
|
||
// PIVOT: recorded_at 기준 그룹화
|
||
const grouped = data.items.reduce((acc, r) => {
|
||
if (!acc[r.recordedAt]) acc[r.recordedAt] = {};
|
||
acc[r.recordedAt][r.tagName] = r.value;
|
||
return acc;
|
||
}, {});
|
||
|
||
const times = Object.keys(grouped).sort();
|
||
const timesNum = times.map(t => new Date(t).getTime());
|
||
|
||
// ✅ 수정: uPlot 데이터 포맷 [[x0,x1,...], [y1_0,y1_1,...], [y2_0,...]] (Claude 진단 #10)
|
||
const datasets = data.tagNames.map(tag => ({
|
||
label: tag,
|
||
data: times.map(t => grouped[t][tag])
|
||
}));
|
||
|
||
// uPlot 사용 (Chart.js 대신)
|
||
const ctx = document.getElementById('fast-chart-container');
|
||
ctx.innerHTML = '';
|
||
|
||
const opts = {
|
||
title: 'fastRecord 실시간 트렌드',
|
||
width: ctx.clientWidth,
|
||
height: 400,
|
||
axes: [{
|
||
time: true,
|
||
label: '시간 (KST)',
|
||
values: (u, vals) => vals.map(v => new Date(v).toLocaleTimeString('ko-KR'))
|
||
}, {
|
||
label: '값'
|
||
}],
|
||
series: [{}, ...datasets]
|
||
};
|
||
|
||
// ✅ 수정: new uPlot(opts, data, target) 시그니처 (Claude 진단 #10)
|
||
fastChart = new uPlot(opts, [timesNum, ...datasets.map(d => d.data)], ctx);
|
||
}
|
||
|
||
function fastClearChart() {
|
||
if (fastChart) {
|
||
fastChart.destroy();
|
||
fastChart = null;
|
||
}
|
||
document.getElementById('fast-chart-container').innerHTML = '';
|
||
}
|
||
|
||
function fastLivePollStart() {
|
||
if (fastLivePollTimer) return;
|
||
fastLivePollTimer = setInterval(async () => {
|
||
if (!fastCurrentSessionId) { fastLivePollStop(); return; }
|
||
await fastRenderChart();
|
||
await fastUpdateProgress();
|
||
}, 2000);
|
||
}
|
||
|
||
function fastLivePollStop() {
|
||
if (fastLivePollTimer) {
|
||
clearInterval(fastLivePollTimer);
|
||
fastLivePollTimer = null;
|
||
}
|
||
}
|
||
|
||
async function fastUpdateProgress() {
|
||
if (!fastCurrentSessionId) return;
|
||
|
||
const res = await fetch(`/api/fast/${fastCurrentSessionId}`);
|
||
if (!res.ok) return;
|
||
const session = await res.json();
|
||
|
||
const elapsed = Math.floor((Date.now() - new Date(session.startedAt).getTime()) / 1000);
|
||
const progress = Math.min((elapsed / session.durationSec) * 100, 100);
|
||
|
||
document.getElementById('fast-progress-bar').style.width = `${progress}%`;
|
||
document.getElementById('fast-progress-text').textContent = `${session.rowCount} / ~${(session.durationSec * session.tagList.length)} (${progress.toFixed(1)}%)`;
|
||
document.getElementById('fast-elapsed-time').textContent = `경과: ${formatDuration(elapsed)}`;
|
||
}
|
||
|
||
// ── Helper Functions ────────────────────────────────────────────────────────
|
||
|
||
function formatDuration(seconds) {
|
||
const h = Math.floor(seconds / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
const s = seconds % 60;
|
||
return `${h}h ${m}m ${s}s`;
|
||
}
|
||
|
||
function formatDateTime(dt) {
|
||
return new Date(dt).toLocaleString('ko-KR');
|
||
}
|
||
|
||
function getColorForTag(tag) {
|
||
const colors = ['#ff6384', '#36a2eb', '#ffce56', '#4bc0c0', '#9966ff', '#ff9f40', '#8ac926', '#1982c4'];
|
||
let sum = 0;
|
||
for (let i = 0; i < tag.length; i++) sum += tag.charCodeAt(i);
|
||
return colors[sum % colors.length];
|
||
}
|
||
|
||
// ── Event Listeners ──────────────────────────────────────────────────────────
|
||
|
||
document.getElementById('btn-fast-new')?.addEventListener('click', () => {
|
||
// 태그 목록 로드
|
||
const select = document.getElementById('fast-tag-select');
|
||
select.innerHTML = '';
|
||
tagNames.forEach(name => {
|
||
const opt = document.createElement('option');
|
||
opt.value = name;
|
||
opt.textContent = name;
|
||
select.appendChild(opt);
|
||
});
|
||
document.getElementById('modal-fast-new').querySelector('.modal-title').textContent = '신규 fastSession';
|
||
document.getElementById('modal-fast-new').style.display = 'block';
|
||
new bootstrap.Modal(document.getElementById('modal-fast-new')).show();
|
||
});
|
||
|
||
document.getElementById('btn-fast-start')?.addEventListener('click', fastStart);
|
||
|
||
document.getElementById('btn-fast-stop')?.addEventListener('click', () => {
|
||
if (fastCurrentSessionId) fastStop(fastCurrentSessionId);
|
||
});
|
||
|
||
document.getElementById('btn-fast-delete')?.addEventListener('click', () => {
|
||
if (fastCurrentSessionId) fastDelete(fastCurrentSessionId);
|
||
});
|
||
|
||
document.getElementById('btn-fast-pin')?.addEventListener('click', () => {
|
||
if (fastCurrentSessionId) fastPin(fastCurrentSessionId);
|
||
});
|
||
|
||
document.getElementById('btn-fast-export-xlsx')?.addEventListener('click', async () => {
|
||
if (!fastCurrentSessionId) return;
|
||
const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`);
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
|
||
// ✅ 수정: 배열의 배열로 변환 (Claude 진단 #11)
|
||
const rows = [['recorded_at', ...data.tagNames]];
|
||
|
||
for (const r of data.items) {
|
||
if (!rows[r.recordedAt]) {
|
||
rows[r.recordedAt] = [new Date(r.recordedAt).toLocaleString('ko-KR')];
|
||
for (let i = 0; i < data.tagNames.length; i++) rows[r.recordedAt].push('');
|
||
}
|
||
rows[r.recordedAt][data.tagNames.indexOf(r.tagName) + 1] = r.value;
|
||
}
|
||
|
||
const ws = XLSX.utils.aoa_to_sheet(rows);
|
||
|
||
const wb = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb, ws, 'fastRecord');
|
||
XLSX.writeFile(wb, `fast-${fastCurrentSessionId}-${new Date().toISOString().slice(0,10)}.xlsx`);
|
||
});
|
||
|
||
document.getElementById('btn-fast-export-csv')?.addEventListener('click', async () => {
|
||
if (!fastCurrentSessionId) return;
|
||
const res = await fetch(`/api/fast/${fastCurrentSessionId}/csv`);
|
||
if (!res.ok) return;
|
||
const blob = await res.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `fast-${fastCurrentSessionId}-${new Date().toISOString().slice(0,10)}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
});
|
||
|
||
// 탭 전환 시 로드
|
||
document.querySelectorAll('[href="#pane-fast"]').forEach(a => {
|
||
a.addEventListener('show.bs.tab', () => {
|
||
fastSessionsLoad();
|
||
});
|
||
});
|
||
```
|
||
|
||
### 3. `style.css` — 스타일 추가
|
||
|
||
**파일**: `src/Web/wwwroot/css/style.css`
|
||
|
||
```css
|
||
/* fastRecord Styles */
|
||
#pane-fast .list-group-item {
|
||
cursor: pointer;
|
||
}
|
||
|
||
#pane-fast .list-group-item:hover {
|
||
background-color: #f8f9fa;
|
||
}
|
||
|
||
#pane-fast .progress-bar-animated {
|
||
background-color: #0d6efd;
|
||
}
|
||
|
||
#pane-fast .fast-stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||
gap: 10px;
|
||
}
|
||
|
||
#pane-fast .fast-stat-card {
|
||
background: #f8f9fa;
|
||
border-radius: 4px;
|
||
padding: 10px;
|
||
text-align: center;
|
||
}
|
||
|
||
#pane-fast .fast-stat-value {
|
||
font-size: 1.2rem;
|
||
font-weight: bold;
|
||
color: #0d6efd;
|
||
}
|
||
|
||
#pane-fast .fast-stat-label {
|
||
font-size: 0.8rem;
|
||
color: #6c757d;
|
||
}
|
||
|
||
#pane-fast .fast-outlier {
|
||
background-color: rgba(255, 0, 0, 0.2);
|
||
border-radius: 2px;
|
||
}
|
||
```
|
||
|
||
### 4. uPlot 라이브러리 추가
|
||
|
||
**파일**: `src/Web/wwwroot/lib/uPlot.iife.min.js` (CDN에서 다운로드)
|
||
|
||
```html
|
||
<!-- index.html의 <head> 또는 </body> 직전 -->
|
||
<script src="lib/uPlot.iife.min.js"></script>
|
||
<link rel="stylesheet" href="lib/uPlot.min.css">
|
||
```
|
||
|
||
---
|
||
|
||
## Task D — 정리/보관 백그라운드
|
||
|
||
### `ExperionFastCleanupService.cs` — 신규 파일 생성
|
||
|
||
**파일**: `src/Infrastructure/OpcUa/ExperionFastCleanupService.cs`
|
||
|
||
```csharp
|
||
using System.Text.Json; // ✅ 추가: JsonSerializer.Deserialize 사용 (Claude 진단 #7)
|
||
using ExperionCrawler.Core.Application.Interfaces;
|
||
using Microsoft.Extensions.Hosting;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace ExperionCrawler.Infrastructure.OpcUa;
|
||
|
||
/// <summary>
|
||
/// fastSession 정리 서비스.
|
||
/// 매일 03:00에 만료된 세션 + 데이터 삭제.
|
||
/// pinned=true 제외.
|
||
/// </summary>
|
||
public class ExperionFastCleanupService : BackgroundService
|
||
{
|
||
private readonly IServiceProvider _sp;
|
||
private readonly ILogger<ExperionFastCleanupService> _logger;
|
||
|
||
public ExperionFastCleanupService(IServiceProvider sp, ILogger<ExperionFastCleanupService> logger)
|
||
{
|
||
_sp = sp;
|
||
_logger = logger;
|
||
}
|
||
|
||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||
{
|
||
while (!stoppingToken.IsCancellationRequested)
|
||
{
|
||
try
|
||
{
|
||
// 매일 03:00에 실행
|
||
var now = DateTime.UtcNow;
|
||
var nextRun = new DateTime(now.Year, now.Month, now.Day, 3, 0, 0, DateTimeKind.Utc);
|
||
if (now > nextRun) nextRun = nextRun.AddDays(1);
|
||
var delay = nextRun - now;
|
||
|
||
await Task.Delay(delay, stoppingToken);
|
||
|
||
using var scope = _sp.CreateScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
|
||
|
||
// 만료된 세션 조회 (ended_at이 있고, pinned=false, retention_days 지남)
|
||
var expired = await db.GetExpiredFastSessionsAsync();
|
||
|
||
foreach (var s in expired)
|
||
{
|
||
_logger.LogInformation("[FastCleanup] 세션 {Id} ({Name}) 삭제 — 만료됨", s.Id, s.Name);
|
||
await db.DeleteFastSessionAsync(s.Id);
|
||
}
|
||
|
||
_logger.LogInformation("[FastCleanup] 정리 완료 — {Count}개 세션 삭제", expired.Count);
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "[FastCleanup] 오류");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
// ✅ 참고: JsonSerializer.Deserialize 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요
|
||
// ✅ 참고: FastSession.TagList는 JSONB이므로 Deserialize 필요 (Claude 진단 #7)
|
||
public async Task<IEnumerable<FastSession>> GetExpiredFastSessionsAsync()
|
||
{
|
||
var now = DateTime.UtcNow;
|
||
return await _ctx.FastSessions
|
||
.Where(x => x.EndedAt != null
|
||
&& !x.Pinned
|
||
&& x.RetentionDays.HasValue
|
||
&& x.EndedAt.Value.AddDays(x.RetentionDays.Value) < now)
|
||
.OrderBy(x => x.EndedAt)
|
||
.ToListAsync();
|
||
}
|
||
```
|
||
|
||
### `Program.cs`에 등록
|
||
|
||
```csharp
|
||
// ── FastTable Cleanup Service ────────────────────────────────────────────────
|
||
builder.Services.AddHostedService<ExperionFastCleanupService>();
|
||
```
|
||
|
||
---
|
||
|
||
## Task E — 안정성 / QA
|
||
|
||
### 구현 시 주의사항
|
||
|
||
1. **노드 유효성 사전 검증**: `StartSessionAsync`에서 각 태그에 대해 `Read` 1회 수행
|
||
2. **동시 세션 수 제한**: `MaxConcurrentSessions` 설정 (기본 3)
|
||
3. **OPC 연결 상태 확인**: 시작 전 `IsConnectedAsync` 체크
|
||
4. **앱 종료 시 graceful 마무리**: `StopAsync`에서 버퍼 flush 후 종료
|
||
5. **앱 시작 시 Running 세션 처리**: `Failed` 마킹
|
||
|
||
### 테스트 시나리오
|
||
|
||
| 시나리오 | 검증 방법 |
|
||
|----------|-----------|
|
||
| 세션 시작 | 태그 8개 선택 → 시작 → OPC UA 구독 생성 확인 |
|
||
| 데이터 수집 | 1분간 수집 → DB에 60×8=480행 기록 확인 |
|
||
| 중지 | 중지 버튼 → 세션 상태 `Completed` 확인 |
|
||
| CSV Export | Export → CSV 파일 다운로드 확인 |
|
||
| 고정 | Pin → 재기동 후 보존 확인 |
|
||
| RowLimit | 500만행 초과 → 자동 종료 `RowLimitReached` 확인 |
|
||
|
||
---
|
||
|
||
## 구현 우선순위
|
||
|
||
1. **MVP (1~2일)**: Task A + B(start/stop/sessions/records) + C(목록/시작/중지/단순 그래프)
|
||
2. **분석 (0.5일)**: 통계 패널 + 이상치 강조
|
||
3. **Export (0.5일)**: xlsx + csv 스트리밍
|
||
4. **운영 (0.5일)**: Task D 정리, retention/pinned
|
||
5. **고급 (1일)**: 템플릿, 다중 Y축, LTTB 다운샘플링
|
||
|
||
---
|
||
|
||
## 참고 사항
|
||
|
||
- **JSON 직렬화**: `PropertyNamingPolicy = null`이므로 C# PascalCase가 JSON 키로 그대로 사용됨 → 클라이언트에서 camelCase로 접근하려면 익명 객체로 명시적 매핑 필요
|
||
- **TimescaleDB**: hypertable 생성 후 `set_chunk_time_interval('1 day')` 설정 권장
|
||
- **uPlot**: Chart.js 대신 시계열 특화 라이브러리 → 100만점도 부드러움
|
||
|
||
---
|
||
|
||
## 코드 진단 (Claude Sonnet 4.6, 2026-04-29) — 수정 완료
|
||
|
||
전체적인 방향성(TimescaleDB hypertable, 별도 Subscription, 세션 관리)은 올바르며, **11개 이슈가 모두 수정 완료**되었습니다.
|
||
|
||
---
|
||
|
||
### 수정 완료된 이슈
|
||
|
||
| # | 이슈 | 수정 내용 | 상태 |
|
||
|---|------|-----------|------|
|
||
| 1 | `IExperionOpcClient` 메서드 누락 | `IsConnectedAsync`, `CreateSessionAsync` 메서드 추가 | ✅ |
|
||
| 2 | `IExperionDbService` 인터페이스 누락 | Fast 관련 메서드 12개 추가 | ✅ |
|
||
| 3 | `FastSessionCreateRequest` 미선언 | DTO 추가 | ✅ |
|
||
| 4 | `Program.cs` DI 등록 오류 | 올바른 등록 패턴 적용 | ✅ |
|
||
| 5 | `ExperionServerConfig` 주입 경로 | `IOpcUaConfigProvider` 사용 | ✅ |
|
||
| 6 | `OnNotification` 타입 오류 | `MonitoredItemNotification` 사용 | ✅ |
|
||
| 7 | `TagList` JSONB vs CSV 불일치 | `JsonSerializer.Serialize/Deserialize` 사용 | ✅ |
|
||
| 8 | `RowCount` 누적 추적 | `FastSessionContext.TotalRows` 추가 | ✅ |
|
||
| 9 | `RowLimit` 체크 오류 | `ctx.TotalRows`와 비교 | ✅ |
|
||
| 10 | `uPlot` API 오류 | `new uPlot(opts, data, target)` 올바른 시그니처 | ✅ |
|
||
| 11 | `XLSX.utils.aoa_to_sheet` 오류 | 배열의 배열로 변환 로직 추가 | ✅ |
|
||
|
||
---
|
||
|
||
### 수정된 핵심 코드
|
||
|
||
#### 1. `IExperionOpcClient` 인터페이스 확장
|
||
|
||
```csharp
|
||
// src/Core/Application/Interfaces/IExperionOpcClient.cs
|
||
public interface IExperionOpcClient
|
||
{
|
||
// ✅ 추가: fastTable용 메서드 (Claude 진단 #1)
|
||
Task<bool> IsConnectedAsync(ApplicationConfiguration cfg);
|
||
Task<ISession> CreateSessionAsync(ApplicationConfiguration cfg);
|
||
|
||
// 기존 메서드들
|
||
Task<bool> TestConnectionAsync(ApplicationConfiguration cfg);
|
||
Task<ExperionReadResult> ReadTagAsync(ApplicationConfiguration cfg, string nodeId);
|
||
Task<ExperionReadResult[]> ReadTagsAsync(ApplicationConfiguration cfg, string[] nodeIds);
|
||
Task<Node[]> BrowseNodesAsync(ApplicationConfiguration cfg, string parentId);
|
||
Task<Node[]> BrowseAllNodesAsync(ApplicationConfiguration cfg);
|
||
}
|
||
```
|
||
|
||
#### 2. `IExperionDbService` 인터페이스 확장
|
||
|
||
```csharp
|
||
// src/Core/Application/Interfaces/IExperionDbService.cs
|
||
public interface IExperionDbService
|
||
{
|
||
// ... 기존 메서드들
|
||
|
||
// FastSession
|
||
Task<FastSession> CreateFastSessionAsync(FastSessionCreateRequest request);
|
||
Task UpdateFastSessionStatusAsync(int sessionId, string status);
|
||
Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount);
|
||
Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned);
|
||
Task<FastSession?> GetFastSessionAsync(int sessionId);
|
||
Task<IEnumerable<FastSession>> GetFastSessionsAsync();
|
||
Task DeleteFastSessionAsync(int sessionId);
|
||
Task<IEnumerable<FastSession>> GetExpiredFastSessionsAsync();
|
||
|
||
// FastRecord
|
||
Task<FastQueryResult> GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to);
|
||
Task BatchInsertFastRecordsAsync(IEnumerable<FastRecord> records);
|
||
Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to);
|
||
}
|
||
```
|
||
|
||
#### 3. `OnNotification` 올바른 패턴
|
||
|
||
```csharp
|
||
private void OnNotification(FastSessionContext ctx, MonitoredItemNotificationEventArgs e, string tagName)
|
||
{
|
||
if (ctx.Cancel) return;
|
||
|
||
if (e.NotificationValue is MonitoredItemNotification notification)
|
||
{
|
||
var dv = notification.Value;
|
||
var value = dv.Value?.ToString();
|
||
|
||
var record = new FastRecord
|
||
{
|
||
SessionId = ctx.SessionId,
|
||
RecordedAt = DateTime.UtcNow,
|
||
TagName = tagName,
|
||
Value = value
|
||
};
|
||
ctx.Buffer.Enqueue(record);
|
||
|
||
// 누적 RowCount 증가
|
||
ctx.TotalRows++;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 4. `TagList` JSONB 저장
|
||
|
||
```csharp
|
||
// 저장 시
|
||
session.TagList = JsonSerializer.Serialize(request.TagList);
|
||
|
||
// 조회 시
|
||
session.TagList = JsonSerializer.Deserialize<string[]>(session.TagList) ?? [];
|
||
```
|
||
|
||
#### 5. `FastSessionContext`에 누적 RowCount 추가
|
||
|
||
```csharp
|
||
private class FastSessionContext
|
||
{
|
||
public int SessionId { get; set; }
|
||
public string[] TagList { get; set; } = [];
|
||
public int SamplingMs { get; set; }
|
||
public int DurationSec { get; set; }
|
||
public DateTime StartedAt { get; set; }
|
||
public ConcurrentQueue<FastRecord> Buffer { get; set; } = new();
|
||
public int TotalRows { get; set; } // 누적 총 행 수
|
||
public bool Cancel { get; set; }
|
||
public ISession? Session { get; set; }
|
||
public Subscription? Subscription { get; set; }
|
||
}
|
||
```
|
||
|
||
#### 6. `RowLimit` 체크 수정
|
||
|
||
```csharp
|
||
// 올바른 체크
|
||
if (ctx.TotalRows + buffer.Count >= _maxRowsPerSession)
|
||
{
|
||
ctx.Cancel = true;
|
||
await StopSessionAsync(ctx.SessionId);
|
||
// ...
|
||
}
|
||
```
|
||
|
||
#### 7. uPlot API 올바른 사용
|
||
|
||
```javascript
|
||
// data: [[x0,x1,...], [y1_0,y1_1,...], [y2_0,...]]
|
||
const times = Object.keys(grouped).sort();
|
||
const timesNum = times.map(t => new Date(t).getTime());
|
||
|
||
const datasets = data.tagNames.map(tag => ({
|
||
label: tag,
|
||
data: times.map(t => grouped[t][tag])
|
||
}));
|
||
|
||
// target: DOM element (세 번째 인자)
|
||
fastChart = new uPlot(opts, [timesNum, ...datasets.map(d => d.data)], ctx);
|
||
```
|
||
|
||
#### 8. XLSX export 올바른 데이터 포맷
|
||
|
||
```javascript
|
||
const rows = [['recorded_at', ...data.tagNames]];
|
||
|
||
for (const r of data.items) {
|
||
if (!rows[r.recordedAt]) {
|
||
rows[r.recordedAt] = [new Date(r.recordedAt).toLocaleString('ko-KR')];
|
||
for (let i = 0; i < data.tagNames.length; i++) rows[r.recordedAt].push('');
|
||
}
|
||
rows[r.recordedAt][data.tagNames.indexOf(r.tagName) + 1] = r.value;
|
||
}
|
||
|
||
const ws = XLSX.utils.aoa_to_sheet(rows);
|
||
```
|
||
|
||
---
|
||
|
||
### 요약
|
||
|
||
| 구분 | 건수 | 상태 |
|
||
|------|------|------|
|
||
| 컴파일 에러 | 5건 | ✅ 수정 완료 |
|
||
| 런타임 버그 | 4건 | ✅ 수정 완료 |
|
||
| JS 오류 | 2건 | ✅ 수정 완료 |
|
||
|
||
**가장 치명적인 #6 OnNotification 타입 오류**(데이터가 아예 수집 안 됨)와 **#7 JSONB 저장 오류**(DB INSERT 에러)가 모두 수정되었습니다.
|