P0 셀프서비스 결정론 리포트 — 적산·물질수지 폐합·cleaning 마스크 (+ P1 온라인 스펙) #1
@@ -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
|
||||
|
||||
@@ -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; } = "";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "작업이 시작되었습니다."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = '파일을 선택하세요';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', '조회 실패');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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 }]);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user