P0 셀프서비스 결정론 리포트 — 적산·물질수지 폐합·cleaning 마스크 (+ P1 온라인 스펙) #1

Open
windpacer wants to merge 43 commits from feat/p0-selfservice-report into main
32 changed files with 935 additions and 848 deletions
Showing only changes of commit f97be981a4 - Show all commits

View File

@@ -114,22 +114,22 @@ public class Hc900PointBuilderBuildDto
public class Hc900PointBuilderPreviewItem
{
[JsonPropertyName("tagName")] public string TagName { get; set; } = "";
[JsonPropertyName("hc900Tag")] public string Hc900Tag { get; set; } = "";
[JsonPropertyName("modbusAddr")] public int ModbusAddr { get; set; }
[JsonPropertyName("paramType")] public string ParamType { get; set; } = "";
[JsonPropertyName("dataType")] public string DataType { get; set; } = "";
[JsonPropertyName("loopNo")] public int? LoopNo { get; set; }
[JsonPropertyName("access")] public string Access { get; set; } = "R";
[JsonPropertyName("controllerId")]public string ControllerId { get; set; } = "HC1";
[JsonPropertyName("group")] public string Group { get; set; } = "";
[JsonPropertyName("isActive")] public bool IsActive { get; set; }
public string TagName { get; set; } = "";
public string Hc900Tag { get; set; } = "";
public int ModbusAddr { get; set; }
public string ParamType { get; set; } = "";
public string DataType { get; set; } = "";
public int? LoopNo { get; set; }
public string Access { get; set; } = "R";
public string ControllerId { get; set; } = "HC1";
public string Group { get; set; } = "";
public bool IsActive { get; set; }
}
public class Hc900PointBuilderPreviewResult
{
[JsonPropertyName("count")] public int Count { get; set; }
[JsonPropertyName("items")] public List<Hc900PointBuilderPreviewItem> Items { get; set; } = new();
public int Count { get; set; }
public List<Hc900PointBuilderPreviewItem> Items { get; set; } = new();
}
public class Hc900PointBuilderApplyDto

View File

@@ -2,74 +2,70 @@ using System.Text.Json.Serialization;
namespace Hc900Crawler.Core.Application.DTOs;
// 이 프로젝트의 기본 JSON 직렬화는 PascalCase 패스스루다(기존 컨트롤러는 익명객체로 직접 camelCase 명명).
// 트렌드 DTO는 프론트(camelCase) 계약에 맞추기 위해 [JsonPropertyName]으로 camelCase 고정.
// (입력 바인딩은 MVC 기본 대소문자 무시라 무관, 출력 일관성 목적)
/// <summary>트렌드 그룹 멤버 — 태그 + 색상 + Y축(좌/우)</summary>
public class TrendMemberDto
{
[JsonPropertyName("tag")] public string Tag { get; set; } = "";
[JsonPropertyName("color")] public string Color { get; set; } = ""; // #rrggbb
[JsonPropertyName("axis")] public string Axis { get; set; } = "left"; // left | right
public string Tag { get; set; } = "";
public string Color { get; set; } = ""; // #rrggbb
public string Axis { get; set; } = "left"; // left | right
}
/// <summary>트렌드 그룹 (멤버는 JSONB 저장)</summary>
public class TrendGroupDto
{
[JsonPropertyName("id")] public int Id { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("members")] public List<TrendMemberDto> Members { get; set; } = new();
[JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; }
[JsonPropertyName("updatedAt")] public DateTime UpdatedAt { get; set; }
public int Id { get; set; }
public string Name { get; set; } = "";
public string? Description { get; set; }
public List<TrendMemberDto> Members { get; set; } = new();
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
/// <summary>그룹 생성/수정 요청</summary>
public class TrendGroupUpsertDto
{
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("members")] public List<TrendMemberDto> Members { get; set; } = new();
public string Name { get; set; } = "";
public string? Description { get; set; }
public List<TrendMemberDto> Members { get; set; } = new();
}
/// <summary>아날로그 포인트 (그룹 빌더용 — 숫자 livevalue 한정)</summary>
public class AnalogPointDto
{
[JsonPropertyName("tagName")] public string TagName { get; set; } = "";
[JsonPropertyName("baseTag")] public string BaseTag { get; set; } = "";
[JsonPropertyName("value")] public double? Value { get; set; }
[JsonPropertyName("timestamp")] public DateTime Timestamp { get; set; }
[JsonPropertyName("unit")] public string? Unit { get; set; }
[JsonPropertyName("euLo")] public double? EuLo { get; set; }
[JsonPropertyName("euHi")] public double? EuHi { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("area")] public string? Area { get; set; }
public string TagName { get; set; } = "";
public string BaseTag { get; set; } = "";
public double? Value { get; set; }
public DateTime Timestamp { get; set; }
public string? Unit { get; set; }
public double? EuLo { get; set; }
public double? EuHi { get; set; }
public string? Description { get; set; }
public string? Area { get; set; }
}
/// <summary>실시간 tail 포인트 (현재값)</summary>
public class TrendLivePointDto
{
[JsonPropertyName("tag")] public string Tag { get; set; } = "";
[JsonPropertyName("value")] public double? Value { get; set; }
[JsonPropertyName("ts")] public DateTime Ts { get; set; }
public string Tag { get; set; } = "";
public double? Value { get; set; }
public DateTime Ts { get; set; }
}
/// <summary>알람 한계선 (HI/LO/SP)</summary>
public class TrendLimitDto
{
[JsonPropertyName("tag")] public string Tag { get; set; } = "";
[JsonPropertyName("hi")] public double? Hi { get; set; }
[JsonPropertyName("lo")] public double? Lo { get; set; }
[JsonPropertyName("sp")] public double? Sp { get; set; }
[JsonPropertyName("unit")] public string? Unit { get; set; }
public string Tag { get; set; } = "";
public double? Hi { get; set; }
public double? Lo { get; set; }
public double? Sp { get; set; }
public string? Unit { get; set; }
}
/// <summary>운전상태 밴드 (RUN/TRIP 구간)</summary>
public class TrendBandDto
{
[JsonPropertyName("tag")] public string Tag { get; set; } = "";
[JsonPropertyName("t0")] public DateTime T0 { get; set; }
[JsonPropertyName("t1")] public DateTime T1 { get; set; }
[JsonPropertyName("state")] public string State { get; set; } = "";
public string Tag { get; set; } = "";
public DateTime T0 { get; set; }
public DateTime T1 { get; set; }
public string State { get; set; } = "";
}

View File

@@ -55,6 +55,7 @@ public class McpToolDto
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public System.Text.Json.JsonElement? InputSchema { get; set; }
}
/// <summary>

View File

@@ -26,18 +26,18 @@ public class DocsController : ControllerBase
private Task<bool> IsAdminAsync(CancellationToken ct)
=> _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct);
private IActionResult Fail(string error) => Ok(new { success = false, error });
private IActionResult Fail(string error) => Ok(new { Success = false, Error = error });
// ── 메타/설정 ───────────────────────────────────────────────
[HttpGet("config")]
public async Task<IActionResult> Config(CancellationToken ct)
=> Ok(new
{
success = true,
root = _docs.Root,
canManage = await IsAdminAsync(ct),
maxTextBytes = _docs.MaxTextBytes,
maxUploadBytes = _docs.MaxUploadBytes,
Success = true,
Root = _docs.Root,
CanManage = await IsAdminAsync(ct),
MaxTextBytes = _docs.MaxTextBytes,
MaxUploadBytes = _docs.MaxUploadBytes,
});
// ── 디렉토리 목록 ───────────────────────────────────────────
@@ -48,14 +48,14 @@ public class DocsController : ControllerBase
{
var entries = _docs.List(path).Select(e => new
{
name = e.Name,
path = e.RelPath,
type = e.IsDir ? "dir" : "file",
size = e.Size,
mtime = e.ModifiedUtc,
ext = e.Ext,
Name = e.Name,
Path = e.RelPath,
Type = e.IsDir ? "dir" : "file",
Size = e.Size,
Mtime = e.ModifiedUtc,
Ext = e.Ext,
});
return Ok(new { success = true, path = path ?? "", entries });
return Ok(new { Success = true, Path = path ?? "", Entries = entries });
}
catch (DocBrowserException ex) { return Fail(ex.Message); }
}
@@ -67,7 +67,7 @@ public class DocsController : ControllerBase
try
{
var r = _docs.ReadText(path);
return Ok(new { success = true, text = r.Text, truncated = r.Truncated, size = r.Size, ext = r.Ext });
return Ok(new { Success = true, Text = r.Text, Truncated = r.Truncated, Size = r.Size, Ext = r.Ext });
}
catch (DocBrowserException ex) { return Fail(ex.Message); }
}
@@ -83,7 +83,7 @@ public class DocsController : ControllerBase
return File(r.Stream, "application/octet-stream", r.FileName);
return File(r.Stream, r.ContentType); // inline (pdf 등)
}
catch (DocBrowserException ex) { return NotFound(new { success = false, error = ex.Message }); }
catch (DocBrowserException ex) { return NotFound(new { Success = false, Error = ex.Message }); }
}
// ── 텍스트 저장 (admin) ─────────────────────────────────────
@@ -93,13 +93,13 @@ public class DocsController : ControllerBase
[RequestSizeLimit(8_000_000)]
public async Task<IActionResult> WriteText([FromBody] WriteRequest req, CancellationToken ct)
{
if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" });
if (!await IsAdminAsync(ct)) return Unauthorized(new { Success = false, Error = "unauthorized" });
if (req == null || string.IsNullOrEmpty(req.Path)) return Fail("path required");
try
{
_docs.WriteText(req.Path, req.Content ?? "");
_logger.LogInformation("[Docs] 저장 {Path}", req.Path);
return Ok(new { success = true });
return Ok(new { Success = true });
}
catch (DocBrowserException ex) { return Fail(ex.Message); }
}
@@ -110,14 +110,14 @@ public class DocsController : ControllerBase
[HttpPost("rename")]
public async Task<IActionResult> Rename([FromBody] RenameRequest req, CancellationToken ct)
{
if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" });
if (!await IsAdminAsync(ct)) return Unauthorized(new { Success = false, Error = "unauthorized" });
if (req == null || string.IsNullOrEmpty(req.From) || string.IsNullOrEmpty(req.To))
return Fail("from/to required");
try
{
var newRel = _docs.Rename(req.From, req.To);
_logger.LogInformation("[Docs] 이름변경 {From} → {To}", req.From, newRel);
return Ok(new { success = true, path = newRel });
return Ok(new { Success = true, Path = newRel });
}
catch (DocBrowserException ex) { return Fail(ex.Message); }
}
@@ -128,12 +128,12 @@ public class DocsController : ControllerBase
[HttpPost("mkdir")]
public async Task<IActionResult> Mkdir([FromBody] MkdirRequest req, CancellationToken ct)
{
if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" });
if (!await IsAdminAsync(ct)) return Unauthorized(new { Success = false, Error = "unauthorized" });
if (req == null || string.IsNullOrEmpty(req.Path)) return Fail("path required");
try
{
var rel = _docs.MakeDir(req.Path);
return Ok(new { success = true, path = rel });
return Ok(new { Success = true, Path = rel });
}
catch (DocBrowserException ex) { return Fail(ex.Message); }
}
@@ -142,13 +142,13 @@ public class DocsController : ControllerBase
[HttpDelete]
public async Task<IActionResult> Delete([FromQuery] string path, [FromQuery] bool recursive, CancellationToken ct)
{
if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" });
if (!await IsAdminAsync(ct)) return Unauthorized(new { Success = false, Error = "unauthorized" });
if (string.IsNullOrEmpty(path)) return Fail("path required");
try
{
_docs.Delete(path, recursive);
_logger.LogInformation("[Docs] 삭제 {Path} (recursive={R})", path, recursive);
return Ok(new { success = true });
return Ok(new { Success = true });
}
catch (DocBrowserException ex) { return Fail(ex.Message); }
}
@@ -158,14 +158,14 @@ public class DocsController : ControllerBase
[RequestSizeLimit(100_000_000)]
public async Task<IActionResult> Upload([FromForm] IFormFile file, [FromForm] string? path, CancellationToken ct)
{
if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" });
if (!await IsAdminAsync(ct)) return Unauthorized(new { Success = false, Error = "unauthorized" });
if (file == null || file.Length == 0) return Fail("file required");
try
{
await using var stream = file.OpenReadStream();
var rel = await _docs.SaveUploadAsync(path, file.FileName, stream, ct);
_logger.LogInformation("[Docs] 업로드 {Path} ({Size} bytes)", rel, file.Length);
return Ok(new { success = true, path = rel });
return Ok(new { Success = true, Path = rel });
}
catch (DocBrowserException ex) { return Fail(ex.Message); }
}

View File

@@ -31,7 +31,7 @@ public class FastController : ControllerBase
? Array.Empty<string>()
: System.Text.Json.JsonSerializer.Deserialize<string[]>(s.TagList) ?? Array.Empty<string>()
});
return Ok(new { items });
return Ok(new { Items = items });
}
[HttpGet("{id}")]
@@ -58,7 +58,7 @@ public class FastController : ControllerBase
var cts = new CancellationTokenSource();
lock (_sessions) { _sessions[session.Id] = cts; }
_ = Task.Run(() => RunSessionAsync(session.Id, req, cts.Token));
return Ok(new { session.Id, status = "Running" });
return Ok(new { Id = session.Id, Status = "Running" });
}
// 백그라운드 수집 루프. 요청 스코프는 응답 후 dispose되므로 매 반복 자체 스코프에서 DbContext를 새로 받는다.
@@ -114,7 +114,7 @@ public class FastController : ControllerBase
{
lock (_sessions) { if (_sessions.TryGetValue(id, out var cts)) { cts.Cancel(); _sessions.Remove(id); } }
await _db.UpdateFastSessionStatusAsync(id, "Stopped");
return Ok(new { success = true });
return Ok(new { Success = true });
}
[HttpDelete("{id}")]
@@ -122,14 +122,14 @@ public class FastController : ControllerBase
{
lock (_sessions) { if (_sessions.TryGetValue(id, out var cts)) { cts.Cancel(); _sessions.Remove(id); } }
await _db.DeleteFastSessionAsync(id);
return Ok(new { success = true });
return Ok(new { Success = true });
}
[HttpPost("{id}/pin")]
public async Task<IActionResult> Pin(int id, [FromBody] bool pinned)
{
await _db.UpdateFastSessionPinnedAsync(id, pinned);
return Ok(new { success = true });
return Ok(new { Success = true });
}
[HttpGet("{id}/records")]
@@ -139,8 +139,8 @@ public class FastController : ControllerBase
return Ok(new
{
result.SessionId, result.From, result.To,
tagNames = result.TagNames,
items = result.Items.Select(r => new
TagNames = result.TagNames,
Items = result.Items.Select(r => new
{
r.SessionId, r.RecordedAt, r.TagName, r.Value
}),

View File

@@ -72,58 +72,58 @@ public sealed class FeedforwardController : ControllerBase
public async Task<IActionResult> GetConfig(CancellationToken ct)
{
var cols = await _config.LoadAllAsync(ct);
return Ok(new { columns = cols.Select(MapConfig) });
return Ok(new { Columns = cols.Select(MapConfig) });
}
[HttpPost("config")]
public async Task<IActionResult> SaveConfig([FromBody] ColumnConfig body, CancellationToken ct)
{
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
if (!await AuthAsync(ct)) return Unauthorized(new { Error = "X-Kb-Token 인증 필요" });
var id = await _config.SaveColumnAsync(body, ct);
return Ok(new { success = true, id });
return Ok(new { Success = true, Id = id });
}
[HttpDelete("config/{id:int}")]
public async Task<IActionResult> DeleteConfig(int id, CancellationToken ct)
{
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
if (!await AuthAsync(ct)) return Unauthorized(new { Error = "X-Kb-Token 인증 필요" });
await _config.DeleteColumnAsync(id, ct);
return Ok(new { success = true });
return Ok(new { Success = true });
}
// ── WO-6 전환류 ARM/취소 ──
[HttpPost("recovery/{id:int}/arm")]
public IActionResult ArmRecovery(int id) => Ok(new { success = _supervisor.Arm(id) });
public IActionResult ArmRecovery(int id) => Ok(new { Success = _supervisor.Arm(id) });
[HttpPost("recovery/{id:int}/cancel")]
public IActionResult CancelRecovery(int id) => Ok(new { success = _supervisor.Cancel(id) });
public IActionResult CancelRecovery(int id) => Ok(new { Success = _supervisor.Cancel(id) });
// ── Phase II: 수동 SP 쓰기 ──
[HttpPost("write/{columnId:int}/{streamKey}")]
public async Task<IActionResult> WriteSp(int columnId, string streamKey, [FromBody] WriteSpBody body, CancellationToken ct)
{
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
if (!await AuthAsync(ct)) return Unauthorized(new { Error = "X-Kb-Token 인증 필요" });
var advisory = _store.Get(columnId);
if (advisory is null) return NotFound(new { error = "advisory 없음" });
if (advisory is null) return NotFound(new { Error = "advisory 없음" });
var cfg = (await _config.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId);
if (cfg is null) return NotFound(new { error = "config 없음" });
if (cfg is null) return NotFound(new { Error = "config 없음" });
var sc = cfg.Streams.FirstOrDefault(s => s.Key == streamKey);
if (sc is null) return NotFound(new { error = "stream 없음" });
if (sc is null) return NotFound(new { Error = "stream 없음" });
// SP 쓰기 대상 = flow 태그에서 WSP(.SP, offset 0x04 RW) 자동 파생. SpNodeId는 선택적 override.
var spTag = FfSpTag.Resolve(sc.FlowTag, sc.SpNodeId);
if (string.IsNullOrWhiteSpace(spTag))
return BadRequest(new { error = "flow 태그가 없어 SP 대상 산출 불가" });
return BadRequest(new { Error = "flow 태그가 없어 SP 대상 산출 불가" });
var adv = advisory.Streams.FirstOrDefault(a => a.Key == streamKey);
if (adv is null) return NotFound(new { error = "stream advisory 없음" });
if (adv is null) return NotFound(new { Error = "stream advisory 없음" });
double spVal = body.value ?? (adv.RecommendedSp ?? double.NaN);
if (double.IsNaN(spVal)) return BadRequest(new { error = "SP 값 없음" });
if (double.IsNaN(spVal)) return BadRequest(new { Error = "SP 값 없음" });
// 범위 클램프(§3.3) 후 WriteGuard 검증 — manualOverride=true(AdvisoryOnly만 우회, 나머지 가드 유지)
spVal = Math.Clamp(spVal, sc.SpMin, sc.SpMax);
var check = _writeGuard.Check(cfg, adv, sc, advisory, manualOverride: true);
if (!check.Allowed)
return BadRequest(new { error = $"WriteGuard 차단: {check.Reason}" });
return BadRequest(new { Error = $"WriteGuard 차단: {check.Reason}" });
// 되돌리기용: 쓰기 전 현재 WSP 값을 캡처(realtime_table)
double? prevSp = await TryReadCurrentAsync(spTag, ct);
@@ -131,7 +131,7 @@ public sealed class FeedforwardController : ControllerBase
// 컨트롤러는 태그→controller_id 매핑에서 해석(DB). 컬럼 ControllerId에 의존하지 않음.
var ctrlId = await _db.GetControllerIdForTagAsync(spTag);
if (string.IsNullOrWhiteSpace(ctrlId))
return BadRequest(new { error = $"태그 {spTag}의 컨트롤러를 DB에서 찾지 못함(realtime 폴링 확인)" });
return BadRequest(new { Error = $"태그 {spTag}의 컨트롤러를 DB에서 찾지 못함(realtime 폴링 확인)" });
// HC900 gRPC 쓰기 (WSP 태그, 해석된 컨트롤러로 라우팅)
var (success, error) = await _writeClient.WriteTagAsync(ctrlId, spTag, spVal);
@@ -143,9 +143,9 @@ public sealed class FeedforwardController : ControllerBase
OperatorName: "manual"), ct);
if (!success)
return StatusCode(502, new { error = $"HC900 쓰기 실패: {error}" });
return StatusCode(502, new { Error = $"HC900 쓰기 실패: {error}" });
return Ok(new { success = true, streamKey, nodeId = spTag, value = spVal, previousSp = prevSp });
return Ok(new { Success = true, StreamKey = streamKey, NodeId = spTag, Value = spVal, PreviousSp = prevSp });
}
// ── 측류 추종 ON/OFF/원복 ───────────────────────────────────────
@@ -154,12 +154,12 @@ public sealed class FeedforwardController : ControllerBase
public async Task<IActionResult> TrackOn(int columnId, string streamKey, CancellationToken ct)
{
var cfg = (await _config.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId);
if (cfg is null) return NotFound(new { error = "config 없음" });
if (cfg is null) return NotFound(new { Error = "config 없음" });
var sc = cfg.Streams.FirstOrDefault(s => s.Key == streamKey);
if (sc is null) return NotFound(new { error = "stream 없음" });
if (sc.Role == StreamRole.Monitor) return BadRequest(new { error = "Monitor 스트림은 추종 대상 아님" });
if (sc is null) return NotFound(new { Error = "stream 없음" });
if (sc.Role == StreamRole.Monitor) return BadRequest(new { Error = "Monitor 스트림은 추종 대상 아님" });
var spTag = FfSpTag.Resolve(sc.FlowTag, sc.SpNodeId);
if (string.IsNullOrWhiteSpace(spTag)) return BadRequest(new { error = "flow 태그 없음 — SP 대상 산출 불가" });
if (string.IsNullOrWhiteSpace(spTag)) return BadRequest(new { Error = "flow 태그 없음 — SP 대상 산출 불가" });
_tracking.Set(new FfTrackingState
{
@@ -168,7 +168,7 @@ public sealed class FeedforwardController : ControllerBase
});
await _audit.LogAsync(new FfActionLogEntry(columnId, "track_on",
StreamKey: streamKey, NodeId: spTag, Result: "started", OperatorName: "manual"), ct);
return Ok(new { success = true, streamKey });
return Ok(new { Success = true, StreamKey = streamKey });
}
// OFF(=취소): 추종 중지. 컨트롤러는 마지막 값 유지.
@@ -183,7 +183,7 @@ public sealed class FeedforwardController : ControllerBase
});
await _audit.LogAsync(new FfActionLogEntry(columnId, "track_off",
StreamKey: streamKey, Result: "stopped", OperatorName: "manual"), ct);
return Ok(new { success = true });
return Ok(new { Success = true });
}
// ── 모듈1 shadow 검증: 최근 shadow 데이터 조회 ──
@@ -191,21 +191,21 @@ public sealed class FeedforwardController : ControllerBase
public IActionResult GetShadow(int columnId, [FromQuery] int count = 100)
{
var entries = _shadow.GetRecent(columnId, count);
return Ok(new { columnId, count = entries.Count, entries });
return Ok(new { ColumnId = columnId, Count = entries.Count, Entries = entries });
}
[HttpPost("shadow/{columnId:int}/clear")]
public IActionResult ClearShadow(int columnId)
{
_shadow.Clear(columnId);
return Ok(new { success = true });
return Ok(new { Success = true });
}
// ── Phase II: 감사 로그 조회 ──
[HttpGet("audit")]
public async Task<IActionResult> GetAudit([FromQuery] int? columnId, [FromQuery] int limit = 50, CancellationToken ct = default)
{
if (!await AuthAsync(ct)) return Unauthorized(new { error = "X-Kb-Token 인증 필요" });
if (!await AuthAsync(ct)) return Unauthorized(new { Error = "X-Kb-Token 인증 필요" });
var rows = await _audit.QueryAsync(columnId, limit, ct);
return Ok(new { rows });
}
@@ -257,7 +257,7 @@ public sealed class FeedforwardController : ControllerBase
[FromQuery] double n = 1.8, CancellationToken ct = default)
{
var a = await _ramp.ComputeAsync(columnId, targetFeed, deltaIAllow, sensibleGain, feedTempRef, floodLimit, n, ct);
if (a is null) return NotFound(new { error = "config 없음" });
if (a is null) return NotFound(new { Error = "config 없음" });
return Ok(MapRamp(a));
}
@@ -268,17 +268,17 @@ public sealed class FeedforwardController : ControllerBase
public async Task<IActionResult> StartFeedRamp(int columnId, [FromBody] FeedRampStartBody body, CancellationToken ct)
{
if (body is null || double.IsNaN(body.targetFeed) || double.IsInfinity(body.targetFeed))
return BadRequest(new { error = "targetFeed 값 필요" });
return BadRequest(new { Error = "targetFeed 값 필요" });
var cfg = (await _config.LoadAllAsync(ct)).FirstOrDefault(c => c.Id == columnId);
if (cfg is null) return NotFound(new { error = "config 없음" });
if (cfg is null) return NotFound(new { Error = "config 없음" });
if (string.IsNullOrWhiteSpace(FfSpTag.Resolve(cfg.FeedTag, cfg.FeedSpNodeId)))
return BadRequest(new { error = "Feed 태그 없음 — FEED SP 대상 산출 불가" });
return BadRequest(new { Error = "Feed 태그 없음 — FEED SP 대상 산출 불가" });
// 현재 피드 확인 + 업램프만 허용
var adv = await _ramp.ComputeAsync(columnId, body.targetFeed, 50, double.NaN, double.NaN, double.NaN, 1.8, ct);
if (adv is null) return NotFound(new { error = "config 없음" });
if (adv.Hold) return BadRequest(new { error = $"피드 불량 — 시작 불가: {string.Join(", ", adv.Warnings)}" });
if (adv is null) return NotFound(new { Error = "config 없음" });
if (adv.Hold) return BadRequest(new { Error = $"피드 불량 — 시작 불가: {string.Join(", ", adv.Warnings)}" });
bool dryRun = RampDryRun() || _sim.Enabled;
var job = _rampJobs.Start(columnId, body.targetFeed, "manual", dryRun);
@@ -287,7 +287,7 @@ public sealed class FeedforwardController : ControllerBase
SpValue: body.targetFeed, NodeId: cfg.FeedSpNodeId,
Result: dryRun ? "started(dry-run)" : "started", OperatorName: "manual"), ct);
return Ok(new { success = true, dryRun, job = MapRampJob(job) });
return Ok(new { Success = true, DryRun = dryRun, Job = MapRampJob(job) });
}
[HttpPost("feed-ramp/{columnId:int}/cancel")]
@@ -297,19 +297,19 @@ public sealed class FeedforwardController : ControllerBase
if (ok)
await _audit.LogAsync(new FfActionLogEntry(columnId, "feed_ramp_cancel",
Result: "canceled", OperatorName: "manual"), ct);
return Ok(new { success = ok });
return Ok(new { Success = ok });
}
[HttpGet("feed-ramp/{columnId:int}")]
public IActionResult GetFeedRamp(int columnId)
{
var job = _rampJobs.Get(columnId);
return Ok(new { dryRun = RampDryRun() || _sim.Enabled, job = job is null ? null : MapRampJob(job) });
return Ok(new { DryRun = RampDryRun() || _sim.Enabled, Job = job is null ? null : MapRampJob(job) });
}
[HttpGet("feed-ramp")]
public IActionResult GetAllFeedRamp()
=> Ok(new { dryRun = RampDryRun() || _sim.Enabled, jobs = _rampJobs.GetAll().Select(MapRampJob) });
=> Ok(new { DryRun = RampDryRun() || _sim.Enabled, Jobs = _rampJobs.GetAll().Select(MapRampJob) });
private static object MapRampJob(FeedRampJob j) => new
{
@@ -333,57 +333,57 @@ public sealed class FeedforwardController : ControllerBase
[HttpGet("sim/override")]
public IActionResult GetSimOverride()
{
if (!SimEnabled()) return StatusCode(403, new { error = "SimOverride 비활성(Feedforward:SimOverrideEnabled=false)" });
return Ok(new { enabled = _sim.Enabled, values = _sim.Snapshot() });
if (!SimEnabled()) return StatusCode(403, new { Error = "SimOverride 비활성(Feedforward:SimOverrideEnabled=false)" });
return Ok(new { Enabled = _sim.Enabled, Values = _sim.Snapshot() });
}
[HttpPost("sim/override")]
public IActionResult SetSimOverride([FromBody] SimOverrideBody body)
{
if (!SimEnabled()) return StatusCode(403, new { error = "SimOverride 비활성" });
if (!SimEnabled()) return StatusCode(403, new { Error = "SimOverride 비활성" });
_sim.SetMany(body.enabled, body.values ?? new Dictionary<string, double>());
return Ok(new { enabled = _sim.Enabled, values = _sim.Snapshot() });
return Ok(new { Enabled = _sim.Enabled, Values = _sim.Snapshot() });
}
[HttpDelete("sim/override")]
public IActionResult ClearSimOverride()
{
if (!SimEnabled()) return StatusCode(403, new { error = "SimOverride 비활성" });
if (!SimEnabled()) return StatusCode(403, new { Error = "SimOverride 비활성" });
_sim.Clear();
return Ok(new { enabled = _sim.Enabled });
return Ok(new { Enabled = _sim.Enabled });
}
// ── WP5 3단계: 조성 분율 수동입력(랩) ──
[HttpGet("composition")]
public IActionResult GetComposition() => Ok(new { fractions = _composition.Snapshot() });
public IActionResult GetComposition() => Ok(new { Fractions = _composition.Snapshot() });
[HttpPost("composition")]
public IActionResult SetComposition([FromBody] CompositionBody b)
{
if (b is null || string.IsNullOrWhiteSpace(b.streamKey)) return BadRequest(new { error = "streamKey 필요" });
if (b is null || string.IsNullOrWhiteSpace(b.streamKey)) return BadRequest(new { Error = "streamKey 필요" });
_composition.Set(b.columnId, b.streamKey, b.fraction);
return Ok(new { fractions = _composition.Snapshot() });
return Ok(new { Fractions = _composition.Snapshot() });
}
[HttpDelete("composition/{columnId:int}/{streamKey}")]
public IActionResult ClearComposition(int columnId, string streamKey)
{ _composition.Clear(columnId, streamKey); return Ok(new { fractions = _composition.Snapshot() }); }
{ _composition.Clear(columnId, streamKey); return Ok(new { Fractions = _composition.Snapshot() }); }
private static double? Fin(double v) => (double.IsNaN(v) || double.IsInfinity(v)) ? (double?)null : v;
private static object MapRamp(FeedRampAdvisory a) => new
{
columnId = a.ColumnId,
currentFeed = Fin(a.CurrentFeed), targetFeed = Fin(a.TargetFeed), clampedTarget = Fin(a.ClampedTarget),
ceiling = new { value = Fin(a.Ceiling.Value), binding = a.Ceiling.Binding },
rampRate = new { value = Fin(a.RampRate.Value), binding = a.RampRate.Binding },
ceiling = new { Value = Fin(a.Ceiling.Value), Binding = a.Ceiling.Binding },
rampRate = new { Value = Fin(a.RampRate.Value), Binding = a.RampRate.Binding },
rampTimeMin = Fin(a.RampTimeMin),
steam = new { fiq6115From = a.Steam.Fiq6115From, fiq6115To = a.Steam.Fiq6115To, startOpPct = a.Steam.StartOpPct },
steam = new { Fiq6115From = a.Steam.Fiq6115From, Fiq6115To = a.Steam.Fiq6115To, StartOpPct = a.Steam.StartOpPct },
simOverrideActive = a.SimOverrideActive, hold = a.Hold, warnings = a.Warnings
};
// ── Advisory (공개 읽기) ───────────────────────────────────────
[HttpGet("advisory")]
public IActionResult GetAll() => Ok(new { columns = _store.GetAll().Select(MapColumn) });
public IActionResult GetAll() => Ok(new { Columns = _store.GetAll().Select(MapColumn) });
[HttpGet("advisory/{columnId:int}")]
public IActionResult Get(int columnId)

View File

@@ -46,7 +46,7 @@ public class GatewayController : ControllerBase
}
catch (Exception ex)
{
return Ok(new { status = "NOT_SERVING", error = ex.Message });
return Ok(new { Status = "NOT_SERVING", Error = ex.Message });
}
}
@@ -84,7 +84,7 @@ public class GatewayController : ControllerBase
public async Task<IActionResult> Write([FromBody] WriteTagDto dto)
{
var (success, error) = await _write.WriteTagAsync(dto.ControllerId, dto.TagName, dto.Value);
return Ok(new { success, error });
return Ok(new { Success = success, Error = error });
}
}
@@ -305,7 +305,7 @@ public class Hc900TagManagerController : ControllerBase
if (entry == null) return NotFound();
entry.IsActive = active;
await _ctx.SaveChangesAsync();
return Ok(new { entry.Id, entry.TagName, entry.IsActive });
return Ok(new { Id = entry.Id, TagName = entry.TagName, IsActive = entry.IsActive });
}
[HttpPost("bulk-active")]
@@ -318,7 +318,7 @@ public class Hc900TagManagerController : ControllerBase
if (!string.IsNullOrEmpty(dto.ControllerId)) q = q.Where(x => x.ControllerId == dto.ControllerId);
var count = await q.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsActive, dto.Active));
return Ok(new { updated = count, active = dto.Active });
return Ok(new { Updated = count, Active = dto.Active });
}
[HttpGet("param-types")]
@@ -370,22 +370,22 @@ public class Hc900TagManagerController : ControllerBase
var byType = await _ctx.Hc900MapEntries
.Where(x => x.ControllerId == controllerId)
.GroupBy(x => x.ParamType)
.Select(g => new { paramType = g.Key, total = g.Count(), active = g.Count(x => x.IsActive) })
.OrderBy(x => x.paramType)
.Select(g => new { ParamType = g.Key, Total = g.Count(), Active = g.Count(x => x.IsActive) })
.OrderBy(x => x.ParamType)
.ToListAsync();
return Ok(new { controllerId, catalog, assigned, live, config, unassigned, byType });
return Ok(new { ControllerId = controllerId, Catalog = catalog, Assigned = assigned, Live = live, Config = config, Unassigned = unassigned, ByType = byType });
}
var total = await _ctx.Hc900MapEntries.CountAsync();
var active = await _ctx.Hc900MapEntries.CountAsync(x => x.IsActive);
var byTypeAll = await _ctx.Hc900MapEntries
.GroupBy(x => x.ParamType)
.Select(g => new { paramType = g.Key, total = g.Count(), active = g.Count(x => x.IsActive) })
.OrderBy(x => x.paramType)
.Select(g => new { ParamType = g.Key, Total = g.Count(), Active = g.Count(x => x.IsActive) })
.OrderBy(x => x.ParamType)
.ToListAsync();
var liveCount = await _ctx.RealtimePoints.CountAsync();
return Ok(new { total, active, inactive = total - active, liveCount, byType = byTypeAll });
return Ok(new { Total = total, Active = active, Inactive = total - active, LiveCount = liveCount, ByType = byTypeAll });
}
}
@@ -419,7 +419,7 @@ public class SubAreaController : ControllerBase
public async Task<IActionResult> Update(string baseTag, [FromBody] string? subArea)
{
var ok = await _db.UpdateSubAreaAsync(baseTag, subArea);
return Ok(new { success = ok });
return Ok(new { Success = ok });
}
[HttpPost("seed")]

View File

@@ -43,7 +43,7 @@ public class HypertableController : ControllerBase
CreateContinuousAggregate = dto.CreateContinuousAggregate
};
var result = await _db.CreateHypertableAsync(request);
return Ok(new { success = result.Success, message = result.Message });
return Ok(new { Success = result.Success, Message = result.Message });
}
}

View File

@@ -23,14 +23,14 @@ public class KbAuthController : ControllerBase
public async Task<IActionResult> Login([FromBody] LoginRequest req, CancellationToken ct)
{
if (req == null || string.IsNullOrWhiteSpace(req.Password))
return Ok(new { success = false, error = "password is required" });
return Ok(new { Success = false, Error = "password is required" });
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var result = await _auth.LoginAsync(req.Password, ip, ct);
if (!result.Success)
return Ok(new { success = false, error = result.Error ?? "login failed" });
return Ok(new { Success = false, Error = result.Error ?? "login failed" });
return Ok(new { success = true, token = result.Token, expiresAt = result.ExpiresAt });
return Ok(new { Success = true, Token = result.Token, ExpiresAt = result.ExpiresAt });
}
[HttpPost("logout")]
@@ -39,7 +39,7 @@ public class KbAuthController : ControllerBase
var token = Request.Headers["X-Kb-Token"].ToString();
if (!string.IsNullOrEmpty(token))
await _auth.LogoutAsync(token, ct);
return Ok(new { success = true });
return Ok(new { Success = true });
}
[HttpGet("status")]
@@ -55,14 +55,14 @@ public class KbAuthController : ControllerBase
{
var token = Request.Headers["X-Kb-Token"].ToString();
if (!await _auth.ValidateAsync(token, ct))
return Unauthorized(new { success = false, error = "invalid token" });
return Unauthorized(new { Success = false, Error = "invalid token" });
if (req == null || string.IsNullOrWhiteSpace(req.OldPassword) || string.IsNullOrWhiteSpace(req.NewPassword))
return Ok(new { success = false, error = "passwords required" });
return Ok(new { Success = false, Error = "passwords required" });
if (req.NewPassword.Length < 6)
return Ok(new { success = false, error = "new password must be at least 6 chars" });
return Ok(new { Success = false, Error = "new password must be at least 6 chars" });
var ok = await _auth.ChangePasswordAsync(req.OldPassword, req.NewPassword, ct);
return Ok(new { success = ok });
return Ok(new { Success = ok });
}
}

View File

@@ -48,10 +48,10 @@ public class KbController : ControllerBase
.OrderBy(c => c.CollectionKey)
.Select(c => new
{
key = c.CollectionKey,
name = c.DisplayName,
qdrant = c.QdrantName,
description = c.Description
Key = c.CollectionKey,
Name = c.DisplayName,
Qdrant = c.QdrantName,
Description = c.Description
})
.ToListAsync(ct);
@@ -64,10 +64,10 @@ public class KbController : ControllerBase
var byKey = docCounts.ToDictionary(x => x.Key, x => (x.Count, x.Chunks));
var result = items.Select(c =>
{
byKey.TryGetValue(c.key, out var counts);
return new { c.key, c.name, c.qdrant, c.description, docCount = counts.Count, chunkCount = counts.Chunks };
byKey.TryGetValue(c.Key, out var counts);
return new { c.Key, c.Name, c.Qdrant, c.Description, DocCount = counts.Count, ChunkCount = counts.Chunks };
});
return Ok(new { success = true, items = result });
return Ok(new { Success = true, Items = result });
}
[HttpPost("upload")]
@@ -80,15 +80,15 @@ public class KbController : ControllerBase
CancellationToken ct)
{
if (!await RequireAdminAsync(ct))
return Unauthorized(new { success = false, error = "unauthorized" });
return Unauthorized(new { Success = false, Error = "unauthorized" });
if (file == null || file.Length == 0)
return BadRequest(new { success = false, error = "file required" });
return BadRequest(new { Success = false, Error = "file required" });
if (string.IsNullOrWhiteSpace(collectionKey))
return BadRequest(new { success = false, error = "collectionKey required" });
return BadRequest(new { Success = false, Error = "collectionKey required" });
var coll = await _db.KbCollections.FirstOrDefaultAsync(c => c.CollectionKey == collectionKey, ct);
if (coll == null) return BadRequest(new { success = false, error = "unknown collectionKey" });
if (coll == null) return BadRequest(new { Success = false, Error = "unknown collectionKey" });
await using var stream = file.OpenReadStream();
var stored = await _storage.SaveAsync(stream, file.FileName, ct);
@@ -123,7 +123,7 @@ public class KbController : ControllerBase
await _db.SaveChangesAsync(ct);
_logger.LogInformation("[Kb] 업로드 {Id} {Title} ({Size} bytes)", doc.Id, doc.Title, doc.FileSize);
return Ok(new { success = true, docId = doc.Id, status = doc.Status });
return Ok(new { Success = true, DocId = doc.Id, Status = doc.Status });
}
[HttpGet("documents")]
@@ -154,45 +154,45 @@ public class KbController : ControllerBase
.Take(pageSize)
.Select(d => new
{
id = d.Id,
title = d.Title,
collection = d.CollectionKey,
tags = d.Tags,
status = d.Status,
chunkCount = d.ChunkCount,
fileSize = d.FileSize,
uploadedAt = d.UploadedAt,
indexedAt = d.IndexedAt,
errorMessage = d.ErrorMessage
Id = d.Id,
Title = d.Title,
Collection = d.CollectionKey,
Tags = d.Tags,
Status = d.Status,
ChunkCount = d.ChunkCount,
FileSize = d.FileSize,
UploadedAt = d.UploadedAt,
IndexedAt = d.IndexedAt,
ErrorMessage = d.ErrorMessage
})
.ToListAsync(ct);
return Ok(new { success = true, total, page, pageSize, items });
return Ok(new { Success = true, Total = total, Page = page, PageSize = pageSize, Items = items });
}
[HttpGet("documents/{id:guid}")]
public async Task<IActionResult> GetDocument(Guid id, CancellationToken ct)
{
var d = await _db.KbDocuments.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (d == null) return NotFound(new { success = false });
if (d == null) return NotFound(new { Success = false });
return Ok(new
{
success = true,
item = new
Success = true,
Item = new
{
id = d.Id,
title = d.Title,
collection = d.CollectionKey,
tags = d.Tags,
status = d.Status,
chunkCount = d.ChunkCount,
fileSize = d.FileSize,
mimeType = d.MimeType,
uploadedAt = d.UploadedAt,
indexedAt = d.IndexedAt,
disabledAt = d.DisabledAt,
originalPath = d.OriginalPath,
fileSha256 = d.FileSha256,
errorMessage = d.ErrorMessage
Id = d.Id,
Title = d.Title,
Collection = d.CollectionKey,
Tags = d.Tags,
Status = d.Status,
ChunkCount = d.ChunkCount,
FileSize = d.FileSize,
MimeType = d.MimeType,
UploadedAt = d.UploadedAt,
IndexedAt = d.IndexedAt,
DisabledAt = d.DisabledAt,
OriginalPath = d.OriginalPath,
FileSha256 = d.FileSha256,
ErrorMessage = d.ErrorMessage
}
});
}
@@ -201,14 +201,14 @@ public class KbController : ControllerBase
public async Task<IActionResult> GetChunks(Guid id, [FromQuery] int limit = 200, CancellationToken ct = default)
{
if (!await RequireAdminAsync(ct))
return Unauthorized(new { success = false, error = "unauthorized" });
return Unauthorized(new { Success = false, Error = "unauthorized" });
var d = await _db.KbDocuments.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (d == null) return NotFound(new { success = false });
if (d == null) return NotFound(new { Success = false });
var coll = await _db.KbCollections.AsNoTracking()
.FirstOrDefaultAsync(c => c.CollectionKey == d.CollectionKey, ct);
if (coll == null) return BadRequest(new { success = false, error = "collection not found" });
if (coll == null) return BadRequest(new { Success = false, Error = "collection not found" });
limit = Math.Clamp(limit, 1, 500);
var rows = await _qdrant.GetChunksByDocIdAsync(coll.QdrantName, d.Id, limit, ct);
@@ -231,11 +231,11 @@ public class KbController : ControllerBase
return Ok(new
{
success = true,
docId = d.Id,
collection = d.CollectionKey,
count = chunks.Count,
chunks
Success = true,
DocId = d.Id,
Collection = d.CollectionKey,
Count = chunks.Count,
Chunks = chunks
});
}
@@ -262,7 +262,7 @@ public class KbController : ControllerBase
startedAt = j.StartedAt,
finishedAt = j.FinishedAt
}).ToListAsync(ct);
return Ok(new { success = true, items });
return Ok(new { Success = true, Items = items });
}
[HttpGet("download/{id:guid}")]
@@ -283,7 +283,7 @@ public class KbController : ControllerBase
public IActionResult DownloadInstrumentsDraft([FromQuery] string path)
{
var abs = _storage.Resolve(path);
if (!System.IO.File.Exists(abs)) return NotFound(new { success = false, error = "file not found" });
if (!System.IO.File.Exists(abs)) return NotFound(new { Success = false, Error = "file not found" });
var stream = new FileStream(abs, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, true);
var fileName = Path.GetFileName(abs);
@@ -294,10 +294,10 @@ public class KbController : ControllerBase
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
if (!await RequireAdminAsync(ct))
return Unauthorized(new { success = false, error = "unauthorized" });
return Unauthorized(new { Success = false, Error = "unauthorized" });
var d = await _db.KbDocuments.FirstOrDefaultAsync(x => x.Id == id, ct);
if (d == null) return NotFound(new { success = false });
if (d == null) return NotFound(new { Success = false });
var coll = await _db.KbCollections.FirstOrDefaultAsync(c => c.CollectionKey == d.CollectionKey, ct);
if (coll != null)
@@ -307,17 +307,17 @@ public class KbController : ControllerBase
_db.KbDocuments.Remove(d);
await _db.SaveChangesAsync(ct);
return Ok(new { success = true });
return Ok(new { Success = true });
}
[HttpPost("documents/{id:guid}/reindex")]
public async Task<IActionResult> Reindex(Guid id, CancellationToken ct)
{
if (!await RequireAdminAsync(ct))
return Unauthorized(new { success = false, error = "unauthorized" });
return Unauthorized(new { Success = false, Error = "unauthorized" });
var d = await _db.KbDocuments.FirstOrDefaultAsync(x => x.Id == id, ct);
if (d == null) return NotFound(new { success = false });
if (d == null) return NotFound(new { Success = false });
var coll = await _db.KbCollections.FirstOrDefaultAsync(c => c.CollectionKey == d.CollectionKey, ct);
if (coll != null)
@@ -330,21 +330,21 @@ public class KbController : ControllerBase
_db.KbIngestJobs.Add(new KbIngestJob { DocId = d.Id, Stage = "parse" });
await _db.SaveChangesAsync(ct);
return Ok(new { success = true });
return Ok(new { Success = true });
}
[HttpPost("documents/{id:guid}/disable")]
public async Task<IActionResult> Disable(Guid id, CancellationToken ct)
{
if (!await RequireAdminAsync(ct))
return Unauthorized(new { success = false, error = "unauthorized" });
return Unauthorized(new { Success = false, Error = "unauthorized" });
var d = await _db.KbDocuments.FirstOrDefaultAsync(x => x.Id == id, ct);
if (d == null) return NotFound(new { success = false });
if (d == null) return NotFound(new { Success = false });
d.Status = "disabled";
d.DisabledAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return Ok(new { success = true });
return Ok(new { Success = true });
}
public sealed record BulkDisableRequest(string Title);
@@ -353,16 +353,16 @@ public class KbController : ControllerBase
public async Task<IActionResult> BulkDisable([FromBody] BulkDisableRequest req, CancellationToken ct)
{
if (!await RequireAdminAsync(ct))
return Unauthorized(new { success = false, error = "unauthorized" });
return Unauthorized(new { Success = false, Error = "unauthorized" });
if (req == null || string.IsNullOrWhiteSpace(req.Title))
return BadRequest(new { success = false, error = "title required" });
return BadRequest(new { Success = false, Error = "title required" });
var rows = await _db.KbDocuments
.Where(d => d.Title == req.Title && d.Status != "disabled")
.ExecuteUpdateAsync(set => set
.SetProperty(x => x.Status, _ => "disabled")
.SetProperty(x => x.DisabledAt, _ => DateTime.UtcNow), ct);
return Ok(new { success = true, affected = rows });
return Ok(new { Success = true, Affected = rows });
}
public sealed record PurgeRequest(int? OlderThanDays);
@@ -371,7 +371,7 @@ public class KbController : ControllerBase
public async Task<IActionResult> PurgeDisabled([FromBody] PurgeRequest req, CancellationToken ct)
{
if (!await RequireAdminAsync(ct))
return Unauthorized(new { success = false, error = "unauthorized" });
return Unauthorized(new { Success = false, Error = "unauthorized" });
var cutoff = req?.OlderThanDays is int days && days > 0
? DateTime.UtcNow.AddDays(-days)
@@ -389,7 +389,7 @@ public class KbController : ControllerBase
}
_db.KbDocuments.RemoveRange(victims);
await _db.SaveChangesAsync(ct);
return Ok(new { success = true, deleted = victims.Count });
return Ok(new { Success = true, Deleted = victims.Count });
}
public sealed record InferInstrumentsRequest(bool UseLlm = false, string? SeedDocId = null);
@@ -398,7 +398,7 @@ public class KbController : ControllerBase
public async Task<IActionResult> InferInstruments([FromBody] InferInstrumentsRequest? req, CancellationToken ct)
{
if (!await RequireAdminAsync(ct))
return Unauthorized(new { success = false, error = "unauthorized" });
return Unauthorized(new { Success = false, Error = "unauthorized" });
try
{
@@ -426,7 +426,7 @@ public class KbController : ControllerBase
var raw = await _mcp.CallToolAsync("infer_field_instruments", args, ct);
if (string.IsNullOrWhiteSpace(raw))
return BadRequest(new { success = false, error = "MCP 응답 없음" });
return BadRequest(new { Success = false, Error = "MCP 응답 없음" });
// [H-2 수정] McpClient.CallToolAsync는 실패 시 평문 문자열 반환
try
@@ -435,7 +435,7 @@ public class KbController : ControllerBase
if (jdoc.RootElement.TryGetProperty("success", out var s) && s.ValueKind == System.Text.Json.JsonValueKind.False)
{
var err = jdoc.RootElement.TryGetProperty("message", out var e) ? e.GetString() : "unknown";
return BadRequest(new { success = false, error = err });
return BadRequest(new { Success = false, Error = err });
}
var docPath = jdoc.RootElement.GetProperty("doc_path").GetString() ?? "";
@@ -457,25 +457,25 @@ public class KbController : ControllerBase
return Ok(new
{
success = true,
downloadPath = stored.RelativePath,
instrumentCount,
powerEquipmentCount,
unmatchedCount,
message = jdoc.RootElement.TryGetProperty("message", out var m) ? m.GetString() : ""
Success = true,
DownloadPath = stored.RelativePath,
InstrumentCount = instrumentCount,
PowerEquipmentCount = powerEquipmentCount,
UnmatchedCount = unmatchedCount,
Message = jdoc.RootElement.TryGetProperty("message", out var m) ? m.GetString() : ""
});
}
catch (System.Text.Json.JsonException)
{
// [H-2 수정] 평문 응답("도구 호출 실패: ...")일 경우
_logger.LogWarning("[Kb][Infer] MCP 평문 응답: {Raw}", raw);
return BadRequest(new { success = false, error = "MCP 도구 실패: " + raw.TrimEnd('\r', '\n', ' ') });
return BadRequest(new { Success = false, Error = "MCP 도구 실패: " + raw.TrimEnd('\r', '\n', ' ') });
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[Kb][Infer] 실패");
return BadRequest(new { success = false, error = ex.Message });
return BadRequest(new { Success = false, Error = ex.Message });
}
}
}

View File

@@ -8,6 +8,7 @@ using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Threading;
namespace Hc900Crawler.Web.Controllers;
@@ -287,7 +288,7 @@ public class OllamaController : ControllerBase
var cfg = LoadConfig();
var res = await _httpClient.GetAsync($"{cfg.BaseUrl}/api/tags");
if (!res.IsSuccessStatusCode)
return Ok(new { success = false, error = $"Ollama HTTP {(int)res.StatusCode}", models = Array.Empty<string>() });
return Ok(new { Success = false, Error = $"Ollama HTTP {(int)res.StatusCode}", Models = Array.Empty<string>() });
var body = await res.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
@@ -328,16 +329,16 @@ public class OllamaController : ControllerBase
return Ok(new
{
success = true,
models = chatModels.OrderBy(x => x).ToArray(),
Success = true,
Models = chatModels.OrderBy(x => x).ToArray(),
// 진단/안내용 — UI에서 셀렉터 옆에 회색 안내로 노출 가능
excluded = embeddingModels.OrderBy(x => x).ToArray()
Excluded = embeddingModels.OrderBy(x => x).ToArray()
});
}
catch (Exception ex)
{
_logger.LogError(ex, "[OllamaController] 모델 조회 실패");
return Ok(new { success = false, error = ex.Message, models = Array.Empty<string>() });
return Ok(new { Success = false, Error = ex.Message, Models = Array.Empty<string>() });
}
}
@@ -389,7 +390,7 @@ public class OllamaController : ControllerBase
if (!res.IsSuccessStatusCode)
{
var err = await res.Content.ReadAsStringAsync();
return Ok(new { success = false, error = err });
return Ok(new { Success = false, Error = err });
}
var body = await res.Content.ReadAsStringAsync();
@@ -400,12 +401,12 @@ public class OllamaController : ControllerBase
{
reply = cnt.GetString();
}
return Ok(new { success = true, reply });
return Ok(new { Success = true, Reply = reply });
}
catch (Exception ex)
{
_logger.LogError(ex, "[OllamaController] 채팅 실패");
return Ok(new { success = false, error = ex.Message, reply = (string?)null });
return Ok(new { Success = false, Error = ex.Message, Reply = (string?)null });
}
}
@@ -489,7 +490,7 @@ public class OllamaController : ControllerBase
public IActionResult GetConfig()
{
var cfg = LoadConfig();
return Ok(new { success = true, host = cfg.Host, port = cfg.Port, baseUrl = cfg.BaseUrl, vllmModel = LoadVllmModel() });
return Ok(new { Success = true, Host = cfg.Host, Port = cfg.Port, BaseUrl = cfg.BaseUrl, VllmModel = LoadVllmModel() });
}
[HttpPost("config")]
@@ -509,12 +510,12 @@ public class OllamaController : ControllerBase
}, new JsonSerializerOptions { WriteIndented = true });
System.IO.File.WriteAllText(path, json);
_logger.LogInformation("[OllamaController] 설정 저장: {Host}:{Port}", cfg.Host, cfg.Port);
return Ok(new { success = true, host = cfg.Host, port = cfg.Port, baseUrl = cfg.BaseUrl });
return Ok(new { Success = true, Host = cfg.Host, Port = cfg.Port, BaseUrl = cfg.BaseUrl });
}
catch (Exception ex)
{
_logger.LogError(ex, "[OllamaController] 설정 저장 실패");
return Ok(new { success = false, error = ex.Message });
return Ok(new { Success = false, Error = ex.Message });
}
}
@@ -525,11 +526,11 @@ public class OllamaController : ControllerBase
{
var cfg = LoadConfig();
var res = await _httpClient.GetAsync($"{cfg.BaseUrl}/api/tags");
return Ok(new { success = res.IsSuccessStatusCode, host = cfg.Host, port = cfg.Port });
return Ok(new { Success = res.IsSuccessStatusCode, Host = cfg.Host, Port = cfg.Port });
}
catch (Exception ex)
{
return Ok(new { success = false, error = ex.Message });
return Ok(new { Success = false, Error = ex.Message });
}
}
@@ -572,7 +573,7 @@ public class OllamaController : ControllerBase
{
var res = await _vllmClient.GetAsync("/v1/models");
if (!res.IsSuccessStatusCode)
return Ok(new { success = false, error = $"vLLM HTTP {(int)res.StatusCode}", models = Array.Empty<string>() });
return Ok(new { Success = false, Error = $"vLLM HTTP {(int)res.StatusCode}", Models = Array.Empty<string>() });
var body = await res.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
@@ -585,12 +586,12 @@ public class OllamaController : ControllerBase
models.Add(name.GetString() ?? "");
}
}
return Ok(new { success = true, models = models.OrderBy(x => x).ToArray() });
return Ok(new { Success = true, Models = models.OrderBy(x => x).ToArray() });
}
catch (Exception ex)
{
_logger.LogError(ex, "[OllamaController] vLLM 모델 조회 실패");
return Ok(new { success = false, error = ex.Message, models = Array.Empty<string>() });
return Ok(new { Success = false, Error = ex.Message, Models = Array.Empty<string>() });
}
}
@@ -619,7 +620,7 @@ public class OllamaController : ControllerBase
if (!res.IsSuccessStatusCode)
{
var err = await res.Content.ReadAsStringAsync();
return Ok(new { success = false, error = err });
return Ok(new { Success = false, Error = err });
}
var body = await res.Content.ReadAsStringAsync();
@@ -631,12 +632,12 @@ public class OllamaController : ControllerBase
if (first.TryGetProperty("message", out var msg) && msg.TryGetProperty("content", out var cnt))
reply = cnt.GetString();
}
return Ok(new { success = true, reply });
return Ok(new { Success = true, Reply = reply });
}
catch (Exception ex)
{
_logger.LogError(ex, "[OllamaController] vLLM 채팅 실패");
return Ok(new { success = false, error = ex.Message, reply = (string?)null });
return Ok(new { Success = false, Error = ex.Message, Reply = (string?)null });
}
}
@@ -647,7 +648,7 @@ public class OllamaController : ControllerBase
try
{
if (req?.Messages == null || req.Messages.Length == 0)
return Ok(new { success = false, error = "messages required", summary = "" });
return Ok(new { Success = false, Error = "messages required", Summary = "" });
// 대화를 평문으로 직렬화
var convo = new StringBuilder();
@@ -689,7 +690,7 @@ public class OllamaController : ControllerBase
if (!res.IsSuccessStatusCode)
{
var err = await res.Content.ReadAsStringAsync();
return Ok(new { success = false, error = err, summary = "" });
return Ok(new { Success = false, Error = err, Summary = "" });
}
var body = await res.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
@@ -700,12 +701,12 @@ public class OllamaController : ControllerBase
if (first.TryGetProperty("message", out var msg) && msg.TryGetProperty("content", out var cnt))
summary = cnt.GetString() ?? "";
}
return Ok(new { success = true, summary = summary.Trim() });
return Ok(new { Success = true, Summary = summary.Trim() });
}
catch (Exception ex)
{
_logger.LogError(ex, "[OllamaController] 대화 요약 실패");
return Ok(new { success = false, error = ex.Message, summary = "" });
return Ok(new { Success = false, Error = ex.Message, Summary = "" });
}
}
@@ -1087,6 +1088,49 @@ public class OllamaController : ControllerBase
}
}
// Python 스타일 도구 호출 감지: tool_name(key="val", ...) — JSON 감지 실패 후 시도
if (!string.IsNullOrEmpty(stopContent))
{
var knownTools = req.Tools?.Select(t => t.Function.Name)
.ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var pythonCalls = ExtractPythonStyleToolCalls(stopContent, knownTools);
if (pythonCalls.Count > 0)
{
bool pyExecuted = false;
var pyResults = new List<string>();
foreach (var (callName, callArgs) in pythonCalls)
{
var pseudoId = $"pytc_{toolRound}_{Guid.NewGuid():N}";
var argsJson = JsonSerializer.Serialize(callArgs);
await EmitToolStart(pseudoId, callName, argsJson);
try
{
var toolResult = await _mcpClient.CallToolAsync(
callName, callArgs, HttpContext.RequestAborted,
onTimeoutExtended: info => EmitToolExtending(pseudoId, info.ToolName, info.SoftTimeoutSec, info.ExtendedTimeoutSec));
await EmitToolResult(pseudoId, callName, ok: true, payload: toolResult);
pyResults.Add($"[{callName} 결과]\n{toolResult}");
pyExecuted = true;
}
catch (Exception ex)
{
await EmitToolResult(pseudoId, callName, ok: false, payload: ex.Message);
_logger.LogWarning(ex, "[OllamaController] Python형 도구 호출 실패: {Tool}", callName);
}
if (HttpContext.RequestAborted.IsCancellationRequested) return;
}
if (pyExecuted)
{
var combined = string.Join("\n\n", pyResults);
messages.Add(new { role = "assistant", content = stopContent });
messages.Add(new { role = "user", content = $"{combined}\n\n위 결과를 바탕으로 사용자의 질문에 자연어로 답변해주세요." });
continue;
}
}
}
// 첫 번째 비스트리밍 응답의 content를 직접 전달 (두 번째 LLM 호출 없이)
if (!string.IsNullOrEmpty(stopContent))
{
@@ -1170,14 +1214,57 @@ public class OllamaController : ControllerBase
try
{
var res = await _vllmClient.GetAsync("/v1/models");
return Ok(new { success = res.IsSuccessStatusCode, model = LoadVllmModel() });
return Ok(new { Success = res.IsSuccessStatusCode, Model = LoadVllmModel() });
}
catch (Exception ex)
{
return Ok(new { success = false, error = ex.Message });
return Ok(new { Success = false, Error = ex.Message });
}
}
// Python 스타일 도구 호출 감지: funcname(key="val", key2="val2", ...)
// knownTools 에 있는 이름만 매칭 (SQL 함수 등 false positive 방지)
private static List<(string name, Dictionary<string, object> args)> ExtractPythonStyleToolCalls(
string content, HashSet<string> knownTools)
{
var result = new List<(string, Dictionary<string, object>)>();
if (string.IsNullOrWhiteSpace(content) || knownTools.Count == 0) return result;
var matches = Regex.Matches(content.Trim(), @"(\w+)\s*\(([^)]*)\)");
foreach (Match m in matches)
{
var name = m.Groups[1].Value;
if (!knownTools.Contains(name)) continue;
var args = ParsePythonCallArgs(m.Groups[2].Value);
if (args.Count > 0)
result.Add((name, args));
}
return result;
}
private static Dictionary<string, object> ParsePythonCallArgs(string argsStr)
{
var args = new Dictionary<string, object>();
if (string.IsNullOrWhiteSpace(argsStr)) return args;
// 키=값 쌍 추출: key="val" | key='val' | key=bareword
foreach (Match m in Regex.Matches(argsStr, @"(\w+)\s*=\s*(?:""([^""]*)""|'([^']*)'|(\S+?))(?:\s*,|$)"))
{
var key = m.Groups[1].Value;
var raw = m.Groups[2].Success ? m.Groups[2].Value
: m.Groups[3].Success ? m.Groups[3].Value
: m.Groups[4].Value.TrimEnd(',').Trim();
// 타입 추론
if (int.TryParse(raw, out var i)) args[key] = (object)i;
else if (double.TryParse(raw, System.Globalization.NumberStyles.Any,
System.Globalization.CultureInfo.InvariantCulture, out var d)) args[key] = d;
else if (raw.Equals("true", StringComparison.OrdinalIgnoreCase)) args[key] = true;
else if (raw.Equals("false", StringComparison.OrdinalIgnoreCase)) args[key] = false;
else args[key] = raw;
}
return args;
}
// content 안에서 첫 번째 완전한 JSON 객체를 추출 (앞뒤 텍스트 무시, 모델 무관)
private static string ExtractFirstJsonObject(string content)
{

View File

@@ -36,11 +36,11 @@ public class PidController : ControllerBase
public async Task<IActionResult> UploadFile(IFormFile file)
{
if (file == null || file.Length == 0)
return BadRequest(new { error = "파일이 없습니다." });
return BadRequest(new { Error = "파일이 없습니다." });
var safeName = Path.GetFileName(file.FileName);
if (!IsValidFileName(safeName))
return BadRequest(new { error = "지원 형식: .dxf, .pdf" });
return BadRequest(new { Error = "지원 형식: .dxf, .pdf" });
Directory.CreateDirectory(UploadDir);
var filePath = Path.Combine(UploadDir, safeName);
@@ -50,44 +50,44 @@ public class PidController : ControllerBase
_logger.LogInformation("[PID] 파일 저장 완료: {FileName} ({Bytes:N0} bytes)", safeName, file.Length);
return Ok(new { fileName = safeName, fileSize = file.Length });
return Ok(new { FileName = safeName, FileSize = file.Length });
}
[HttpGet("server-files")]
public IActionResult GetServerFiles()
{
if (!Directory.Exists(UploadDir))
return Ok(new { files = Array.Empty<object>() });
return Ok(new { Files = Array.Empty<object>() });
var files = Directory.GetFiles(UploadDir)
.Where(f => IsValidFileName(Path.GetFileName(f)))
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.LastWriteTimeUtc)
.Select(f => new { fileName = f.Name, fileSize = f.Length, uploadedAt = f.LastWriteTimeUtc })
.Select(f => new { FileName = f.Name, FileSize = f.Length, UploadedAt = f.LastWriteTimeUtc })
.ToArray();
return Ok(new { files });
return Ok(new { Files = files });
}
[HttpPost("extract")]
public async Task<IActionResult> Extract([FromBody] ExtractRequest request)
{
if (!IsValidFileName(request.FileName))
return BadRequest(new { error = "파일명이 유효하지 않습니다." });
return BadRequest(new { Error = "파일명이 유효하지 않습니다." });
var filePath = Path.Combine(UploadDir, request.FileName);
if (!System.IO.File.Exists(filePath))
return NotFound(new { error = $"서버에 파일이 없습니다: {request.FileName}" });
return NotFound(new { Error = $"서버에 파일이 없습니다: {request.FileName}" });
_logger.LogInformation("[PID] 추출 시작: {FileName}, imageMode={ImageMode}", request.FileName, request.UseImageMode);
var result = await _pidExtractor.ExtractFromFileAsync(filePath, request.UseImageMode);
return Ok(new
{
totalCount = result.TotalCount,
confidenceItems = result.ConfidenceItems,
lowConfidenceItems = result.LowConfidenceItems,
skippedDuplicates = result.SkippedDuplicates
TotalCount = result.TotalCount,
ConfidenceItems = result.ConfidenceItems,
LowConfidenceItems = result.LowConfidenceItems,
SkippedDuplicates = result.SkippedDuplicates
});
}
@@ -98,12 +98,12 @@ public class PidController : ControllerBase
public async Task<IActionResult> AnalyzeConnections([FromBody] ConnectionsRequest request)
{
if (!IsValidFileName(request.FileName))
return BadRequest(new { error = "파일명이 유효하지 않습니다." });
return BadRequest(new { Error = "파일명이 유효하지 않습니다." });
_logger.LogInformation("[PID] 연결 분석 시작: {FileName}", request.FileName);
var count = await _pidExtractor.AnalyzeConnectionsAsync(request.FileName);
return Ok(new { connectionCount = count });
return Ok(new { ConnectionCount = count });
}
[HttpGet("equipment")]
@@ -114,39 +114,39 @@ public class PidController : ControllerBase
var equipmentDtos = itemList.Select(e => new
{
id = e.Id,
tagName = e.TagNo,
equipmentName = e.EquipmentName,
instrumentType = e.InstrumentType,
lineNumber = e.LineNumber,
pidDrawingNo = e.PidDrawingNo,
confidence = e.Confidence,
isActive = e.IsActive,
extractedAt = e.ExtractedAt,
updatedAt = e.UpdatedAt,
experionTagId = e.ExperionTagId,
experionTagName = e.ExperionTag?.TagName,
category = e.Category,
tagClass = e.TagClass,
tagDcs = e.TagDcs,
role = e.Role,
fromTag = e.FromTag,
fromAt = e.FromAt,
toTag = e.ToTag,
toAt = e.ToAt,
subArea = e.SubArea,
posX = e.PosX,
posY = e.PosY,
drawingFile = e.DrawingFile
Id = e.Id,
TagNo = e.TagNo,
EquipmentName = e.EquipmentName,
InstrumentType = e.InstrumentType,
LineNumber = e.LineNumber,
PidDrawingNo = e.PidDrawingNo,
Confidence = e.Confidence,
IsActive = e.IsActive,
ExtractedAt = e.ExtractedAt,
UpdatedAt = e.UpdatedAt,
ExperionTagId = e.ExperionTagId,
ExperionTagName = e.ExperionTag?.TagName,
Category = e.Category,
TagClass = e.TagClass,
TagDcs = e.TagDcs,
Role = e.Role,
FromTag = e.FromTag,
FromAt = e.FromAt,
ToTag = e.ToTag,
ToAt = e.ToAt,
SubArea = e.SubArea,
PosX = e.PosX,
PosY = e.PosY,
DrawingFile = e.DrawingFile
});
return Ok(new
{
total,
page,
pageSize,
confidenceRate = itemList.Count > 0 ? itemList.Average(e => e.Confidence) : 0,
items = equipmentDtos
Total = total,
Page = page,
PageSize = pageSize,
ConfidenceRate = itemList.Count > 0 ? itemList.Average(e => e.Confidence) : 0,
Items = equipmentDtos
});
}
@@ -154,15 +154,15 @@ public class PidController : ControllerBase
public async Task<IActionResult> AutoMap(long id)
{
var (found, tagName) = await _pidExtractor.AutoMapTagAsync(id);
if (!found) return NotFound(new { error = "매칭되는 Experion 태그가 없습니다." });
return Ok(new { tagName });
if (!found) return NotFound(new { Error = "매칭되는 Experion 태그가 없습니다." });
return Ok(new { TagName = tagName });
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateEquipment(long id, [FromBody] UpdateEquipmentRequest request)
{
var ok = await _pidExtractor.UpdateEquipmentAsync(id, request);
if (!ok) return NotFound(new { error = "레코드를 찾을 수 없습니다." });
if (!ok) return NotFound(new { Error = "레코드를 찾을 수 없습니다." });
return NoContent();
}
@@ -170,18 +170,18 @@ public class PidController : ControllerBase
public async Task<IActionResult> CreateEquipment([FromBody] CreateEquipmentRequest request)
{
var e = await _pidExtractor.CreateEquipmentAsync(request);
return CreatedAtAction(nameof(CreateEquipment), new { id = e.Id }, new
return CreatedAtAction(nameof(CreateEquipment), new { Id = e.Id }, new
{
id = e.Id,
tagName = e.TagNo,
equipmentName = e.EquipmentName,
instrumentType = e.InstrumentType,
category = e.Category,
tagDcs = e.TagDcs,
tagClass = e.TagClass,
role = e.Role,
fromTag = e.FromTag,
toTag = e.ToTag
Id = e.Id,
TagName = e.TagNo,
EquipmentName = e.EquipmentName,
InstrumentType = e.InstrumentType,
Category = e.Category,
TagDcs = e.TagDcs,
TagClass = e.TagClass,
Role = e.Role,
FromTag = e.FromTag,
ToTag = e.ToTag
});
}
@@ -197,12 +197,12 @@ public class PidController : ControllerBase
return Ok(new
{
confidenceItems,
lowConfidenceItems,
confidenceRange,
drawingCount,
unmappedCount,
mappedCount
ConfidenceItems = confidenceItems,
LowConfidenceItems = lowConfidenceItems,
ConfidenceRange = confidenceRange,
DrawingCount = drawingCount,
UnmappedCount = unmappedCount,
MappedCount = mappedCount
});
}
@@ -210,7 +210,7 @@ public class PidController : ControllerBase
public async Task<IActionResult> UpdateConfidence(long id, [FromBody] double confidence)
{
if (confidence < 0 || confidence > 1)
return BadRequest(new { error = "신뢰도는 0~1 사이여야 합니다." });
return BadRequest(new { Error = "신뢰도는 0~1 사이여야 합니다." });
await _pidExtractor.UpdateConfidenceAsync(id, confidence);
return NoContent();
@@ -234,7 +234,7 @@ public class PidController : ControllerBase
public async Task<IActionResult> Delete(long id)
{
var ok = await _pidExtractor.DeleteAsync(id);
if (!ok) return NotFound(new { error = "레코드를 찾을 수 없습니다." });
if (!ok) return NotFound(new { Error = "레코드를 찾을 수 없습니다." });
return NoContent();
}
@@ -242,7 +242,7 @@ public class PidController : ControllerBase
public async Task<IActionResult> DeleteAll()
{
var count = await _pidExtractor.DeleteAllEquipmentAsync();
return Ok(new { deletedCount = count, message = $"{count}건 삭제됨" });
return Ok(new { DeletedCount = count, Message = $"{count}건 삭제됨" });
}
[HttpGet("mappings")]
@@ -252,10 +252,10 @@ public class PidController : ControllerBase
return Ok(new
{
total,
page,
pageSize,
items = items
Total = total,
Page = page,
PageSize = pageSize,
Items = items
});
}
@@ -263,7 +263,7 @@ public class PidController : ControllerBase
public async Task<IActionResult> CreateMapping([FromBody] CreateMappingRequest request)
{
var result = await _tagMapping.CreateMappingAsync(request);
return Ok(new { result = result });
return Ok(new { Result = result });
}
[HttpPut("mappings/{id}")]
@@ -284,21 +284,21 @@ public class PidController : ControllerBase
public async Task<IActionResult> GetUnmappedCount()
{
var count = await _tagMapping.GetUnmappedCountAsync();
return Ok(new { count });
return Ok(new { Count = count });
}
[HttpGet("mappings/mapped")]
public async Task<IActionResult> GetMappedCount()
{
var count = await _tagMapping.GetMappedCountAsync();
return Ok(new { count });
return Ok(new { Count = count });
}
[HttpGet("mappings/available-tags")]
public async Task<IActionResult> GetAvailableTags()
{
var tags = await _tagMapping.GetAvailableTagsAsync();
return Ok(new { tags });
return Ok(new { Tags = tags });
}
// ── Prefix 규칙 CRUD ──────────────────────────────────────────────────
@@ -309,15 +309,15 @@ public class PidController : ControllerBase
var rules = await _pidExtractor.GetPrefixRulesAsync();
return Ok(new
{
items = rules.Select(r => new
Items = rules.Select(r => new
{
id = r.Id,
prefix = r.Prefix,
category = r.Category,
tagDcs = r.TagDcs,
description = r.Description,
sortOrder = r.SortOrder,
createdAt = r.CreatedAt
Id = r.Id,
Prefix = r.Prefix,
Category = r.Category,
TagDcs = r.TagDcs,
Description = r.Description,
SortOrder = r.SortOrder,
CreatedAt = r.CreatedAt
})
});
}
@@ -326,32 +326,32 @@ public class PidController : ControllerBase
public async Task<IActionResult> CreatePrefixRule([FromBody] CreatePidPrefixRuleRequest request)
{
if (string.IsNullOrWhiteSpace(request.Prefix))
return BadRequest(new { error = "prefix는 필수입니다." });
return BadRequest(new { Error = "prefix는 필수입니다." });
if (!PidEquipment.AllCategories.Contains(request.Category))
return BadRequest(new { error = $"category는 {string.Join(", ", PidEquipment.AllCategories)} 중 하나여야 합니다." });
return BadRequest(new { Error = $"category는 {string.Join(", ", PidEquipment.AllCategories)} 중 하나여야 합니다." });
var rule = await _pidExtractor.CreatePrefixRuleAsync(request);
return Ok(new { id = rule.Id, prefix = rule.Prefix, category = rule.Category });
return Ok(new { Id = rule.Id, Prefix = rule.Prefix, Category = rule.Category });
}
[HttpPut("prefix-rules/{id}")]
public async Task<IActionResult> UpdatePrefixRule(int id, [FromBody] UpdatePidPrefixRuleRequest request)
{
if (string.IsNullOrWhiteSpace(request.Prefix))
return BadRequest(new { error = "prefix는 필수입니다." });
return BadRequest(new { Error = "prefix는 필수입니다." });
if (!PidEquipment.AllCategories.Contains(request.Category))
return BadRequest(new { error = $"category는 {string.Join(", ", PidEquipment.AllCategories)} 중 하나여야 합니다." });
return BadRequest(new { Error = $"category는 {string.Join(", ", PidEquipment.AllCategories)} 중 하나여야 합니다." });
var rule = await _pidExtractor.UpdatePrefixRuleAsync(id, request);
if (rule == null) return NotFound(new { error = "규칙을 찾을 수 없습니다." });
return Ok(new { id = rule.Id, prefix = rule.Prefix, category = rule.Category });
if (rule == null) return NotFound(new { Error = "규칙을 찾을 수 없습니다." });
return Ok(new { Id = rule.Id, Prefix = rule.Prefix, Category = rule.Category });
}
[HttpDelete("prefix-rules/{id}")]
public async Task<IActionResult> DeletePrefixRule(int id)
{
var ok = await _pidExtractor.DeletePrefixRuleAsync(id);
if (!ok) return NotFound(new { error = "규칙을 찾을 수 없습니다." });
if (!ok) return NotFound(new { Error = "규칙을 찾을 수 없습니다." });
return NoContent();
}
@@ -359,7 +359,7 @@ public class PidController : ControllerBase
public async Task<IActionResult> ApplyCategories()
{
var count = await _pidExtractor.ApplyCategoriesToExistingAsync();
return Ok(new { applied = count });
return Ok(new { Applied = count });
}
/// <summary>편집한 export 엑셀(.xlsx)을 업로드하여 pid_equipment 를 UPSERT.</summary>
@@ -368,9 +368,9 @@ public class PidController : ControllerBase
public async Task<IActionResult> ImportExcel(IFormFile file)
{
if (file == null || file.Length == 0)
return BadRequest(new { error = "파일이 없습니다." });
return BadRequest(new { Error = "파일이 없습니다." });
if (!file.FileName.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase))
return BadRequest(new { error = "지원 형식: .xlsx (export 엑셀)" });
return BadRequest(new { Error = "지원 형식: .xlsx (export 엑셀)" });
try
{
@@ -384,7 +384,7 @@ public class PidController : ControllerBase
catch (Exception ex)
{
_logger.LogError(ex, "[PID] 엑셀 import 실패: {File}", file.FileName);
return BadRequest(new { error = $"import 실패: {ex.Message}" });
return BadRequest(new { Error = $"import 실패: {ex.Message}" });
}
}
}

View File

@@ -42,20 +42,20 @@ public class PidGraphController : ControllerBase
// 프론트엔드 camelCase 규칙 준수 및 PidResponse 래핑
return Ok(new
{
success = true,
data = new
Success = true,
Data = new
{
startNode = result.StartNode,
impactedNodes = result.ImpactedNodes,
paths = result.Paths
StartNode = result.StartNode,
ImpactedNodes = result.ImpactedNodes,
Paths = result.Paths
},
message = "분석 완료"
Message = "분석 완료"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error during impact analysis");
return StatusCode(500, new { error = "Internal server error", details = ex.Message });
return StatusCode(500, new { Error = "Internal server error", Details = ex.Message });
}
}
@@ -67,13 +67,13 @@ public class PidGraphController : ControllerBase
{
return Ok(new
{
success = true,
data = new
Success = true,
Data = new
{
taskId = status.TaskId,
progress = status.Progress,
status = status.Status,
message = status.Message
TaskId = status.TaskId,
Progress = status.Progress,
Status = status.Status,
Message = status.Message
}
});
}
@@ -95,10 +95,10 @@ public class PidGraphController : ControllerBase
{
var data = System.Text.Json.JsonSerializer.Serialize(new
{
taskId = status.TaskId,
progress = status.Progress,
status = status.Status,
message = status.Message
TaskId = status.TaskId,
Progress = status.Progress,
Status = status.Status,
Message = status.Message
});
await Response.WriteAsync($"data: {data}\n\n", ct);
@@ -120,7 +120,7 @@ public class PidGraphController : ControllerBase
public async Task<IActionResult> BuildGraph([FromBody] BuildGraphRequest request)
{
if (string.IsNullOrEmpty(request.Filepath))
return BadRequest(new { error = "Filepath is required" });
return BadRequest(new { Error = "Filepath is required" });
var taskId = Guid.NewGuid().ToString();
await _statusStore.UpdateStatusAsync(new AnalysisStatus(taskId, 0, "Starting", "추출 준비 중..."));
@@ -181,9 +181,9 @@ public class PidGraphController : ControllerBase
return Ok(new
{
success = true,
data = new { taskId = taskId },
message = "작업이 시작되었습니다."
Success = true,
Data = new { TaskId = taskId },
Message = "작업이 시작되었습니다."
});
}

View File

@@ -202,7 +202,7 @@ public class PointBuilderController : ControllerBase
public IActionResult ListSinamFiles()
{
if (!Directory.Exists(SinamUploadDir))
return Ok(new { success = true, files = Array.Empty<object>() });
return Ok(new { Success = true, Files = Array.Empty<object>() });
var files = Directory.GetFiles(SinamUploadDir, "*.xlsx")
.Select(f => new
@@ -215,7 +215,7 @@ public class PointBuilderController : ControllerBase
.OrderByDescending(f => f.modified)
.ToList();
return Ok(new { success = true, files });
return Ok(new { Success = true, Files = files });
}
[HttpPost("sinam/upload")]
@@ -223,11 +223,11 @@ public class PointBuilderController : ControllerBase
public async Task<IActionResult> UploadSinam(IFormFile file, CancellationToken ct)
{
if (file == null || file.Length == 0)
return BadRequest(new { success = false, error = "file required" });
return BadRequest(new { Success = false, Error = "file required" });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext != ".xlsx")
return BadRequest(new { success = false, error = ".xlsx 파일만 업로드 가능" });
return BadRequest(new { Success = false, Error = ".xlsx 파일만 업로드 가능" });
Directory.CreateDirectory(SinamUploadDir);
var savePath = Path.Combine(SinamUploadDir, $"Sinam_{DateTime.UtcNow:yyyyMMdd-HHmmss}.xlsx");
@@ -236,33 +236,33 @@ public class PointBuilderController : ControllerBase
await file.CopyToAsync(stream, ct);
_log.LogInformation("[Sinam] 업로드: {Path} ({Size} bytes)", savePath, file.Length);
return Ok(new { success = true, file = savePath });
return Ok(new { Success = true, File = savePath });
}
[HttpPost("sinam/parse")]
public async Task<IActionResult> ParseSinam([FromBody] SinamParseRequest dto, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(dto.File))
return BadRequest(new { success = false, error = "file required" });
return BadRequest(new { Success = false, Error = "file required" });
if (!System.IO.File.Exists(dto.File))
return BadRequest(new { success = false, error = "file not found" });
return BadRequest(new { Success = false, Error = "file not found" });
var validControllers = new[] { "C1", "C2", "C3", "C4" };
if (!validControllers.Contains(dto.Controller))
return BadRequest(new { success = false, error = $"controller must be one of: {string.Join(", ", validControllers)}" });
return BadRequest(new { Success = false, Error = $"controller must be one of: {string.Join(", ", validControllers)}" });
var result = await RunSinamScriptAsync(dto.File, dto.Controller, dto.ApplyDb, ct);
if (!result.Success)
return StatusCode(500, new { success = false, error = result.Error });
return StatusCode(500, new { Success = false, Error = result.Error });
return Ok(new
{
success = true,
registers = result.Registers,
archive = result.Archive,
loops = result.Loops,
stdout = result.Stdout,
Success = true,
Registers = result.Registers,
Archive = result.Archive,
Loops = result.Loops,
Stdout = result.Stdout,
});
}
@@ -303,33 +303,33 @@ public class PointBuilderController : ControllerBase
var count = await _db.Hc900BuildRealtimeTableAsync(groups);
_log.LogInformation("[PointBuilder] Build 완료: {Count}개 활성화", count);
return Ok(new { success = true, count, message = $"{count}개 포인트 활성화 완료" });
return Ok(new { Success = true, Count = count, Message = $"{count}개 포인트 활성화 완료" });
}
[HttpPost("apply")]
public async Task<IActionResult> Apply([FromBody] Hc900PointBuilderApplyDto dto)
{
if (dto.SelectedTagNames == null || dto.SelectedTagNames.Count == 0)
return BadRequest(new { success = false, error = "selectedTagNames는 최소 1개 이상 필요합니다" });
return BadRequest(new { Success = false, Error = "selectedTagNames는 최소 1개 이상 필요합니다" });
if (string.IsNullOrEmpty(dto.ControllerId))
return BadRequest(new { success = false, error = "controllerId는 필수입니다" });
return BadRequest(new { Success = false, Error = "controllerId는 필수입니다" });
var count = await _db.Hc900ApplySelectedPointsAsync(dto.ControllerId, dto.SelectedTagNames);
_log.LogInformation("[PointBuilder] Apply 완료: {Count}개 활성화 (controller={Ctrl})", count, dto.ControllerId);
return Ok(new { success = true, count, message = $"{count}개 포인트 활성화 완료" });
return Ok(new { Success = true, Count = count, Message = $"{count}개 포인트 활성화 완료" });
}
[HttpPost("append")]
public async Task<IActionResult> Append([FromBody] Hc900PointBuilderApplyDto dto)
{
if (dto.SelectedTagNames == null || dto.SelectedTagNames.Count == 0)
return BadRequest(new { success = false, error = "selectedTagNames는 최소 1개 이상 필요합니다" });
return BadRequest(new { Success = false, Error = "selectedTagNames는 최소 1개 이상 필요합니다" });
if (string.IsNullOrEmpty(dto.ControllerId))
return BadRequest(new { success = false, error = "controllerId는 필수입니다" });
return BadRequest(new { Success = false, Error = "controllerId는 필수입니다" });
var count = await _db.Hc900AppendPointsAsync(dto.ControllerId, dto.SelectedTagNames);
_log.LogInformation("[PointBuilder] Append 완료: {Count}개 추가 (controller={Ctrl})", count, dto.ControllerId);
return Ok(new { success = true, count, message = $"{count}개 포인트 추가 완료" });
return Ok(new { Success = true, Count = count, Message = $"{count}개 포인트 추가 완료" });
}
public sealed record RealtimeBulkDto(List<int>? Ids, string? ControllerId);
@@ -339,13 +339,13 @@ public class PointBuilderController : ControllerBase
public async Task<IActionResult> RealtimeAdd([FromBody] RealtimeBulkDto dto)
{
if (dto.Ids == null || dto.Ids.Count == 0)
return BadRequest(new { success = false, error = "ids는 최소 1개 이상 필요합니다" });
return BadRequest(new { Success = false, Error = "ids는 최소 1개 이상 필요합니다" });
if (string.IsNullOrEmpty(dto.ControllerId))
return BadRequest(new { success = false, error = "controllerId는 필수입니다" });
return BadRequest(new { Success = false, Error = "controllerId는 필수입니다" });
var count = await _db.Hc900RealtimeAddByIdsAsync(dto.ControllerId, dto.Ids);
_log.LogInformation("[PointBuilder] 실시간 추가: {Count}개 (controller={Ctrl})", count, dto.ControllerId);
return Ok(new { success = true, count, message = $"{count}개 실시간 편입 완료" });
return Ok(new { Success = true, Count = count, Message = $"{count}개 실시간 편입 완료" });
}
// 선택(id)한 태그를 realtime_table에서 제거
@@ -353,13 +353,13 @@ public class PointBuilderController : ControllerBase
public async Task<IActionResult> RealtimeRemove([FromBody] RealtimeBulkDto dto)
{
if (dto.Ids == null || dto.Ids.Count == 0)
return BadRequest(new { success = false, error = "ids는 최소 1개 이상 필요합니다" });
return BadRequest(new { Success = false, Error = "ids는 최소 1개 이상 필요합니다" });
if (string.IsNullOrEmpty(dto.ControllerId))
return BadRequest(new { success = false, error = "controllerId는 필수입니다" });
return BadRequest(new { Success = false, Error = "controllerId는 필수입니다" });
var count = await _db.Hc900RealtimeRemoveByIdsAsync(dto.ControllerId, dto.Ids);
_log.LogInformation("[PointBuilder] 실시간 제거: {Count}개 (controller={Ctrl})", count, dto.ControllerId);
return Ok(new { success = true, count, message = $"{count}개 실시간 제거 완료" });
return Ok(new { Success = true, Count = count, Message = $"{count}개 실시간 제거 완료" });
}
[HttpGet("points")]
@@ -448,18 +448,18 @@ public class PointBuilderController : ControllerBase
public async Task<IActionResult> Add([FromBody] Hc900PointBuilderAddDto dto)
{
if (string.IsNullOrWhiteSpace(dto.TagName))
return BadRequest(new { success = false, error = "tagName은 필수입니다" });
return BadRequest(new { Success = false, Error = "tagName은 필수입니다" });
try
{
var point = await _db.Hc900AddRealtimePointAsync(
dto.TagName, dto.Hc900Tag, dto.ModbusAddr,
dto.DataType, dto.ParamType, dto.Access, dto.ControllerId);
return Ok(new { success = true, point });
return Ok(new { Success = true, Point = point });
}
catch (InvalidOperationException ex)
{
return Conflict(new { success = false, error = ex.Message });
return Conflict(new { Success = false, Error = ex.Message });
}
}
@@ -468,7 +468,7 @@ public class PointBuilderController : ControllerBase
{
var entry = await _db.Hc900MapEntries.FindAsync(id);
if (entry == null)
return NotFound(new { success = false, error = "지정한 id의 태그가 존재하지 않습니다" });
return NotFound(new { Success = false, Error = "지정한 id의 태그가 존재하지 않습니다" });
entry.IsActive = false;
await _db.SaveChangesAsync();
@@ -500,6 +500,6 @@ public class PointBuilderController : ControllerBase
}
_log.LogInformation("[PointBuilder] 삭제: {TagName} (purgeHistory={Purge})", entry.TagName, purgeHistory);
return Ok(new { success = true, tagName = entry.TagName });
return Ok(new { Success = true, TagName = entry.TagName });
}
}

View File

@@ -88,11 +88,11 @@ public class SetupController : ControllerBase
{
ControllerProcessManager.SaveConfig(cfg);
_procMgr.ReloadConfig();
return Ok(new { success = true, message = "설정 저장 완료" });
return Ok(new { Success = true, Message = "설정 저장 완료" });
}
catch (Exception ex)
{
return StatusCode(500, new { success = false, message = ex.Message });
return StatusCode(500, new { Success = false, Message = ex.Message });
}
}
@@ -103,13 +103,13 @@ public class SetupController : ControllerBase
if (id != null)
{
var (ok, msg) = await _procMgr.StartAsync(id);
return Ok(new { success = ok, message = msg });
return Ok(new { Success = ok, Message = msg });
}
var results = new List<object>();
foreach (var ctrl in _procMgr.Config.Controllers.Where(c => c.Enabled))
{
var (ok, msg) = await _procMgr.StartAsync(ctrl.Id);
results.Add(new { ctrl.Id, ok, msg });
results.Add(new { ctrl.Id, Ok = ok, Msg = msg });
}
return Ok(results);
}
@@ -121,13 +121,13 @@ public class SetupController : ControllerBase
if (id != null)
{
var (ok, msg) = await _procMgr.StopAsync(id);
return Ok(new { success = ok, message = msg });
return Ok(new { Success = ok, Message = msg });
}
var results = new List<object>();
foreach (var ctrl in _procMgr.Config.Controllers)
{
var (ok, msg) = await _procMgr.StopAsync(ctrl.Id);
results.Add(new { ctrl.Id, ok, msg });
results.Add(new { ctrl.Id, Ok = ok, Msg = msg });
}
return Ok(results);
}
@@ -142,7 +142,7 @@ public class SetupController : ControllerBase
await _procMgr.StopAsync(id);
await Task.Delay(500);
var (ok, msg) = await _procMgr.StartAsync(id);
return Ok(new { success = ok, message = msg });
return Ok(new { Success = ok, Message = msg });
}
foreach (var ctrl in _procMgr.Config.Controllers)
await _procMgr.StopAsync(ctrl.Id);
@@ -151,7 +151,7 @@ public class SetupController : ControllerBase
foreach (var ctrl in _procMgr.Config.Controllers.Where(c => c.Enabled))
{
var (ok, msg) = await _procMgr.StartAsync(ctrl.Id);
results.Add(new { ctrl.Id, ok, msg });
results.Add(new { ctrl.Id, Ok = ok, Msg = msg });
}
return Ok(results);
}
@@ -161,8 +161,8 @@ public class SetupController : ControllerBase
public IActionResult GetLog([FromQuery] string? id = null, [FromQuery] int lines = 50)
{
var target = id ?? _procMgr.Config.Controllers.FirstOrDefault()?.Id;
if (target == null) return Ok(new { log = Array.Empty<string>() });
if (target == null) return Ok(new { Log = Array.Empty<string>() });
var status = _procMgr.GetStatus(target);
return Ok(new { log = status.RecentLog.TakeLast(lines) });
return Ok(new { Log = status.RecentLog.TakeLast(lines) });
}
}

View File

@@ -54,7 +54,7 @@ public sealed class SteamAdvisorController : ControllerBase
[HttpGet("health")]
public IActionResult Health()
{
return Ok(new { loaded = _advisor.IsLoaded });
return Ok(new { Loaded = _advisor.IsLoaded });
}
[HttpGet("predict")]
@@ -80,7 +80,7 @@ public sealed class SteamAdvisorController : ControllerBase
var modelDir = _config.GetValue<string>("SteamAdvisor:ModelDir")
?? "/home/windpacer/projects/hc900_ax/scripts/analysis";
if (!Directory.Exists(modelDir))
return Ok(new { columns = Array.Empty<string>() });
return Ok(new { Columns = Array.Empty<string>() });
var columns = Directory.GetFiles(modelDir, "*_model.json")
.Select(Path.GetFileNameWithoutExtension)
@@ -94,7 +94,7 @@ public sealed class SteamAdvisorController : ControllerBase
.ToHashSet();
var defaultCol = _config.GetValue<string>("SteamAdvisor:DefaultColumn") ?? "C-6111";
return Ok(new { columns, configured = configured.OrderBy(x => x).ToList(), defaultColumn = defaultCol });
return Ok(new { Columns = columns, Configured = configured.OrderBy(x => x).ToList(), DefaultColumn = defaultCol });
}
[HttpGet("backtest/{col}")]
@@ -104,7 +104,7 @@ public sealed class SteamAdvisorController : ControllerBase
?? "/home/windpacer/projects/hc900_ax/scripts/analysis";
var path = Path.Combine(plotDir, $"{col}_plotdata.json");
if (!System.IO.File.Exists(path))
return NotFound(new { error = $"플롯데이터 없음: {col}" });
return NotFound(new { Error = $"플롯데이터 없음: {col}" });
try
{
@@ -113,7 +113,7 @@ public sealed class SteamAdvisorController : ControllerBase
}
catch (Exception ex)
{
return StatusCode(500, new { error = $"플롯데이터 읽기 실패: {ex.Message}" });
return StatusCode(500, new { Error = $"플롯데이터 읽기 실패: {ex.Message}" });
}
}
@@ -129,7 +129,7 @@ public sealed class SteamAdvisorController : ControllerBase
var steamFlowTag = tags["SteamFlow"];
if (string.IsNullOrEmpty(feedTag) || string.IsNullOrEmpty(productTag) || string.IsNullOrEmpty(tcTag))
return BadRequest(new { error = $"컬럼 {col} 태그 매핑 불완전" });
return BadRequest(new { Error = $"컬럼 {col} 태그 매핑 불완전" });
var tagNames = new[] { feedTag, productTag, tcTag };
if (!string.IsNullOrEmpty(steamOpTag)) tagNames = tagNames.Append(steamOpTag).ToArray();
@@ -145,8 +145,8 @@ public sealed class SteamAdvisorController : ControllerBase
var missing = new[] { feedTag, productTag, tcTag }
.Where(t => !live.ContainsKey(t))
.ToList();
return Ok(new { col, status = "missing_tags", missing,
message = "일부 태그 실시간값 없음 — 게이트웨이 폴링 확인" });
return Ok(new { Col = col, Status = "missing_tags", Missing = missing,
Message = "일부 태그 실시간값 없음 — 게이트웨이 폴링 확인" });
}
var feed = double.TryParse(live[feedTag], out var f) ? f : double.NaN;
@@ -164,7 +164,7 @@ public sealed class SteamAdvisorController : ControllerBase
return Ok(new
{
col,
Col = col,
result.RecOp, result.RecSteam, result.Confidence,
result.Mode, result.Ood, result.InEnv, result.Message,
Feed = result.Feed,
@@ -172,8 +172,8 @@ public sealed class SteamAdvisorController : ControllerBase
TC = result.TC,
ActualOp = steamOp,
ActualSteamFlow = steamFlow,
Tags = new { feed = feedTag, product = productTag, tC = tcTag, steamOp = steamOpTag, steamFlow = steamFlowTag },
Descs = new { feed = GetDesc(feedTag), product = GetDesc(productTag), tC = GetDesc(tcTag), steamOp = GetDesc(steamOpTag), steamFlow = GetDesc(steamFlowTag) },
Tags = new { Feed = feedTag, Product = productTag, TC = tcTag, SteamOp = steamOpTag, SteamFlow = steamFlowTag },
Descs = new { Feed = GetDesc(feedTag), Product = GetDesc(productTag), TC = GetDesc(tcTag), SteamOp = GetDesc(steamOpTag), SteamFlow = GetDesc(steamFlowTag) },
Timestamp = DateTime.UtcNow,
});
}
@@ -187,7 +187,7 @@ public sealed class SteamAdvisorController : ControllerBase
[FromQuery] string? product = null)
{
var tref = await LoadTempRef(col);
if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" });
if (tref is null) return NotFound(new { Error = $"기준 프로파일 없음: {col}_tempref.json" });
tref = MergeProductLabels(col, tref);
@@ -204,38 +204,38 @@ public sealed class SteamAdvisorController : ControllerBase
return Ok(new
{
column = tref.Column,
period = tref.Period,
matchedProduct = prod?.Name ?? prod?.Label, // 미선택이면 null
nProducts = tref.NProducts,
stages,
vacuum,
spanAD,
spanRef = prod?.SpanAD,
Column = tref.Column,
Period = tref.Period,
MatchedProduct = prod?.Name ?? prod?.Label, // 미선택이면 null
NProducts = tref.NProducts,
Stages = stages,
Vacuum = vacuum,
SpanAD = spanAD,
SpanRef = prod?.SpanAD,
// 소문자 키로 projection (TempProduct 레코드는 PascalCase 직렬화 → 프론트가 못 읽음)
products = tref.Products.Select(p => new {
label = p.Label,
name = p.Name,
rebMedian = p.Stages.GetValueOrDefault("reb_temp")?.Median,
vacMedian = p.Vacuum.Median,
nRows = p.NRows,
Products = tref.Products.Select(p => new {
Label = p.Label,
Name = p.Name,
RebMedian = p.Stages.GetValueOrDefault("reb_temp")?.Median,
VacMedian = p.Vacuum.Median,
NRows = p.NRows,
}).ToList(),
timestamp = DateTime.UtcNow,
flow = new {
feed = flow["feed"],
reflux = flow["reflux"],
overhead = flow["overhead"],
bottom = flow["bottom"],
product = flow["product"],
steam = flow["steam"],
Timestamp = DateTime.UtcNow,
Flow = new {
Feed = flow["feed"],
Reflux = flow["reflux"],
Overhead = flow["overhead"],
Bottom = flow["bottom"],
Product = flow["product"],
Steam = flow["steam"],
},
flowTags = new {
feed = $"{bases["feed"]}.PV",
reflux = $"{bases["reflux"]}.PV",
overhead = $"{bases["overhead"]}.PV",
bottom = $"{bases["bottom"]}.PV",
product = $"{bases["product"]}.PV",
steam = $"FIQ-{ToSuffix(col).Substring(0, ToSuffix(col).Length - 2)}15.PV",
FlowTags = new {
Feed = $"{bases["feed"]}.PV",
Reflux = $"{bases["reflux"]}.PV",
Overhead = $"{bases["overhead"]}.PV",
Bottom = $"{bases["bottom"]}.PV",
Product = $"{bases["product"]}.PV",
Steam = $"FIQ-{ToSuffix(col).Substring(0, ToSuffix(col).Length - 2)}15.PV",
},
});
}
@@ -253,7 +253,7 @@ public sealed class SteamAdvisorController : ControllerBase
try
{
var tref = await LoadTempRef(col);
if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" });
if (tref is null) return NotFound(new { Error = $"기준 프로파일 없음: {col}_tempref.json" });
tref = MergeProductLabels(col, tref);
@@ -296,48 +296,48 @@ public sealed class SteamAdvisorController : ControllerBase
var (prod, stages, vacuum, spanAD) = ComputeStages(cur, tref, selected);
object FlowRole(string baseTag, string role)
=> new { pv = cur.GetValueOrDefault(role), sp = V(row.Values, $"{baseTag}.SP"), op = V(row.Values, $"{baseTag}.OP") };
=> new { Pv = cur.GetValueOrDefault(role), Sp = V(row.Values, $"{baseTag}.SP"), Op = V(row.Values, $"{baseTag}.OP") };
snapshots.Add(new
{
ts = row.TimeBucket,
matchedProduct = prod?.Name ?? prod?.Label, // 미선택이면 null
stages,
vacuum,
spanAD,
flow = new {
feed = FlowRole(flowBases["feed"], "feed"),
reflux = FlowRole(flowBases["reflux"], "reflux"),
overhead = FlowRole(flowBases["overhead"], "overhead"),
bottom = FlowRole(flowBases["bottom"], "bottom"),
product = FlowRole(flowBases["product"], "product"),
steam = new { pv = cur.GetValueOrDefault("steam") },
Ts = row.TimeBucket,
MatchedProduct = prod?.Name ?? prod?.Label, // 미선택이면 null
Stages = stages,
Vacuum = vacuum,
SpanAD = spanAD,
Flow = new {
Feed = FlowRole(flowBases["feed"], "feed"),
Reflux = FlowRole(flowBases["reflux"], "reflux"),
Overhead = FlowRole(flowBases["overhead"], "overhead"),
Bottom = FlowRole(flowBases["bottom"], "bottom"),
Product = FlowRole(flowBases["product"], "product"),
Steam = new { Pv = cur.GetValueOrDefault("steam") },
},
flowTags = new {
feed = $"{flowBases["feed"]}.PV",
reflux = $"{flowBases["reflux"]}.PV",
overhead = $"{flowBases["overhead"]}.PV",
bottom = $"{flowBases["bottom"]}.PV",
product = $"{flowBases["product"]}.PV",
steam = $"FIQ-{ToSuffix(col).Substring(0, ToSuffix(col).Length - 2)}15.PV",
FlowTags = new {
Feed = $"{flowBases["feed"]}.PV",
Reflux = $"{flowBases["reflux"]}.PV",
Overhead = $"{flowBases["overhead"]}.PV",
Bottom = $"{flowBases["bottom"]}.PV",
Product = $"{flowBases["product"]}.PV",
Steam = $"FIQ-{ToSuffix(col).Substring(0, ToSuffix(col).Length - 2)}15.PV",
},
});
}
return Ok(new
{
column = tref.Column,
period = tref.Period,
from,
to,
interval = "1 minute",
n = snapshots.Count,
snapshots,
timestamp = DateTime.UtcNow,
Column = tref.Column,
Period = tref.Period,
From = from,
To = to,
Interval = "1 minute",
N = snapshots.Count,
Snapshots = snapshots,
Timestamp = DateTime.UtcNow,
});
}
catch (Exception ex)
{
return StatusCode(500, new { error = ex.Message, stack = ex.ToString() });
return StatusCode(500, new { Error = ex.Message, Stack = ex.ToString() });
}
}
@@ -345,9 +345,9 @@ public sealed class SteamAdvisorController : ControllerBase
// MSDS 후보 (docs/MSDS_PMA_PGMEA_EL.md). 자유 입력도 허용하되 기본 제안 목록 제공.
private static readonly object[] _productSuggestions =
{
new { name = "PMA", casNo = "108-65-6" },
new { name = "PGMEA", casNo = "108-65-6" },
new { name = "EL", casNo = "97-64-3" },
new { Name = "PMA", CasNo = "108-65-6" },
new { Name = "PGMEA", CasNo = "108-65-6" },
new { Name = "EL", CasNo = "97-64-3" },
};
private string ProductLabelsPath()
@@ -362,20 +362,20 @@ public sealed class SteamAdvisorController : ControllerBase
public async Task<IActionResult> GetProductLabels(string col)
{
var tref = await LoadTempRef(col);
if (tref is null) return NotFound(new { error = $"기준 프로파일 없음: {col}_tempref.json" });
if (tref is null) return NotFound(new { Error = $"기준 프로파일 없음: {col}_tempref.json" });
tref = MergeProductLabels(col, tref);
var clusters = tref.Products.Select(p => new
{
cluster = p.Label,
rebMedian = p.Stages.GetValueOrDefault("reb_temp")?.Median,
vacMedian = p.Vacuum.Median,
nRows = p.NRows,
Cluster = p.Label,
RebMedian = p.Stages.GetValueOrDefault("reb_temp")?.Median,
VacMedian = p.Vacuum.Median,
NRows = p.NRows,
// 현재 지정명: 매핑이 있으면 Name, 없으면 빈값(=미지정)
name = string.IsNullOrEmpty(p.Name) || p.Name == p.Label ? "" : p.Name,
Name = string.IsNullOrEmpty(p.Name) || p.Name == p.Label ? "" : p.Name,
}).ToList();
return Ok(new { column = col, clusters, suggestions = _productSuggestions });
return Ok(new { Column = col, Clusters = clusters, Suggestions = _productSuggestions });
}
public sealed record ProductLabelEntry
@@ -389,7 +389,7 @@ public sealed class SteamAdvisorController : ControllerBase
[HttpPost("productlabels/{col}")]
public IActionResult SaveProductLabels(string col, [FromBody] List<ProductLabelEntry> entries)
{
if (entries is null) return BadRequest(new { error = "본문(entries) 누락" });
if (entries is null) return BadRequest(new { Error = "본문(entries) 누락" });
var path = ProductLabelsPath();
try
@@ -422,11 +422,11 @@ public sealed class SteamAdvisorController : ControllerBase
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
});
System.IO.File.WriteAllText(path, json);
return Ok(new { success = true, saved = clean.Count, path });
return Ok(new { Success = true, Saved = clean.Count, Path = path });
}
catch (Exception ex)
{
return StatusCode(500, new { success = false, error = ex.Message });
return StatusCode(500, new { Success = false, Error = ex.Message });
}
}
@@ -519,8 +519,8 @@ public sealed class SteamAdvisorController : ControllerBase
var result = new Dictionary<string, object>();
foreach (var (role, baseTag) in ficqBases)
result[role] = new { pv = P(live, $"{baseTag}.PV"), sp = P(live, $"{baseTag}.SP"), op = P(live, $"{baseTag}.OP") };
result["steam"] = new { pv = P(live, steamTag) };
result[role] = new { Pv = P(live, $"{baseTag}.PV"), Sp = P(live, $"{baseTag}.SP"), Op = P(live, $"{baseTag}.OP") };
result["steam"] = new { Pv = P(live, steamTag) };
return result;
}
@@ -554,12 +554,12 @@ public sealed class SteamAdvisorController : ControllerBase
var z = Z(c, rs);
return (object)new
{
stage = s,
current = c,
refMedian = rs?.Median,
refStd = rs?.Std,
z,
deviated = z is double zz && Math.Abs(zz) > 2
Stage = s,
Current = c,
RefMedian = rs?.Median,
RefStd = rs?.Std,
Z = z,
Deviated = z is double zz && Math.Abs(zz) > 2
};
}).ToList();
@@ -569,11 +569,11 @@ public sealed class SteamAdvisorController : ControllerBase
return (prod, stages, new
{
current = vac,
refMedian = prod?.Vacuum.Median,
refStd = prod?.Vacuum.Std,
z = vz,
deviated = vz is double v && Math.Abs(v) > 2
Current = vac,
RefMedian = prod?.Vacuum.Median,
RefStd = prod?.Vacuum.Std,
Z = vz,
Deviated = vz is double v && Math.Abs(v) > 2
}, spanAD);
}

View File

@@ -41,11 +41,11 @@ public class TextToSqlController : ControllerBase
try
{
var sql = await _textToSqlService.ParseNaturalLanguageAsync(dto.Query);
return Ok(new { success = true, sql });
return Ok(new { Success = true, Sql = sql });
}
catch (Exception ex)
{
return Ok(new { success = false, error = ex.Message });
return Ok(new { Success = false, Error = ex.Message });
}
}

View File

@@ -22,15 +22,15 @@ public class TrendController : ControllerBase
[HttpGet("analog-points")]
public async Task<IActionResult> AnalogPoints([FromQuery] string? q, CancellationToken ct)
{
try { return Ok(new { items = await _svc.GetAnalogPointsAsync(q ?? "", ct) }); }
catch (Exception ex) { _logger.LogError(ex, "[Trend] analog-points 실패"); return StatusCode(500, new { error = ex.Message }); }
try { return Ok(new { Items = await _svc.GetAnalogPointsAsync(q ?? "", ct) }); }
catch (Exception ex) { _logger.LogError(ex, "[Trend] analog-points 실패"); return StatusCode(500, new { Error = ex.Message }); }
}
[HttpGet("groups")]
public async Task<IActionResult> Groups(CancellationToken ct)
{
try { return Ok(new { items = await _svc.GetGroupsAsync(ct) }); }
catch (Exception ex) { _logger.LogError(ex, "[Trend] groups 조회 실패"); return StatusCode(500, new { error = ex.Message }); }
try { return Ok(new { Items = await _svc.GetGroupsAsync(ct) }); }
catch (Exception ex) { _logger.LogError(ex, "[Trend] groups 조회 실패"); return StatusCode(500, new { Error = ex.Message }); }
}
[HttpGet("groups/{id:int}")]
@@ -44,31 +44,31 @@ public class TrendController : ControllerBase
public async Task<IActionResult> Create([FromBody] TrendGroupUpsertDto dto, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(dto.Name))
return BadRequest(new { error = "name 은 필수입니다." });
return BadRequest(new { Error = "name 은 필수입니다." });
try { return Ok(await _svc.CreateGroupAsync(dto, ct)); }
catch (Exception ex) { _logger.LogError(ex, "[Trend] 그룹 생성 실패"); return StatusCode(500, new { error = ex.Message }); }
catch (Exception ex) { _logger.LogError(ex, "[Trend] 그룹 생성 실패"); return StatusCode(500, new { Error = ex.Message }); }
}
[HttpPut("groups/{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] TrendGroupUpsertDto dto, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(dto.Name))
return BadRequest(new { error = "name 은 필수입니다." });
return BadRequest(new { Error = "name 은 필수입니다." });
var g = await _svc.UpdateGroupAsync(id, dto, ct);
return g is null ? NotFound() : Ok(g);
}
[HttpDelete("groups/{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
=> await _svc.DeleteGroupAsync(id, ct) ? Ok(new { success = true }) : NotFound();
=> await _svc.DeleteGroupAsync(id, ct) ? Ok(new { Success = true }) : NotFound();
/// <summary>실시간 tail — 그룹 멤버 현재값(아날로그)</summary>
[HttpGet("live")]
public async Task<IActionResult> Live([FromQuery] string? tags, CancellationToken ct)
{
var list = (tags ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
try { return Ok(new { items = await _svc.GetLiveAsync(list, ct) }); }
catch (Exception ex) { _logger.LogError(ex, "[Trend] live 실패"); return StatusCode(500, new { error = ex.Message }); }
try { return Ok(new { Items = await _svc.GetLiveAsync(list, ct) }); }
catch (Exception ex) { _logger.LogError(ex, "[Trend] live 실패"); return StatusCode(500, new { Error = ex.Message }); }
}
/// <summary>알람 한계선 (HI/LO/SP 수평선용)</summary>
@@ -76,15 +76,15 @@ public class TrendController : ControllerBase
public async Task<IActionResult> Limits([FromQuery] string? tags, CancellationToken ct)
{
var list = (tags ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
try { return Ok(new { items = await _svc.GetLimitsAsync(list, ct) }); }
catch (Exception ex) { _logger.LogError(ex, "[Trend] limits 실패"); return StatusCode(500, new { error = ex.Message }); }
try { return Ok(new { Items = await _svc.GetLimitsAsync(list, ct) }); }
catch (Exception ex) { _logger.LogError(ex, "[Trend] limits 실패"); return StatusCode(500, new { Error = ex.Message }); }
}
/// <summary>운전상태 밴드 (RUN/TRIP 구간)</summary>
[HttpGet("runbands")]
public async Task<IActionResult> RunBands([FromQuery] DateTime from, [FromQuery] DateTime to, [FromQuery] string? area, CancellationToken ct)
{
try { return Ok(new { items = await _svc.GetRunBandsAsync(from, to, area, ct) }); }
catch (Exception ex) { _logger.LogError(ex, "[Trend] runbands 실패"); return StatusCode(500, new { error = ex.Message }); }
try { return Ok(new { Items = await _svc.GetRunBandsAsync(from, to, area, ct) }); }
catch (Exception ex) { _logger.LogError(ex, "[Trend] runbands 실패"); return StatusCode(500, new { Error = ex.Message }); }
}
}

View File

@@ -73,9 +73,9 @@ async function docsUnlock() {
body: JSON.stringify({ password: pw }),
});
const d = await r.json();
if (!d.success) { alert('로그인 실패: ' + (d.error || '')); return; }
sessionStorage.setItem('kbToken', d.token);
try { kbToken = d.token; } catch (e) {}
if (!d.Success) { alert('로그인 실패: ' + (d.Error || '')); return; }
sessionStorage.setItem('kbToken', d.Token);
try { kbToken = d.Token; } catch (e) {}
await docsLoadConfig();
docsToast('관리 잠금해제됨');
} catch (e) { alert('로그인 오류: ' + e.message); }
@@ -85,8 +85,8 @@ async function docsUnlock() {
async function docsFetchTree(path) {
const r = await fetch('/api/docs/tree?path=' + encodeURIComponent(path || ''), { headers: docsHeaders() });
const d = await r.json();
if (!d.success) throw new Error(d.error || '목록 조회 실패');
return d.entries;
if (!d.Success) throw new Error(d.Error || '목록 조회 실패');
return d.Entries;
}
async function docsRefresh() {
@@ -200,7 +200,7 @@ function docsOpenPath(path) {
async function docsFetchText(path) {
const r = await fetch('/api/docs/text?path=' + encodeURIComponent(path), { headers: docsHeaders() });
const d = await r.json();
if (!d.success) throw new Error(d.error || '파일 읽기 실패');
if (!d.Success) throw new Error(d.Error || '파일 읽기 실패');
return d;
}
@@ -223,10 +223,10 @@ async function docsViewText(path) {
try {
const d = await docsFetchText(path);
v.innerHTML = '';
if (d.truncated) v.appendChild(docsTruncBanner(d.size));
if (d.Truncated) v.appendChild(docsTruncBanner(d.Size));
const pre = document.createElement('pre');
pre.className = 'docs-text-pre';
pre.textContent = d.text;
pre.textContent = d.Text;
v.appendChild(pre);
} catch (e) { docsViewerError(e); }
}
@@ -246,11 +246,11 @@ async function docsViewMarkdown(path) {
await docsEnsureMdLibs();
const d = await docsFetchText(path);
v.innerHTML = '';
if (d.truncated) v.appendChild(docsTruncBanner(d.size));
if (d.Truncated) v.appendChild(docsTruncBanner(d.Size));
const body = document.createElement('div');
body.className = 'md-body';
v.appendChild(body);
docsRenderMarkdownInto(body, d.text);
docsRenderMarkdownInto(body, d.Text);
} catch (e) { docsViewerError(e); }
}
@@ -549,7 +549,7 @@ async function docsEditStart() {
${isMd ? '<div id="docs-edit-prev" class="md-body docs-edit-prev"></div>' : ''}
</div>`;
const ta = document.getElementById('docs-edit-ta');
ta.value = d.text;
ta.value = d.Text;
if (isMd) {
await docsEnsureMdLibs();
const prev = document.getElementById('docs-edit-prev');
@@ -570,7 +570,7 @@ async function docsEditSave() {
body: JSON.stringify({ path: f.path, content: ta.value }),
});
const d = await r.json();
if (!d.success) { alert('저장 실패: ' + (d.error || r.status)); return; }
if (!d.Success) { alert('저장 실패: ' + (d.Error || r.status)); return; }
docsState.editing = false;
docsOpenPath(f.path);
docsToast('저장됨');
@@ -592,7 +592,7 @@ async function docsMkdirPrompt() {
body: JSON.stringify({ path }),
});
const d = await r.json();
if (!d.success) { alert('실패: ' + (d.error || '')); return; }
if (!d.Success) { alert('실패: ' + (d.Error || '')); return; }
await docsRefresh();
docsToast('폴더 생성됨');
} catch (e) { alert(e.message); }
@@ -614,9 +614,9 @@ async function docsUploadDo() {
const r = await fetch('/api/docs/upload', { method: 'POST', headers: docsHeaders(), body: fd });
const d = await r.json();
input.value = '';
if (!d.success) { alert('업로드 실패: ' + (d.error || '')); return; }
if (!d.Success) { alert('업로드 실패: ' + (d.Error || '')); return; }
await docsRefresh();
docsToast('업로드됨: ' + d.path);
docsToast('업로드됨: ' + d.Path);
} catch (e) { input.value = ''; alert(e.message); }
}
@@ -630,7 +630,7 @@ async function docsRenamePrompt(path) {
body: JSON.stringify({ from: path, to: next }),
});
const d = await r.json();
if (!d.success) { alert('실패: ' + (d.error || '')); return; }
if (!d.Success) { alert('실패: ' + (d.Error || '')); return; }
await docsRefresh();
docsToast('이름 변경됨');
} catch (e) { alert(e.message); }
@@ -647,7 +647,7 @@ async function docsDeletePrompt(path, type) {
const url = '/api/docs?path=' + encodeURIComponent(path) + (isDir ? '&recursive=true' : '');
const r = await fetch(url, { method: 'DELETE', headers: docsHeaders() });
const d = await r.json();
if (!d.success) { alert('삭제 실패: ' + (d.error || '')); return; }
if (!d.Success) { alert('삭제 실패: ' + (d.Error || '')); return; }
if (docsState.curFile && docsState.curFile.path === path) {
docsState.curFile = null;
document.getElementById('docs-cur-path').textContent = '파일을 선택하세요';

View File

@@ -65,7 +65,7 @@ async function fastSessionsLoad() {
const list = document.getElementById('fast-session-list');
list.innerHTML = '';
if (!data.items || data.items.length === 0) {
if (!data.Items || data.Items.length === 0) {
list.innerHTML = '<span style="color:var(--t3);font-size:12px;padding:4px 0">세션이 없습니다. + 신규를 눌러 시작하세요.</span>';
return;
}
@@ -80,7 +80,7 @@ async function fastSessionsLoad() {
Failed: '실패', RowLimitReached: '행제한', Pending: '대기'
};
data.items.forEach(s => {
data.Items.forEach(s => {
const isActive = s.Id === fastCurrentSessionId;
const dot = statusColor[s.Status] ?? '#aaa';
const label = statusLabel[s.Status] ?? s.Status;
@@ -137,7 +137,7 @@ async function fastStart() {
if (!res.ok) {
const err = await res.json();
alert('오류: ' + (err.error ?? '알 수 없는 오류'));
alert('오류: ' + (err.Error ?? '알 수 없는 오류'));
return;
}
@@ -228,14 +228,14 @@ async function fastRenderChart() {
const container = document.getElementById('fast-chart-container');
if (!data.items || data.items.length === 0) {
if (!data.Items || data.Items.length === 0) {
container.innerHTML = '<div class="text-center text-muted pt-5">수집된 데이터가 없습니다.</div>';
return;
}
// Long 포맷 → PIVOT (recorded_at 기준 그룹화)
const grouped = {};
for (const r of data.items) {
for (const r of data.Items) {
if (!grouped[r.RecordedAt]) grouped[r.RecordedAt] = {};
grouped[r.RecordedAt][r.TagName] = parseFloat(r.Value) || null;
}
@@ -244,10 +244,10 @@ async function fastRenderChart() {
const timesNum = times.map(t => new Date(t).getTime() / 1000); // uPlot: Unix seconds
// uPlot data: [[x...], [y1...], [y2...], ...]
const uData = [timesNum, ...data.tagNames.map(tag => times.map(t => grouped[t][tag] ?? null))];
const uData = [timesNum, ...data.TagNames.map(tag => times.map(t => grouped[t][tag] ?? null))];
// 동일 태그 구성이면 setData()로 인플레이스 업데이트 → zoom/pan 상태 유지
const tagsKey = data.tagNames.join('\0');
const tagsKey = data.TagNames.join('\0');
if (fastChart && fastChartTagNames === tagsKey) {
fastChart.setData(uData, false); // false = 스케일 유지 (zoom 보존)
return;
@@ -272,7 +272,7 @@ async function fastRenderChart() {
],
series: [
{},
...data.tagNames.map(tag => ({
...data.TagNames.map(tag => ({
label: tag,
stroke: fastTagColor(tag),
width: 2
@@ -458,15 +458,15 @@ paneInit.fast = function() {
// Long → Wide (배열의 배열 형식으로 XLSX.utils.aoa_to_sheet에 전달)
const timeMap = {};
for (const r of data.items) {
for (const r of data.Items) {
if (!timeMap[r.RecordedAt]) timeMap[r.RecordedAt] = {};
timeMap[r.RecordedAt][r.TagName] = r.Value;
}
const rows = [['recorded_at', ...data.tagNames]];
const rows = [['recorded_at', ...data.TagNames]];
for (const t of Object.keys(timeMap).sort()) {
rows.push([new Date(t).toLocaleString('ko-KR'),
...data.tagNames.map(tag => timeMap[t][tag] ?? '')]);
...data.TagNames.map(tag => timeMap[t][tag] ?? '')]);
}
const ws = XLSX.utils.aoa_to_sheet(rows);

View File

@@ -175,8 +175,8 @@ async function ffLoadDash() {
catch (e) { return; }
try {
const ramp = await api('GET', '/api/ff/feed-ramp');
ffRampJobs = {}; (ramp.jobs || []).forEach(j => ffRampJobs[j.columnId] = j);
ffRampDryRun = !!ramp.dryRun;
ffRampJobs = {}; (ramp.Jobs || []).forEach(j => ffRampJobs[j.columnId] = j);
ffRampDryRun = !!ramp.DryRun;
const modeEl = document.getElementById('ff-ramp-mode');
if (modeEl) modeEl.textContent = ffRampDryRun ? '모의(DryRun)' : '실쓰기 모드';
} catch (e) { /* 무시 */ }
@@ -185,7 +185,7 @@ async function ffLoadDash() {
// FEED Target SP 입력 중이면 재렌더 보류(타이핑/포커스 유지)
const ae = document.activeElement;
if (ae && ae.classList && ae.classList.contains('ff-rt')) return;
const cols = data.columns || [];
const cols = data.Columns || [];
if (!cols.length) { host.innerHTML = '<div class="ff-empty">활성 컬럼 없음</div>'; return; }
host.innerHTML = cols.map(ffCard).join('');
}
@@ -468,16 +468,16 @@ function ffMsg(m, err) { const e=document.getElementById('ff-cfg-msg'); e.textCo
async function ffLoadConfig() {
const data = await ffApi('GET', '/api/ff/config');
const host = document.getElementById('ff-cfg-list');
host.innerHTML = (data.columns||[]).map(ffCfgRow).join('') || '<div class="ff-empty">설정 없음</div>';
host.innerHTML = (data.Columns||[]).map(ffCfgRow).join('') || '<div class="ff-empty">설정 없음</div>';
host.querySelectorAll('[data-edit]').forEach(b => b.onclick = () =>
ffEditColumn(data.columns.find(c => c.id == b.dataset.edit)));
ffEditColumn(data.Columns.find(c => c.id == b.dataset.edit)));
host.querySelectorAll('[data-del]').forEach(b => b.onclick = () => ffDelete(b.dataset.del));
}
function ffCfgRow(c) {
return `<div class="ff-cfg-item"><b>${esc(c.name)}</b> (id ${c.id}) — feed ${esc(c.feedTag)},
스트림 ${c.streams.length}개, ${c.enabled?'활성':'비활성'}
<button class="btn sm" data-edit="${c.id}">편집</button>
<button class="btn sm danger" data-del="${c.id}">삭제</button></div>`;
return `<div class="ff-cfg-item"><b>${esc(c.Name)}</b> (id ${c.Id}) — feed ${esc(c.FeedTag)},
스트림 ${c.Streams.length}개, ${c.Enabled?'활성':'비활성'}
<button class="btn sm" data-edit="${c.Id}">편집</button>
<button class="btn sm danger" data-del="${c.Id}">삭제</button></div>`;
}
async function ffDelete(id) {
if (!confirm(`컬럼 ${id} 삭제?`)) return;

View File

@@ -119,14 +119,14 @@ async function histQuery() {
try {
const d = await api('POST', '/api/text-to-sql/query-history-interval', body);
if (!d.success) {
throw new Error(d.error || '조회 실패');
if (!d.Success) {
throw new Error(d.Error || '조회 실패');
}
const rows = d.rows || [];
const tNames = d.tagNames || [];
const rows = d.Rows || [];
const tNames = d.TagNames || [];
renderHistoryTable(rows, tNames, interval, d.baseIntervalSeconds, d.queryInterval);
renderHistoryTable(rows, tNames, interval, d.BaseIntervalSeconds, d.QueryInterval);
} catch (e) {
histShowStatus('err', '❌', '조회 실패', ` ${e.message}\n\n컨솔에서 상세 오류를 확인하세요.`);
setGlobal('err', '조회 실패');

View File

@@ -26,7 +26,7 @@ async function kbFetch(method, path, body, opt) {
paneInit.kbadmin = async function() {
if (kbToken) {
const r = await kbFetch('GET', '/api/kb/auth/status');
if (r.ok && r.data && r.data.valid) {
if (r.ok && r.data && r.data.Valid) {
kbShowMain();
} else {
kbShowLogin('세션이 만료되었습니다. 다시 로그인하세요.');
@@ -76,12 +76,12 @@ async function kbLogin() {
body: JSON.stringify({ password: pw })
});
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.success) {
msg.textContent = '❌ ' + (data.error || '로그인 실패');
if (!r.ok || !data.Success) {
msg.textContent = '❌ ' + (data.Error || '로그인 실패');
return;
}
kbToken = data.token;
kbExpiresAt = data.expiresAt;
kbToken = data.Token;
kbExpiresAt = data.ExpiresAt;
sessionStorage.setItem('kbToken', kbToken);
sessionStorage.setItem('kbExpiresAt', kbExpiresAt || '');
document.getElementById('kb-pw').value = '';
@@ -97,14 +97,14 @@ async function kbLogout() {
// ── 컬렉션 ─────────────────────────────────────────────
async function kbLoadCollections() {
const r = await kbFetch('GET', '/api/kb/collections');
if (!r.ok || !r.data || !r.data.success) return;
kbCollections = r.data.items || [];
if (!r.ok || !r.data || !r.data.Success) return;
kbCollections = r.data.Items || [];
const fSel = document.getElementById('kb-f-coll');
const uSel = document.getElementById('kb-up-coll');
fSel.innerHTML = '<option value="">전체</option>' +
kbCollections.map(c => `<option value="${c.key}">${c.name}</option>`).join('');
kbCollections.map(c => `<option value="${c.Key}">${c.Name}</option>`).join('');
uSel.innerHTML = '<option value="">-- 선택 --</option>' +
kbCollections.map(c => `<option value="${c.key}">${c.name}</option>`).join('');
kbCollections.map(c => `<option value="${c.Key}">${c.Name}</option>`).join('');
}
// ── 목록 ───────────────────────────────────────────────
@@ -119,11 +119,11 @@ async function kbRefresh() {
qs.set('pageSize', '200');
const r = await kbFetch('GET', '/api/kb/documents?' + qs.toString());
if (!r.ok || !r.data || !r.data.success) {
if (!r.ok || !r.data || !r.data.Success) {
document.getElementById('kb-doc-table').innerHTML = '<div class="placeholder">조회 실패</div>';
return;
}
kbRenderDocs(r.data.items, r.data.total);
kbRenderDocs(r.data.Items, r.data.Total);
}
function kbRenderDocs(items, total) {
@@ -134,26 +134,26 @@ function kbRenderDocs(items, total) {
tbl.innerHTML = '<div class="placeholder">문서 없음</div>';
return;
}
const collMap = Object.fromEntries(kbCollections.map(c => [c.key, c.name]));
const collMap = Object.fromEntries(kbCollections.map(c => [c.Key, c.Name]));
const rows = items.map(d => {
const tags = (d.tags || []).map(t => `<span class="kb-tag">${t}</span>`).join(' ');
const dt = d.uploadedAt ? new Date(d.uploadedAt).toLocaleString('ko-KR') : '';
const size = d.fileSize ? kbFmtSize(d.fileSize) : '';
const tags = (d.Tags || []).map(t => `<span class="kb-tag">${t}</span>`).join(' ');
const dt = d.UploadedAt ? new Date(d.UploadedAt).toLocaleString('ko-KR') : '';
const size = d.FileSize ? kbFmtSize(d.FileSize) : '';
return `<tr>
<td class="mono">${kbShortId(d.id)}</td>
<td>${kbEscape(d.title)}</td>
<td>${collMap[d.collection] || d.collection}</td>
<td class="mono">${kbShortId(d.Id)}</td>
<td>${kbEscape(d.Title)}</td>
<td>${collMap[d.Collection] || d.Collection}</td>
<td>${tags}</td>
<td class="mono">${size}</td>
<td><span class="kb-status kb-st-${d.status}">${d.status}</span>${d.errorMessage ? `<div class="kb-err" title="${kbEscape(d.errorMessage)}">${kbEscape(d.errorMessage.slice(0,60))}…</div>`:''}</td>
<td class="mono">${d.chunkCount || 0}</td>
<td><span class="kb-status kb-st-${d.Status}">${d.Status}</span>${d.ErrorMessage ? `<div class="kb-err" title="${kbEscape(d.ErrorMessage)}">${kbEscape(d.ErrorMessage.slice(0,60))}…</div>`:''}</td>
<td class="mono">${d.ChunkCount || 0}</td>
<td class="mono">${dt}</td>
<td>
<button class="btn-b btn-sm" onclick="kbDownload('${d.id}')">⬇</button>
${(d.chunkCount || 0) > 0 ? `<button class="btn-b btn-sm" onclick="kbShowChunks('${d.id}','${kbEscape(d.title)}')">🔍</button>` : ''}
<button class="btn-b btn-sm" onclick="kbReindex('${d.id}')">↻</button>
<button class="btn-b btn-sm" onclick="kbDisable('${d.id}')">🚫</button>
<button class="btn-b btn-sm" onclick="kbDelete('${d.id}','${kbEscape(d.title)}')">✖</button>
<button class="btn-b btn-sm" onclick="kbDownload('${d.Id}')">⬇</button>
${(d.ChunkCount || 0) > 0 ? `<button class="btn-b btn-sm" onclick="kbShowChunks('${d.Id}','${kbEscape(d.Title)}')">🔍</button>` : ''}
<button class="btn-b btn-sm" onclick="kbReindex('${d.Id}')">↻</button>
<button class="btn-b btn-sm" onclick="kbDisable('${d.Id}')">🚫</button>
<button class="btn-b btn-sm" onclick="kbDelete('${d.Id}','${kbEscape(d.Title)}')">✖</button>
</td>
</tr>`;
}).join('');
@@ -199,8 +199,8 @@ async function kbUploadSubmit() {
msg.textContent = '업로드 중...';
const r = await fetch('/api/kb/upload', { method: 'POST', headers: kbHeaders(), body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.success) {
msg.textContent = '❌ ' + (data.error || ('HTTP ' + r.status));
if (!r.ok || !data.Success) {
msg.textContent = '❌ ' + (data.Error || ('HTTP ' + r.status));
return;
}
msg.textContent = '✅ 업로드 완료. 인덱싱 진행 중...';
@@ -218,8 +218,8 @@ async function kbShowChunks(id, title) {
modal.classList.remove('hidden');
const r = await kbFetch('GET', '/api/kb/documents/' + id + '/chunks?limit=200');
if (!r.ok || !r.data || !r.data.success) {
body.innerHTML = '<div class="placeholder">조회 실패: ' + ((r.data && r.data.error) || r.status) + '</div>';
if (!r.ok || !r.data || !r.data.Success) {
body.innerHTML = '<div class="placeholder">조회 실패: ' + ((r.data && r.data.Error) || r.status) + '</div>';
return;
}
kbRenderChunks(r.data.chunks || []);
@@ -262,7 +262,7 @@ function kbDownload(id) {
async function kbReindex(id) {
if (!confirm('재인덱싱하시겠습니까? (Qdrant 기존 청크 삭제 후 다시 처리)')) return;
const r = await kbFetch('POST', '/api/kb/documents/' + id + '/reindex');
if (!r.ok) alert('실패: ' + (r.data && r.data.error ? r.data.error : r.status));
if (!r.ok) alert('실패: ' + (r.data && r.data.Error ? r.data.Error : r.status));
kbRefresh();
}
@@ -284,7 +284,7 @@ async function kbBulkDisable() {
const title = prompt('일괄 비활성화할 제목을 정확히 입력하세요:');
if (!title) return;
const r = await kbFetch('POST', '/api/kb/documents/bulk-disable', { title });
if (r.ok && r.data && r.data.success) alert(`${r.data.affected}건 비활성화 완료`);
if (r.ok && r.data && r.data.Success) alert(`${r.data.Affected}건 비활성화 완료`);
else alert('실패');
kbRefresh();
}
@@ -299,7 +299,7 @@ async function kbPurgeDisabled() {
}
if (!confirm('정말 영구삭제하시겠습니까? (되돌릴 수 없습니다)')) return;
const r = await kbFetch('POST', '/api/kb/documents/purge-disabled', body);
if (r.ok && r.data && r.data.success) alert(`${r.data.deleted}건 영구삭제 완료`);
if (r.ok && r.data && r.data.Success) alert(`${r.data.Deleted}건 영구삭제 완료`);
else alert('실패');
kbRefresh();
}
@@ -321,11 +321,11 @@ async function kbChangePwSubmit() {
if (!oldPw || !newPw) { msg.textContent = '❌ 비밀번호를 입력하세요.'; return; }
if (newPw.length < 6) { msg.textContent = '❌ 새 비밀번호는 6자 이상.'; return; }
const r = await kbFetch('POST', '/api/kb/auth/change-password', { oldPassword: oldPw, newPassword: newPw });
if (r.ok && r.data && r.data.success) {
if (r.ok && r.data && r.data.Success) {
msg.textContent = '✅ 변경 완료. 다시 로그인해 주세요.';
setTimeout(() => { kbChangePwClose(); kbLogout(); }, 800);
} else {
msg.textContent = '❌ ' + (r.data && r.data.error ? r.data.error : '변경 실패');
msg.textContent = '❌ ' + (r.data && r.data.Error ? r.data.Error : '변경 실패');
}
}
@@ -338,7 +338,7 @@ function kbStartPoll() {
const r = await kbFetch('GET', '/api/kb/documents?status=parsing&pageSize=1');
const r2 = await kbFetch('GET', '/api/kb/documents?status=embedding&pageSize=1');
const r3 = await kbFetch('GET', '/api/kb/documents?status=pending&pageSize=1');
const active = [r, r2, r3].some(x => x.ok && x.data && x.data.total > 0);
const active = [r, r2, r3].some(x => x.ok && x.data && x.data.Total > 0);
if (active) kbRefresh();
}, 1500);
}
@@ -369,8 +369,8 @@ async function kbInferStart() {
seedDocId: ''
});
if (!r.ok || !r.data || !r.data.success) {
msg.textContent = '❌ ' + (r.data && r.data.error ? r.data.error : 'HTTP ' + r.status);
if (!r.ok || !r.data || !r.data.Success) {
msg.textContent = '❌ ' + (r.data && r.data.Error ? r.data.Error : 'HTTP ' + r.status);
return;
}

View File

@@ -359,13 +359,13 @@ function llmRenderToolPayload(name, ok, preview, payload) {
// search_kb 형태
if (Array.isArray(j.hits)) return llmRenderKbHits(j.hits);
// run_sql/query_with_nl 형태: {success, columns:[], data:[{...}, ...]}
if (j.success && Array.isArray(j.columns) && Array.isArray(j.data)) {
if (j.Success && Array.isArray(j.Columns) && Array.isArray(j.data)) {
const ts = llmDetectTimeSeries(j.data);
const sparkHtml = ts ? llmBuildSparklineHtml(j.data, ts) : '';
return sparkHtml + llmRenderTable(j.columns, j.data);
return sparkHtml + llmRenderTable(j.Columns, j.data);
}
// query_pv_history 형태: {success, data:[{tag_name, timestamp, value}, ...]}
if (j.success && Array.isArray(j.data) && j.data.length > 0 && typeof j.data[0] === 'object') {
// query_pv_history 형태: {Success, data:[{tag_name, timestamp, value}, ...]}
if (j.Success && Array.isArray(j.data) && j.data.length > 0 && typeof j.data[0] === 'object') {
const cols = Object.keys(j.data[0]);
const ts = llmDetectTimeSeries(j.data);
const sparkHtml = ts ? llmBuildSparklineHtml(j.data, ts) : '';
@@ -502,8 +502,8 @@ async function llmLoadModels() {
const d = await api('GET', `${prefix}/models`);
sel.innerHTML = '<option value="">-- 모델을 선택하세요 --</option>';
if (d.success && d.models) {
d.models.forEach(m => {
if (d.Success && d.Models) {
d.Models.forEach(m => {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
@@ -515,14 +515,14 @@ async function llmLoadModels() {
if (llmType === 'vllm') {
try {
const cfg = await api('GET', '/api/ollama/config');
if (cfg.success && cfg.vllmModel) {
if (![...sel.options].some(o => o.value === cfg.vllmModel)) {
if (cfg.Success && cfg.VllmModel) {
if (![...sel.options].some(o => o.value === cfg.VllmModel)) {
const opt = document.createElement('option');
opt.value = cfg.vllmModel;
opt.textContent = cfg.vllmModel;
opt.value = cfg.VllmModel;
opt.textContent = cfg.VllmModel;
sel.appendChild(opt);
}
sel.value = cfg.vllmModel;
sel.value = cfg.VllmModel;
}
} catch (_) {}
}
@@ -534,8 +534,8 @@ async function llmLoadModels() {
const dot = document.getElementById('llm-conn-status');
if (dot) {
dot.className = d.success ? 'llm-conn-dot connected' : 'llm-conn-dot error';
dot.title = d.success ? `${label} 연결됨` : `${label} 연결 실패`;
dot.className = d.Success ? 'llm-conn-dot connected' : 'llm-conn-dot error';
dot.title = d.Success ? `${label} 연결됨` : `${label} 연결 실패`;
}
} catch (e) {
const dot = document.getElementById('llm-conn-status');
@@ -578,8 +578,11 @@ async function llmLoadMcpTools() {
if (!llmUseTools) { llmMcpTools = []; return; }
try {
const d = await api('GET', '/api/text-to-sql/tools');
if (d.success && d.tools) {
llmMcpTools = d.tools.map(t => ({
// 백엔드 응답 필드는 PascalCase(Success/Tools) — camelCase 폴백 병행
const ok = d.Success ?? d.success;
const toolList = d.Tools ?? d.tools;
if (ok && toolList) {
llmMcpTools = toolList.map(t => ({
type: 'function',
function: {
name: t.Name || t.name,
@@ -638,8 +641,8 @@ async function llmEnsureSummary(sess, model) {
try {
const r = await api('POST', '/api/ollama/summarize', { model, messages: merged });
if (r && r.success && r.summary) {
sess.summary = r.summary;
if (r && r.Success && r.Summary) {
sess.summary = r.Summary;
sess.summarizedUpTo = targetEnd;
llmSaveSessions();
}
@@ -948,10 +951,10 @@ async function llmSaveConfig() {
const port = parseInt(document.getElementById('llm-port').value) || 11434;
try {
const d = await api('POST', '/api/ollama/config', { host, port });
if (d.success) {
if (d.Success) {
alert('설정 저장 완료. 변경 사항 적용을 위해 페이지를 새로고침하세요.');
} else {
alert('설정 저장 실패: ' + (d.error || '알 수 없는 오류'));
alert('설정 저장 실패: ' + (d.Error || '알 수 없는 오류'));
}
} catch (e) {
alert('설정 저장 실패: ' + e.message);
@@ -965,10 +968,10 @@ async function llmTestConnection() {
const d = await api('GET', `${prefix}/ping`);
const dot = document.getElementById('llm-conn-status');
if (dot) {
dot.className = d.success ? 'llm-conn-dot connected' : 'llm-conn-dot error';
dot.title = d.success ? `${label} 연결됨` : `${label} 연결 실패: ${d.error || ''}`;
dot.className = d.Success ? 'llm-conn-dot connected' : 'llm-conn-dot error';
dot.title = d.Success ? `${label} 연결됨` : `${label} 연결 실패: ${d.Error || ''}`;
}
alert(d.success ? `${label} 연결 성공!` : `${label} 연결 실패: ${d.error || ''}`);
alert(d.Success ? `${label} 연결 성공!` : `${label} 연결 실패: ${d.Error || ''}`);
} catch (e) {
alert(`${label} 연결 테스트 실패: ` + e.message);
}
@@ -976,25 +979,25 @@ async function llmTestConnection() {
function llmLoadConfigToUI() {
api('GET', '/api/ollama/config').then(d => {
if (d.success) {
if (d.Success) {
const hostEl = document.getElementById('llm-host');
const portEl = document.getElementById('llm-port');
if (hostEl && d.host) hostEl.value = d.host;
if (portEl && d.port) portEl.value = d.port;
if (hostEl && d.Host) hostEl.value = d.Host;
if (portEl && d.Port) portEl.value = d.Port;
}
}).catch(() => {});
api('GET', '/api/ollama/config').then(d => {
if (d.success && d.vllmModel) {
if (d.Success && d.VllmModel) {
const sel = document.getElementById('llm-model-select');
if (!sel) return;
if (![...sel.options].some(o => o.value === d.vllmModel)) {
if (![...sel.options].some(o => o.value === d.VllmModel)) {
const opt = document.createElement('option');
opt.value = d.vllmModel;
opt.textContent = d.vllmModel;
opt.value = d.VllmModel;
opt.textContent = d.VllmModel;
sel.appendChild(opt);
}
sel.value = d.vllmModel;
sel.value = d.VllmModel;
}
}).catch(() => {});
}

View File

@@ -81,7 +81,7 @@ async function pbBuild() {
if (!confirm('모든 활성 태그를 해제하고 조건에 맞는 태그만 활성화합니다. 계속하시겠습니까?')) return;
const payload = pbCollectAllGroups();
const res = await pbApi('/api/pointbuilder/build', 'POST', payload);
alert(res.message || `${res.count}개 활성화됨`);
alert(res.Message || `${res.Count}개 활성화됨`);
pbRefresh();
pbLoadSummary();
}
@@ -97,7 +97,7 @@ async function pbApply() {
if (!ctrl) { alert('컨트롤러를 선택해주세요.'); return; }
if (!confirm(`[${ctrl}] 기존 활성화를 모두 해제하고 선택한 ${selected.length}개만 활성화합니다. 계속하시겠습니까?`)) return;
const res = await pbApi('/api/pointbuilder/apply', 'POST', { selectedTagNames: selected, controllerId: ctrl });
alert(res.message || `${res.count}개 활성화됨`);
alert(res.Message || `${res.Count}개 활성화됨`);
pbRefresh();
pbLoadSummary();
}
@@ -108,8 +108,8 @@ async function pbAppend() {
if (selected.length === 0) { alert('미리보기에서 추가할 태그를 선택해주세요.'); return; }
if (!ctrl) { alert('컨트롤러를 선택해주세요.'); return; }
const res = await pbApi('/api/pointbuilder/append', 'POST', { selectedTagNames: selected, controllerId: ctrl });
if (res.count > 0) {
alert(`${res.count}개 추가됨`);
if (res.Count > 0) {
alert(`${res.Count}개 추가됨`);
} else {
alert('추가할 새 태그가 없습니다 (이미 모두 활성화되어 있음)');
}
@@ -132,7 +132,7 @@ async function pbAddManual() {
};
const res = await pbApi('/api/pointbuilder/add', 'POST', payload);
if (res.success) {
if (res.Success) {
alert(`등록 완료: ${tagName}`);
document.getElementById('pb-add-tag').value = '';
document.getElementById('pb-add-addr').value = '';
@@ -140,7 +140,7 @@ async function pbAddManual() {
pbRefresh();
pbLoadSummary();
} else {
alert(`오류: ${res.error}`);
alert(`오류: ${res.Error}`);
}
}
@@ -227,7 +227,7 @@ function pbRenderPreview(res) {
const tbody = document.getElementById('pb-preview-tbody');
const count = document.getElementById('pb-preview-count');
if (!res.items || res.items.length === 0) {
if (!res.Items || res.Items.length === 0) {
area.style.display = 'block';
count.textContent = '(0건)';
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:20px;color:#555">조건에 맞는 태그가 없습니다</td></tr>';
@@ -235,20 +235,20 @@ function pbRenderPreview(res) {
}
area.style.display = 'block';
count.textContent = `(${res.count}건)`;
count.textContent = `(${res.Count}건)`;
tbody.innerHTML = res.items.map(item => {
const badgeClass = `param-badge p-${item.paramType}`.replace(/[^a-zA-Z0-9\s-]/g, '');
tbody.innerHTML = res.Items.map(item => {
const badgeClass = `param-badge p-${item.ParamType}`.replace(/[^a-zA-Z0-9\s-]/g, '');
return `<tr>
<td><input type="checkbox" value="${item.tagName}" ${item.isActive ? 'checked' : ''}></td>
<td>${item.tagName}</td>
<td><span class="${badgeClass}">${item.paramType || '—'}</span></td>
<td>${item.dataType}</td>
<td>0x${(item.modbusAddr ?? 0).toString(16).toUpperCase().padStart(4,'0')}</td>
<td>${item.loopNo ?? '—'}</td>
<td>${item.access}</td>
<td><span class="group-badge">${item.group}</span></td>
<td>${item.isActive ? '✓' : ''}</td>
<td><input type="checkbox" value="${item.TagName}" ${item.IsActive ? 'checked' : ''}></td>
<td>${item.TagName}</td>
<td><span class="${badgeClass}">${item.ParamType || '—'}</span></td>
<td>${item.DataType}</td>
<td>0x${(item.ModbusAddr ?? 0).toString(16).toUpperCase().padStart(4,'0')}</td>
<td>${item.LoopNo ?? '—'}</td>
<td>${item.Access}</td>
<td><span class="group-badge">${item.Group}</span></td>
<td>${item.IsActive ? '✓' : ''}</td>
</tr>`;
}).join('');
}
@@ -346,7 +346,7 @@ async function pbRealtimeAdd() {
if (!ctrl) { alert('컨트롤러를 선택해주세요.'); return; }
if (!confirm(`[${ctrl}] 선택한 ${ids.length}개를 실시간(realtime_table)에 추가합니다. 계속하시겠습니까?`)) return;
const res = await pbApi('/api/pointbuilder/realtime-add', 'POST', { ids, controllerId: ctrl });
alert(res.message || `${res.count}개 실시간 편입`);
alert(res.Message || `${res.Count}개 실시간 편입`);
pbRefresh();
pbLoadSummary();
}
@@ -359,7 +359,7 @@ async function pbRealtimeRemove() {
if (!ctrl) { alert('컨트롤러를 선택해주세요.'); return; }
if (!confirm(`[${ctrl}] 선택한 ${ids.length}개를 실시간(realtime_table)에서 제거합니다. 계속하시겠습니까?`)) return;
const res = await pbApi('/api/pointbuilder/realtime-remove', 'POST', { ids, controllerId: ctrl });
alert(res.message || `${res.count}개 실시간 제거`);
alert(res.Message || `${res.Count}개 실시간 제거`);
pbRefresh();
pbLoadSummary();
}
@@ -367,7 +367,7 @@ async function pbRealtimeRemove() {
async function pbDeleteOne(id) {
if (!confirm('이 태그를 비활성화하고 realtime_table에서 제거합니다. 계속하시겠습니까?')) return;
const res = await pbApi(`/api/pointbuilder/${id}`, 'DELETE');
if (res.success) {
if (res.Success) {
pbRefresh();
pbLoadSummary();
}
@@ -431,10 +431,10 @@ async function pbSinamUploadBtn() {
fd.append('file', file);
const res = await fetch('/api/pointbuilder/sinam/upload', { method: 'POST', body: fd });
const data = await res.json();
if (!data.success) throw new Error(data.error || 'upload failed');
if (!data.Success) throw new Error(data.Error || 'upload failed');
await pbSinamLoadExisting();
_pbSinamSetFile(data.file);
_pbSinamSetFile(data.File);
fileInput.value = '';
} catch (e) {
alert('업로드 오류: ' + e.message);
@@ -466,17 +466,17 @@ async function pbSinamParse() {
try {
const res = await pbApi('/api/pointbuilder/sinam/parse', 'POST', { file: filePath, controller: ctrl, applyDb });
if (!res.success) {
resultEl.innerHTML = `<span style="color:#c55">오류: ${res.error || '알 수 없는 오류'}</span>`;
if (!res.Success) {
resultEl.innerHTML = `<span style="color:#c55">오류: ${res.Error || '알 수 없는 오류'}</span>`;
return;
}
const sel = document.getElementById('pb-sinam-existing');
const fileName = sel?.options[sel.selectedIndex]?.text || '';
const lines = [`<span style="color:#3c3">✓ 완료</span>`];
if (res.registers !== undefined) lines.push(`레지스터: ${res.registers}`);
if (res.archive !== undefined) lines.push(`archive: ${res.archive}`);
if (res.loops !== undefined) lines.push(`루프: ${res.loops}`);
if (res.Registers !== undefined) lines.push(`레지스터: ${res.Registers}`);
if (res.Archive !== undefined) lines.push(`archive: ${res.Archive}`);
if (res.Loops !== undefined) lines.push(`루프: ${res.Loops}`);
lines.push(`<span style="color:#888;font-size:11px">${fileName}</span>`);
resultEl.innerHTML = lines.join('<br>');
@@ -500,9 +500,9 @@ async function pbSinamGwRestart() {
resultEl.innerHTML = '<span style="color:#888">재시작 중...</span>';
try {
const res = await pbApi(`/api/setup/gateway/restart?id=${ctrl}`, 'POST');
resultEl.innerHTML = res.success
? `<span style="color:#3c3">✓ ${res.message || '재시작 완료'}</span>`
: `<span style="color:#c55">${res.message || '재시작 실패'}</span>`;
resultEl.innerHTML = res.Success
? `<span style="color:#3c3">✓ ${res.Message || '재시작 완료'}</span>`
: `<span style="color:#c55">${res.Message || '재시작 실패'}</span>`;
} catch (e) {
resultEl.innerHTML = `<span style="color:#c55">오류: ${e.message}</span>`;
}

View File

@@ -17,7 +17,7 @@ function pidHandleError(error, context = '작업') {
let msg = '알 수 없는 오류가 발생했습니다.';
if (error && typeof error === 'object') {
msg = error.message || error.error || error.details || (error instanceof Error ? error.message : msg);
msg = error.message || error.Error || error.Details || (error instanceof Error ? error.message : msg);
} else if (typeof error === 'string') {
msg = error;
}
@@ -66,7 +66,7 @@ async function pidRestoreBuildStatus() {
try {
const statusRes = await api('GET', `/api/pidgraph/status/${savedTaskId}`);
if (statusRes.status === 'Processing' || statusRes.status === 'Starting') {
if (statusRes.Status === 'Processing' || statusRes.Status === 'Starting') {
progWrap.classList.remove('hidden');
const startTime = parseInt(savedStartTime);
@@ -90,18 +90,18 @@ async function pidRestoreBuildStatus() {
eventSource.onmessage = (event) => {
const res = JSON.parse(event.data);
progBar.style.width = `${res.progress}%`;
statusTxt.textContent = res.message;
progBar.style.width = `${res.Progress}%`;
statusTxt.textContent = res.Message;
updateTimer();
if (res.status === 'Completed') {
if (res.Status === 'Completed') {
statusTxt.textContent = '추출이 완료되었습니다.';
progWrap.classList.add('hidden');
localStorage.removeItem('pid_build_task_id');
localStorage.removeItem('pid_build_start_time');
eventSource.close();
} else if (res.status === 'Failed') {
statusTxt.textContent = `오류 발생: ${res.message}`;
} else if (res.Status === 'Failed') {
statusTxt.textContent = `오류 발생: ${res.Message}`;
progWrap.classList.add('hidden');
localStorage.removeItem('pid_build_task_id');
localStorage.removeItem('pid_build_start_time');
@@ -119,7 +119,7 @@ async function pidRestoreBuildStatus() {
setInterval(updateTimer, 1000);
startSse(savedTaskId);
} else if (statusRes.status === 'Completed') {
} else if (statusRes.Status === 'Completed') {
localStorage.removeItem('pid_build_task_id');
localStorage.removeItem('pid_build_start_time');
}
@@ -193,7 +193,7 @@ async function pidBuildGraph(filepath) {
// 1. 빌드 요청
statusTxt.textContent = '추출 요청 중...';
const res = await api('POST', '/api/pidgraph/build', { filepath });
const taskId = res.taskId;
const taskId = res.Data.TaskId;
// 상태 복구를 위해 localStorage에 저장
localStorage.setItem('pid_build_task_id', taskId);
@@ -205,17 +205,17 @@ async function pidBuildGraph(filepath) {
eventSource.onmessage = (event) => {
const statusRes = JSON.parse(event.data);
progBar.style.width = `${statusRes.progress}%`;
statusTxt.textContent = statusRes.message;
progBar.style.width = `${statusRes.Progress}%`;
statusTxt.textContent = statusRes.Message;
updateTimer();
if (statusRes.status === 'Completed') {
if (statusRes.Status === 'Completed') {
eventSource.close();
setGlobal('ok', '추출 완료');
resolve();
} else if (statusRes.status === 'Failed') {
} else if (statusRes.Status === 'Failed') {
eventSource.close();
reject(new Error(statusRes.message));
reject(new Error(statusRes.Message));
}
};

View File

@@ -31,13 +31,13 @@ async function pidUpload() {
const res = await fetch('/api/pid/upload', { method: 'POST', body: formData });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
const err = await res.json().catch(() => ({ Error: res.statusText }));
throw new Error(err.Error || res.statusText);
}
const data = await res.json();
if (statusEl) statusEl.textContent = `✅ 전송 완료: ${data.fileName} (${(data.fileSize / 1024).toFixed(0)} KB)`;
if (statusEl) statusEl.textContent = `✅ 전송 완료: ${data.FileName} (${(data.FileSize / 1024).toFixed(0)} KB)`;
await pidLoadServerFiles(data.fileName);
await pidLoadServerFiles(data.FileName);
} catch (e) {
if (statusEl) statusEl.textContent = `❌ 전송 실패: ${e.message}`;
}
@@ -64,12 +64,12 @@ async function pidLoadServerFiles(selectFileName) {
const data = await res.json();
if (!data.files || data.files.length === 0) {
if (!data.Files || data.Files.length === 0) {
sel.innerHTML = '<option value="">-- 서버에 파일 없음 --</option>';
return;
}
sel.innerHTML = data.files.map(f =>
`<option value="${esc(f.fileName)}">${esc(f.fileName)} (${(f.fileSize / 1024).toFixed(0)} KB)</option>`
sel.innerHTML = data.Files.map(f =>
`<option value="${esc(f.FileName)}">${esc(f.FileName)} (${(f.FileSize / 1024).toFixed(0)} KB)</option>`
).join('');
if (selectFileName) sel.value = selectFileName;
@@ -120,19 +120,19 @@ async function pidExtract() {
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
const err = await res.json().catch(() => ({ Error: res.statusText }));
throw new Error(err.Error || res.statusText);
}
const data = await res.json();
pidExtracting = false;
const skipMsg = data.skippedDuplicates > 0 ? ` (${data.skippedDuplicates}건 중복 제외)` : '';
if (statusEl) statusEl.textContent = `✅ 추출 완료: ${data.totalCount}${skipMsg}`;
const skipMsg = data.SkippedDuplicates > 0 ? ` (${data.SkippedDuplicates}건 중복 제외)` : '';
if (statusEl) statusEl.textContent = `✅ 추출 완료: ${data.TotalCount}${skipMsg}`;
log('pid-log', [
{ c: 'ok', t: `✅ 추출 완료: ${data.totalCount}${skipMsg}` },
{ c: 'inf', t: ` 신뢰도 70%+: ${data.confidenceItems}` },
{ c: 'inf', t: ` 신뢰도 50%미만: ${data.lowConfidenceItems}` },
...(data.skippedDuplicates > 0 ? [{ c: 'warn', t: ` 중복 스킵: ${data.skippedDuplicates}` }] : [])
{ c: 'ok', t: `✅ 추출 완료: ${data.TotalCount}${skipMsg}` },
{ c: 'inf', t: ` 신뢰도 70%+: ${data.ConfidenceItems}` },
{ c: 'inf', t: ` 신뢰도 50%미만: ${data.LowConfidenceItems}` },
...(data.SkippedDuplicates > 0 ? [{ c: 'warn', t: ` 중복 스킵: ${data.SkippedDuplicates}` }] : [])
]);
pidCurrentPage = 1;
@@ -189,7 +189,7 @@ async function pidLoadTable(page = 1) {
const data = await res.json();
pidLastResult = data;
if (!data.items || data.items.length === 0) {
if (!data.Items || data.Items.length === 0) {
tbody.innerHTML = '<tr><td colspan="14" style="text-align:center;padding:20px">데이터가 없습니다</td></tr>';
document.getElementById('pid-pagination').innerHTML = '';
return;
@@ -200,48 +200,48 @@ async function pidLoadTable(page = 1) {
// 현재 카테고리 → 가상 키 변환 (instrument + tagDcs 조합)
function pidVcat(item) {
if (item.category === 'instrument') return item.tagDcs ? 'instrument_dcs' : 'instrument_field';
return item.category || '';
if (item.Category === 'instrument') return item.TagDcs ? 'instrument_dcs' : 'instrument_field';
return item.Category || '';
}
tbody.innerHTML = data.items.map(item => {
tbody.innerHTML = data.Items.map(item => {
const vcat = pidVcat(item);
const catOpts = CATEGORY_ORDER.map(k =>
`<option value="${k}" ${vcat === k ? 'selected' : ''}>${CATEGORY_META[k]?.label || k}</option>`
).join('');
return `<tr>
<td>${item.id}</td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="tagNo" value="${esc((item.tagName||'').toUpperCase())}" style="width:100%;font-family:var(--mono)"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="equipmentName" value="${esc(item.equipmentName) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="instrumentType" value="${esc((item.instrumentType||'').toUpperCase())}" style="width:100%"/></td>
<td>${item.Id}</td>
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="tagNo" value="${esc((item.TagNo||'').toUpperCase())}" style="width:100%;font-family:var(--mono)"/></td>
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="equipmentName" value="${esc(item.EquipmentName) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="instrumentType" value="${esc((item.InstrumentType||'').toUpperCase())}" style="width:100%"/></td>
<td>
<select class="inp pid-edit-vcat" data-id="${item.id}" style="width:100%">
<select class="inp pid-edit-vcat" data-id="${item.Id}" style="width:100%">
<option value="">-</option>
${catOpts}
</select>
</td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="role" value="${esc(item.role) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="fromTag" value="${esc(item.fromTag) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="fromAt" value="${esc(item.fromAt) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="toTag" value="${esc(item.toTag) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="toAt" value="${esc(item.toAt) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="subArea" value="${esc(item.subArea) || ''}" placeholder="예: P9-1" style="width:100%;font-family:var(--mono)"/></td>
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="role" value="${esc(item.Role) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="fromTag" value="${esc(item.FromTag) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="fromAt" value="${esc(item.FromAt) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="toTag" value="${esc(item.ToTag) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="toAt" value="${esc(item.ToAt) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.Id}" data-field="subArea" value="${esc(item.SubArea) || ''}" placeholder="예: P9-1" style="width:100%;font-family:var(--mono)"/></td>
<td>
${item.experionTagId
? `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id}, this)">✅</button>`
: `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id}, this)">매핑</button>`
${item.ExperionTagId
? `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.Id}, this)">✅</button>`
: `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.Id}, this)">매핑</button>`
}
</td>
<td style="text-align:center">
<button class="btn-sm btn-a" onclick="pidSaveRow(${item.id})">💾</button>
<button class="btn-sm btn-a" onclick="pidSaveRow(${item.Id})">💾</button>
</td>
<td style="text-align:center">
<button class="btn-sm btn-b" onclick="pidDeleteRow(${item.id})" title="삭제">✕</button>
<button class="btn-sm btn-b" onclick="pidDeleteRow(${item.Id})" title="삭제">✕</button>
</td>
</tr>`;
}).join('');
pidRenderPagination(data.total, page);
pidRenderPagination(data.Total, page);
} catch (e) {
tbody.innerHTML = `<tr><td colspan="14" style="text-align:center;padding:20px;color:var(--red)">오류: ${e.message}</td></tr>`;
}
@@ -331,8 +331,8 @@ async function pidCreateRow(btn) {
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
const err = await res.json().catch(() => ({ Error: res.statusText }));
throw new Error(err.Error || res.statusText);
}
row.remove();
await pidLoadTable(pidCurrentPage);
@@ -371,7 +371,7 @@ async function pidDeleteRow(id) {
const res = await fetch(`/api/pid/${id}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || res.statusText);
throw new Error(err.Error || res.statusText);
}
await pidLoadTable(pidCurrentPage);
pidUpdateStats();
@@ -381,11 +381,11 @@ async function pidDeleteRow(id) {
}
function pidUpdateStats() {
if (!pidLastResult || !pidLastResult.items) return;
if (!pidLastResult || !pidLastResult.Items) return;
const total = pidLastResult.total || 0;
const highConf = pidLastResult.items.filter(i => i.confidence >= 0.7).length;
const mapped = pidLastResult.items.filter(i => i.experionTagId).length;
const total = pidLastResult.Total || 0;
const highConf = pidLastResult.Items.filter(i => i.Confidence >= 0.7).length;
const mapped = pidLastResult.Items.filter(i => i.ExperionTagId).length;
const elTotal = document.getElementById('pid-stat-total');
const elHigh = document.getElementById('pid-stat-high');
@@ -424,13 +424,13 @@ async function pidAnalyzeConnections() {
body: JSON.stringify({ fileName })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
const err = await res.json().catch(() => ({ Error: res.statusText }));
throw new Error(err.Error || res.statusText);
}
const data = await res.json();
if (statusEl) statusEl.textContent = `✅ 연결 분석 완료: ${data.connectionCount}`;
if (statusEl) statusEl.textContent = `✅ 연결 분석 완료: ${data.ConnectionCount}`;
log('pid-log', [
{ c: 'ok', t: `✅ 연결 분석 완료: ${data.connectionCount}개 from→to 연결` }
{ c: 'ok', t: `✅ 연결 분석 완료: ${data.ConnectionCount}개 from→to 연결` }
]);
pidCurrentPage = 1;
await pidLoadTable();
@@ -457,8 +457,8 @@ async function pidDeleteAll() {
const res = await fetch('/api/pid/all', { method: 'DELETE' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (statusEl) statusEl.textContent = `🗑 ${data.message}`;
log('pid-log', [{ c: 'warn', t: `🗑 전체 삭제 완료: ${data.deletedCount}` }]);
if (statusEl) statusEl.textContent = `🗑 ${data.Message}`;
log('pid-log', [{ c: 'warn', t: `🗑 전체 삭제 완료: ${data.DeletedCount}` }]);
pidCurrentPage = 1;
await pidLoadTable();
pidUpdateStats();
@@ -490,7 +490,7 @@ const CATEGORY_META = {
process_equipment:{ label: 'Process Equipment', badge: '', dbCat: 'process_equipment', tagDcs: false },
utility_equipment:{ label: 'Utility Equipment', badge: 'warn', dbCat: 'utility_equipment', tagDcs: false },
pipings: { label: 'Pipings', badge: 'ok', dbCat: 'pipings', tagDcs: false },
// ── 장비 테이블 배지용 (equipment 목록의 r.category 값과 매핑) ──
// ── 장비 테이블 배지용 (equipment 목록의 r.Category 값과 매핑) ──
instrument: { label: 'Instrument', badge: 'ok' },
};
const CATEGORY_ORDER = [
@@ -511,7 +511,7 @@ async function pidRefreshPrefixRules() {
const res = await fetch('/api/pid/prefix-rules');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const items = data.items || [];
const items = data.Items || [];
if (items.length === 0) {
container.innerHTML = '<div style="text-align:center;padding:12px;color:var(--t2)">규칙이 없습니다. 아래에서 추가하세요.</div>';
@@ -521,8 +521,8 @@ async function pidRefreshPrefixRules() {
// instrument → tag_dcs 기준으로 가상 분할
const grouped = {};
for (const r of items) {
let key = r.category;
if (r.category === 'instrument') {
let key = r.Category;
if (r.Category === 'instrument') {
key = r.tagDcs ? 'instrument_dcs' : 'instrument_field';
}
if (!grouped[key]) grouped[key] = [];
@@ -550,11 +550,11 @@ async function pidRefreshPrefixRules() {
if (rules) {
for (const r of rules) {
html += `<div class="pid-cat-row" data-sort-order="${r.sortOrder}">
<input class="inp pid-cat-prefix-input" value="${esc(r.prefix)}" style="width:80px;font-family:var(--mono)" />
<input class="inp pid-cat-prefix-input" value="${esc(r.Prefix)}" style="width:80px;font-family:var(--mono)" />
<input class="inp pid-cat-desc-input" value="${esc(r.description) || ''}" style="flex:1;min-width:0" />
<span class="pid-cat-actions">
<button class="btn-sm btn-a" onclick="pidUpdatePrefixRule(${r.id}, this)">수정</button>
<button class="btn-sm btn-b" onclick="pidDeletePrefixRule(${r.id}, '${esc(r.prefix)}')">삭제</button>
<button class="btn-sm btn-a" onclick="pidUpdatePrefixRule(${r.Id}, this)">수정</button>
<button class="btn-sm btn-b" onclick="pidDeletePrefixRule(${r.Id}, '${esc(r.Prefix)}')">삭제</button>
</span>
</div>`;
}
@@ -594,8 +594,8 @@ async function pidAddPrefixRule(vcat) {
body: JSON.stringify({ prefix, category, tagDcs, description: desc, sortOrder: order })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
const err = await res.json().catch(() => ({ Error: res.statusText }));
throw new Error(err.Error || res.statusText);
}
row.querySelector('.pid-cat-prefix-input').value = '';
row.querySelector('.pid-cat-desc-input').value = '';
@@ -626,8 +626,8 @@ async function pidUpdatePrefixRule(id, btn) {
body: JSON.stringify({ prefix, category, tagDcs, description: desc, sortOrder: order })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
const err = await res.json().catch(() => ({ Error: res.statusText }));
throw new Error(err.Error || res.statusText);
}
await pidRefreshPrefixRules();
} catch (e) {
@@ -652,7 +652,7 @@ async function pidApplyCategories() {
const res = await fetch('/api/pid/apply-categories', { method: 'POST' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
alert(`${data.applied}건 category 적용 완료`);
alert(`${data.Applied}건 category 적용 완료`);
await pidLoadTable(pidCurrentPage);
pidUpdateStats();
} catch (e) {

View File

@@ -161,19 +161,19 @@ async function refreshAll() {
async function startCtrl(id) {
const r = await _setupApi('POST', '/gateway/start?id=' + encodeURIComponent(id));
_setupMsg('msg-' + id, r.success ?? r.ok, r.message ?? (r.ok ? '시작됨' : '실패'));
_setupMsg('msg-' + id, r.Success ?? r.ok, r.Message ?? (r.ok ? '시작됨' : '실패'));
setTimeout(refreshAll, 2500);
}
async function stopCtrl(id) {
const r = await _setupApi('POST', '/gateway/stop?id=' + encodeURIComponent(id));
_setupMsg('msg-' + id, r.success ?? r.ok, r.message ?? (r.ok ? '중지됨' : '실패'));
_setupMsg('msg-' + id, r.Success ?? r.ok, r.Message ?? (r.ok ? '중지됨' : '실패'));
setTimeout(refreshAll, 1000);
}
async function restartCtrl(id) {
const r = await _setupApi('POST', '/gateway/restart?id=' + encodeURIComponent(id));
_setupMsg('msg-' + id, r.success ?? r.ok, r.message ?? (r.ok ? '재시작됨' : '실패'));
_setupMsg('msg-' + id, r.Success ?? r.ok, r.Message ?? (r.ok ? '재시작됨' : '실패'));
setTimeout(refreshAll, 3000);
}
@@ -194,7 +194,7 @@ async function saveCtrl(id) {
const enEl = document.getElementById('enabled-' + id);
if (enEl) ctrl.enabled = enEl.checked;
const r = await _setupApi('POST', '/config', cfg);
_setupMsg('msg-' + id, r.success, r.message);
_setupMsg('msg-' + id, r.Success, r.Message);
}
async function deleteCtrl(id) {
@@ -204,8 +204,8 @@ async function deleteCtrl(id) {
cfg.controllers = cfg.controllers.filter(c => (c.Id ?? c.id) !== id);
if (cfg.controllers.length === before) return;
const r = await _setupApi('POST', '/config', cfg);
_setupMsg('add-msg', r.success, r.message);
if (r.success) loadAll();
_setupMsg('add-msg', r.Success, r.Message);
if (r.Success) loadAll();
}
async function saveSharedConfig() {
@@ -214,7 +214,7 @@ async function saveSharedConfig() {
cfg.shared.ldLibraryPath = document.getElementById('cfg-ld').value.trim();
cfg.shared.logDir = document.getElementById('cfg-logdir').value.trim();
const r = await _setupApi('POST', '/config', cfg);
_setupMsg('shared-msg', r.success, r.message);
_setupMsg('shared-msg', r.Success, r.Message);
}
async function addController() {
@@ -230,8 +230,8 @@ async function addController() {
enabled: true,
});
const r = await _setupApi('POST', '/config', cfg);
_setupMsg('add-msg', r.success, r.message);
if (r.success) {
_setupMsg('add-msg', r.Success, r.Message);
if (r.Success) {
loadAll();
document.getElementById('new-id').value = '';
document.getElementById('new-name').value = '';
@@ -247,26 +247,26 @@ async function refreshLog() {
const d = await _setupApi('GET', '/gateway/log?id=' + encodeURIComponent(id) + '&lines=80');
const lb = document.getElementById('log-box');
if (lb) {
lb.textContent = (d.log || []).join('\n') || '로그 없음';
lb.textContent = (d.Log || []).join('\n') || '로그 없음';
lb.scrollTop = 9999;
}
}
async function startAll() {
const r = await _setupApi('POST', '/gateway/start');
_setupMsg('global-msg', true, Array.isArray(r) ? r.map(x => `${x.Id ?? x.id}:${(x.ok ?? x.success) ? 'OK' : 'FAIL'}`).join(', ') : (r.success ? '전체 시작됨' : '실패'));
_setupMsg('global-msg', true, Array.isArray(r) ? r.map(x => `${x.Id ?? x.id}:${(x.Ok ?? x.success) ? 'OK' : 'FAIL'}`).join(', ') : (r.Success ? '전체 시작됨' : '실패'));
setTimeout(refreshAll, 2500);
}
async function stopAll() {
const r = await _setupApi('POST', '/gateway/stop');
_setupMsg('global-msg', true, Array.isArray(r) ? r.map(x => `${x.Id ?? x.id}:${(x.ok ?? x.success) ? 'OK' : 'FAIL'}`).join(', ') : (r.success ? '전체 중지됨' : '실패'));
_setupMsg('global-msg', true, Array.isArray(r) ? r.map(x => `${x.Id ?? x.id}:${(x.Ok ?? x.success) ? 'OK' : 'FAIL'}`).join(', ') : (r.Success ? '전체 중지됨' : '실패'));
setTimeout(refreshAll, 1000);
}
async function restartAll() {
const r = await _setupApi('POST', '/gateway/restart');
_setupMsg('global-msg', true, Array.isArray(r) ? r.map(x => `${x.Id ?? x.id}:${(x.ok ?? x.success) ? 'OK' : 'FAIL'}`).join(', ') : (r.success ? '전체 재시작됨' : '실패'));
_setupMsg('global-msg', true, Array.isArray(r) ? r.map(x => `${x.Id ?? x.id}:${(x.Ok ?? x.success) ? 'OK' : 'FAIL'}`).join(', ') : (r.Success ? '전체 재시작됨' : '실패'));
setTimeout(refreshAll, 3000);
}

View File

@@ -62,8 +62,8 @@ async function stLoadColumns() {
const d = await api('GET', '/api/steam/models');
const sel1 = document.getElementById('st-col');
const sel2 = document.getElementById('st-bt-col');
const cols = d.configured || d.columns || [];
const defaultCol = d.defaultColumn || 'C-6111';
const cols = d.Configured || d.Columns || [];
const defaultCol = d.DefaultColumn || 'C-6111';
[sel1, sel2].forEach(sel => {
sel.innerHTML = cols.map(c => `<option value="${c}" ${c===defaultCol?'selected':''}>${c}</option>`).join('');
});
@@ -100,8 +100,8 @@ async function stLoadProdOptions(col) {
if (!sel) return;
let data;
try { data = await api('GET', `/api/steam/productlabels/${col}`); } catch (_) { return; }
const opts = (data.clusters || []).map(c => {
const v = c.name || c.cluster; // 매핑된 이름 우선, 없으면 클러스터 라벨(P0…)
const opts = (data.Clusters || []).map(c => {
const v = c.Name || c.Cluster; // 매핑된 이름 우선, 없으면 클러스터 라벨(P0…)
return `<option value="${escHtml(v)}">${escHtml(v)}</option>`;
}).join('');
const saved = stGetSavedProduct(col);
@@ -185,18 +185,18 @@ async function stOpenProdModal() {
// 제안 목록(datalist)
const dl = document.getElementById('st-prod-suggest');
dl.innerHTML = (data.suggestions || []).map(s => `<option value="${escHtml(s.name)}">`).join('');
dl.innerHTML = (data.Suggestions || []).map(s => `<option value="${escHtml(s.Name)}">`).join('');
// 클러스터별 행 (cluster를 data 속성으로 보관)
rows.innerHTML = (data.clusters || []).map(c => `
rows.innerHTML = (data.Clusters || []).map(c => `
<tr>
<td><strong>${escHtml(c.cluster)}</strong></td>
<td class="num">${stFmt(c.rebMedian)}</td>
<td class="num">${stFmt(c.vacMedian)}</td>
<td class="num">${c.nRows ?? '—'}</td>
<td><strong>${escHtml(c.Cluster)}</strong></td>
<td class="num">${stFmt(c.RebMedian)}</td>
<td class="num">${stFmt(c.VacMedian)}</td>
<td class="num">${c.NRows ?? '—'}</td>
<td><input class="inp st-prod-name" list="st-prod-suggest"
data-cluster="${escHtml(c.cluster)}"
value="${escHtml(c.name || '')}" placeholder="${escHtml(c.cluster)} (미지정)"></td>
data-cluster="${escHtml(c.Cluster)}"
value="${escHtml(c.Name || '')}" placeholder="${escHtml(c.Cluster)} (미지정)"></td>
</tr>`).join('') || '<tr><td colspan="5" style="color:#789">클러스터 없음</td></tr>';
}
@@ -208,13 +208,13 @@ async function stSaveProdLabels() {
const col = document.getElementById('st-temp-col').value;
const msg = document.getElementById('st-prod-modal-msg');
const entries = [...document.querySelectorAll('#st-prod-rows .st-prod-name')].map(inp => ({
cluster: inp.dataset.cluster,
name: inp.value.trim(),
Cluster: inp.dataset.cluster,
Name: inp.value.trim(),
}));
msg.textContent = '저장 중…'; msg.className = 'st-modal-msg';
try {
const r = await api('POST', `/api/steam/productlabels/${col}`, entries);
msg.textContent = `저장됨 (${r.saved}개 지정)`; msg.className = 'st-modal-msg ok';
msg.textContent = `저장됨 (${r.Saved}개 지정)`; msg.className = 'st-modal-msg ok';
setTimeout(() => { stCloseProdModal(); stTempLoad(); }, 600); // 저장 후 재조회로 즉시 반영
} catch (e) {
msg.textContent = '저장 실패: ' + e.message; msg.className = 'st-modal-msg err';
@@ -241,10 +241,10 @@ async function stTempLoad() {
// 제품 선택 드롭다운 옵션을 API 응답(products)으로 채움. 사용자 선택은 유지.
function stUpdateProdDropdown(resp) {
const sel = document.getElementById('st-temp-prod-sel');
if (!sel || !resp || !Array.isArray(resp.products)) return;
if (!sel || !resp || !Array.isArray(resp.Products)) return;
const prev = stTempSelectedProduct;
const opts = resp.products.map(p => {
const v = p.name || p.label;
const opts = resp.Products.map(p => {
const v = p.Name || p.Label;
return `<option value="${escHtml(v)}">${escHtml(v)}</option>`;
}).join('');
sel.innerHTML = `<option value="">제품 선택…</option>` + opts;
@@ -298,7 +298,7 @@ async function stTempHistLoad() {
try {
const h = await api('GET', url);
console.log('[steam] hist response:', JSON.stringify(h).slice(0, 300));
stTempSnapshots = h && h.snapshots || [];
stTempSnapshots = h && h.Snapshots || [];
} catch (e) {
console.warn('[steam] hist load fail:', e);
stTempSnapshots = [];
@@ -308,11 +308,11 @@ async function stTempHistLoad() {
if (n >= 2) {
slider.max = (n - 1).toString();
slider.value = (n - 1).toString();
document.getElementById('st-scrub-from').textContent = stFmtDT(stTempSnapshots[0].ts);
document.getElementById('st-scrub-to').textContent = stFmtDT(stTempSnapshots[n - 1].ts);
document.getElementById('st-scrub-from').textContent = stFmtDT(stTempSnapshots[0].Ts);
document.getElementById('st-scrub-to').textContent = stFmtDT(stTempSnapshots[n - 1].Ts);
} else {
slider.max = '0'; slider.value = '0';
document.getElementById('st-scrub-ts').textContent = n === 0 ? '히스토리 없음' : stFmtDT(stTempSnapshots[0].ts);
document.getElementById('st-scrub-ts').textContent = n === 0 ? '히스토리 없음' : stFmtDT(stTempSnapshots[0].Ts);
document.getElementById('st-scrub-from').textContent = '—';
document.getElementById('st-scrub-to').textContent = '—';
}
@@ -332,7 +332,7 @@ function stTempScrub() {
return;
}
stTempIsLive = false;
document.getElementById('st-scrub-ts').textContent = stFmtDT(snap.ts);
document.getElementById('st-scrub-ts').textContent = stFmtDT(snap.Ts);
stRenderTemp(snap);
}
@@ -343,9 +343,9 @@ function stTempUpdateBadges(src) {
// 제품명은 드롭다운이 표시 — 별도 배지 없음 (중복 제거)
const vb = document.getElementById('st-temp-vac');
vb.textContent = `진공 ${stFmt(s.vacuum.current)} (기준 ${stFmt(s.vacuum.refMedian)})` + (s.vacuum.deviated ? ' ⚠이격' : '');
vb.style.background = s.vacuum.deviated ? '#3a1a1a' : '#1a1a3a';
vb.style.color = s.vacuum.deviated ? '#f66' : '#66f';
vb.textContent = `진공 ${stFmt(s.Vacuum.Current)} (기준 ${stFmt(s.Vacuum.RefMedian)})` + (s.Vacuum.Deviated ? ' ⚠이격' : '');
vb.style.background = s.Vacuum.Deviated ? '#3a1a1a' : '#1a1a3a';
vb.style.color = s.Vacuum.Deviated ? '#f66' : '#66f';
}
let stRenderTempCol = '';
@@ -363,13 +363,13 @@ function stRenderTemp(snap) {
stTempUpdateBadges(snapSrc);
stRenderTempCol = document.getElementById('st-temp-col').value;
const stages = stTempLive.stages;
const stages = stTempLive.Stages;
if (!stages || !stages.length) return;
const cats = stages.map(s => ST_STAGE_LABEL[s.stage] || s.stage);
const lo = stages.map(s => (s.refMedian != null && s.refStd != null) ? +(s.refMedian - 2 * s.refStd).toFixed(2) : null);
const band = stages.map(s => s.refStd != null ? +(4 * s.refStd).toFixed(2) : null);
const med = stages.map(s => s.refMedian);
const cats = stages.map(s => ST_STAGE_LABEL[s.Stage] || s.Stage);
const lo = stages.map(s => (s.RefMedian != null && s.RefStd != null) ? +(s.RefMedian - 2 * s.RefStd).toFixed(2) : null);
const band = stages.map(s => s.RefStd != null ? +(4 * s.RefStd).toFixed(2) : null);
const med = stages.map(s => s.RefMedian);
const col = stRenderTempCol;
const el = document.getElementById('st-chart-temp');
@@ -385,13 +385,13 @@ function stRenderTemp(snap) {
];
if (snap) {
const curD = stTempLive.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#6cf' } }));
const snapD = snapSrc.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#fa3' } }));
const curD = stTempLive.Stages.map(s => ({ value: s.Current, itemStyle: { color: s.Deviated ? '#f66' : '#6cf' } }));
const snapD = snapSrc.Stages.map(s => ({ value: s.Current, itemStyle: { color: s.Deviated ? '#f66' : '#fa3' } }));
series.push({ name: '실시간참조', type: 'line', data: curD, lineStyle: { color: '#6cf', width: 1, type: 'dashed' }, symbol: 'none', silent: true });
series.push({ name: '선택시점', type: 'line', data: snapD, lineStyle: { color: '#fa3', width: 2.5 }, symbol: 'circle', symbolSize: 9 });
leg.push('실시간참조', '선택시점');
} else {
const curD = stTempLive.stages.map(s => ({ value: s.current, itemStyle: { color: s.deviated ? '#f66' : '#6cf' } }));
const curD = stTempLive.Stages.map(s => ({ value: s.Current, itemStyle: { color: s.Deviated ? '#f66' : '#6cf' } }));
series.push({ name: '실시간', type: 'line', data: curD, lineStyle: { color: '#6cf', width: 2.5 }, symbol: 'circle', symbolSize: 9 });
leg.push('실시간');
}
@@ -399,12 +399,12 @@ function stRenderTemp(snap) {
chart.setOption({
backgroundColor: 'transparent',
title: {
text: snap ? `${col} · ${stFmtDT(snap.ts)}` : `${col} 단면 온도 프로파일`,
text: snap ? `${col} · ${stFmtDT(snap.Ts)}` : `${col} 단면 온도 프로파일`,
subtext: snap
? `기준제품 ${snap.matchedProduct || '미선택'} · 진공 ${stFmt(snap.vacuum?.current)} (기준 ${stFmt(snap.vacuum?.refMedian)})${snap.vacuum?.deviated ? ' ⚠' : ''}`
: `기준제품 ${stTempLive.matchedProduct || '미선택'} · 진공 ${stFmt(stTempLive.vacuum.current)} (기준 ${stFmt(stTempLive.vacuum.refMedian)})${stTempLive.vacuum.deviated ? ' ⚠' : ''}`,
? `기준제품 ${snap.MatchedProduct || '미선택'} · 진공 ${stFmt(snap.Vacuum?.Current)} (기준 ${stFmt(snap.Vacuum?.RefMedian)})${snap.Vacuum?.Deviated ? ' ⚠' : ''}`
: `기준제품 ${stTempLive.MatchedProduct || '미선택'} · 진공 ${stFmt(stTempLive.Vacuum.Current)} (기준 ${stFmt(stTempLive.Vacuum.RefMedian)})${stTempLive.Vacuum.Deviated ? ' ⚠' : ''}`,
textStyle: { color: '#ccc', fontSize: 13 },
subtextStyle: { color: snap ? (snap.vacuum?.deviated ? '#f66' : '#888') : (stTempLive.vacuum.deviated ? '#f66' : '#888'), fontSize: 11 } },
subtextStyle: { color: snap ? (snap.Vacuum?.Deviated ? '#f66' : '#888') : (stTempLive.Vacuum.Deviated ? '#f66' : '#888'), fontSize: 11 } },
tooltip: { trigger: 'axis' },
legend: { data: leg, textStyle: { color: '#888' }, top: 6, right: 10 },
grid: { top: 64, left: 48, right: 20, bottom: 28 },
@@ -420,41 +420,41 @@ function stRenderTemp(snap) {
// 왼쪽 표: 온도
// 표는 D(탑)→C→B→A(보텀) 순서로 표시 (차트는 A→B→C→D 유지)
let stgRows = snapSrc.stages.map(s => {
const label = ST_STAGE_LABEL[s.stage] || s.stage;
const cur = stFmt(s.current) + '℃';
const ref = s.refMedian != null ? `${stFmt(s.refMedian)}±${stFmt(s.refStd)}` : '—';
let stgRows = snapSrc.Stages.map(s => {
const label = ST_STAGE_LABEL[s.Stage] || s.Stage;
const cur = stFmt(s.Current) + '℃';
const ref = s.RefMedian != null ? `${stFmt(s.RefMedian)}±${stFmt(s.RefStd)}` : '—';
return `<tr><td>${label}</td><td>${cur}</td><td>${ref}</td></tr>`;
}).reverse();
if (snapSrc.spanAD != null) {
stgRows.push(`<tr><td>ΔT(A-D)</td><td>${stFmt(snapSrc.spanAD)}℃</td><td>—</td></tr>`);
if (snapSrc.SpanAD != null) {
stgRows.push(`<tr><td>ΔT(A-D)</td><td>${stFmt(snapSrc.SpanAD)}℃</td><td>—</td></tr>`);
}
if (snapSrc.vacuum) {
const v = snapSrc.vacuum;
const cur = stFmt(v.current);
const ref = v.refMedian != null ? `${stFmt(v.refMedian)}±${stFmt(v.refStd)}` : '—';
if (snapSrc.Vacuum) {
const v = snapSrc.Vacuum;
const cur = stFmt(v.Current);
const ref = v.RefMedian != null ? `${stFmt(v.RefMedian)}±${stFmt(v.RefStd)}` : '—';
stgRows.push(`<tr><td>진공</td><td>${cur}</td><td>${ref}</td></tr>`);
}
// 오른쪽 표: 유량
let flowHtml = '';
if (snapSrc.flow) {
const f = snapSrc.flow;
const ft = snapSrc.flowTags || {};
if (snapSrc.Flow) {
const f = snapSrc.Flow;
const ft = snapSrc.FlowTags || {};
const flowHeaders = [
'<th></th>',
`<th>FEED<br><small>${escHtml((ft.feed || 'FICQ-??01.PV').replace('.PV',''))}</small></th>`,
`<th>REFLUX<br><small>${escHtml((ft.reflux || 'FICQ-??13.PV').replace('.PV',''))}</small></th>`,
`<th>제품추출<br><small>${escHtml((ft.product || 'FICQ-??18.PV').replace('.PV',''))}</small></th>`,
`<th>경비물<br><small>${escHtml((ft.overhead || 'FICQ-??14.PV').replace('.PV',''))}</small></th>`,
`<th>중비물<br><small>${escHtml((ft.bottom || 'FICQ-??16.PV').replace('.PV',''))}</small></th>`,
`<th>스팀<br><small>${escHtml((ft.steam || 'FIQ-???15.PV').replace('.PV',''))}</small></th>`,
`<th>FEED<br><small>${escHtml((ft.Feed || 'FICQ-??01.PV').replace('.PV',''))}</small></th>`,
`<th>REFLUX<br><small>${escHtml((ft.Reflux || 'FICQ-??13.PV').replace('.PV',''))}</small></th>`,
`<th>제품추출<br><small>${escHtml((ft.Product || 'FICQ-??18.PV').replace('.PV',''))}</small></th>`,
`<th>경비물<br><small>${escHtml((ft.Overhead || 'FICQ-??14.PV').replace('.PV',''))}</small></th>`,
`<th>중비물<br><small>${escHtml((ft.Bottom || 'FICQ-??16.PV').replace('.PV',''))}</small></th>`,
`<th>스팀<br><small>${escHtml((ft.Steam || 'FIQ-???15.PV').replace('.PV',''))}</small></th>`,
].join('');
const stPv = stFmt(f.steam?.pv);
const stPv = stFmt(f.Steam?.Pv);
const flowRows = [
`<tr><td>PV</td><td>${stFmt(f.feed?.pv)}</td><td>${stFmt(f.reflux?.pv)}</td><td>${stFmt(f.product?.pv)}</td><td>${stFmt(f.overhead?.pv)}</td><td>${stFmt(f.bottom?.pv)}</td><td>${stPv}</td></tr>`,
`<tr><td>SP</td><td>${stFmt(f.feed?.sp)}</td><td>${stFmt(f.reflux?.sp)}</td><td>${stFmt(f.product?.sp)}</td><td>${stFmt(f.overhead?.sp)}</td><td>${stFmt(f.bottom?.sp)}</td><td>—</td></tr>`,
`<tr><td>OP</td><td>${stFmt(f.feed?.op)}</td><td>${stFmt(f.reflux?.op)}</td><td>${stFmt(f.product?.op)}</td><td>${stFmt(f.overhead?.op)}</td><td>${stFmt(f.bottom?.op)}</td><td>—</td></tr>`,
`<tr><td>PV</td><td>${stFmt(f.Feed?.Pv)}</td><td>${stFmt(f.Reflux?.Pv)}</td><td>${stFmt(f.Product?.Pv)}</td><td>${stFmt(f.Overhead?.Pv)}</td><td>${stFmt(f.Bottom?.Pv)}</td><td>${stPv}</td></tr>`,
`<tr><td>SP</td><td>${stFmt(f.Feed?.Sp)}</td><td>${stFmt(f.Reflux?.Sp)}</td><td>${stFmt(f.Product?.Sp)}</td><td>${stFmt(f.Overhead?.Sp)}</td><td>${stFmt(f.Bottom?.Sp)}</td><td>—</td></tr>`,
`<tr><td>OP</td><td>${stFmt(f.Feed?.Op)}</td><td>${stFmt(f.Reflux?.Op)}</td><td>${stFmt(f.Product?.Op)}</td><td>${stFmt(f.Overhead?.Op)}</td><td>${stFmt(f.Bottom?.Op)}</td><td>—</td></tr>`,
].join('');
flowHtml = `<table class="st-meta-flow"><thead><tr>${flowHeaders}</tr></thead><tbody>${flowRows}</tbody></table>`;
}
@@ -481,8 +481,8 @@ async function stLiveTick() {
const col = document.getElementById('st-col').value;
try {
const d = await api('GET', `/api/steam/live?col=${col}`);
if (d.status === 'missing_tags') {
document.getElementById('st-live-msg').textContent = `⚠ 태그 없음: ${d.missing?.join(', ') || '—'} (${d.message || '게이트웨이 폴링 확인'})`;
if (d.Status === 'missing_tags') {
document.getElementById('st-live-msg').textContent = `⚠ 태그 없음: ${d.Missing?.join(', ') || '—'} (${d.Message || '게이트웨이 폴링 확인'})`;
return;
}
stUpdateLive(d);
@@ -492,35 +492,35 @@ async function stLiveTick() {
}
function stUpdateLive(d) {
document.getElementById('st-mode').textContent = d.mode || '—';
document.getElementById('st-conf').textContent = d.confidence || '—';
document.getElementById('st-mode').textContent = d.Mode || '—';
document.getElementById('st-conf').textContent = d.Confidence || '—';
const oodEl = document.getElementById('st-ood');
if (d.ood) { oodEl.style.display = 'inline-block'; } else { oodEl.style.display = 'none'; }
if (d.Ood) { oodEl.style.display = 'inline-block'; } else { oodEl.style.display = 'none'; }
document.getElementById('st-op-rec').textContent = d.recOp != null ? d.recOp.toFixed(1) : '—';
document.getElementById('st-op-act').textContent = d.actualOp != null ? d.actualOp.toFixed(1) : '—';
document.getElementById('st-op-rec').textContent = d.RecOp != null ? d.RecOp.toFixed(1) : '—';
document.getElementById('st-op-act').textContent = d.ActualOp != null ? d.ActualOp.toFixed(1) : '—';
const opTag = document.getElementById('st-op-act-tag');
if (opTag) opTag.textContent = (d.Tags?.steamOp || '') + (d.Descs?.steamOp ? ' · ' + d.Descs.steamOp : '');
if (opTag) opTag.textContent = (d.Tags?.SteamOp || '') + (d.Descs?.SteamOp ? ' · ' + d.Descs.SteamOp : '');
// 게이지
const fill = document.getElementById('st-gauge-fill');
if (d.recOp != null) fill.style.width = Math.min(100, Math.max(0, d.recOp)) + '%';
if (d.RecOp != null) fill.style.width = Math.min(100, Math.max(0, d.RecOp)) + '%';
// 입력값 + 태그명 + 설명
document.getElementById('st-live-feed').textContent = d.feed != null ? d.feed.toFixed(1) : '—';
document.getElementById('st-live-feed-tag').textContent = (d.Tags?.feed || '') + (d.Descs?.feed ? ' · ' + d.Descs.feed : '');
document.getElementById('st-live-product').textContent = d.product != null ? d.product.toFixed(1) : '—';
document.getElementById('st-live-product-tag').textContent = (d.Tags?.product || '') + (d.Descs?.product ? ' · ' + d.Descs.product : '');
document.getElementById('st-live-tc').textContent = d.tC != null ? d.tC.toFixed(1) : '—';
document.getElementById('st-live-tc-tag').textContent = (d.Tags?.tC || '') + (d.Descs?.tC ? ' · ' + d.Descs.tC : '');
document.getElementById('st-live-sf').textContent = d.actualSteamFlow != null ? d.actualSteamFlow.toFixed(1) : '—';
document.getElementById('st-live-sf-tag').textContent = (d.Tags?.steamFlow || '') + (d.Descs?.steamFlow ? ' · ' + d.Descs.steamFlow : '');
document.getElementById('st-live-feed').textContent = d.Feed != null ? d.Feed.toFixed(1) : '—';
document.getElementById('st-live-feed-tag').textContent = (d.Tags?.Feed || '') + (d.Descs?.Feed ? ' · ' + d.Descs.Feed : '');
document.getElementById('st-live-product').textContent = d.Product != null ? d.Product.toFixed(1) : '—';
document.getElementById('st-live-product-tag').textContent = (d.Tags?.Product || '') + (d.Descs?.Product ? ' · ' + d.Descs.Product : '');
document.getElementById('st-live-tc').textContent = d.TC != null ? d.TC.toFixed(1) : '—';
document.getElementById('st-live-tc-tag').textContent = (d.Tags?.TC || '') + (d.Descs?.TC ? ' · ' + d.Descs.TC : '');
document.getElementById('st-live-sf').textContent = d.ActualSteamFlow != null ? d.ActualSteamFlow.toFixed(1) : '—';
document.getElementById('st-live-sf-tag').textContent = (d.Tags?.SteamFlow || '') + (d.Descs?.SteamFlow ? ' · ' + d.Descs.SteamFlow : '');
document.getElementById('st-live-msg').textContent = d.message || '';
document.getElementById('st-live-msg').textContent = d.Message || '';
// uPlot data
const now = Date.now() / 1000;
stLiveData.push({ ts: now, recOp: d.recOp, actOp: d.actualOp });
stLiveData.push({ ts: now, recOp: d.RecOp, actOp: d.ActualOp });
if (stLiveData.length > ST_MAX_POINTS) stLiveData.splice(0, stLiveData.length - ST_MAX_POINTS);
stUpdateUplot();
}

View File

@@ -170,7 +170,7 @@ async function trLoadLimits() {
try {
const d = await api('GET', `/api/trend/limits?tags=${encodeURIComponent(tags.join(','))}`);
trState.cache.limits = {};
for (const l of (d.items || [])) trState.cache.limits[l.tag] = l;
for (const l of (d.Items || [])) trState.cache.limits[l.Tag] = l;
} catch (e) { console.error('trLoadLimits:', e); trState.cache.limits = {}; }
}
@@ -183,8 +183,8 @@ async function trLoadRunbands() {
p.set('from', new Date(from).toISOString());
p.set('to', new Date(to).toISOString());
const d = await api('GET', `/api/trend/runbands?${p}`);
trState.cache.runbands = (d.items || []).map(b => ({
tag: b.tag, t0: new Date(b.t0).getTime(), t1: new Date(b.t1).getTime(), state: b.state
trState.cache.runbands = (d.Items || []).map(b => ({
Tag: b.Tag, T0: new Date(b.T0).getTime(), T1: new Date(b.T1).getTime(), State: b.State
}));
} catch (e) { console.error('trLoadRunbands:', e); trState.cache.runbands = []; }
}
@@ -337,7 +337,7 @@ async function trQuery() {
if (from) body.from = new Date(from).toISOString();
if (to) body.to = new Date(to).toISOString();
const d = await api('POST', '/api/history/query', body);
const raw = d.rows || d.Rows || [];
const raw = d.Rows || [];
const rows = raw.map(r => ({
recordedAt: r.recordedAt ?? r.RecordedAt,
values: r.values ?? r.Values
@@ -355,7 +355,7 @@ async function trQuery() {
to: to ? new Date(to).toISOString() : null,
interval, limit: 5000
});
rows = (d && d.success !== false) ? (d.rows || []) : [];
rows = (d && d.Success !== false) ? d.Rows : [];
tk = rows[0]?.timeBucket != null ? 'timeBucket' : 'RecordedAt';
// 집계가 0행이면(엔드포인트 이슈 등) 원시로 폴백 → 차트 공백 방지
if (!rows.length) { const r = await rawQuery(); rows = r.rows; tk = r.tk; }
@@ -650,11 +650,11 @@ async function trLiveTick() {
try {
const d = await api('GET', `/api/trend/live?tags=${encodeURIComponent(tags.join(','))}`);
let changed = false;
for (const pt of (d.items || [])) {
if (pt.value == null) continue;
trState.liveNow[pt.tag] = +pt.value; // 현재값은 매 틱 갱신(타임스탬프 동일해도)
const arr = trState.seriesData[pt.tag]; if (!arr) continue;
const ms = new Date(pt.ts).getTime();
for (const pt of (d.Items || [])) {
if (pt.Value == null) continue;
trState.liveNow[pt.Tag] = +pt.Value;
const arr = trState.seriesData[pt.Tag]; if (!arr) continue;
const ms = new Date(pt.Ts).getTime();
if (!isFinite(ms)) continue;
if (!arr.length || arr[arr.length - 1][0] !== ms) { arr.push([ms, +pt.value]); changed = true; }
}
@@ -671,9 +671,9 @@ function trAnalyze() { document.getElementById('tr-ins-body').textContent = 'P3
async function trEnsureAnalogMap() {
if (trAnalogMap) return trAnalogMap;
const d = await api('GET', '/api/trend/analog-points');
trAnalogAll = d.items || [];
trAnalogAll = d.Items || [];
trAnalogMap = {};
for (const p of trAnalogAll) trAnalogMap[p.tagName] = { desc: p.description, unit: p.unit, euLo: p.euLo, euHi: p.euHi };
for (const p of trAnalogAll) trAnalogMap[p.TagName] = { Desc: p.Desc, Unit: p.Unit, EuLo: p.EuLo, EuHi: p.EuHi };
return trAnalogMap;
}
@@ -740,7 +740,7 @@ async function trLoadGroupList() {
const sel = document.getElementById('tr-group-select');
const cur = sel.value;
sel.innerHTML = '<option value="">— 그룹 선택 —</option>' +
(d.items || []).map(g => `<option value="${g.id}">${esc(g.name)} (${(g.members || []).length})</option>`).join('');
(d.Items || []).map(g => `<option value="${g.Id}">${esc(g.Name)} (${(g.Members || []).length})</option>`).join('');
if (cur) sel.value = cur;
} catch (e) { console.error('trLoadGroupList:', e); }
}

View File

@@ -30,7 +30,7 @@ async function wrWriteTag() {
try {
const d = await api('POST', '/api/gateway/write', { controllerId, tagName, value });
log('wr-log', [
{ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + 'Write ' + esc(tagName) + ' = ' + value + (d.error ? ' → ' + esc(d.error) : '') },
{ c: d.Success ? 'ok' : 'err', t: (d.Success ? '✅ ' : '❌ ') + 'Write ' + esc(tagName) + ' = ' + value + (d.Error ? ' → ' + esc(d.Error) : '') },
]);
} catch (e) {
log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]);
@@ -53,7 +53,7 @@ async function wrSetMode() {
try {
const d = await api('POST', '/api/gateway/write', { controllerId, tagName: tagName + '.' + modeTag, value: writeValue });
log('wr-log', [
{ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + 'Mode ' + esc(tagName) + ' → ' + mode + ' (' + modeTag + ')' + (d.error ? ' → ' + esc(d.error) : '') },
{ c: d.Success ? 'ok' : 'err', t: (d.Success ? '✅ ' : '❌ ') + 'Mode ' + esc(tagName) + ' → ' + mode + ' (' + modeTag + ')' + (d.Error ? ' → ' + esc(d.Error) : '') },
]);
} catch (e) {
log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]);
@@ -71,7 +71,7 @@ async function wrControlOp() {
try {
const d = await api('POST', '/api/gateway/write', { controllerId, tagName, value: opValue });
log('wr-log', [
{ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + '통합 제어 ' + esc(tagName) + ' OP=' + opValue + (d.error ? ' — ' + esc(d.error) : '') },
{ c: d.Success ? 'ok' : 'err', t: (d.Success ? '✅ ' : '❌ ') + '통합 제어 ' + esc(tagName) + ' OP=' + opValue + (d.Error ? ' — ' + esc(d.Error) : '') },
]);
} catch (e) {
log('wr-log', [{ c: 'err', t: '❌ ' + e.message }]);

View File

@@ -21,7 +21,7 @@ public class McpService : IMcpService
public async Task<List<McpToolDto>> ListToolsAsync()
{
var tools = await _client.ListToolsAsync();
return tools.Select(t => new McpToolDto { Name = t.Name, Description = t.Description }).ToList();
return tools.Select(t => new McpToolDto { Name = t.Name, Description = t.Description, InputSchema = t.InputSchema }).ToList();
}
public async Task<McpQueryResult> RunSqlAsync(string sql)