feat: 포인트빌더 그룹화 UI + 미리보기/선택적 적용 기능
기존 단일 조건(name/dataType) 방식에서 그룹별 패턴 기반 방식으로 개편. 컨트롤러/아날로그/디지털/사용자정의 5개 그룹에 대해 태그 패턴, 속성 체크박스, DataType을 각각 설정 가능. 미리보기(PREVIEW) API 추가하여 조건에 매칭되는 포인트를 확인 후 선택적으로 적용 가능.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ExperionCrawler.Core.Application.DTOs;
|
||||
|
||||
public class ExperionServerConfigDto
|
||||
@@ -49,10 +51,21 @@ public class ExperionNodeMapCrawlRequestDto
|
||||
public int MaxDepth { get; set; } = 10;
|
||||
}
|
||||
|
||||
public class PointBuilderGroupDto
|
||||
{
|
||||
public List<string> TagPatterns { get; set; } = new();
|
||||
public List<string> Attributes { get; set; } = new();
|
||||
public string? DataType { get; set; }
|
||||
}
|
||||
|
||||
public class PointBuilderBuildDto
|
||||
{
|
||||
public List<string> Names { get; set; } = new();
|
||||
public List<string> DataTypes { get; set; } = new();
|
||||
public PointBuilderGroupDto Controller1 { get; set; } = new();
|
||||
[JsonPropertyName("analogmon1")]
|
||||
public PointBuilderGroupDto AnalogMonitor1 { get; set; } = new();
|
||||
public PointBuilderGroupDto Digital1 { get; set; } = new();
|
||||
public PointBuilderGroupDto Digital2 { get; set; } = new();
|
||||
public PointBuilderGroupDto Custom { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PointBuilderAddDto
|
||||
@@ -60,6 +73,26 @@ public class PointBuilderAddDto
|
||||
public string NodeId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class PointBuilderPreviewItem
|
||||
{
|
||||
public string NodeId { get; set; } = string.Empty;
|
||||
public string TagName { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string DataType { get; set; } = string.Empty;
|
||||
public string Group { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class PointBuilderPreviewResult
|
||||
{
|
||||
public int Count { get; set; }
|
||||
public List<PointBuilderPreviewItem> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PointBuilderApplyDto
|
||||
{
|
||||
public List<string> SelectedNodeIds { get; set; } = new();
|
||||
}
|
||||
|
||||
// ── TimeScaleDB 하이퍼테이블 ────────────────────────────────────────────────────
|
||||
|
||||
public class HypertableStatusDto
|
||||
|
||||
@@ -71,7 +71,9 @@ public interface IExperionDbService
|
||||
int limit, int offset);
|
||||
|
||||
// ── RealtimeTable ─────────────────────────────────────────────────────────
|
||||
Task<int> BuildRealtimeTableAsync(IEnumerable<string> names, IEnumerable<string> dataTypes);
|
||||
Task<int> BuildRealtimeTableAsync(IEnumerable<PointBuilderGroupDto> groups);
|
||||
Task<PointBuilderPreviewResult> PreviewRealtimeBuildAsync(IEnumerable<(string GroupKey, PointBuilderGroupDto Group)> groups);
|
||||
Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedNodeIds);
|
||||
Task<IEnumerable<RealtimePoint>> GetRealtimePointsAsync();
|
||||
Task<RealtimePoint> AddRealtimePointAsync(string nodeId);
|
||||
Task<bool> DeleteRealtimePointAsync(int id);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ExperionCrawler.Core.Application.DTOs;
|
||||
using ExperionCrawler.Core.Application.Interfaces;
|
||||
using ExperionCrawler.Core.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -299,9 +300,10 @@ public class ExperionDbService : IExperionDbService
|
||||
CREATE INDEX IF NOT EXISTS idx_tag_metadata_base ON tag_metadata(base_tag)
|
||||
""");
|
||||
|
||||
// v_tag_summary 뷰 생성 (실시간 + 메타데이터 통합)
|
||||
// v_tag_summary 뷰 재생성 (컬럼 변경 시 DROP 필요)
|
||||
await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_tag_summary");
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
CREATE OR REPLACE VIEW v_tag_summary AS
|
||||
CREATE VIEW v_tag_summary AS
|
||||
SELECT
|
||||
rt_base.base_tag,
|
||||
pv_rt.livevalue AS pv,
|
||||
@@ -323,15 +325,6 @@ public class ExperionDbService : IExperionDbService
|
||||
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
|
||||
""");
|
||||
|
||||
// state descriptor 고아 데이터 정리 (state0~7descriptor는 더 이상 로딩하지 않음)
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
DELETE FROM tag_metadata WHERE attribute IN (
|
||||
'state0descriptor', 'state1descriptor', 'state2descriptor',
|
||||
'state3descriptor', 'state4descriptor', 'state5descriptor',
|
||||
'state6descriptor', 'state7descriptor'
|
||||
)
|
||||
""");
|
||||
|
||||
// history 테이블은 수동으로 하이퍼테이블 생성 필요
|
||||
// CreateHypertableAsync() 메서드를 사용하여 수동 생성 가능
|
||||
// 참고: 하이퍼테이블 생성 후 보존 정책, 압축 정책, 연속 집계 설정은
|
||||
@@ -518,22 +511,54 @@ public class ExperionDbService : IExperionDbService
|
||||
return idx >= 0 ? nodeId[(idx + 1)..] : nodeId;
|
||||
}
|
||||
|
||||
public async Task<int> BuildRealtimeTableAsync(
|
||||
IEnumerable<string> names, IEnumerable<string> dataTypes)
|
||||
public async Task<int> BuildRealtimeTableAsync(IEnumerable<PointBuilderGroupDto> groups)
|
||||
{
|
||||
var nameList = names.Where(n => !string.IsNullOrEmpty(n)).ToList();
|
||||
var dtList = dataTypes.Where(d => !string.IsNullOrEmpty(d)).ToList();
|
||||
var activeGroups = groups.Where(g =>
|
||||
g.TagPatterns != null && g.TagPatterns.Count > 0
|
||||
).ToList();
|
||||
|
||||
var q = _ctx.NodeMapMasters.AsQueryable();
|
||||
if (nameList.Count > 0) q = q.Where(x => nameList.Contains(x.Name));
|
||||
if (dtList.Count > 0) q = q.Where(x => dtList.Contains(x.DataType));
|
||||
if (activeGroups.Count == 0)
|
||||
return 0;
|
||||
|
||||
var sources = await q.ToListAsync();
|
||||
var allSources = new List<NodeMapMaster>();
|
||||
|
||||
foreach (var g in activeGroups)
|
||||
{
|
||||
var patterns = g.TagPatterns.Where(p => !string.IsNullOrEmpty(p)).ToList();
|
||||
var attrs = g.Attributes.Where(a => !string.IsNullOrEmpty(a)).ToList();
|
||||
|
||||
if (patterns.Count == 0) continue;
|
||||
|
||||
var q = _ctx.NodeMapMasters.Where(x => x.Level == 3);
|
||||
|
||||
var patternList = patterns;
|
||||
q = q.Where(x => patternList.Any(p => EF.Functions.Like(x.NodeId, p)));
|
||||
|
||||
if (attrs.Count > 0)
|
||||
{
|
||||
var attrList = attrs;
|
||||
q = q.Where(x => attrList.Contains(x.Name));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(g.DataType))
|
||||
{
|
||||
var dt = g.DataType;
|
||||
q = q.Where(x => x.DataType == dt);
|
||||
}
|
||||
|
||||
var sources = await q.ToListAsync();
|
||||
allSources.AddRange(sources);
|
||||
}
|
||||
|
||||
var distinctSources = allSources
|
||||
.GroupBy(s => s.NodeId)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
|
||||
await _ctx.Database.ExecuteSqlRawAsync(
|
||||
"TRUNCATE TABLE realtime_table RESTART IDENTITY");
|
||||
|
||||
var points = sources.Select(s => new RealtimePoint
|
||||
var points = distinctSources.Select(s => new RealtimePoint
|
||||
{
|
||||
TagName = ExtractTagName(s.NodeId),
|
||||
NodeId = s.NodeId,
|
||||
@@ -547,6 +572,87 @@ public class ExperionDbService : IExperionDbService
|
||||
return saved;
|
||||
}
|
||||
|
||||
public async Task<PointBuilderPreviewResult> PreviewRealtimeBuildAsync(
|
||||
IEnumerable<(string GroupKey, PointBuilderGroupDto Group)> groups)
|
||||
{
|
||||
var activeGroups = groups.Where(g =>
|
||||
g.Group.TagPatterns != null && g.Group.TagPatterns.Count > 0
|
||||
).ToList();
|
||||
|
||||
if (activeGroups.Count == 0)
|
||||
return new PointBuilderPreviewResult();
|
||||
|
||||
var allSources = new List<(NodeMapMaster Node, string Group)>();
|
||||
|
||||
foreach (var (groupKey, g) in activeGroups)
|
||||
{
|
||||
var patterns = g.TagPatterns.Where(p => !string.IsNullOrEmpty(p)).ToList();
|
||||
var attrs = g.Attributes.Where(a => !string.IsNullOrEmpty(a)).ToList();
|
||||
|
||||
if (patterns.Count == 0) continue;
|
||||
|
||||
var q = _ctx.NodeMapMasters.Where(x => x.Level == 3);
|
||||
|
||||
var patternList = patterns;
|
||||
q = q.Where(x => patternList.Any(p => EF.Functions.Like(x.NodeId, p)));
|
||||
|
||||
if (attrs.Count > 0)
|
||||
{
|
||||
var attrList = attrs;
|
||||
q = q.Where(x => attrList.Contains(x.Name));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(g.DataType))
|
||||
{
|
||||
var dt = g.DataType;
|
||||
q = q.Where(x => x.DataType == dt);
|
||||
}
|
||||
|
||||
var sources = await q.ToListAsync();
|
||||
foreach (var s in sources)
|
||||
allSources.Add((s, groupKey));
|
||||
}
|
||||
|
||||
var distinct = allSources
|
||||
.GroupBy(x => x.Node.NodeId)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
|
||||
var items = distinct.Select(x => new PointBuilderPreviewItem
|
||||
{
|
||||
NodeId = x.Node.NodeId,
|
||||
TagName = ExtractTagName(x.Node.NodeId),
|
||||
Name = x.Node.Name,
|
||||
DataType = x.Node.DataType,
|
||||
Group = x.Group
|
||||
}).ToList();
|
||||
|
||||
return new PointBuilderPreviewResult { Count = items.Count, Items = items };
|
||||
}
|
||||
|
||||
public async Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedNodeIds)
|
||||
{
|
||||
var nodeIds = selectedNodeIds.Where(n => !string.IsNullOrEmpty(n)).ToList();
|
||||
if (nodeIds.Count == 0) return 0;
|
||||
|
||||
await _ctx.Database.ExecuteSqlRawAsync(
|
||||
"TRUNCATE TABLE realtime_table RESTART IDENTITY");
|
||||
|
||||
var points = nodeIds.Select(nodeId => new RealtimePoint
|
||||
{
|
||||
TagName = ExtractTagName(nodeId),
|
||||
NodeId = nodeId,
|
||||
LiveValue = null,
|
||||
Timestamp = DateTime.UtcNow
|
||||
}).ToList();
|
||||
|
||||
await _ctx.RealtimePoints.AddRangeAsync(points);
|
||||
await _ctx.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("[ExperionDb] realtime_table 적용: {Count}건 (선택)", points.Count);
|
||||
return points.Count;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<RealtimePoint>> GetRealtimePointsAsync()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -275,20 +275,109 @@ public class ExperionPointBuilderController : ControllerBase
|
||||
{
|
||||
private readonly IExperionDbService _dbSvc;
|
||||
private readonly IExperionRealtimeService _realtimeSvc;
|
||||
private readonly IMetadataLoaderService _metaSvc;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<ExperionPointBuilderController> _logger;
|
||||
public ExperionPointBuilderController(
|
||||
IExperionDbService dbSvc,
|
||||
IExperionRealtimeService realtimeSvc)
|
||||
IExperionRealtimeService realtimeSvc,
|
||||
IMetadataLoaderService metaSvc,
|
||||
IConfiguration config,
|
||||
ILogger<ExperionPointBuilderController> logger)
|
||||
{
|
||||
_dbSvc = dbSvc;
|
||||
_realtimeSvc = realtimeSvc;
|
||||
_metaSvc = metaSvc;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>node_map_master → realtime_table 빌드 (기존 데이터 전체 교체)</summary>
|
||||
private ExperionServerConfig GetServerConfig()
|
||||
{
|
||||
var section = _config.GetSection("Experion:Default");
|
||||
return new ExperionServerConfig
|
||||
{
|
||||
ServerHostName = section["ServerHostName"] ?? "",
|
||||
Port = section.GetValue<int?>("Port") ?? 4840,
|
||||
ClientHostName = section["ClientHostName"] ?? "dbsvr",
|
||||
UserName = section["UserName"] ?? "",
|
||||
Password = section["Password"] ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>node_map_master → realtime_table 빌드 (기존 데이터 전체 교체) + 메타데이터 자동 로드</summary>
|
||||
[HttpPost("build")]
|
||||
public async Task<IActionResult> Build([FromBody] PointBuilderBuildDto dto)
|
||||
{
|
||||
var count = await _dbSvc.BuildRealtimeTableAsync(dto.Names, dto.DataTypes);
|
||||
return Ok(new { success = true, count, message = $"{count}개 포인트 생성 완료" });
|
||||
var count = await _dbSvc.BuildRealtimeTableAsync(new[]
|
||||
{
|
||||
dto.Controller1, dto.AnalogMonitor1, dto.Digital1, dto.Digital2, dto.Custom
|
||||
});
|
||||
|
||||
var metaCount = 0;
|
||||
try
|
||||
{
|
||||
var cfg = GetServerConfig();
|
||||
metaCount = await _metaSvc.ReloadMetadataAsync(cfg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[PointBuilder] 메타데이터 자동 로드 실패 (포인트 빌드는 완료됨)");
|
||||
}
|
||||
|
||||
return Ok(new { success = true, count, metaCount, message = $"{count}개 포인트 생성 완료 (메타데이터: {metaCount}개)" });
|
||||
}
|
||||
|
||||
/// <summary>조건에 맞는 포인트 미리보기 (READ-ONLY, DB 변경 없음)</summary>
|
||||
[HttpPost("preview")]
|
||||
public async Task<IActionResult> Preview([FromBody] PointBuilderBuildDto dto)
|
||||
{
|
||||
var groups = new[]
|
||||
{
|
||||
("controller1", dto.Controller1),
|
||||
("analogmon1", dto.AnalogMonitor1),
|
||||
("digital1", dto.Digital1),
|
||||
("digital2", dto.Digital2),
|
||||
("custom", dto.Custom)
|
||||
};
|
||||
|
||||
var result = await _dbSvc.PreviewRealtimeBuildAsync(groups);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
count = result.Count,
|
||||
items = result.Items.Select(i => new
|
||||
{
|
||||
nodeId = i.NodeId,
|
||||
tagName = i.TagName,
|
||||
name = i.Name,
|
||||
dataType = i.DataType,
|
||||
group = i.Group
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>선택된 포인트만 realtime_table에 적용</summary>
|
||||
[HttpPost("apply")]
|
||||
public async Task<IActionResult> Apply([FromBody] PointBuilderApplyDto dto)
|
||||
{
|
||||
if (dto.SelectedNodeIds == null || dto.SelectedNodeIds.Count == 0)
|
||||
return BadRequest(new { success = false, message = "선택된 포인트가 없습니다." });
|
||||
|
||||
var count = await _dbSvc.ApplySelectedPointsAsync(dto.SelectedNodeIds);
|
||||
|
||||
var metaCount = 0;
|
||||
try
|
||||
{
|
||||
var cfg = GetServerConfig();
|
||||
metaCount = await _metaSvc.ReloadMetadataAsync(cfg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[PointBuilder] 메타데이터 자동 로드 실패 (포인트 적용은 완료됨)");
|
||||
}
|
||||
|
||||
return Ok(new { success = true, count, metaCount, message = $"{count}개 포인트 적용 완료 (메타데이터: {metaCount}개)" });
|
||||
}
|
||||
|
||||
/// <summary>realtime_table 전체 조회</summary>
|
||||
|
||||
@@ -507,8 +507,68 @@ tr:last-child td { border-bottom: none; }
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.pb-group-card {
|
||||
background: var(--s3);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pb-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-sub-cap {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--a2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
|
||||
.pb-pattern-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pb-attr-checkboxes {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin: 6px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pb-attr-checkboxes label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--t1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pb-custom-attr-inputs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pb-custom-attr-inputs .inp {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.pb-datatype-select {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.pb-name-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.pb-custom-attr-inputs { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* ── Custom DateTime Picker ──────────────────────────────── */
|
||||
@@ -1376,3 +1436,54 @@ tr:last-child td { border-bottom: none; }
|
||||
#pane-pid .logbox .ok { color: var(--grn); }
|
||||
#pane-pid .logbox .err { color: var(--red); }
|
||||
#pane-pid .logbox .inf { color: var(--blu); }
|
||||
|
||||
/* ── 포인트빌더 미리보기 ───────────────────────────────────── */
|
||||
.pb-preview {
|
||||
background: var(--s3);
|
||||
border: 1px solid var(--bd);
|
||||
border-radius: var(--r);
|
||||
padding: 14px 16px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.pb-preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pb-preview-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pb-preview table th:first-child,
|
||||
.pb-preview table td:first-child {
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pb-preview table input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.pb-preview .group-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--ag);
|
||||
color: var(--a);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.pb-preview-header { flex-direction: column; gap: 8px; align-items: flex-start; }
|
||||
.pb-preview-actions { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
@@ -415,33 +415,213 @@
|
||||
<div class="cols-2">
|
||||
<div class="card">
|
||||
<div class="card-cap">조건으로 테이블 작성</div>
|
||||
<div class="fg">
|
||||
<label>이름(name) 선택 <em>(OR 조건, 최대 10개)</em>
|
||||
<button class="btn-b btn-sm" onclick="pbLoad()" style="margin-left:4px">▼ 옵션 불러오기</button>
|
||||
</label>
|
||||
<div class="pb-name-grid" id="pb-name-grid">
|
||||
<!-- JS 에서 드롭다운 동적 생성 -->
|
||||
<select id="pb-n1" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n2" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n3" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n4" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n5" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n6" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n7" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n8" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n9" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<select id="pb-n10" class="inp"><option value="">— 선택 안 함 —</option></select>
|
||||
<div id="pb-build-log" class="logbox hidden" style="margin-bottom:10px"></div>
|
||||
|
||||
<!-- 컨트롤러 포인트 #1 -->
|
||||
<div class="pb-group-card" id="pb-group-controller1">
|
||||
<div class="pb-group-header">
|
||||
<span class="card-sub-cap">컨트롤러 포인트 #1</span>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>태그명 패턴 <em>(쉼표 구분, LIKE)</em></label>
|
||||
<input class="pb-pattern-input inp" data-group="controller1" data-field="tagPatterns" placeholder="예: %ctl-61%.pv, %ctl-62%.sp"/>
|
||||
</div>
|
||||
<div class="pb-attr-checkboxes">
|
||||
<label><input type="checkbox" data-group="controller1" data-field="attributes" value="pv"/> pv</label>
|
||||
<label><input type="checkbox" data-group="controller1" data-field="attributes" value="op"/> op</label>
|
||||
<label><input type="checkbox" data-group="controller1" data-field="attributes" value="sp"/> sp</label>
|
||||
<label><input type="checkbox" data-group="controller1" data-field="attributes" value="md"/> md</label>
|
||||
</div>
|
||||
<div class="pb-custom-attr-inputs">
|
||||
<input class="inp" data-group="controller1" data-field="customAttrs" placeholder="추가 속성 1"/>
|
||||
<input class="inp" data-group="controller1" data-field="customAttrs" placeholder="추가 속성 2"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<select class="pb-datatype-select inp" data-group="controller1" data-field="dataType">
|
||||
<option value="">전체</option>
|
||||
<option value="Double">Double</option>
|
||||
<option value="i=7594">i=7594</option>
|
||||
<option value="Boolean">Boolean</option>
|
||||
<option value="String">String</option>
|
||||
<option value="Int16">Int16</option>
|
||||
<option value="Int32">Int32</option>
|
||||
<option value="UInt16">UInt16</option>
|
||||
<option value="UInt32">UInt32</option>
|
||||
<option value="Float">Float</option>
|
||||
<option value="DateTime">DateTime</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>데이터 타입(data_type) 직접 입력 <em>(OR 조건, 최대 2개)</em></label>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<input id="pb-dt1" class="inp" placeholder="예: Double"/>
|
||||
<input id="pb-dt2" class="inp" placeholder="예: Int32"/>
|
||||
|
||||
<!-- 아날로그 모니터링 포인트 #2 -->
|
||||
<div class="pb-group-card" id="pb-group-analogmon1">
|
||||
<div class="pb-group-header">
|
||||
<span class="card-sub-cap">아날로그 모니터링 포인트 #2</span>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>태그명 패턴 <em>(쉼표 구분, LIKE)</em></label>
|
||||
<input class="pb-pattern-input inp" data-group="analogmon1" data-field="tagPatterns" placeholder="예: %ti-61%.pv, %pi-62%.pv"/>
|
||||
</div>
|
||||
<div class="pb-attr-checkboxes">
|
||||
<label><input type="checkbox" data-group="analogmon1" data-field="attributes" value="pv"/> pv</label>
|
||||
<label><input type="checkbox" data-group="analogmon1" data-field="attributes" value="op"/> op</label>
|
||||
<label><input type="checkbox" data-group="analogmon1" data-field="attributes" value="sp"/> sp</label>
|
||||
<label><input type="checkbox" data-group="analogmon1" data-field="attributes" value="md"/> md</label>
|
||||
</div>
|
||||
<div class="pb-custom-attr-inputs">
|
||||
<input class="inp" data-group="analogmon1" data-field="customAttrs" placeholder="추가 속성 1"/>
|
||||
<input class="inp" data-group="analogmon1" data-field="customAttrs" placeholder="추가 속성 2"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<select class="pb-datatype-select inp" data-group="analogmon1" data-field="dataType">
|
||||
<option value="">전체</option>
|
||||
<option value="Double">Double</option>
|
||||
<option value="i=7594">i=7594</option>
|
||||
<option value="Boolean">Boolean</option>
|
||||
<option value="String">String</option>
|
||||
<option value="Int16">Int16</option>
|
||||
<option value="Int32">Int32</option>
|
||||
<option value="UInt16">UInt16</option>
|
||||
<option value="UInt32">UInt32</option>
|
||||
<option value="Float">Float</option>
|
||||
<option value="DateTime">DateTime</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 디지털 포인트 #1 -->
|
||||
<div class="pb-group-card" id="pb-group-digital1">
|
||||
<div class="pb-group-header">
|
||||
<span class="card-sub-cap">디지털 포인트 #1</span>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>태그명 패턴 <em>(쉼표 구분, LIKE)</em></label>
|
||||
<input class="pb-pattern-input inp" data-group="digital1" data-field="tagPatterns" placeholder="예: %ys-61%.pv, %yt-62%.pv"/>
|
||||
</div>
|
||||
<div class="pb-attr-checkboxes">
|
||||
<label><input type="checkbox" data-group="digital1" data-field="attributes" value="pv"/> pv</label>
|
||||
<label><input type="checkbox" data-group="digital1" data-field="attributes" value="op"/> op</label>
|
||||
<label><input type="checkbox" data-group="digital1" data-field="attributes" value="sp"/> sp</label>
|
||||
<label><input type="checkbox" data-group="digital1" data-field="attributes" value="md"/> md</label>
|
||||
</div>
|
||||
<div class="pb-custom-attr-inputs">
|
||||
<input class="inp" data-group="digital1" data-field="customAttrs" placeholder="추가 속성 1"/>
|
||||
<input class="inp" data-group="digital1" data-field="customAttrs" placeholder="추가 속성 2"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<select class="pb-datatype-select inp" data-group="digital1" data-field="dataType">
|
||||
<option value="">전체</option>
|
||||
<option value="Double">Double</option>
|
||||
<option value="i=7594">i=7594</option>
|
||||
<option value="Boolean">Boolean</option>
|
||||
<option value="String">String</option>
|
||||
<option value="Int16">Int16</option>
|
||||
<option value="Int32">Int32</option>
|
||||
<option value="UInt16">UInt16</option>
|
||||
<option value="UInt32">UInt32</option>
|
||||
<option value="Float">Float</option>
|
||||
<option value="DateTime">DateTime</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 디지털 포인트 #2 -->
|
||||
<div class="pb-group-card" id="pb-group-digital2">
|
||||
<div class="pb-group-header">
|
||||
<span class="card-sub-cap">디지털 포인트 #2</span>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>태그명 패턴 <em>(쉼표 구분, LIKE)</em></label>
|
||||
<input class="pb-pattern-input inp" data-group="digital2" data-field="tagPatterns" placeholder="예: %ys-63%.pv, %yt-64%.pv"/>
|
||||
</div>
|
||||
<div class="pb-attr-checkboxes">
|
||||
<label><input type="checkbox" data-group="digital2" data-field="attributes" value="pv"/> pv</label>
|
||||
<label><input type="checkbox" data-group="digital2" data-field="attributes" value="op"/> op</label>
|
||||
<label><input type="checkbox" data-group="digital2" data-field="attributes" value="sp"/> sp</label>
|
||||
<label><input type="checkbox" data-group="digital2" data-field="attributes" value="md"/> md</label>
|
||||
</div>
|
||||
<div class="pb-custom-attr-inputs">
|
||||
<input class="inp" data-group="digital2" data-field="customAttrs" placeholder="추가 속성 1"/>
|
||||
<input class="inp" data-group="digital2" data-field="customAttrs" placeholder="추가 속성 2"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<select class="pb-datatype-select inp" data-group="digital2" data-field="dataType">
|
||||
<option value="">전체</option>
|
||||
<option value="Double">Double</option>
|
||||
<option value="i=7594">i=7594</option>
|
||||
<option value="Boolean">Boolean</option>
|
||||
<option value="String">String</option>
|
||||
<option value="Int16">Int16</option>
|
||||
<option value="Int32">Int32</option>
|
||||
<option value="UInt16">UInt16</option>
|
||||
<option value="UInt32">UInt32</option>
|
||||
<option value="Float">Float</option>
|
||||
<option value="DateTime">DateTime</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 정의 -->
|
||||
<div class="pb-group-card" id="pb-group-custom">
|
||||
<div class="pb-group-header">
|
||||
<span class="card-sub-cap">사용자 정의</span>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>태그명 패턴 <em>(쉼표 구분, LIKE)</em></label>
|
||||
<input class="pb-pattern-input inp" data-group="custom" data-field="tagPatterns" placeholder="예: %custom%.pv"/>
|
||||
</div>
|
||||
<div class="pb-attr-checkboxes">
|
||||
<label><input type="checkbox" data-group="custom" data-field="attributes" value="pv"/> pv</label>
|
||||
<label><input type="checkbox" data-group="custom" data-field="attributes" value="op"/> op</label>
|
||||
<label><input type="checkbox" data-group="custom" data-field="attributes" value="sp"/> sp</label>
|
||||
<label><input type="checkbox" data-group="custom" data-field="attributes" value="md"/> md</label>
|
||||
</div>
|
||||
<div class="pb-custom-attr-inputs">
|
||||
<input class="inp" data-group="custom" data-field="customAttrs" placeholder="추가 속성 1"/>
|
||||
<input class="inp" data-group="custom" data-field="customAttrs" placeholder="추가 속성 2"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<select class="pb-datatype-select inp" data-group="custom" data-field="dataType">
|
||||
<option value="">전체</option>
|
||||
<option value="Double">Double</option>
|
||||
<option value="i=7594">i=7594</option>
|
||||
<option value="Boolean">Boolean</option>
|
||||
<option value="String">String</option>
|
||||
<option value="Int16">Int16</option>
|
||||
<option value="Int32">Int32</option>
|
||||
<option value="UInt16">UInt16</option>
|
||||
<option value="UInt32">UInt32</option>
|
||||
<option value="Float">Float</option>
|
||||
<option value="DateTime">DateTime</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn-b" onclick="pbPreview()">🔍 미리보기</button>
|
||||
<button class="btn-a" onclick="pbBuild()">🔨 테이블 작성하기</button>
|
||||
<button class="btn-b" onclick="pbRefresh()">📋 테이블 조회</button>
|
||||
</div>
|
||||
|
||||
<div id="pb-preview" class="pb-preview hidden">
|
||||
<div class="pb-preview-header">
|
||||
<span>미리보기 결과 <span id="pb-preview-count" class="mut">(0개)</span></span>
|
||||
<div class="pb-preview-actions">
|
||||
<button class="btn-sm btn-b" onclick="pbPreviewSelectAll()">전체 선택</button>
|
||||
<button class="btn-sm btn-b" onclick="pbPreviewDeselectAll()">전체 해제</button>
|
||||
<button class="btn-sm btn-b" onclick="pbPreviewInvert()">역전</button>
|
||||
<span id="pb-preview-selected" class="mut">선택: 0/0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fg" style="margin-bottom:8px">
|
||||
<input class="inp" id="pb-preview-search" placeholder="태그명으로 검색..." oninput="pbPreviewFilter()"/>
|
||||
</div>
|
||||
<div id="pb-preview-table" class="tbl-wrap" style="max-height:420px;overflow:auto"></div>
|
||||
<div class="btn-row" style="margin-top:10px;margin-bottom:0">
|
||||
<button class="btn-b" onclick="pbCancelPreview()">취소</button>
|
||||
<button class="btn-a" id="pb-apply-btn" onclick="pbApplySelected()">✓ 선택된 포인트 적용하기</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-a" onclick="pbBuild()">🔨 테이블 작성하기</button>
|
||||
<div id="pb-build-log" class="logbox hidden" style="margin-top:10px"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
|
||||
@@ -572,32 +572,211 @@ function nmReset() {
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
06 포인트빌더
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const PB_NAME_IDS = ['pb-n1','pb-n2','pb-n3','pb-n4','pb-n5','pb-n6','pb-n7','pb-n8','pb-n9','pb-n10'];
|
||||
const PB_DT_IDS = ['pb-dt1','pb-dt2'];
|
||||
const PB_GROUPS = ['controller1', 'analogmon1', 'digital1', 'digital2', 'custom'];
|
||||
let pbPreviewData = [];
|
||||
|
||||
async function pbLoad() {
|
||||
const logEl = document.getElementById('pb-build-log');
|
||||
logEl.classList.remove('hidden');
|
||||
logEl.innerHTML = '<div class="ll inf">옵션 불러오기 시작...</div>';
|
||||
setGlobal('busy', '옵션 불러오는 중');
|
||||
function pbCollectGroupData(groupKey) {
|
||||
const patternInput = document.querySelector(`input[data-group="${groupKey}"][data-field="tagPatterns"]`);
|
||||
const tagPatterns = patternInput?.value
|
||||
? patternInput.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const checkedAttrs = Array.from(
|
||||
document.querySelectorAll(`input[data-group="${groupKey}"][data-field="attributes"]:checked`)
|
||||
).map(cb => cb.value);
|
||||
|
||||
const customInputs = document.querySelectorAll(`input[data-group="${groupKey}"][data-field="customAttrs"]`);
|
||||
customInputs.forEach(inp => {
|
||||
const v = inp.value.trim();
|
||||
if (v) checkedAttrs.push(v);
|
||||
});
|
||||
|
||||
const dataTypeEl = document.querySelector(`select[data-group="${groupKey}"][data-field="dataType"]`);
|
||||
const dataType = dataTypeEl?.value || null;
|
||||
|
||||
return { tagPatterns, attributes: checkedAttrs, dataType };
|
||||
}
|
||||
|
||||
async function pbBuild() {
|
||||
const groups = {};
|
||||
for (const gk of PB_GROUPS) {
|
||||
const gd = pbCollectGroupData(gk);
|
||||
if (gd.tagPatterns.length > 0) {
|
||||
groups[gk] = gd;
|
||||
}
|
||||
}
|
||||
|
||||
const activeKeys = Object.keys(groups);
|
||||
if (activeKeys.length === 0) {
|
||||
const logEl = document.getElementById('pb-build-log');
|
||||
logEl.classList.remove('hidden');
|
||||
logEl.innerHTML = '<div class="ll err">⚠️ 태그명 패턴을 최소 1개 입력하세요.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
setGlobal('busy', '포인트 빌드 중');
|
||||
try {
|
||||
const n = await api('GET', '/api/nodemap/names');
|
||||
const names = n.names || [];
|
||||
const nameOpts = '<option value="">— 선택 안 함 —</option>' +
|
||||
names.map(nm => `<option value="${esc(nm)}">${esc(nm)}</option>`).join('');
|
||||
PB_NAME_IDS.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
const cur = el.value;
|
||||
el.innerHTML = nameOpts;
|
||||
if (cur) el.value = cur;
|
||||
});
|
||||
logEl.innerHTML += `<div class="ll ok">✅ ${names.length}개 이름 로드 완료</div>`;
|
||||
setGlobal('ok', `${names.length}개 로드`);
|
||||
const d = await api('POST', '/api/pointbuilder/build', groups);
|
||||
const logEl = document.getElementById('pb-build-log');
|
||||
logEl.classList.remove('hidden');
|
||||
logEl.innerHTML = `<div class="ll ${d.success ? 'ok' : 'err'}">${d.success ? '✅' : '❌'} ${esc(d.message)}</div>`;
|
||||
setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트` : '빌드 실패');
|
||||
if (d.success) await pbRefresh();
|
||||
} catch (e) {
|
||||
logEl.innerHTML += `<div class="ll err">❌ 옵션 불러오기 실패: ${esc(e.message)}</div>`;
|
||||
setGlobal('err', '로드 실패');
|
||||
console.error('pbLoad:', e);
|
||||
const logEl = document.getElementById('pb-build-log');
|
||||
logEl.classList.remove('hidden');
|
||||
logEl.innerHTML = `<div class="ll err">❌ ${esc(e.message)}</div>`;
|
||||
setGlobal('err', '오류');
|
||||
}
|
||||
}
|
||||
|
||||
async function pbPreview() {
|
||||
const groups = {};
|
||||
for (const gk of PB_GROUPS) {
|
||||
const gd = pbCollectGroupData(gk);
|
||||
if (gd.tagPatterns.length > 0) {
|
||||
groups[gk] = gd;
|
||||
}
|
||||
}
|
||||
|
||||
const activeKeys = Object.keys(groups);
|
||||
if (activeKeys.length === 0) {
|
||||
const logEl = document.getElementById('pb-build-log');
|
||||
logEl.classList.remove('hidden');
|
||||
logEl.innerHTML = '<div class="ll err">⚠️ 태그명 패턴을 최소 1개 입력하세요.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
setGlobal('busy', '미리보기 조회 중');
|
||||
try {
|
||||
const d = await api('POST', '/api/pointbuilder/preview', groups);
|
||||
pbPreviewData = (d.items || []).map((item, idx) => ({ ...item, selected: true, idx }));
|
||||
document.getElementById('pb-preview-count').textContent = `(${d.count}개)`;
|
||||
document.getElementById('pb-preview').classList.remove('hidden');
|
||||
pbRenderPreview(pbPreviewData);
|
||||
setGlobal('ok', `미리보기: ${d.count}개 포인트`);
|
||||
} catch (e) {
|
||||
setGlobal('err', '미리보기 실패');
|
||||
}
|
||||
}
|
||||
|
||||
function pbRenderPreview(data) {
|
||||
const el = document.getElementById('pb-preview-table');
|
||||
const filtered = pbGetFilteredPreview();
|
||||
const pts = filtered.length > 0 ? filtered : data;
|
||||
|
||||
if (pts.length === 0) {
|
||||
el.innerHTML = '<div style="padding:20px;color:var(--t2)">조건에 맞는 포인트가 없습니다.</div>';
|
||||
pbUpdatePreviewCount();
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" onchange="pbPreviewToggleAll(this.checked)" title="전체 선택/해제"/></th>
|
||||
<th>ID</th>
|
||||
<th>TagName</th>
|
||||
<th>NodeType</th>
|
||||
<th>DataType</th>
|
||||
<th>Group</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${pts.map((p, i) => `
|
||||
<tr style="${!p.selected ? 'opacity:0.5' : ''}">
|
||||
<td><input type="checkbox" ${p.selected ? 'checked' : ''} onchange="pbPreviewToggleItem(${p.idx})"/></td>
|
||||
<td class="mut">${i + 1}</td>
|
||||
<td style="font-weight:600">${esc((p?.tagName)?.toUpperCase() || '')}</td>
|
||||
<td>${esc(p?.name || '')}</td>
|
||||
<td class="mut">${esc(p?.dataType || '')}</td>
|
||||
<td><span class="group-badge">${esc(p?.group || '')}</span></td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
pbUpdatePreviewCount();
|
||||
}
|
||||
|
||||
function pbPreviewToggleItem(idx) {
|
||||
pbPreviewData[idx].selected = !pbPreviewData[idx].selected;
|
||||
pbRenderPreview(pbPreviewData);
|
||||
}
|
||||
|
||||
function pbPreviewToggleAll(checked) {
|
||||
pbPreviewData.forEach((item) => {
|
||||
const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase();
|
||||
if (searchVal) {
|
||||
const filtered = pbGetFilteredPreview();
|
||||
if (filtered.includes(item)) {
|
||||
item.selected = checked;
|
||||
}
|
||||
} else {
|
||||
item.selected = checked;
|
||||
}
|
||||
});
|
||||
pbRenderPreview(pbPreviewData);
|
||||
}
|
||||
|
||||
function pbPreviewSelectAll() {
|
||||
pbPreviewData.forEach(p => p.selected = true);
|
||||
pbRenderPreview(pbPreviewData);
|
||||
}
|
||||
|
||||
function pbPreviewDeselectAll() {
|
||||
pbPreviewData.forEach(p => p.selected = false);
|
||||
pbRenderPreview(pbPreviewData);
|
||||
}
|
||||
|
||||
function pbPreviewInvert() {
|
||||
pbPreviewData.forEach(p => p.selected = !p.selected);
|
||||
pbRenderPreview(pbPreviewData);
|
||||
}
|
||||
|
||||
function pbGetFilteredPreview() {
|
||||
const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase();
|
||||
if (!searchVal) return [];
|
||||
return pbPreviewData.filter(p =>
|
||||
(p.tagName || '').toLowerCase().includes(searchVal) ||
|
||||
(p.nodeId || '').toLowerCase().includes(searchVal) ||
|
||||
(p.name || '').toLowerCase().includes(searchVal)
|
||||
);
|
||||
}
|
||||
|
||||
function pbPreviewFilter() {
|
||||
pbRenderPreview(pbPreviewData);
|
||||
}
|
||||
|
||||
function pbUpdatePreviewCount() {
|
||||
const selected = pbPreviewData.filter(p => p.selected).length;
|
||||
const total = pbPreviewData.length;
|
||||
document.getElementById('pb-preview-selected').textContent = `선택: ${selected}/${total}`;
|
||||
}
|
||||
|
||||
function pbCancelPreview() {
|
||||
document.getElementById('pb-preview').classList.add('hidden');
|
||||
pbPreviewData = [];
|
||||
}
|
||||
|
||||
async function pbApplySelected() {
|
||||
const selected = pbPreviewData.filter(p => p.selected).map(p => p.nodeId);
|
||||
if (selected.length === 0) {
|
||||
setGlobal('err', '적용할 포인트를 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setGlobal('busy', `${selected.length}개 포인트 적용 중`);
|
||||
try {
|
||||
const d = await api('POST', '/api/pointbuilder/apply', { selectedNodeIds: selected });
|
||||
setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트 적용 완료` : '적용 실패');
|
||||
if (d.success) {
|
||||
pbCancelPreview();
|
||||
await pbRefresh();
|
||||
}
|
||||
} catch (e) {
|
||||
setGlobal('err', '적용 오류');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,22 +819,6 @@ function pbRender(points) {
|
||||
`;
|
||||
}
|
||||
|
||||
async function pbBuild() {
|
||||
const names = PB_NAME_IDS.map(id => document.getElementById(id).value).filter(Boolean);
|
||||
const dataTypes = PB_DT_IDS.map(id => document.getElementById(id).value).filter(Boolean);
|
||||
|
||||
setGlobal('busy', '포인트 빌드 중');
|
||||
try {
|
||||
const d = await api('POST', '/api/pointbuilder/build', { names, dataTypes });
|
||||
log('pb-build-log', [{ c: d.success ? 'ok' : 'err', t: (d.success ? '✅ ' : '❌ ') + d.message }]);
|
||||
setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트` : '빌드 실패');
|
||||
if (d.success) await pbRefresh();
|
||||
} catch (e) {
|
||||
log('pb-build-log', [{ c: 'err', t: '❌ ' + e.message }]);
|
||||
setGlobal('err', '오류');
|
||||
}
|
||||
}
|
||||
|
||||
async function pbAddManual() {
|
||||
const nodeId = document.getElementById('pb-manual-nid').value.trim();
|
||||
if (!nodeId) { alert('Node ID를 입력하세요.'); return; }
|
||||
|
||||
Reference in New Issue
Block a user