feat: 포인트빌더 그룹화 UI + 미리보기/선택적 적용 기능

기존 단일 조건(name/dataType) 방식에서 그룹별 패턴 기반 방식으로 개편.
컨트롤러/아날로그/디지털/사용자정의 5개 그룹에 대해 태그 패턴, 속성 체크박스, DataType을 각각 설정 가능.
미리보기(PREVIEW) API 추가하여 조건에 매칭되는 포인트를 확인 후 선택적으로 적용 가능.
This commit is contained in:
windpacer
2026-05-10 17:22:37 +09:00
parent 05e2156843
commit f73ec217ad
7 changed files with 772 additions and 88 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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