P0 셀프서비스 결정론 리포트 — 적산·물질수지 폐합·cleaning 마스크 (+ P1 온라인 스펙) #1
459
src/Hc900Crawler/Controllers/PointBuilderController.cs
Normal file
459
src/Hc900Crawler/Controllers/PointBuilderController.cs
Normal file
@@ -0,0 +1,459 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Hc900Crawler.Core.Application.DTOs;
|
||||
using Hc900Crawler.Infrastructure.Database;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Hc900Crawler.Web.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/pointbuilder")]
|
||||
public class PointBuilderController : ControllerBase
|
||||
{
|
||||
private readonly Hc900DbContext _db;
|
||||
private readonly ILogger<PointBuilderController> _log;
|
||||
private readonly IConfiguration _config;
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
};
|
||||
|
||||
public PointBuilderController(Hc900DbContext db, ILogger<PointBuilderController> log, IConfiguration config)
|
||||
{
|
||||
_db = db;
|
||||
_log = log;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
private string SinamWorkingDirectory =>
|
||||
_config["Sinam:WorkingDirectory"] ?? _config["DocBrowser:Root"] ?? Directory.GetCurrentDirectory();
|
||||
|
||||
private string SinamScriptPath
|
||||
{
|
||||
get
|
||||
{
|
||||
var path = _config["Sinam:ScriptPath"] ?? "scripts/build_register_map_from_sinam.py";
|
||||
return Path.IsPathRooted(path) ? path : Path.Combine(SinamWorkingDirectory, path);
|
||||
}
|
||||
}
|
||||
|
||||
private string SinamUploadDir
|
||||
{
|
||||
get
|
||||
{
|
||||
var dir = _config["Sinam:UploadDir"] ?? "docs/uploads";
|
||||
return Path.IsPathRooted(dir) ? dir : Path.Combine(SinamWorkingDirectory, dir);
|
||||
}
|
||||
}
|
||||
|
||||
private string SinamOutputDir
|
||||
{
|
||||
get
|
||||
{
|
||||
var dir = _config["Sinam:OutputDir"] ?? "docs";
|
||||
return Path.IsPathRooted(dir) ? dir : Path.Combine(SinamWorkingDirectory, dir);
|
||||
}
|
||||
}
|
||||
|
||||
private int SinamTimeoutMs =>
|
||||
(_config.GetValue<int?>("Sinam:ProcessTimeoutSeconds") ?? 120) * 1000;
|
||||
|
||||
private static string ConvertToPsycopg2Dsn(string npgsqlConnStr)
|
||||
{
|
||||
var parts = npgsqlConnStr.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var dsnParts = new List<string>();
|
||||
string? searchPath = null;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var eq = part.IndexOf('=');
|
||||
if (eq < 0) continue;
|
||||
var key = part[..eq].Trim().ToLowerInvariant();
|
||||
var val = part[(eq + 1)..].Trim();
|
||||
|
||||
switch (key)
|
||||
{
|
||||
case "host": dsnParts.Add($"host={val}"); break;
|
||||
case "port": dsnParts.Add($"port={val}"); break;
|
||||
case "database": dsnParts.Add($"dbname={val}"); break;
|
||||
case "username": dsnParts.Add($"user={val}"); break;
|
||||
case "password": dsnParts.Add($"password={val}"); break;
|
||||
case "search path": searchPath = val; break;
|
||||
}
|
||||
}
|
||||
|
||||
if (searchPath != null)
|
||||
dsnParts.Add($"options=-csearch_path={searchPath}");
|
||||
|
||||
return string.Join(' ', dsnParts);
|
||||
}
|
||||
|
||||
private async Task<SinamParseResult> RunSinamScriptAsync(
|
||||
string filePath, string controller, bool applyDb, CancellationToken ct)
|
||||
{
|
||||
var outputPath = Path.Combine(SinamOutputDir, $"register-map-{controller.ToLowerInvariant()}.json");
|
||||
var psi = new ProcessStartInfo("python3")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = SinamWorkingDirectory,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.ArgumentList.Add(SinamScriptPath);
|
||||
psi.ArgumentList.Add("--controller");
|
||||
psi.ArgumentList.Add(controller);
|
||||
psi.ArgumentList.Add("--sinam");
|
||||
psi.ArgumentList.Add(filePath);
|
||||
psi.ArgumentList.Add("-o");
|
||||
psi.ArgumentList.Add(outputPath);
|
||||
|
||||
if (applyDb)
|
||||
{
|
||||
var connStr = _config.GetConnectionString("DefaultConnection");
|
||||
if (string.IsNullOrEmpty(connStr))
|
||||
return new SinamParseResult { Error = "DefaultConnection not configured" };
|
||||
var dsn = ConvertToPsycopg2Dsn(connStr);
|
||||
psi.ArgumentList.Add("--db-conn");
|
||||
psi.ArgumentList.Add(dsn);
|
||||
}
|
||||
|
||||
string? stdout, stderr;
|
||||
int exitCode;
|
||||
|
||||
using (var proc = new Process { StartInfo = psi })
|
||||
{
|
||||
proc.Start();
|
||||
|
||||
var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct);
|
||||
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
|
||||
|
||||
if (!proc.WaitForExit(SinamTimeoutMs))
|
||||
{
|
||||
try { proc.Kill(entireProcessTree: true); } catch { }
|
||||
return new SinamParseResult { Error = $"파싱 시간 초과 ({SinamTimeoutMs / 1000}초)" };
|
||||
}
|
||||
|
||||
exitCode = proc.ExitCode;
|
||||
stdout = await stdoutTask;
|
||||
stderr = await stderrTask;
|
||||
}
|
||||
|
||||
if (exitCode != 0)
|
||||
return new SinamParseResult { Error = stderr?.Trim() ?? $"exit code {exitCode}" };
|
||||
|
||||
// register-map JSON 읽어서 counts 추출
|
||||
var result = new SinamParseResult { Stdout = stdout };
|
||||
try
|
||||
{
|
||||
var json = System.Text.Json.JsonDocument.Parse(
|
||||
await System.IO.File.ReadAllTextAsync(outputPath, ct));
|
||||
var root = json.RootElement;
|
||||
|
||||
result.Registers = root.TryGetProperty("register_count", out var rc) ? rc.GetInt32() : 0;
|
||||
result.Archive = root.TryGetProperty("registers", out var regs)
|
||||
? regs.EnumerateArray().Count(r => r.TryGetProperty("archive", out var a) && a.GetBoolean())
|
||||
: 0;
|
||||
result.Loops = root.TryGetProperty("registers", out var regs2)
|
||||
? regs2.EnumerateArray().Select(r => r.TryGetProperty("description", out var d) ? d.GetString() : null)
|
||||
.Where(d => d != null && d.StartsWith("LOOP"))
|
||||
.Select(d => d!.Split(' ').ElementAtOrDefault(1))
|
||||
.Distinct().Count()
|
||||
: 0;
|
||||
|
||||
// register-map JSON 에 없는 태그 = 고아 → cleanup
|
||||
if (applyDb)
|
||||
{
|
||||
var validTags = root.TryGetProperty("registers", out var regs3)
|
||||
? regs3.EnumerateArray().Select(r => r.GetProperty("tag").GetString()!).ToList()
|
||||
: new List<string>();
|
||||
await OrphanCleanupAsync(controller, validTags);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "register-map JSON 읽기 실패: {Path}", outputPath);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task OrphanCleanupAsync(string controllerId, List<string> validTags)
|
||||
{
|
||||
if (validTags.Count == 0) return;
|
||||
var deleted = await _db.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"DELETE FROM hc900.hc900_map_master m WHERE m.controller_id = {controllerId} AND NOT (m.tagname = ANY({validTags.ToArray()}))");
|
||||
if (deleted > 0)
|
||||
_log.LogInformation("[Sinam] 고아 정리: {Count}개 삭제 (controller={Ctrl})", deleted, controllerId);
|
||||
}
|
||||
|
||||
public class SinamParseResult
|
||||
{
|
||||
public bool Success => Error == null;
|
||||
public string? Error { get; set; }
|
||||
public int Registers { get; set; }
|
||||
public int Archive { get; set; }
|
||||
public int Loops { get; set; }
|
||||
public string? Stdout { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet("sinam/files")]
|
||||
public IActionResult ListSinamFiles()
|
||||
{
|
||||
if (!Directory.Exists(SinamUploadDir))
|
||||
return Ok(new { success = true, files = Array.Empty<object>() });
|
||||
|
||||
var files = Directory.GetFiles(SinamUploadDir, "*.xlsx")
|
||||
.Select(f => new
|
||||
{
|
||||
path = f,
|
||||
name = Path.GetFileName(f),
|
||||
size = new FileInfo(f).Length,
|
||||
modified = System.IO.File.GetLastWriteTimeUtc(f),
|
||||
})
|
||||
.OrderByDescending(f => f.modified)
|
||||
.ToList();
|
||||
|
||||
return Ok(new { success = true, files });
|
||||
}
|
||||
|
||||
[HttpPost("sinam/upload")]
|
||||
[RequestSizeLimit(50_000_000)]
|
||||
public async Task<IActionResult> UploadSinam(IFormFile file, CancellationToken ct)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
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 파일만 업로드 가능" });
|
||||
|
||||
Directory.CreateDirectory(SinamUploadDir);
|
||||
var savePath = Path.Combine(SinamUploadDir, $"Sinam_{DateTime.UtcNow:yyyyMMdd-HHmmss}.xlsx");
|
||||
|
||||
await using (var stream = new FileStream(savePath, FileMode.Create))
|
||||
await file.CopyToAsync(stream, ct);
|
||||
|
||||
_log.LogInformation("[Sinam] 업로드: {Path} ({Size} bytes)", savePath, file.Length);
|
||||
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" });
|
||||
if (!System.IO.File.Exists(dto.File))
|
||||
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)}" });
|
||||
|
||||
var result = await RunSinamScriptAsync(dto.File, dto.Controller, dto.ApplyDb, ct);
|
||||
|
||||
if (!result.Success)
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
public class SinamParseRequest
|
||||
{
|
||||
public string File { get; set; } = "";
|
||||
public string Controller { get; set; } = "";
|
||||
public bool ApplyDb { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("preview")]
|
||||
public async Task<IActionResult> Preview([FromBody] Hc900PointBuilderBuildDto dto)
|
||||
{
|
||||
var groups = new List<(string GroupKey, Hc900PointBuilderGroupDto Group)>
|
||||
{
|
||||
("loop", dto.Loop),
|
||||
("signal", dto.Signal),
|
||||
("digital", dto.Digital),
|
||||
("variable", dto.Variable),
|
||||
("custom", dto.Custom),
|
||||
};
|
||||
|
||||
var result = await _db.Hc900PreviewRealtimeBuildAsync(groups);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("build")]
|
||||
public async Task<IActionResult> Build([FromBody] Hc900PointBuilderBuildDto dto)
|
||||
{
|
||||
var groups = new List<(string GroupKey, Hc900PointBuilderGroupDto Group)>
|
||||
{
|
||||
("loop", dto.Loop),
|
||||
("signal", dto.Signal),
|
||||
("digital", dto.Digital),
|
||||
("variable", dto.Variable),
|
||||
("custom", dto.Custom),
|
||||
};
|
||||
|
||||
var count = await _db.Hc900BuildRealtimeTableAsync(groups);
|
||||
_log.LogInformation("[PointBuilder] Build 완료: {Count}개 활성화", count);
|
||||
return Ok(new { success = true, 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개 이상 필요합니다" });
|
||||
|
||||
var count = await _db.Hc900ApplySelectedPointsAsync(dto.SelectedTagNames);
|
||||
_log.LogInformation("[PointBuilder] Apply 완료: {Count}개 활성화", count);
|
||||
return Ok(new { success = true, 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개 이상 필요합니다" });
|
||||
|
||||
var count = await _db.Hc900AppendPointsAsync(dto.SelectedTagNames);
|
||||
_log.LogInformation("[PointBuilder] Append 완료: {Count}개 추가", count);
|
||||
return Ok(new { success = true, count, message = $"{count}개 포인트 추가 완료" });
|
||||
}
|
||||
|
||||
[HttpGet("points")]
|
||||
public async Task<IActionResult> GetPoints(
|
||||
[FromQuery] bool? active = null,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string? paramType = null,
|
||||
[FromQuery] int? loopNo = null,
|
||||
[FromQuery] string? controllerId = null,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 100)
|
||||
{
|
||||
var q = _db.Hc900MapEntries.AsQueryable();
|
||||
|
||||
if (active.HasValue)
|
||||
q = q.Where(x => x.IsActive == active.Value);
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
q = q.Where(x => x.TagName.Contains(search));
|
||||
if (!string.IsNullOrEmpty(paramType))
|
||||
q = q.Where(x => x.ParamType == paramType);
|
||||
if (loopNo.HasValue)
|
||||
q = q.Where(x => x.LoopNo == loopNo.Value);
|
||||
if (!string.IsNullOrEmpty(controllerId))
|
||||
q = q.Where(x => x.ControllerId == controllerId);
|
||||
|
||||
// 전체 건수는 페이징 전에 계산. map_master는 수천 행이라 필터+페이징을 DB에서 처리해
|
||||
// 모든 행을 메모리/DOM에 올리던 문제를 막는다.
|
||||
var total = await q.CountAsync();
|
||||
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1) pageSize = 100;
|
||||
if (pageSize > 1000) pageSize = 1000;
|
||||
var totalPages = (int)Math.Ceiling(total / (double)pageSize);
|
||||
|
||||
var entries = await q.OrderBy(x => x.TagName)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
var tagNames = entries.Select(e => e.TagName).ToList();
|
||||
var liveDict = await _db.RealtimePoints
|
||||
.Where(r => tagNames.Contains(r.TagName))
|
||||
.GroupBy(r => r.TagName)
|
||||
.ToDictionaryAsync(g => g.Key, g =>
|
||||
{
|
||||
var first = g.OrderByDescending(r => r.Timestamp).FirstOrDefault();
|
||||
return new { first?.LiveValue, Timestamp = first?.Timestamp };
|
||||
});
|
||||
|
||||
var items = entries.Select(e =>
|
||||
{
|
||||
liveDict.TryGetValue(e.TagName, out var live);
|
||||
return new
|
||||
{
|
||||
id = e.Id,
|
||||
tagName = e.TagName,
|
||||
hc900Tag = e.Hc900Tag,
|
||||
modbusAddr = e.ModbusAddr,
|
||||
dataType = e.DataType,
|
||||
paramType = e.ParamType,
|
||||
loopNo = e.LoopNo,
|
||||
controllerId = e.ControllerId,
|
||||
isActive = e.IsActive,
|
||||
access = e.Access,
|
||||
liveValue = live?.LiveValue,
|
||||
timestamp = live?.Timestamp,
|
||||
};
|
||||
});
|
||||
|
||||
return Ok(new { total, page, pageSize, totalPages, items });
|
||||
}
|
||||
|
||||
[HttpPost("add")]
|
||||
public async Task<IActionResult> Add([FromBody] Hc900PointBuilderAddDto dto)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.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 });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Conflict(new { success = false, error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(int id, [FromQuery] bool purgeHistory = false)
|
||||
{
|
||||
var entry = await _db.Hc900MapEntries.FindAsync(id);
|
||||
if (entry == null)
|
||||
return NotFound(new { success = false, error = "지정한 id의 태그가 존재하지 않습니다" });
|
||||
|
||||
entry.IsActive = false;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var rtPoint = await _db.RealtimePoints
|
||||
.FirstOrDefaultAsync(r => r.TagName == entry.TagName);
|
||||
if (rtPoint != null)
|
||||
{
|
||||
_db.RealtimePoints.Remove(rtPoint);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var baseTag = entry.TagName.Contains('.')
|
||||
? entry.TagName[..entry.TagName.IndexOf('.')]
|
||||
: entry.TagName;
|
||||
|
||||
var remaining = await _db.RealtimePoints
|
||||
.CountAsync(p => p.TagName == baseTag || p.TagName.StartsWith(baseTag + "."));
|
||||
if (remaining == 0)
|
||||
{
|
||||
await _db.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM tag_metadata WHERE base_tag = {0}", baseTag);
|
||||
}
|
||||
|
||||
if (purgeHistory)
|
||||
{
|
||||
await _db.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM history_table WHERE tagname = {0}", entry.TagName);
|
||||
}
|
||||
|
||||
_log.LogInformation("[PointBuilder] 삭제: {TagName} (purgeHistory={Purge})", entry.TagName, purgeHistory);
|
||||
return Ok(new { success = true, tagName = entry.TagName });
|
||||
}
|
||||
}
|
||||
@@ -1,213 +1,457 @@
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
06 HC900 태그 관리
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const PB_GROUPS = ['loop','signal','digital','variable','custom'];
|
||||
|
||||
let _pbSearchTimer;
|
||||
function pbApi(url, method, body) {
|
||||
return fetch(url, {
|
||||
method, headers: body ? {'Content-Type':'application/json'} : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
}).then(async r => {
|
||||
const text = await r.text();
|
||||
try { return JSON.parse(text); }
|
||||
catch { throw new Error(`HTTP ${r.status}: ${text.slice(0,200)}`); }
|
||||
});
|
||||
}
|
||||
|
||||
const _PB_PARAM_CLASS = {
|
||||
PV:'p-PV', SP:'p-SP', OP:'p-OP', RSP:'p-PV', LSP1:'p-SP', LSP2:'p-SP',
|
||||
DEV:'p-OP', MODE:'p-MODE', STATUS:'p-STATUS', SIG:'p-SIG', VAR:'p-VAR',
|
||||
};
|
||||
function pbCollectGroupData(groupKey) {
|
||||
const tagPatterns = document.querySelector(
|
||||
`input[data-group="${groupKey}"][data-field="tagPatterns"]`
|
||||
)?.value.split(',').map(s => s.trim()).filter(Boolean) || [];
|
||||
|
||||
async function pbApi(url, method='GET', body) {
|
||||
const opt = { method, headers:{'Content-Type':'application/json'} };
|
||||
if (body !== undefined) opt.body = JSON.stringify(body);
|
||||
return (await fetch(url, opt)).json();
|
||||
const checkedParamTypes = Array.from(
|
||||
document.querySelectorAll(
|
||||
`input[data-group="${groupKey}"][data-field="paramTypes"]:checked`
|
||||
) || []
|
||||
).map(cb => cb.value);
|
||||
|
||||
const customInputs = document.querySelectorAll(
|
||||
`input[data-group="${groupKey}"][data-field="customParamTypes"]`
|
||||
);
|
||||
customInputs.forEach(inp => {
|
||||
if (inp.value.trim()) checkedParamTypes.push(inp.value.trim());
|
||||
});
|
||||
|
||||
const dataType = document.querySelector(
|
||||
`select[data-group="${groupKey}"][data-field="dataType"]`
|
||||
)?.value || null;
|
||||
|
||||
return { tagPatterns, paramTypes: checkedParamTypes, dataType };
|
||||
}
|
||||
|
||||
function pbCollectAllGroups() {
|
||||
const payload = {};
|
||||
PB_GROUPS.forEach(key => { payload[key] = pbCollectGroupData(key); });
|
||||
return payload;
|
||||
}
|
||||
|
||||
function pbGetSelectedTagNames() {
|
||||
const checks = document.querySelectorAll('#pb-preview-tbody input[type="checkbox"]:checked');
|
||||
return Array.from(checks).map(cb => cb.value).filter(Boolean);
|
||||
}
|
||||
|
||||
// ── API calls ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function pbPreview() {
|
||||
try {
|
||||
const payload = pbCollectAllGroups();
|
||||
const hasAnyFilter = PB_GROUPS.some(key => {
|
||||
const g = payload[key];
|
||||
return (g.tagPatterns?.length > 0 || g.paramTypes?.length > 0);
|
||||
});
|
||||
if (!hasAnyFilter) {
|
||||
alert('최소 하나의 그룹에 패턴 또는 속성을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
console.log('Preview payload:', JSON.stringify(payload));
|
||||
const res = await pbApi('/api/pointbuilder/preview', 'POST', payload);
|
||||
console.log('Preview response:', res);
|
||||
pbRenderPreview(res);
|
||||
} catch (e) {
|
||||
alert('미리보기 오류: ' + e.message);
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function pbBuild() {
|
||||
if (!confirm('모든 활성 태그를 해제하고 조건에 맞는 태그만 활성화합니다. 계속하시겠습니까?')) return;
|
||||
const payload = pbCollectAllGroups();
|
||||
const res = await pbApi('/api/pointbuilder/build', 'POST', payload);
|
||||
alert(res.message || `${res.count}개 활성화됨`);
|
||||
pbRefresh();
|
||||
pbLoadSummary();
|
||||
}
|
||||
|
||||
async function pbApply() {
|
||||
const selected = pbGetSelectedTagNames();
|
||||
if (selected.length === 0) { alert('미리보기에서 활성화할 태그를 선택해주세요.'); return; }
|
||||
if (!confirm(`기존 활성화를 모두 해제하고 선택한 ${selected.length}개만 활성화합니다. 계속하시겠습니까?`)) return;
|
||||
const res = await pbApi('/api/pointbuilder/apply', 'POST', { selectedTagNames: selected });
|
||||
alert(res.message || `${res.count}개 활성화됨`);
|
||||
pbRefresh();
|
||||
pbLoadSummary();
|
||||
}
|
||||
|
||||
async function pbAppend() {
|
||||
const selected = pbGetSelectedTagNames();
|
||||
if (selected.length === 0) { alert('미리보기에서 추가할 태그를 선택해주세요.'); return; }
|
||||
const res = await pbApi('/api/pointbuilder/append', 'POST', { selectedTagNames: selected });
|
||||
if (res.count > 0) {
|
||||
alert(`${res.count}개 추가됨`);
|
||||
} else {
|
||||
alert('추가할 새 태그가 없습니다 (이미 모두 활성화되어 있음)');
|
||||
}
|
||||
pbRefresh();
|
||||
pbLoadSummary();
|
||||
}
|
||||
|
||||
async function pbAddManual() {
|
||||
const tagName = document.getElementById('pb-add-tag')?.value.trim();
|
||||
if (!tagName) { alert('TagName을 입력해주세요.'); return; }
|
||||
|
||||
const payload = {
|
||||
tagName,
|
||||
hc900Tag: tagName,
|
||||
modbusAddr: parseInt(document.getElementById('pb-add-addr')?.value) || 0,
|
||||
dataType: document.getElementById('pb-add-dtype')?.value || 'float32',
|
||||
paramType: document.getElementById('pb-add-param')?.value.trim().toUpperCase() || null,
|
||||
access: document.getElementById('pb-add-access')?.value || 'R',
|
||||
controllerId: document.getElementById('pb-add-ctrl')?.value.trim() || 'HC1'
|
||||
};
|
||||
|
||||
const res = await pbApi('/api/pointbuilder/add', 'POST', payload);
|
||||
if (res.success) {
|
||||
alert(`등록 완료: ${tagName}`);
|
||||
document.getElementById('pb-add-tag').value = '';
|
||||
document.getElementById('pb-add-addr').value = '';
|
||||
document.getElementById('pb-add-param').value = '';
|
||||
pbRefresh();
|
||||
pbLoadSummary();
|
||||
} else {
|
||||
alert(`오류: ${res.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
let _pbReloadTimer = null;
|
||||
let pbPage = 1;
|
||||
const pbPageSize = 100;
|
||||
let pbTotalPages = 1;
|
||||
|
||||
function pbScheduleReload() {
|
||||
clearTimeout(_pbReloadTimer);
|
||||
_pbReloadTimer = setTimeout(() => pbReload(true), 300);
|
||||
}
|
||||
|
||||
// resetPage=true: 필터 변경 등으로 1페이지부터. false: 변경 후 새로고침 시 현재 페이지 유지.
|
||||
async function pbReload(resetPage = true) {
|
||||
if (resetPage) pbPage = 1;
|
||||
const search = document.getElementById('pf-search')?.value.trim() || '';
|
||||
const active = document.getElementById('pf-active')?.value || '';
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('search', search);
|
||||
if (active) params.set('active', active);
|
||||
params.set('page', pbPage);
|
||||
params.set('pageSize', pbPageSize);
|
||||
const res = await pbApi(`/api/pointbuilder/points?${params.toString()}`, 'GET');
|
||||
pbRenderPoints(res);
|
||||
}
|
||||
|
||||
// 변경(토글/삭제/빌드) 후 새로고침 — 필터·현재 페이지 유지
|
||||
async function pbRefresh() {
|
||||
await pbReload(false);
|
||||
}
|
||||
|
||||
function pbGoPage(delta) {
|
||||
const next = pbPage + delta;
|
||||
if (next < 1 || next > pbTotalPages) return;
|
||||
pbPage = next;
|
||||
pbReload(false);
|
||||
}
|
||||
|
||||
async function pbLoadSummary() {
|
||||
const d = await pbApi('/api/hc900/tags/summary');
|
||||
document.getElementById('s-total').textContent = d.total.toLocaleString();
|
||||
document.getElementById('s-active').textContent = d.active.toLocaleString();
|
||||
document.getElementById('s-inactive').textContent = d.inactive.toLocaleString();
|
||||
document.getElementById('s-live').textContent = d.liveCount.toLocaleString();
|
||||
const s = await pbApi('/api/hc900/tags/summary', 'GET');
|
||||
document.getElementById('s-total').textContent = s.total ?? '—';
|
||||
document.getElementById('s-active').textContent = s.active ?? '—';
|
||||
document.getElementById('s-inactive').textContent = s.inactive ?? '—';
|
||||
document.getElementById('s-live').textContent = s.liveCount ?? '—';
|
||||
}
|
||||
|
||||
async function pbInitFilters() {
|
||||
const types = await pbApi('/api/hc900/tags/param-types');
|
||||
const ps = document.getElementById('f-param');
|
||||
if (!ps) return;
|
||||
types.forEach(t => {
|
||||
const o = document.createElement('option');
|
||||
o.value = o.textContent = t;
|
||||
ps.appendChild(o);
|
||||
});
|
||||
const ls = document.getElementById('f-loop');
|
||||
for (let i = 1; i <= 32; i++) {
|
||||
const o = document.createElement('option');
|
||||
o.value = i; o.textContent = `Loop #${i}`;
|
||||
ls.appendChild(o);
|
||||
}
|
||||
const ctrls = await pbApi('/api/hc900/tags/controller-ids');
|
||||
const cs = document.getElementById('f-ctrl');
|
||||
if (cs) {
|
||||
ctrls.forEach(id => {
|
||||
const o = document.createElement('option');
|
||||
o.value = o.textContent = id;
|
||||
cs.appendChild(o);
|
||||
});
|
||||
async function pbLoadGatewayStatus() {
|
||||
try {
|
||||
const h = await pbApi('/api/gateway/health', 'GET');
|
||||
document.getElementById('pb-gw-status').textContent = h.status || 'UNKNOWN';
|
||||
document.getElementById('pb-gw-status').className = (h.status === 'SERVING' || h.status === 'OK')
|
||||
? 'pb-status-ok' : 'pb-status-err';
|
||||
document.getElementById('pb-gw-ip').textContent = h.controllerIp || '—';
|
||||
document.getElementById('pb-gw-poll').textContent = h.pollCount ?? '—';
|
||||
document.getElementById('pb-gw-last').textContent = h.lastPollMs ? `${h.lastPollMs}ms` : '—';
|
||||
document.getElementById('pb-gw-tags').textContent = h.activeTags ?? '—';
|
||||
} catch {
|
||||
document.getElementById('pb-gw-status').textContent = '연결 실패';
|
||||
document.getElementById('pb-gw-status').className = 'pb-status-err';
|
||||
}
|
||||
}
|
||||
|
||||
function pbBuildUrl() {
|
||||
const s = document.getElementById('f-search')?.value.trim() || '';
|
||||
const p = document.getElementById('f-param')?.value || '';
|
||||
const l = document.getElementById('f-loop')?.value || '';
|
||||
const c = document.getElementById('f-ctrl')?.value || '';
|
||||
const a = document.getElementById('f-active')?.value || '';
|
||||
let url = '/api/hc900/tags?';
|
||||
if (s) url += `search=${encodeURIComponent(s)}&`;
|
||||
if (p) url += `paramType=${encodeURIComponent(p)}&`;
|
||||
if (l) url += `loopNo=${l}&`;
|
||||
if (c) url += `controllerId=${encodeURIComponent(c)}&`;
|
||||
if (a === 'live') url += `hasLive=true&`;
|
||||
else if (a) url += `active=${a}&`;
|
||||
return url;
|
||||
}
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function pbReload() {
|
||||
const tbody = document.getElementById('pb-tbody');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;padding:30px;color:#555">조회 중...</td></tr>';
|
||||
function pbRenderPreview(res) {
|
||||
const area = document.getElementById('pb-preview-area');
|
||||
const tbody = document.getElementById('pb-preview-tbody');
|
||||
const count = document.getElementById('pb-preview-count');
|
||||
|
||||
const data = await pbApi(pbBuildUrl());
|
||||
pbRenderTable(data);
|
||||
pbUpdateBulkBar();
|
||||
}
|
||||
|
||||
function pbRenderTable(data) {
|
||||
const tbody = document.getElementById('pb-tbody');
|
||||
if (!tbody) return;
|
||||
if (!data.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;padding:30px;color:#555">결과 없음</td></tr>';
|
||||
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>';
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
tbody.innerHTML = data.map(r => {
|
||||
const pc = _PB_PARAM_CLASS[r.ParamType] || 'p-other';
|
||||
const val = r.LiveValue ?? '—';
|
||||
const ts = r.Timestamp ? new Date(r.Timestamp) : null;
|
||||
const fresh = ts && (now - ts.getTime()) < 30000;
|
||||
const tsStr = ts ? ts.toLocaleTimeString('ko-KR') : '—';
|
||||
const shortVal = val.length > 20 ? val.slice(0, 20) + '…' : val;
|
||||
|
||||
area.style.display = 'block';
|
||||
count.textContent = `(${res.count}건)`;
|
||||
|
||||
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" class="pb-rc" data-id="${r.Id}" onchange="pbUpdateBulkBar()"></td>
|
||||
<td style="color:#8cf;font-weight:bold">${r.TagName}</td>
|
||||
<td style="color:#aaa">${r.Hc900Tag}</td>
|
||||
<td><span class="pb-ctrl" data-id="${r.Id}" data-ctrl="${r.ControllerId}"
|
||||
style="cursor:pointer;color:#fa3;font-size:11px"
|
||||
onclick="pbEditCtrl(this)" title="클릭하여 컨트롤러 ID 변경">${r.ControllerId}</span></td>
|
||||
<td style="color:#888;font-size:11px">${r.ModbusAddrHex}</td>
|
||||
<td style="color:#666;font-size:11px">${r.DataType}</td>
|
||||
<td style="color:#666">${r.LoopNo ?? '—'}</td>
|
||||
<td><span class="param-badge ${pc}">${r.ParamType ?? '—'}</span></td>
|
||||
<td style="cursor:pointer;font-size:16px;color:${r.IsActive ? '#3c3' : '#444'}"
|
||||
onclick="pbToggleOne(${r.Id}, ${!r.IsActive})"
|
||||
title="${r.IsActive ? '클릭하면 비활성화' : '클릭하면 활성화'}">
|
||||
${r.IsActive ? '●' : '○'}
|
||||
</td>
|
||||
<td style="color:${fresh ? '#eee' : '#444'};max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
|
||||
title="${val}">${shortVal}</td>
|
||||
<td style="color:#666;font-size:11px">${tsStr}</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('');
|
||||
}
|
||||
|
||||
async function pbToggleOne(id, active) {
|
||||
await pbApi(`/api/hc900/tags/${id}/active`, 'PUT', active);
|
||||
pbReload(); pbLoadSummary();
|
||||
function pbRenderPagination(res) {
|
||||
pbTotalPages = res.totalPages ?? 1;
|
||||
const info = document.getElementById('pb-pager-info');
|
||||
if (!info) return;
|
||||
const total = res.total ?? 0;
|
||||
const page = res.page ?? 1;
|
||||
const size = res.pageSize ?? pbPageSize;
|
||||
const from = total === 0 ? 0 : (page - 1) * size + 1;
|
||||
const to = Math.min(total, page * size);
|
||||
info.textContent = `${from}–${to} / 전체 ${total}건 (페이지 ${page}/${pbTotalPages || 1})`;
|
||||
const prev = document.getElementById('pb-pager-prev');
|
||||
const next = document.getElementById('pb-pager-next');
|
||||
if (prev) prev.disabled = page <= 1;
|
||||
if (next) next.disabled = page >= (pbTotalPages || 1);
|
||||
}
|
||||
|
||||
async function pbEditCtrl(el) {
|
||||
const id = +el.dataset.id;
|
||||
const old = el.dataset.ctrl;
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'text';
|
||||
inp.value = old;
|
||||
inp.style.cssText = 'width:50px;background:#111;border:1px solid #4af;color:#fa3;font-size:11px;padding:1px 4px;border-radius:2px;outline:none';
|
||||
el.replaceWith(inp);
|
||||
inp.focus();
|
||||
inp.select();
|
||||
function pbRenderPoints(res) {
|
||||
pbRenderPagination(res);
|
||||
const tbody = document.getElementById('pb-points-tbody');
|
||||
if (!res.items || res.items.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:30px;color:#555">표시할 태그가 없습니다 (필터 변경 또는 미리보기로 추가해보세요)</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const done = async (save) => {
|
||||
const val = inp.value.trim();
|
||||
if (save && val && val !== old) {
|
||||
await pbApi(`/api/hc900/tags/${id}/controller`, 'PUT', val);
|
||||
pbReload(); pbLoadSummary();
|
||||
} else {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'pb-ctrl';
|
||||
span.dataset.id = id;
|
||||
span.dataset.ctrl = old;
|
||||
span.style.cssText = 'cursor:pointer;color:#fa3;font-size:11px';
|
||||
span.textContent = old;
|
||||
span.onclick = () => pbEditCtrl(span);
|
||||
inp.replaceWith(span);
|
||||
}
|
||||
};
|
||||
|
||||
inp.addEventListener('blur', () => done(true));
|
||||
inp.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); inp.blur(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); done(false); }
|
||||
});
|
||||
tbody.innerHTML = res.items.map(item => {
|
||||
const badgeClass = `param-badge p-${item.paramType}`.replace(/[^a-zA-Z0-9\s-]/g, '');
|
||||
const activeLabel = item.isActive
|
||||
? '<span style="color:#3c3;font-weight:600">활성</span>'
|
||||
: '<span style="color:#c55">비활성</span>';
|
||||
return `<tr style="${item.isActive ? '' : 'opacity:0.6'}">
|
||||
<td><input type="checkbox" value="${item.id}" class="pb-point-chk" onchange="pbUpdateBulkBar()"></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.controllerId}</td>
|
||||
<td>${activeLabel}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<span style="cursor:pointer;font-size:14px" onclick="pbToggleOne(${item.id}, ${!item.isActive})" title="${item.isActive ? '비활성화' : '활성화'}">${item.isActive ? '🟢' : '🔴'}</span>
|
||||
<button class="btn-b btn-sm" style="margin-left:6px;color:var(--red,#e55);border-color:var(--red,#e55)" onclick="pbDeleteOne(${item.id})">🗑 삭제</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function pbToggleAll(chk) {
|
||||
document.querySelectorAll('.pb-rc').forEach(c => c.checked = chk.checked);
|
||||
function pbToggleAllPreview(master) {
|
||||
const checked = master.checked;
|
||||
document.querySelectorAll('#pb-preview-tbody input[type="checkbox"]').forEach(cb => cb.checked = checked);
|
||||
}
|
||||
|
||||
function pbToggleAllPoints(master) {
|
||||
const checked = master.checked;
|
||||
document.querySelectorAll('#pb-points-tbody .pb-point-chk').forEach(cb => cb.checked = checked);
|
||||
pbUpdateBulkBar();
|
||||
}
|
||||
|
||||
function pbUpdateBulkBar() {
|
||||
const n = document.querySelectorAll('.pb-rc:checked').length;
|
||||
const el = document.getElementById('pb-bulk-count');
|
||||
if (el) el.textContent = `${n}개 선택`;
|
||||
const bar = document.getElementById('pb-bulk-bar');
|
||||
if (bar) bar.style.display = n > 0 ? 'flex' : 'none';
|
||||
const checked = document.querySelectorAll('#pb-points-tbody .pb-point-chk:checked');
|
||||
const bar = document.getElementById('pb-points-bulk');
|
||||
bar.hidden = checked.length === 0;
|
||||
}
|
||||
|
||||
function pbSelectedIds() {
|
||||
return [...document.querySelectorAll('.pb-rc:checked')].map(c => +c.dataset.id);
|
||||
}
|
||||
|
||||
async function pbBulkSelected(active) {
|
||||
const ids = pbSelectedIds();
|
||||
if (!ids.length) return;
|
||||
await pbApi('/api/hc900/tags/bulk-active', 'POST', { active, ids });
|
||||
pbReload(); pbLoadSummary();
|
||||
}
|
||||
|
||||
async function pbBulkFilter(active) {
|
||||
const p = document.getElementById('f-param')?.value || '';
|
||||
const l = document.getElementById('f-loop')?.value || '';
|
||||
if (!p && !l && !confirm(`필터 없이 전체 ${active ? '활성화' : '비활성화'}합니까?`)) return;
|
||||
await pbApi('/api/hc900/tags/bulk-active', 'POST',
|
||||
{ active, paramType: p || null, loopNo: l ? +l : null });
|
||||
pbReload(); pbLoadSummary();
|
||||
}
|
||||
|
||||
function pbScheduleReload() {
|
||||
clearTimeout(_pbSearchTimer);
|
||||
_pbSearchTimer = setTimeout(pbReload, 350);
|
||||
}
|
||||
|
||||
// 탭 활성화 시 초기화
|
||||
(function pbInit() {
|
||||
const pane = document.getElementById('pane-pb');
|
||||
if (!pane) return;
|
||||
|
||||
const obs = new MutationObserver(() => {
|
||||
if (pane.classList.contains('active') && document.getElementById('pb-tbody')) {
|
||||
pbInitFilters().then(() => pbReload());
|
||||
pbLoadSummary();
|
||||
obs.disconnect();
|
||||
}
|
||||
});
|
||||
obs.observe(pane, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
// 이미 활성화된 경우
|
||||
if (pane.classList.contains('active') && document.getElementById('pb-tbody')) {
|
||||
pbInitFilters().then(() => pbReload());
|
||||
async function pbToggleOne(id, active) {
|
||||
const res = await pbApi(`/api/hc900/tags/${id}/active`, 'PUT', active);
|
||||
if (res) {
|
||||
pbRefresh();
|
||||
pbLoadSummary();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function pbDeleteOne(id) {
|
||||
if (!confirm('이 태그를 비활성화하고 realtime_table에서 제거합니다. 계속하시겠습니까?')) return;
|
||||
const res = await pbApi(`/api/pointbuilder/${id}`, 'DELETE');
|
||||
if (res.success) {
|
||||
pbRefresh();
|
||||
pbLoadSummary();
|
||||
}
|
||||
}
|
||||
|
||||
async function pbBulkActivate(active) {
|
||||
const ids = Array.from(document.querySelectorAll('#pb-points-tbody .pb-point-chk:checked'))
|
||||
.map(cb => parseInt(cb.value));
|
||||
if (ids.length === 0) return;
|
||||
const res = await pbApi('/api/hc900/tags/bulk-active', 'POST', { ids, active });
|
||||
if (res) {
|
||||
pbRefresh();
|
||||
pbLoadSummary();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sinam xlsx 파싱 ────────────────────────────────────────────────────────────
|
||||
|
||||
let _pbSinamBusy = false;
|
||||
let _pbSinamCurrentFile = null; // 현재 선택된 파일 경로 (JS 변수로 직접 관리)
|
||||
|
||||
function _pbSinamFilePath() {
|
||||
const sel = document.getElementById('pb-sinam-existing');
|
||||
return _pbSinamCurrentFile || sel?.value || null;
|
||||
}
|
||||
|
||||
function _pbSinamSetFile(path) {
|
||||
_pbSinamCurrentFile = path || null;
|
||||
const sel = document.getElementById('pb-sinam-existing');
|
||||
if (sel && path) sel.value = path;
|
||||
const label = document.getElementById('pb-sinam-file-label');
|
||||
if (label) {
|
||||
const name = path ? path.split('/').pop() || path.split('\\').pop() : '';
|
||||
label.textContent = name ? `✓ ${name}` : '';
|
||||
}
|
||||
}
|
||||
|
||||
function pbSinamOnSelect() {
|
||||
const sel = document.getElementById('pb-sinam-existing');
|
||||
if (sel) _pbSinamSetFile(sel.value);
|
||||
}
|
||||
|
||||
async function pbSinamLoadExisting() {
|
||||
try {
|
||||
const res = await pbApi('/api/pointbuilder/sinam/files', 'GET');
|
||||
const sel = document.getElementById('pb-sinam-existing');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '<option value="">— 기존 파일 선택 —</option>';
|
||||
if (res.files) {
|
||||
res.files.forEach(f => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = f.path;
|
||||
const dt = new Date(f.modified).toLocaleString('ko-KR', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
|
||||
opt.textContent = `${f.name} (${dt})`;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function pbSinamUploadBtn() {
|
||||
const fileInput = document.getElementById('pb-sinam-file');
|
||||
const file = fileInput?.files?.[0];
|
||||
if (!file) { alert('업로드할 파일을 선택해주세요.'); return; }
|
||||
|
||||
const btn = document.querySelector('.pb-right-card button[onclick*="pbSinamUploadBtn"]');
|
||||
const orig = btn?.textContent;
|
||||
if (btn) { btn.disabled = true; btn.textContent = '업로드 중...'; }
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
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');
|
||||
|
||||
await pbSinamLoadExisting();
|
||||
_pbSinamSetFile(data.file);
|
||||
fileInput.value = '';
|
||||
} catch (e) {
|
||||
alert('업로드 오류: ' + e.message);
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = orig; }
|
||||
}
|
||||
}
|
||||
|
||||
async function pbSinamParse() {
|
||||
if (_pbSinamBusy) return;
|
||||
const btn = document.getElementById('pb-sinam-btn');
|
||||
const resultEl = document.getElementById('pb-sinam-result');
|
||||
const ctrl = document.getElementById('pb-sinam-ctrl')?.value || 'C3';
|
||||
const applyDb = document.getElementById('pb-sinam-apply')?.checked || false;
|
||||
|
||||
const filePath = _pbSinamFilePath();
|
||||
if (!filePath) {
|
||||
resultEl.innerHTML = '<span style="color:#c55">새 파일 업로드 또는 기존 파일을 선택해주세요</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (applyDb && !confirm(`컨트롤러 ${ctrl}의 map_master/tag_metadata를 Sinam 기준으로 재생성합니다. 계속하시겠습니까?`)) return;
|
||||
|
||||
_pbSinamBusy = true;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '파싱 중...';
|
||||
resultEl.innerHTML = '<span style="color:#888">파싱 중...</span>';
|
||||
|
||||
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>`;
|
||||
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}개`);
|
||||
lines.push(`<span style="color:#888;font-size:11px">${fileName}</span>`);
|
||||
resultEl.innerHTML = lines.join('<br>');
|
||||
|
||||
if (applyDb) {
|
||||
pbRefresh();
|
||||
pbLoadSummary();
|
||||
}
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = `<span style="color:#c55">오류: ${e.message}</span>`;
|
||||
} finally {
|
||||
_pbSinamBusy = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = '파싱 시작';
|
||||
}
|
||||
}
|
||||
|
||||
async function pbSinamGwRestart() {
|
||||
if (!confirm('선택한 컨트롤러의 게이트웨이를 재시작합니다. 계속하시겠습니까?')) return;
|
||||
const ctrl = document.getElementById('pb-sinam-ctrl')?.value || 'C3';
|
||||
const resultEl = document.getElementById('pb-sinam-result');
|
||||
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>`;
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = `<span style="color:#c55">오류: ${e.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
paneInit.pb = function() {
|
||||
pbLoadSummary();
|
||||
pbReload();
|
||||
pbLoadGatewayStatus();
|
||||
pbSinamLoadExisting();
|
||||
};
|
||||
|
||||
@@ -14,12 +14,19 @@
|
||||
.pb-scard.active .num { color:#3c3; }
|
||||
.pb-scard.inactive .num { color:#c55; }
|
||||
.pb-scard.live .num { color:#fa3; }
|
||||
.pb-right-card { background:var(--s3); border:1px solid var(--bd); border-radius:var(--r); padding:14px 16px; margin-bottom:12px; }
|
||||
.pb-right-card h3 { font-size:13px; font-weight:600; margin:0 0 8px; color:var(--t1); }
|
||||
.pb-status-ok { color:#3c3; font-weight:600; }
|
||||
.pb-status-err { color:#c55; font-weight:600; }
|
||||
.pb-table-wrap { overflow-x:auto; max-height:calc(100vh - 320px); overflow-y:auto; }
|
||||
.pb-table-wrap td { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pb-table-wrap td:last-child { white-space:nowrap; overflow:visible; }
|
||||
</style>
|
||||
|
||||
<header class="pane-hdr">
|
||||
<div>
|
||||
<h1>태그 관리</h1>
|
||||
<p>HC900 등록 태그 조회 및 폴링 활성화 관리</p>
|
||||
<h1>포인트빌더</h1>
|
||||
<p>HC900 태그 그룹별 필터링 및 폴링 활성화 관리</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -33,62 +40,281 @@
|
||||
<div class="pb-scard live"><div class="num" id="s-live">—</div><div class="lbl">실시간 값 보유</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<input id="f-search" class="inp" style="width:200px" placeholder="태그명 검색..."
|
||||
oninput="pbScheduleReload()">
|
||||
<select id="f-param" class="inp" style="width:160px" onchange="pbReload()">
|
||||
<option value="">전체 파라미터</option>
|
||||
</select>
|
||||
<select id="f-loop" class="inp" style="width:130px" onchange="pbReload()">
|
||||
<option value="">전체 루프</option>
|
||||
</select>
|
||||
<select id="f-ctrl" class="inp" style="width:100px" onchange="pbReload()">
|
||||
<option value="">전체 컨트롤러</option>
|
||||
</select>
|
||||
<select id="f-active" class="inp" style="width:120px" onchange="pbReload()">
|
||||
<option value="">전체</option>
|
||||
<option value="true">활성만</option>
|
||||
<option value="false">비활성만</option>
|
||||
<option value="live">실시간</option>
|
||||
</select>
|
||||
<button class="btn-a" onclick="pbReload()">🔍 조회</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:14px;flex-wrap:wrap">
|
||||
<!-- 좌측: 그룹 카드 + 미리보기 -->
|
||||
<div style="flex:5;min-width:480px">
|
||||
|
||||
<!-- 일괄 액션 -->
|
||||
<div id="pb-bulk-bar" style="display:none;gap:8px;align-items:center;
|
||||
background:#1a2a1a;border:1px solid #2a4a2a;border-radius:4px;padding:8px 12px">
|
||||
<span id="pb-bulk-count" style="font-size:12px;color:#8c8">0개 선택</span>
|
||||
<button class="btn-a" style="padding:4px 10px;font-size:11px" onclick="pbBulkSelected(true)">✓ 선택 활성화</button>
|
||||
<button class="btn-b" style="padding:4px 10px;font-size:11px;background:#5a1a1a;color:#faa" onclick="pbBulkSelected(false)">✗ 선택 비활성화</button>
|
||||
<span style="color:#333;margin:0 4px">|</span>
|
||||
<span style="font-size:11px;color:#555">필터 일괄:</span>
|
||||
<button class="btn-a" style="padding:4px 10px;font-size:11px" onclick="pbBulkFilter(true)">전체 활성화</button>
|
||||
<button class="btn-b" style="padding:4px 10px;font-size:11px;background:#5a1a1a;color:#faa" onclick="pbBulkFilter(false)">전체 비활성화</button>
|
||||
</div>
|
||||
<!-- 루프 파라미터 -->
|
||||
<div class="pb-group-card" data-group="loop">
|
||||
<div class="pb-group-header">
|
||||
<span class="card-sub-cap">PID 루프 파라미터 #1</span>
|
||||
</div>
|
||||
<input class="inp pb-pattern-input"
|
||||
data-group="loop" data-field="tagPatterns"
|
||||
placeholder="FICQ-61, TICQ-62, ... (쉼표 구분)">
|
||||
<div class="pb-attr-checkboxes">
|
||||
<label><input type="checkbox" value="PV" checked data-group="loop" data-field="paramTypes"> PV</label>
|
||||
<label><input type="checkbox" value="SP" checked data-group="loop" data-field="paramTypes"> SP</label>
|
||||
<label><input type="checkbox" value="OP" checked data-group="loop" data-field="paramTypes"> OP</label>
|
||||
<label><input type="checkbox" value="MD" data-group="loop" data-field="paramTypes"> MD</label>
|
||||
<label><input type="checkbox" value="QV" data-group="loop" data-field="paramTypes"> QV</label>
|
||||
</div>
|
||||
<div class="pb-custom-attr-inputs">
|
||||
<input class="inp" data-group="loop" data-field="customParamTypes" placeholder="추가 속성 (SIG, VAR ...)">
|
||||
</div>
|
||||
<select class="inp pb-datatype-select" data-group="loop" data-field="dataType">
|
||||
<option value="">모든 타입</option>
|
||||
<option value="float32">float32</option>
|
||||
<option value="uint16">uint16</option>
|
||||
<option value="int32">int32</option>
|
||||
<option value="int64">int64</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div style="overflow:auto;flex:1">
|
||||
<table class="tbl" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:30px"><input type="checkbox" id="pb-chk-all" onchange="pbToggleAll(this)"></th>
|
||||
<th>태그명 (Experion)</th>
|
||||
<th>HC900 태그</th>
|
||||
<th>컨트롤러</th>
|
||||
<th>Modbus 주소</th>
|
||||
<th>데이터 타입</th>
|
||||
<th>루프#</th>
|
||||
<th>파라미터</th>
|
||||
<th>폴링</th>
|
||||
<th>현재값</th>
|
||||
<th>갱신 시각</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pb-tbody">
|
||||
<tr><td colspan="11" style="text-align:center;padding:30px;color:#555">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 시그널 태그 -->
|
||||
<div class="pb-group-card" data-group="signal">
|
||||
<div class="pb-group-header">
|
||||
<span class="card-sub-cap">Signal 태그 #2</span>
|
||||
</div>
|
||||
<input class="inp pb-pattern-input"
|
||||
data-group="signal" data-field="tagPatterns"
|
||||
placeholder="TI-61, PI-62, ... (쉼표 구분)">
|
||||
<div class="pb-attr-checkboxes">
|
||||
<label><input type="checkbox" value="PV" checked data-group="signal" data-field="paramTypes"> PV</label>
|
||||
<label><input type="checkbox" value="OP" data-group="signal" data-field="paramTypes"> OP</label>
|
||||
</div>
|
||||
<div class="pb-custom-attr-inputs">
|
||||
<input class="inp" data-group="signal" data-field="customParamTypes" placeholder="추가 속성">
|
||||
</div>
|
||||
<select class="inp pb-datatype-select" data-group="signal" data-field="dataType">
|
||||
<option value="">모든 타입</option>
|
||||
<option value="float32">float32</option>
|
||||
<option value="uint16">uint16</option>
|
||||
<option value="int32">int32</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 디지털 입력 -->
|
||||
<div class="pb-group-card" data-group="digital">
|
||||
<div class="pb-group-header">
|
||||
<span class="card-sub-cap">디지털 입력 #3</span>
|
||||
</div>
|
||||
<input class="inp pb-pattern-input"
|
||||
data-group="digital" data-field="tagPatterns"
|
||||
placeholder="YS-61, YT-62, ... (쉼표 구분)">
|
||||
<div class="pb-attr-checkboxes">
|
||||
<label><input type="checkbox" value="PV" checked data-group="digital" data-field="paramTypes"> PV</label>
|
||||
<label><input type="checkbox" value="OP" data-group="digital" data-field="paramTypes"> OP</label>
|
||||
<label><input type="checkbox" value="MD" data-group="digital" data-field="paramTypes"> MD</label>
|
||||
</div>
|
||||
<div class="pb-custom-attr-inputs">
|
||||
<input class="inp" data-group="digital" data-field="customParamTypes" placeholder="추가 속성">
|
||||
</div>
|
||||
<select class="inp pb-datatype-select" data-group="digital" data-field="dataType">
|
||||
<option value="">모든 타입</option>
|
||||
<option value="uint16">uint16</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Variable -->
|
||||
<div class="pb-group-card" data-group="variable">
|
||||
<div class="pb-group-header">
|
||||
<span class="card-sub-cap">Variable (R/W) #4</span>
|
||||
</div>
|
||||
<input class="inp pb-pattern-input"
|
||||
data-group="variable" data-field="tagPatterns"
|
||||
placeholder="V-6101, ... (쉼표 구분)">
|
||||
<div class="pb-attr-checkboxes">
|
||||
<label><input type="checkbox" value="PV" checked data-group="variable" data-field="paramTypes"> PV</label>
|
||||
<label><input type="checkbox" value="OP" data-group="variable" data-field="paramTypes"> OP</label>
|
||||
</div>
|
||||
<div class="pb-custom-attr-inputs">
|
||||
<input class="inp" data-group="variable" data-field="customParamTypes" placeholder="추가 속성">
|
||||
</div>
|
||||
<select class="inp pb-datatype-select" data-group="variable" data-field="dataType">
|
||||
<option value="">모든 타입</option>
|
||||
<option value="uint16">uint16</option>
|
||||
<option value="float32">float32</option>
|
||||
<option value="int32">int32</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Custom -->
|
||||
<div class="pb-group-card" data-group="custom">
|
||||
<div class="pb-group-header">
|
||||
<span class="card-sub-cap">사용자 정의 #5</span>
|
||||
</div>
|
||||
<input class="inp pb-pattern-input"
|
||||
data-group="custom" data-field="tagPatterns"
|
||||
placeholder="패턴 입력 (쉼표 구분)">
|
||||
<div class="pb-attr-checkboxes">
|
||||
<label><input type="checkbox" value="PV" data-group="custom" data-field="paramTypes"> PV</label>
|
||||
<label><input type="checkbox" value="SP" data-group="custom" data-field="paramTypes"> SP</label>
|
||||
<label><input type="checkbox" value="OP" data-group="custom" data-field="paramTypes"> OP</label>
|
||||
<label><input type="checkbox" value="MD" data-group="custom" data-field="paramTypes"> MD</label>
|
||||
<label><input type="checkbox" value="QV" data-group="custom" data-field="paramTypes"> QV</label>
|
||||
</div>
|
||||
<div class="pb-custom-attr-inputs">
|
||||
<input class="inp" data-group="custom" data-field="customParamTypes" placeholder="추가 속성">
|
||||
</div>
|
||||
<select class="inp pb-datatype-select" data-group="custom" data-field="dataType">
|
||||
<option value="">모든 타입</option>
|
||||
<option value="float32">float32</option>
|
||||
<option value="uint16">uint16</option>
|
||||
<option value="int32">int32</option>
|
||||
<option value="int64">int64</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin:8px 0">
|
||||
<button class="btn-a" onclick="pbPreview()">미리보기</button>
|
||||
<button class="btn-a" onclick="pbBuild()">Build (재구축)</button>
|
||||
<button class="btn-b" onclick="pbApply()">Apply (선택 적용)</button>
|
||||
<button class="btn-b" onclick="pbAppend()">Append (추가)</button>
|
||||
<span style="flex:1"></span>
|
||||
<button class="btn-c" onclick="pbReload()">새로고침</button>
|
||||
</div>
|
||||
|
||||
<!-- 미리보기 테이블 -->
|
||||
<div id="pb-preview-area" class="pb-preview" style="display:none">
|
||||
<div class="pb-preview-header">
|
||||
<span>미리보기 <span id="pb-preview-count" style="color:var(--a)"></span></span>
|
||||
<div class="pb-preview-actions">
|
||||
<label style="font-size:12px;display:flex;align-items:center;gap:4px;cursor:pointer">
|
||||
<input type="checkbox" id="pb-chk-all-preview" onchange="pbToggleAllPreview(this)"> 전체 선택
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table class="tbl" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:30px"><input type="checkbox" id="pb-chk-all" onchange="pbToggleAllPreview(this)"></th>
|
||||
<th>태그명</th>
|
||||
<th>파라미터</th>
|
||||
<th>데이터 타입</th>
|
||||
<th>Modbus</th>
|
||||
<th>루프#</th>
|
||||
<th>Access</th>
|
||||
<th>그룹</th>
|
||||
<th>현재 활성</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pb-preview-tbody">
|
||||
<tr><td colspan="9" style="text-align:center;padding:30px;color:#555">미리보기를 실행해주세요</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 전체 태그 목록 -->
|
||||
<div style="margin-top:14px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;flex-wrap:wrap;gap:6px">
|
||||
<h3 style="font-size:13px;font-weight:600;margin:0;color:var(--t1)">전체 태그 목록</h3>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
|
||||
<input id="pf-search" class="inp" style="width:160px" placeholder="태그명 검색..." oninput="pbScheduleReload()">
|
||||
<select id="pf-active" class="inp" style="width:110px" onchange="pbReload()">
|
||||
<option value="">전체</option>
|
||||
<option value="true">활성만</option>
|
||||
<option value="false">비활성만</option>
|
||||
</select>
|
||||
<button class="btn-c" onclick="pbReload()" style="padding:3px 10px;font-size:11px">조회</button>
|
||||
<div style="display:flex;gap:6px" id="pb-points-bulk" hidden>
|
||||
<button class="btn-a" style="padding:3px 10px;font-size:11px" onclick="pbBulkActivate(true)">✓ 선택 활성화</button>
|
||||
<button class="btn-b" style="padding:3px 10px;font-size:11px;background:#5a1a1a;color:#faa" onclick="pbBulkActivate(false)">✗ 선택 비활성화</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pb-table-wrap">
|
||||
<table class="tbl" style="width:100%;table-layout:fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:30px"><input type="checkbox" id="pb-chk-all-points" onchange="pbToggleAllPoints(this)"></th>
|
||||
<th style="width:120px">태그명</th>
|
||||
<th style="width:60px">파라미터</th>
|
||||
<th style="width:85px">데이터 타입</th>
|
||||
<th style="width:85px">Modbus</th>
|
||||
<th style="width:80px">컨트롤러</th>
|
||||
<th style="width:70px">상태</th>
|
||||
<th style="width:130px">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pb-points-tbody">
|
||||
<tr><td colspan="8" style="text-align:center;padding:30px;color:#555">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-top:8px;font-size:12px;color:var(--t1)">
|
||||
<span id="pb-pager-info">—</span>
|
||||
<button class="btn-c" id="pb-pager-prev" style="padding:3px 10px;font-size:11px" onclick="pbGoPage(-1)">‹ 이전</button>
|
||||
<button class="btn-c" id="pb-pager-next" style="padding:3px 10px;font-size:11px" onclick="pbGoPage(1)">다음 ›</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 우측: Sinam 파싱 + 상태 + 수동 추가 -->
|
||||
<div style="flex:1;min-width:260px;max-width:340px">
|
||||
|
||||
<div class="pb-right-card">
|
||||
<h3>Sinam xlsx 파싱</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:5px">
|
||||
<div style="display:flex;gap:4px">
|
||||
<input type="file" id="pb-sinam-file" accept=".xlsx" style="font-size:12px;flex:1">
|
||||
<button class="btn-b" style="font-size:11px;padding:2px 8px" onclick="pbSinamUploadBtn()">업로드</button>
|
||||
</div>
|
||||
<span id="pb-sinam-file-label" style="font-size:11px;color:#888"></span>
|
||||
<div style="border-top:1px solid #2a3a4a;margin:2px 0"></div>
|
||||
<select id="pb-sinam-existing" class="inp" style="font-size:12px" onchange="pbSinamOnSelect()">
|
||||
<option value="">— 기존 파일 선택 —</option>
|
||||
</select>
|
||||
<select id="pb-sinam-ctrl" class="inp">
|
||||
<option value="C1">C1</option>
|
||||
<option value="C2">C2</option>
|
||||
<option value="C3" selected>C3</option>
|
||||
<option value="C4">C4</option>
|
||||
</select>
|
||||
<label style="font-size:12px;display:flex;align-items:center;gap:4px;cursor:pointer">
|
||||
<input type="checkbox" id="pb-sinam-apply"> DB 적용 (체크=map_master/tag_metadata 갱신)
|
||||
</label>
|
||||
<button class="btn-a" id="pb-sinam-btn" onclick="pbSinamParse()">파싱 시작</button>
|
||||
<button class="btn-c" onclick="pbSinamGwRestart()">게이트웨이 재시작</button>
|
||||
<div id="pb-sinam-result" style="font-size:12px;line-height:1.6;margin-top:4px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pb-right-card">
|
||||
<h3>Gateway 상태</h3>
|
||||
<div style="font-size:12px;line-height:1.8">
|
||||
<div>상태: <span id="pb-gw-status" class="pb-status-err">확인 중...</span></div>
|
||||
<div>Controller IP: <span id="pb-gw-ip">—</span></div>
|
||||
<div>폴링 횟수: <span id="pb-gw-poll">—</span></div>
|
||||
<div>마지막 폴링: <span id="pb-gw-last">—</span></div>
|
||||
<div>활성 태그: <span id="pb-gw-tags">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pb-right-card">
|
||||
<h3>수동 추가</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<input id="pb-add-tag" class="inp" placeholder="TagName (예: FICQ-6201.PV)">
|
||||
<input id="pb-add-addr" class="inp" placeholder="ModbusAddr (10진수)" type="number">
|
||||
<select id="pb-add-dtype" class="inp">
|
||||
<option value="float32">float32</option>
|
||||
<option value="uint16">uint16</option>
|
||||
<option value="int32">int32</option>
|
||||
<option value="int64">int64</option>
|
||||
</select>
|
||||
<input id="pb-add-param" class="inp" placeholder="ParamType (PV/SP/OP/MODE...)" style="text-transform:uppercase">
|
||||
<select id="pb-add-access" class="inp">
|
||||
<option value="R">Read Only</option>
|
||||
<option value="RW">Read/Write</option>
|
||||
</select>
|
||||
<input id="pb-add-ctrl" class="inp" placeholder="ControllerId (HC1)" value="HC1">
|
||||
<button class="btn-a" onclick="pbAddManual()" style="margin-top:4px">등록</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user