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

Open
windpacer wants to merge 43 commits from feat/p0-selfservice-report into main
3 changed files with 1166 additions and 237 deletions
Showing only changes of commit c850948630 - Show all commits

View 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 });
}
}

View File

@@ -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();
};

View File

@@ -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>