60 KiB
Roo 작업 지시: fastTable/fastRecord 기능 구현
⚠️ 필독 — 컨텍스트 관리 규칙
이 작업은 파일 11개를 수정/생성하는 대규모 작업이다. 컨텍스트 오염 방지를 위해 아래 규칙을 철저히 따른다.
시작 전 (최초 1회)
.roo.md읽기.roo/rules-code/glm-code-rules.md읽기task_state.md생성 — 아래 초기 템플릿 그대로 작성
단위 작업(Step)마다 반드시
- 해당 파일 백업 →
/.rooBackup/YYYYMMDD_HHMMSS_파일명으로 복사 read_file로 수정 대상 파일 전체 확인- 코드 수정
dotnet build실행 → 에러 0건 확인task_state.md즉시 업데이트 (완료 표시 + 빌드 결과)
이관 트리거 (즉시 중단 후 이관)
- Step 완료 시마다 컨텍스트 70% 이상 판단되면 즉시 이관
task_state.md최신화 후 이관 신호 출력- 이관 후 첫 문장: "task_state.md 를 읽고 [다음 Step 번호]부터 이어서 진행하세요"
task_state.md 초기 템플릿
이 파일을 프로젝트 루트에 생성하고 시작한다:
## 작업명: fastTable/fastRecord 기능 구현
## 시작시각: [실제 시작 시각 기록]
## 참조: plans/roo-fasttable-implementation.md
## 전체 Step 목록
- [ ] Step 1: ExperionEntities.cs — 엔티티 2개 추가
- [ ] Step 2: IExperionServices.cs — IExperionDbService Fast 메서드 추가
- [ ] Step 3: IExperionServices.cs — IExperionFastService + DTOs 추가
- [ ] Step 4: ExperionDbContext.cs — DbSet + OnModelCreating 추가
- [ ] Step 5: ExperionDbContext.cs — InitializeAsync DDL 추가
- [ ] Step 6: ExperionDbContext.cs — Fast DB 메서드 구현 (Create/Update/Get)
- [ ] Step 7: ExperionDbContext.cs — Fast DB 메서드 구현 (Insert/Export/Expired)
- [ ] Step 8: ExperionFastService.cs — 신규 파일 생성 (상단부: 필드/생성자/IHostedService)
- [ ] Step 9: ExperionFastService.cs — StartSessionAsync 구현
- [ ] Step 10: ExperionFastService.cs — OPC UA Subscription + OnNotification 구현
- [ ] Step 11: ExperionFastService.cs — FlushBuffer + MonitorLoop + 헬퍼 구현
- [ ] Step 12: ExperionFastCleanupService.cs — 신규 파일 생성
- [ ] Step 13: ExperionControllers.cs — ExperionFastController 추가
- [ ] Step 14: Program.cs — 서비스 등록
- [ ] Step 15: appsettings.json — Fast 섹션 추가
- [ ] Step 16: index.html — 탭 메뉴 + 패널 + 모달 추가
- [ ] Step 17: app.js — Fast 함수 추가 + 탭 핸들러 연결
- [ ] Step 18: style.css — Fast 전용 스타일 추가
## 완료된 Step
(없음)
## 발견된 문제
| Step | 파일 | 내용 | 상태 |
|------|------|------|------|
배경
fastTable-coding-plan-byQwen3.md 에 구현 계획이 있다.
Claude Sonnet 4.6이 기존 코드베이스와 비교 진단한 결과 버그 11건 이 발견되었다.
이 파일은 그 오류를 수정한 올바른 구현 지침이다.
원본 계획과 충돌 시 이 파일의 지시를 우선한다.
Step 1 — ExperionEntities.cs 엔티티 추가
파일: src/Core/Domain/Entities/ExperionEntities.cs
파일 끝에 두 클래스를 추가한다.
/// <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";
[Column("sampling_ms")] public int SamplingMs { get; set; }
[Column("duration_sec")] public int DurationSec { get; set; }
[Column("tag_list")] public string TagList { get; set; } = "[]";
[Column("row_count")] public int RowCount { get; set; }
[Column("retention_days")] public int? RetentionDays { get; set; }
[Column("pinned")] public bool Pinned { get; set; }
}
/// <summary>fastRecord — 시계열 데이터 (Long 포맷)</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; }
}
완료 후:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인 → task_state.md 에 Step 1 완료 기록
Step 2 — IExperionServices.cs IExperionDbService Fast 메서드 추가
파일: src/Core/Application/Interfaces/IExperionServices.cs
IExperionDbService 인터페이스의 마지막 메서드(GetRealtimeNodeDataTypesAsync) 바로 뒤에 추가:
// ── FastSession / FastRecord ──────────────────────────────────────────────
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<FastQueryResult> GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to);
Task BatchInsertFastRecordsAsync(IEnumerable<FastRecord> records);
Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to);
Task<string?> GetNodeIdByTagNameAsync(string tagName);
Task<IEnumerable<FastSession>> GetExpiredFastSessionsAsync();
완료 후:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인 → task_state.md 에 Step 2 완료 기록
Step 3 — IExperionServices.cs IExperionFastService + DTOs 추가
파일: src/Core/Application/Interfaces/IExperionServices.cs
파일 맨 끝에 추가:
// ── Fast Service ─────────────────────────────────────────────────────────────
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);
Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null);
}
public record FastSessionStartRequest(
string Name,
int SamplingMs,
int DurationSec,
string[] TagList,
int? RetentionDays = null
);
public record FastSessionCreateRequest(
string Name,
int SamplingMs,
int DurationSec,
string[] TagList,
int? RetentionDays
);
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
);
public record FastQueryResult(
int SessionId,
DateTime From,
DateTime To,
string[] TagNames,
IEnumerable<FastRecord> Items,
int TotalCount
);
완료 후:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인 → task_state.md 에 Step 3 완료 기록
Step 4 — ExperionDbContext.cs DbSet + OnModelCreating 추가
파일: src/Infrastructure/Database/ExperionDbContext.cs
4-a. DbSet 추가
기존 DbSet 선언들이 있는 위치(파일 상단부)에 두 줄 추가:
public DbSet<FastSession> FastSessions => Set<FastSession>();
public DbSet<FastRecord> FastRecords => Set<FastRecord>();
4-b. OnModelCreating 에 인덱스 추가
기존 modelBuilder.Entity<...> 블록들이 있는 OnModelCreating 메서드 안에 추가:
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 });
});
완료 후:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인 → task_state.md 에 Step 4 완료 기록
Step 5 — ExperionDbContext.cs InitializeAsync DDL 추가
파일: src/Infrastructure/Database/ExperionDbContext.cs
InitializeAsync() 메서드 내부, 기존 테이블 DDL 블록들 끝에 추가.
주의:
tag_list컬럼은 반드시TEXT로 선언한다. 원본 계획의JSONB는 EF Corestring타입과 충돌하여 INSERT 에러가 발생한다.
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 TEXT 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
)
""");
await _ctx.Database.ExecuteSqlRawAsync("""
SELECT create_hypertable('fast_record', 'recorded_at', if_not_exists => TRUE)
""");
await _ctx.Database.ExecuteSqlRawAsync("""
SELECT set_chunk_time_interval('fast_record', INTERVAL '1 day')
""");
완료 후:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인 → task_state.md 에 Step 5 완료 기록
Step 6 — ExperionDbContext.cs Fast DB 메서드 (Create / Update / Get)
파일: src/Infrastructure/Database/ExperionDbContext.cs
ExperionDbService 클래스 안, 기존 메서드들 끝에 아래 메서드들을 추가한다.
주의:
TagList는 반드시JsonSerializer.Serialize()로 저장한다.string.Join(',')은 JSON 형식이 아니라 파싱 불일치가 발생한다.
// ── FastSession / FastRecord ────────────────────────────────────────────────
public async Task<FastSession> CreateFastSessionAsync(FastSessionCreateRequest request)
{
var session = new FastSession
{
Name = request.Name,
SamplingMs = request.SamplingMs,
DurationSec = request.DurationSec,
TagList = System.Text.Json.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 is "Completed" or "Cancelled" or "Failed" or "RowLimitReached")
session.EndedAt = DateTime.UtcNow;
await _ctx.SaveChangesAsync();
}
public async Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount)
{
await _ctx.FastSessions
.Where(x => x.Id == sessionId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.RowCount, rowCount));
}
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);
public async Task<IEnumerable<FastSession>> GetFastSessionsAsync()
=> await _ctx.FastSessions.OrderByDescending(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();
}
완료 후:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인 → task_state.md 에 Step 6 완료 기록
Step 7 — ExperionDbContext.cs Fast DB 메서드 (Insert / Export / Expired)
파일: src/Infrastructure/Database/ExperionDbContext.cs
Step 6에서 추가한 메서드들 바로 뒤에 이어서 추가:
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();
var minTime = records.Count > 0 ? records[0].RecordedAt : DateTime.UtcNow;
var maxTime = records.Count > 0 ? records[^1].RecordedAt : DateTime.UtcNow;
return new FastQueryResult(sessionId, from ?? minTime, to ?? maxTime,
tagNames, records, records.Count);
}
public async Task BatchInsertFastRecordsAsync(IEnumerable<FastRecord> records)
{
var list = records.ToList();
if (list.Count == 0) return;
_ctx.FastRecords.AddRange(list);
await _ctx.SaveChangesAsync();
}
public async Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream,
DateTime? from, DateTime? to)
{
var result = await GetFastRecordsAsync(sessionId, from, to);
using var writer = new StreamWriter(stream, leaveOpen: true);
await writer.WriteLineAsync("recorded_at," + string.Join(",", result.TagNames));
foreach (var g in result.Items.GroupBy(x => x.RecordedAt).OrderBy(g => g.Key))
{
var values = g.ToDictionary(r => r.TagName, r => r.Value);
var row = g.Key.ToString("o") + "," +
string.Join(",", result.TagNames.Select(
t => values.TryGetValue(t, out var v) ? $"\"{v}\"" : ""));
await writer.WriteLineAsync(row);
}
await writer.FlushAsync();
}
public async Task<string?> GetNodeIdByTagNameAsync(string tagName)
=> await _ctx.RealtimePoints
.Where(x => x.TagName == tagName)
.Select(x => x.NodeId)
.FirstOrDefaultAsync();
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)
.ToListAsync();
}
완료 후:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인 → task_state.md 에 Step 7 완료 기록
Step 8 — ExperionFastService.cs 신규 생성 (상단부)
파일 신규 생성: src/Infrastructure/OpcUa/ExperionFastService.cs
이 Step에서는 파일 전체 골격 + 필드 + 생성자 + IHostedService 구현만 작성한다.
using System.Collections.Concurrent;
using System.Text.Json;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using ISession = Opc.Ua.Client.ISession;
namespace ExperionCrawler.Infrastructure.OpcUa;
public class ExperionFastService : IExperionFastService, IHostedService, IDisposable
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ExperionFastService> _logger;
private readonly IOpcUaConfigProvider _configProvider;
private readonly ConcurrentDictionary<int, FastSessionContext> _sessions = new();
private CancellationTokenSource? _cts;
private Task? _monitorTask;
private readonly int _maxConcurrentSessions = 3;
private readonly int _maxRowsPerSession = 5_000_000;
private readonly int _flushIntervalMs = 2_000;
// ExperionServerConfig 는 realtime_autostart.json 에서 읽음 (RealtimeService 와 공유)
private static readonly string RealtimeFlagPath =
Path.GetFullPath("realtime_autostart.json");
public ExperionFastService(
IServiceScopeFactory scopeFactory,
ILogger<ExperionFastService> logger,
IOpcUaConfigProvider configProvider)
{
_scopeFactory = scopeFactory;
_logger = logger;
_configProvider = configProvider;
}
// ── IHostedService ────────────────────────────────────────────────────────
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var stale = (await db.GetFastSessionsAsync())
.Where(s => s.Status == "Running").ToList();
foreach (var s in stale)
{
_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);
foreach (var kvp in _sessions)
kvp.Value.Cancel = true;
await Task.Delay(2_000).ConfigureAwait(false);
}
public void Dispose() { _cts?.Dispose(); }
// ── 나머지 메서드는 Step 9~11 에서 추가 ──────────────────────────────────
}
완료 후:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인 → task_state.md 에 Step 8 완료 기록
Step 9 — ExperionFastService.cs IExperionFastService 공개 메서드 구현
파일: src/Infrastructure/OpcUa/ExperionFastService.cs
Dispose() 바로 뒤, // 나머지 메서드 주석을 지우고 아래로 교체한다:
// ── IExperionFastService ──────────────────────────────────────────────────
public async Task<FastSessionInfo> StartSessionAsync(FastSessionStartRequest request)
{
if (request.TagList.Length == 0 || request.TagList.Length > 8)
throw new ArgumentException("태그는 1~8개여야 합니다.");
if (request.SamplingMs is not (100 or 250 or 500 or 1000))
throw new ArgumentException("샘플링 간격은 100/250/500/1000ms 중 하나여야 합니다.");
var serverCfg = await ReadServerConfigAsync();
if (serverCfg == null)
throw new InvalidOperationException(
"OPC UA 서버 설정을 찾을 수 없습니다. 실시간 구독을 먼저 시작하세요.");
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}개입니다.");
var nodeIds = new Dictionary<string, string>();
foreach (var tag in request.TagList)
{
var nodeId = await db.GetNodeIdByTagNameAsync(tag);
if (string.IsNullOrEmpty(nodeId))
throw new ArgumentException($"태그 '{tag}'의 nodeId를 찾을 수 없습니다.");
nodeIds[tag] = nodeId;
}
var session = await db.CreateFastSessionAsync(new FastSessionCreateRequest(
request.Name, request.SamplingMs, request.DurationSec,
request.TagList, request.RetentionDays));
var ctx = new FastSessionContext
{
SessionId = session.Id,
TagList = request.TagList,
NodeIds = nodeIds,
SamplingMs = request.SamplingMs,
DurationSec = request.DurationSec,
StartedAt = DateTime.UtcNow,
};
_sessions[session.Id] = ctx;
try
{
await StartSubscriptionAsync(ctx, serverCfg);
await db.UpdateFastSessionStatusAsync(session.Id, "Running");
_logger.LogInformation("[Fast] 세션 {Id} 시작 — 태그 {Count}개 {Ms}ms {Sec}s",
session.Id, request.TagList.Length, request.SamplingMs, request.DurationSec);
}
catch (Exception ex)
{
_sessions.TryRemove(session.Id, out _);
await db.UpdateFastSessionStatusAsync(session.Id, "Failed");
throw new InvalidOperationException($"OPC UA 구독 시작 실패: {ex.Message}", ex);
}
var tags = JsonSerializer.Deserialize<string[]>(session.TagList) ?? [];
return MapToInfo(session, tags);
}
public async Task StopSessionAsync(int sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var ctx))
throw new InvalidOperationException($"세션 {sessionId}를 찾을 수 없습니다.");
ctx.Cancel = true;
await FlushBufferAsync(ctx);
await StopSubscriptionAsync(ctx);
_sessions.TryRemove(sessionId, out _);
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.UpdateFastSessionStatusAsync(sessionId, "Completed");
await db.UpdateFastSessionRowCountAsync(sessionId, ctx.TotalRows);
_logger.LogInformation("[Fast] 세션 {Id} 수동 중지 — 총 {Count}행", sessionId, ctx.TotalRows);
}
public async Task DeleteSessionAsync(int sessionId)
{
if (_sessions.TryGetValue(sessionId, out var ctx))
{
ctx.Cancel = true;
await StopSubscriptionAsync(ctx);
_sessions.TryRemove(sessionId, out _);
}
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.DeleteFastSessionAsync(sessionId);
}
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 s = await db.GetFastSessionAsync(sessionId);
if (s == null) return null;
var tags = JsonSerializer.Deserialize<string[]>(s.TagList) ?? [];
return MapToInfo(s, tags);
}
public async Task<IEnumerable<FastSessionInfo>> GetSessionsAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
return (await db.GetFastSessionsAsync()).Select(s =>
{
var tags = JsonSerializer.Deserialize<string[]>(s.TagList) ?? [];
return MapToInfo(s, tags);
});
}
public async Task<FastQueryResult> GetRecordsAsync(int sessionId, DateTime? from, DateTime? to)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
return await db.GetFastRecordsAsync(sessionId, from, to);
}
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);
}
완료 후:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인 → task_state.md 에 Step 9 완료 기록
Step 10 — ExperionFastService.cs OPC UA Subscription + OnNotification
파일: src/Infrastructure/OpcUa/ExperionFastService.cs
Step 9에서 추가한 ExportCsvAsync 바로 뒤에 추가:
핵심 버그 수정:
OnNotification의e.NotificationValue타입은MonitoredItemNotification이다. 원본 계획의DataChangeNotification은 잘못된 타입이며, 이 오류로 데이터가 수집되지 않는다.
// ── Private: OPC UA ────────────────────────────────────────────────────────
private async Task StartSubscriptionAsync(
FastSessionContext ctx, ExperionServerConfig serverCfg)
{
var appConfig = await _configProvider.GetConfigAsync(serverCfg);
var endpoint = await SelectEndpointAsync(appConfig, serverCfg.EndpointUrl);
var identity = new UserIdentity(
serverCfg.UserName,
System.Text.Encoding.UTF8.GetBytes(serverCfg.Password));
ctx.OpcSession = await new DefaultSessionFactory(null).CreateAsync(
appConfig, endpoint, false, "ExperionFastSession",
60_000, identity, null, CancellationToken.None);
var subscription = new Subscription(ctx.OpcSession.DefaultSubscription)
{
PublishingInterval = ctx.SamplingMs,
KeepAliveCount = 10,
LifetimeCount = 100,
};
foreach (var tag in ctx.TagList)
{
var nodeId = ctx.NodeIds[tag];
var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = new NodeId(nodeId),
AttributeId = Attributes.Value,
SamplingInterval = ctx.SamplingMs,
QueueSize = 1,
DiscardOldest = true,
DisplayName = tag
};
// [버그 수정] MonitoredItemNotification — DataChangeNotification 아님
item.Notification += (monItem, e) =>
{
if (ctx.Cancel) return;
if (e.NotificationValue is MonitoredItemNotification notification)
{
ctx.Buffer.Enqueue(new FastRecord
{
SessionId = ctx.SessionId,
RecordedAt = DateTime.UtcNow,
TagName = monItem.DisplayName,
Value = notification.Value?.Value?.ToString()
});
}
};
subscription.AddItem(item);
}
await ctx.OpcSession.AddSubscriptionAsync(subscription);
#pragma warning disable CS0618
subscription.Create();
#pragma warning restore CS0618
ctx.Subscription = subscription;
}
private static async Task<ConfiguredEndpoint> SelectEndpointAsync(
ApplicationConfiguration appConfig, string endpointUrl)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var endpointConfig = EndpointConfiguration.Create(appConfig);
using var discovery = await DiscoveryClient.CreateAsync(
appConfig, new Uri(endpointUrl), DiagnosticsMasks.All, cts.Token);
var endpoints = await discovery.GetEndpointsAsync(null);
var selected = endpoints
.OrderByDescending(e => e.SecurityLevel)
.FirstOrDefault(e => e.SecurityPolicyUri.Contains("Basic256Sha256"))
?? endpoints[0];
return new ConfiguredEndpoint(null, selected, endpointConfig);
}
private static async Task StopSubscriptionAsync(FastSessionContext ctx)
{
if (ctx.Subscription != null)
{
#pragma warning disable CS0618
ctx.Subscription.Delete(false);
#pragma warning restore CS0618
ctx.Subscription = null;
}
if (ctx.OpcSession != null)
{
try { await ctx.OpcSession.CloseAsync(); } catch { }
try { ctx.OpcSession.Dispose(); } catch { }
ctx.OpcSession = null;
}
}
완료 후:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인 → task_state.md 에 Step 10 완료 기록
Step 11 — ExperionFastService.cs FlushBuffer + MonitorLoop + 헬퍼
파일: src/Infrastructure/OpcUa/ExperionFastService.cs
Step 10에서 추가한 StopSubscriptionAsync 바로 뒤에 추가:
버그 수정:
TotalRows필드로 누적 관리. 원본 계획의ctx.Buffer.Count비교는 드레인 후 항상 0이라 작동하지 않음.
// ── Private: Flush + Monitor ──────────────────────────────────────────────
private async Task FlushBufferAsync(FastSessionContext ctx)
{
var batch = new List<FastRecord>();
while (ctx.Buffer.TryDequeue(out var record))
batch.Add(record);
if (batch.Count == 0) return;
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.BatchInsertFastRecordsAsync(batch);
// [버그 수정] 누적 총 행 수로 관리
ctx.TotalRows += batch.Count;
await db.UpdateFastSessionRowCountAsync(ctx.SessionId, ctx.TotalRows);
if (ctx.TotalRows >= _maxRowsPerSession)
{
_logger.LogWarning("[Fast] 세션 {Id} RowLimit 도달 — 자동 종료", ctx.SessionId);
ctx.Cancel = true;
await StopSubscriptionAsync(ctx);
_sessions.TryRemove(ctx.SessionId, out _);
await db.UpdateFastSessionStatusAsync(ctx.SessionId, "RowLimitReached");
}
}
private async Task MonitorLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(_flushIntervalMs, ct);
foreach (var kvp in _sessions.ToArray())
{
var ctx = kvp.Value;
if (ctx.Cancel) continue;
var elapsed = (DateTime.UtcNow - ctx.StartedAt).TotalSeconds;
if (elapsed >= ctx.DurationSec)
{
_logger.LogInformation("[Fast] 세션 {Id} 기간 만료 — 자동 종료", ctx.SessionId);
ctx.Cancel = true;
await FlushBufferAsync(ctx);
await StopSubscriptionAsync(ctx);
_sessions.TryRemove(ctx.SessionId, out _);
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.UpdateFastSessionStatusAsync(ctx.SessionId, "Completed");
await db.UpdateFastSessionRowCountAsync(ctx.SessionId, ctx.TotalRows);
continue;
}
await FlushBufferAsync(ctx);
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.LogError(ex, "[Fast] 모니터링 루프 오류");
}
}
}
private static async Task<ExperionServerConfig?> ReadServerConfigAsync()
{
if (!File.Exists(RealtimeFlagPath)) return null;
try
{
var json = await File.ReadAllTextAsync(RealtimeFlagPath);
return JsonSerializer.Deserialize<ExperionServerConfig>(json);
}
catch { return null; }
}
private static FastSessionInfo MapToInfo(FastSession s, string[] tags)
=> new(s.Id, s.Name, s.StartedAt, s.EndedAt, s.Status,
s.SamplingMs, s.DurationSec, tags,
s.RowCount, s.RetentionDays, s.Pinned);
// ── Inner Class ───────────────────────────────────────────────────────────
private sealed class FastSessionContext
{
public int SessionId { get; init; }
public string[] TagList { get; init; } = [];
public Dictionary<string, string> NodeIds { get; init; } = new();
public int SamplingMs { get; init; }
public int DurationSec { get; init; }
public DateTime StartedAt { get; init; }
public ConcurrentQueue<FastRecord> Buffer { get; } = new();
public int TotalRows { get; set; }
public bool Cancel { get; set; }
public ISession? OpcSession { get; set; }
public Subscription? Subscription { get; set; }
}
}
완료 후:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인 → task_state.md 에 Step 11 완료 기록
Step 12 — ExperionFastCleanupService.cs 신규 생성
파일 신규 생성: src/Infrastructure/OpcUa/ExperionFastCleanupService.cs
using ExperionCrawler.Core.Application.Interfaces;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ExperionCrawler.Infrastructure.OpcUa;
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
{
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);
await Task.Delay(nextRun - now, stoppingToken);
using var scope = _sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var expired = (await db.GetExpiredFastSessionsAsync()).ToList();
foreach (var s in expired)
{
_logger.LogInformation("[FastCleanup] 세션 {Id} ({Name}) 삭제", s.Id, s.Name);
await db.DeleteFastSessionAsync(s.Id);
}
if (expired.Count > 0)
_logger.LogInformation("[FastCleanup] 정리 완료 — {Count}개", expired.Count);
}
catch (OperationCanceledException) { }
catch (Exception ex) { _logger.LogError(ex, "[FastCleanup] 오류"); }
}
}
}
완료 후:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인 → task_state.md 에 Step 12 완료 기록
Step 13 — ExperionControllers.cs ExperionFastController 추가
파일: src/Web/Controllers/ExperionControllers.cs
파일 끝에 추가. 모든 Ok(...) 응답은 명시적 camelCase 익명 객체 (PropertyNamingPolicy = null 규칙 준수).
// ── FastTable ─────────────────────────────────────────────────────────────────
[ApiController]
[Route("api/fast")]
public class ExperionFastController : ControllerBase
{
private readonly IExperionFastService _fastSvc;
public ExperionFastController(IExperionFastService fastSvc)
=> _fastSvc = fastSvc;
[HttpPost("start")]
public async Task<IActionResult> Start([FromBody] FastSessionStartRequest request)
{
try
{
var s = await _fastSvc.StartSessionAsync(request);
return Ok(new { id = s.Id, name = s.Name, status = s.Status, startedAt = s.StartedAt });
}
catch (ArgumentException ex) { return BadRequest(new { error = ex.Message }); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
[HttpPost("{id:int}/stop")]
public async Task<IActionResult> Stop(int id)
{
try
{
await _fastSvc.StopSessionAsync(id);
return Ok(new { success = true });
}
catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); }
}
[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,
tagList = s.TagList,
rowCount = s.RowCount,
startedAt = s.StartedAt,
endedAt = s.EndedAt,
retentionDays = s.RetentionDays,
pinned = s.Pinned
})
});
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetSession(int id)
{
var s = await _fastSvc.GetSessionAsync(id);
if (s == null) return NotFound();
return Ok(new
{
id = s.Id,
name = s.Name,
status = s.Status,
samplingMs = s.SamplingMs,
durationSec = s.DurationSec,
tagList = s.TagList,
rowCount = s.RowCount,
startedAt = s.StartedAt,
endedAt = s.EndedAt,
retentionDays = s.RetentionDays,
pinned = s.Pinned
});
}
[HttpGet("{id:int}/records")]
public async Task<IActionResult> GetRecords(int id,
[FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var result = await _fastSvc.GetRecordsAsync(id, from, to);
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
})
});
}
[HttpGet("{id:int}/csv")]
public async Task<IActionResult> ExportCsv(int id,
[FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
var ms = new MemoryStream();
await _fastSvc.ExportCsvAsync(id, ms, from, to);
ms.Position = 0;
return File(ms, "text/csv", $"fast-{id}-{DateTime.UtcNow:yyyyMMddHHmm}.csv");
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
await _fastSvc.DeleteSessionAsync(id);
return Ok(new { success = true });
}
[HttpPost("{id:int}/pin")]
public async Task<IActionResult> Pin(int id, [FromBody] FastPinRequest request)
{
await _fastSvc.PinSessionAsync(id, request.Pinned);
return Ok(new { success = true, pinned = request.Pinned });
}
}
public record FastPinRequest(bool Pinned);
완료 후:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인 → task_state.md 에 Step 13 완료 기록
Step 14 — Program.cs 서비스 등록
파일: src/Web/Program.cs
기존 OPC 서버 서비스 등록 블록 바로 뒤에 추가.
버그 수정: 원본 계획의
AddSingleton<IExperionFastService, ExperionFastService>()후GetRequiredService<ExperionFastService>()는 콘크리트 타입 미등록으로 에러가 발생한다. 아래 3줄 패턴을 반드시 사용한다.
// ── FastTable Service ─────────────────────────────────────────────────────────
builder.Services.AddSingleton<ExperionFastService>();
builder.Services.AddSingleton<IExperionFastService>(
sp => sp.GetRequiredService<ExperionFastService>());
builder.Services.AddHostedService(
sp => sp.GetRequiredService<ExperionFastService>());
builder.Services.AddHostedService<ExperionFastCleanupService>();
완료 후:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인 → task_state.md 에 Step 14 완료 기록
Step 15 — appsettings.json Fast 섹션 추가
파일: src/Web/appsettings.json
기존 JSON 객체 안에 항목 추가 (쉼표 위치 주의):
"Fast": {
"MaxConcurrentSessions": 3,
"MaxRowsPerSession": 5000000,
"FlushIntervalMs": 2000
}
완료 후:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인 → task_state.md 에 Step 15 완료 기록
Step 16 — index.html 탭 메뉴 + 패널 + 모달 추가
파일: src/Web/wwwroot/index.html
16-a. 사이드바 메뉴 항목 추가
기존 08 OPC UA 서버 메뉴 항목 바로 아래:
<li><a href="#" data-tab="fast">09 fastRecord</a></li>
16-b. 패널 추가
기존 pane-opcsvr 패널 바로 아래:
<div id="pane-fast" class="tab-pane" style="display:none">
<h4>fastRecord 수집</h4>
<div class="fast-toolbar">
<button class="btn" onclick="fastNewModal()">+ 신규 세션</button>
<button class="btn" onclick="fastSessionsLoad()">↺ 새로고침</button>
</div>
<div id="fast-session-list" class="fast-session-list"></div>
<div id="fast-detail" style="display:none">
<div class="fast-detail-header">
<span id="fast-detail-name"></span>
<span id="fast-detail-status" class="fast-badge"></span>
<div class="fast-detail-btns">
<button class="btn btn-sm" id="btn-fast-stop"
onclick="fastStop()" style="display:none">■ 중지</button>
<button class="btn btn-sm" id="btn-fast-csv"
onclick="fastExportCsv()">CSV</button>
<button class="btn btn-sm" id="btn-fast-pin"
onclick="fastTogglePin()">📌</button>
<button class="btn btn-sm btn-danger"
onclick="fastDelete()">삭제</button>
</div>
</div>
<div class="fast-progress-wrap">
<div class="fast-progress-bar" id="fast-progress-bar" style="width:0%"></div>
</div>
<div class="fast-progress-info">
<span id="fast-progress-text">0행</span>
<span id="fast-elapsed-text">경과: 0s</span>
</div>
<div id="fast-chart-container" class="fast-chart-container"></div>
</div>
</div>
<!-- 신규 세션 모달 -->
<div id="modal-fast" class="dt-overlay" style="display:none"
onclick="if(event.target===this)fastModalClose()">
<div class="dt-popup fast-modal">
<div class="fast-modal-title">신규 fastSession</div>
<label>세션 이름</label>
<input type="text" id="fast-name" class="fast-input"
placeholder="예: 공정온도_분석">
<label>태그 선택 (최대 8개, Ctrl+클릭 다중선택)</label>
<select id="fast-tag-select" class="fast-input" multiple size="8"></select>
<div class="fast-row2">
<div>
<label>샘플링 (ms)</label>
<select id="fast-sampling-ms" class="fast-input">
<option value="100">100ms</option>
<option value="250">250ms</option>
<option value="500" selected>500ms</option>
<option value="1000">1000ms</option>
</select>
</div>
<div>
<label>수집 기간</label>
<select id="fast-duration-sec" class="fast-input">
<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="86400">24시간</option>
</select>
</div>
</div>
<label>보관 기간 (일, 비우면 무한)</label>
<input type="number" id="fast-retention" class="fast-input" placeholder="30">
<div class="fast-modal-footer">
<button class="btn" onclick="fastModalClose()">취소</button>
<button class="btn btn-primary" onclick="fastStart()">시작</button>
</div>
</div>
</div>
완료 후 브라우저 열어서 09 탭 표시 여부 확인 (빌드 불필요, HTML만 수정)
→ task_state.md 에 Step 16 완료 기록
Step 17 — app.js Fast 함수 추가 + 탭 핸들러 연결
파일: src/Web/wwwroot/js/app.js
17-a. 파일 끝에 Fast 함수 추가
버그 수정: uPlot/XLSX 제거. 단순 table 렌더링과 CSV만 사용한다.
// ── fastRecord ────────────────────────────────────────────────────────────────
let _fastCurrentId = null;
let _fastPollTimer = null;
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');
if (!list) return;
list.innerHTML = '';
(data.items || []).forEach(s => {
const div = document.createElement('div');
div.className = 'fast-session-item' +
(s.id === _fastCurrentId ? ' active' : '');
div.onclick = () => fastSelect(s.id);
const cls = s.status === 'Running' ? 'fast-badge-run' : 'fast-badge-done';
div.innerHTML = `
<div class="fast-si-top">
<span class="fast-si-name">${s.name}</span>
<span class="fast-badge ${cls}">${s.status}</span>
${s.pinned ? '<span>📌</span>' : ''}
</div>
<div class="fast-si-meta">
${s.tagCount}개 태그 · ${s.samplingMs}ms · ${_fastFmtDur(s.durationSec)}
</div>
<div class="fast-si-meta">${_fastFmtDt(s.startedAt)}</div>`;
list.appendChild(div);
});
}
async function fastSelect(id) {
_fastCurrentId = id;
fastLivePollStop();
await fastSessionsLoad();
const res = await fetch(`/api/fast/${id}`);
if (!res.ok) return;
const s = await res.json();
document.getElementById('fast-detail').style.display = '';
document.getElementById('fast-detail-name').textContent = s.name;
const badge = document.getElementById('fast-detail-status');
badge.textContent = s.status;
badge.className = 'fast-badge ' +
(s.status === 'Running' ? 'fast-badge-run' : 'fast-badge-done');
document.getElementById('btn-fast-stop').style.display =
s.status === 'Running' ? '' : 'none';
document.getElementById('btn-fast-pin').textContent =
s.pinned ? '📌 고정됨' : '📌 고정';
await fastRenderChart(id);
if (s.status === 'Running') fastLivePollStart(id);
}
async function fastRenderChart(id) {
const res = await fetch(`/api/fast/${id}/records`);
if (!res.ok) return;
const data = await res.json();
const container = document.getElementById('fast-chart-container');
if (!container) return;
if (!data.items || data.items.length === 0) {
container.innerHTML =
'<div class="fast-empty">수집된 데이터가 없습니다.</div>';
return;
}
const latest = {};
data.items.forEach(r => { latest[r.tagName] = r.value; });
let html = `<table class="fast-table">
<thead><tr><th>태그명</th><th>최신값</th></tr></thead><tbody>`;
data.tagNames.forEach(t => {
html += `<tr><td>${t}</td><td>${latest[t] ?? '-'}</td></tr>`;
});
html += `</tbody></table>`;
html += `<div class="fast-rec-count">총 ${data.total.toLocaleString()}행</div>`;
container.innerHTML = html;
}
function fastLivePollStart(id) {
if (_fastPollTimer) return;
_fastPollTimer = setInterval(async () => {
if (!_fastCurrentId) { fastLivePollStop(); return; }
const res = await fetch(`/api/fast/${_fastCurrentId}`);
if (!res.ok) { fastLivePollStop(); return; }
const s = await res.json();
if (s.status !== 'Running') {
fastLivePollStop();
await fastSelect(_fastCurrentId);
return;
}
const elapsed = Math.floor(
(Date.now() - new Date(s.startedAt).getTime()) / 1000);
const pct = Math.min(elapsed / s.durationSec * 100, 100).toFixed(1);
const bar = document.getElementById('fast-progress-bar');
if (bar) bar.style.width = pct + '%';
const pt = document.getElementById('fast-progress-text');
if (pt) pt.textContent = `${s.rowCount.toLocaleString()}행`;
const et = document.getElementById('fast-elapsed-text');
if (et) et.textContent = `경과: ${_fastFmtDur(elapsed)}`;
await fastRenderChart(_fastCurrentId);
}, 3000);
}
function fastLivePollStop() {
if (_fastPollTimer) { clearInterval(_fastPollTimer); _fastPollTimer = null; }
}
async function fastNewModal() {
const res = await fetch('/api/realtime/points');
const select = document.getElementById('fast-tag-select');
select.innerHTML = '';
if (res.ok) {
const data = await res.json();
(data.items || []).forEach(p => {
const opt = document.createElement('option');
opt.value = p.tagName;
opt.textContent = p.tagName;
select.appendChild(opt);
});
}
document.getElementById('fast-name').value = '';
document.getElementById('modal-fast').style.display = '';
}
function fastModalClose() {
document.getElementById('modal-fast').style.display = 'none';
}
async function fastStart() {
const name = document.getElementById('fast-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('태그를 하나 이상 선택하세요.'); 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 retRaw = document.getElementById('fast-retention').value.trim();
const res = await fetch('/api/fast/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Name: name, SamplingMs: samplingMs, DurationSec: durationSec,
TagList: tags, RetentionDays: retRaw ? parseInt(retRaw) : null
})
});
if (!res.ok) {
const err = await res.json();
alert('오류: ' + (err.error || '알 수 없는 오류'));
return;
}
const data = await res.json();
fastModalClose();
await fastSessionsLoad();
await fastSelect(data.id);
}
async function fastStop() {
if (!_fastCurrentId) return;
const res = await fetch(`/api/fast/${_fastCurrentId}/stop`, { method: 'POST' });
if (!res.ok) { alert('중지 실패'); return; }
fastLivePollStop();
await fastSessionsLoad();
await fastSelect(_fastCurrentId);
}
async function fastDelete() {
if (!_fastCurrentId || !confirm('세션을 삭제하시겠습니까?')) return;
await fetch(`/api/fast/${_fastCurrentId}`, { method: 'DELETE' });
_fastCurrentId = null;
fastLivePollStop();
document.getElementById('fast-detail').style.display = 'none';
await fastSessionsLoad();
}
async function fastTogglePin() {
if (!_fastCurrentId) return;
const res = await fetch(`/api/fast/${_fastCurrentId}`);
if (!res.ok) return;
const s = await res.json();
await fetch(`/api/fast/${_fastCurrentId}/pin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Pinned: !s.pinned })
});
await fastSelect(_fastCurrentId);
}
async function fastExportCsv() {
if (!_fastCurrentId) return;
const res = await fetch(`/api/fast/${_fastCurrentId}/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-${_fastCurrentId}-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
function _fastFmtDur(sec) {
if (sec < 60) return sec + 's';
if (sec < 3600) return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm';
}
function _fastFmtDt(dt) {
return new Date(dt).toLocaleString('ko-KR');
}
17-b. 탭 핸들러에 조건 추가
기존 showTab 함수(또는 data-tab 처리 구간)에서 탭 전환 조건 블록 안에 추가:
if (tab === 'fast') fastSessionsLoad();
완료 후 → task_state.md 에 Step 17 완료 기록
Step 18 — style.css Fast 전용 스타일 추가
파일: src/Web/wwwroot/css/style.css
파일 끝에 추가:
/* ── fastRecord ───────────────────────────────────────────────────────────── */
.fast-toolbar { display:flex; gap:8px; margin-bottom:12px; }
.fast-session-list { display:flex; flex-direction:column; gap:6px; margin-bottom:16px; }
.fast-session-item { background:#1e1e1e; border:1px solid #333; border-radius:6px;
padding:10px 14px; cursor:pointer; }
.fast-session-item:hover,
.fast-session-item.active { border-color:#f59e0b; }
.fast-si-top { display:flex; align-items:center; gap:8px; margin-bottom:4px; }
.fast-si-name { font-weight:600; }
.fast-si-meta { font-size:0.8rem; color:#888; }
.fast-badge { font-size:0.75rem; padding:2px 8px; border-radius:10px; }
.fast-badge-run { background:#166534; color:#86efac; }
.fast-badge-done { background:#1e3a5f; color:#93c5fd; }
.fast-detail-header { display:flex; align-items:center; gap:10px;
margin-bottom:10px; flex-wrap:wrap; }
.fast-detail-btns { display:flex; gap:6px; margin-left:auto; flex-wrap:wrap; }
.fast-progress-wrap { background:#333; border-radius:4px; height:12px;
margin-bottom:4px; overflow:hidden; }
.fast-progress-bar { height:100%; background:#f59e0b; transition:width 0.5s; }
.fast-progress-info { display:flex; justify-content:space-between;
font-size:0.8rem; color:#888; margin-bottom:12px; }
.fast-chart-container{ min-height:80px; }
.fast-table { width:100%; border-collapse:collapse; font-size:0.9rem; }
.fast-table th,
.fast-table td { border:1px solid #333; padding:6px 10px; }
.fast-table th { background:#1e1e1e; }
.fast-rec-count { margin-top:8px; font-size:0.8rem; color:#888; text-align:right; }
.fast-empty { color:#666; padding:24px; text-align:center; }
.fast-modal { background:#1a1a1a; border-radius:8px; padding:24px;
width:480px; max-width:95vw; }
.fast-modal-title { font-size:1.1rem; font-weight:600; margin-bottom:16px; }
.fast-input { width:100%; background:#111; border:1px solid #444; color:#eee;
border-radius:4px; padding:6px 8px; margin-bottom:10px;
box-sizing:border-box; }
.fast-row2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.fast-modal-footer { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; }
최종 빌드 검증:
dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q
→ 에러 0건 확인
→ task_state.md 에 Step 18 완료 및 전체 작업 완료 기록
전체 완료 체크리스트
| Step | 파일 | 작업 내용 |
|---|---|---|
| 1 | ExperionEntities.cs | FastSession, FastRecord 엔티티 추가 |
| 2 | IExperionServices.cs | IExperionDbService Fast 메서드 추가 |
| 3 | IExperionServices.cs | IExperionFastService + DTOs 추가 |
| 4 | ExperionDbContext.cs | DbSet + OnModelCreating 추가 |
| 5 | ExperionDbContext.cs | InitializeAsync DDL 추가 (TEXT, not JSONB) |
| 6 | ExperionDbContext.cs | Fast DB 메서드 (Create/Update/Get) |
| 7 | ExperionDbContext.cs | Fast DB 메서드 (Insert/Export/Expired) |
| 8 | ExperionFastService.cs | 신규 생성 — 골격 + IHostedService |
| 9 | ExperionFastService.cs | IExperionFastService 공개 메서드 |
| 10 | ExperionFastService.cs | OPC UA Subscription + OnNotification (버그 수정) |
| 11 | ExperionFastService.cs | FlushBuffer + MonitorLoop + 헬퍼 (TotalRows 누적) |
| 12 | ExperionFastCleanupService.cs | 신규 생성 |
| 13 | ExperionControllers.cs | ExperionFastController (camelCase 응답) |
| 14 | Program.cs | 올바른 DI 패턴 등록 |
| 15 | appsettings.json | Fast 섹션 추가 |
| 16 | index.html | 09 탭 + 패널 + 모달 |
| 17 | app.js | Fast 함수 + 탭 핸들러 연결 |
| 18 | style.css | Fast 전용 스타일 |