diff --git a/backend/internal/adapters/mock/port_listener_conflicts_test.go b/backend/internal/adapters/mock/port_listener_conflicts_test.go new file mode 100644 index 0000000..10b5b2e --- /dev/null +++ b/backend/internal/adapters/mock/port_listener_conflicts_test.go @@ -0,0 +1,28 @@ +package mock + +import ( + "testing" + + "tera-clone/services/tera-api/internal/domain" +) + +func TestPortListenerConflictsAreScopedToOperationInterface(t *testing.T) { + ssh := domain.PortOperation{Mode: "reverse_ssh", Port: 10001} + telnet := domain.PortOperation{Mode: "reverse_telnet", Port: 10001} + for _, test := range []struct { + name string + configs map[string]portConfig + conflict bool + }{ + {"same channel different interfaces", map[string]portConfig{"CH01": {Operation1: ssh, Operation2: telnet}}, false}, + {"different channels same eth0", map[string]portConfig{"CH01": {Operation1: ssh}, "CH02": {Operation1: telnet}}, true}, + {"different channels same eth1", map[string]portConfig{"CH01": {Operation2: ssh}, "CH02": {Operation2: telnet}}, true}, + {"different channels different interfaces", map[string]portConfig{"CH01": {Operation1: ssh}, "CH02": {Operation2: telnet}}, false}, + } { + t.Run(test.name, func(t *testing.T) { + if err := validatePortOperationConflicts(test.configs); (err != nil) != test.conflict { + t.Fatalf("conflict=%v, error=%v", test.conflict, err) + } + }) + } +} diff --git a/backend/internal/adapters/mock/store.go b/backend/internal/adapters/mock/store.go index ee197e5..f172567 100644 --- a/backend/internal/adapters/mock/store.go +++ b/backend/internal/adapters/mock/store.go @@ -5080,7 +5080,9 @@ func (s *Store) effectivePortConfigsLocked() map[string]portConfig { } func validatePortOperationConflicts(configs map[string]portConfig) error { - owners := make(map[int]string) + // Operation 1 listens on eth0 and operation 2 on eth1. Match the + // gateway's interface-scoped bind identity when staging/saving settings. + owners := make(map[[2]int]string) channels := make([]string, 0, len(configs)) for channel := range configs { channels = append(channels, channel) @@ -5093,10 +5095,11 @@ func validatePortOperationConflicts(configs map[string]portConfig) error { continue } owner := fmt.Sprintf("%s operation_%d", channel, number+1) - if previous := owners[operation.Port]; previous != "" { - return fmt.Errorf("listener port %d conflicts between %s and %s", operation.Port, previous, owner) + listener := [2]int{number, operation.Port} + if previous := owners[listener]; previous != "" { + return fmt.Errorf("listener port %d on eth%d conflicts between %s and %s", operation.Port, number, previous, owner) } - owners[operation.Port] = owner + owners[listener] = owner } } return nil diff --git a/backend/internal/api/port_operation_interfaces_test.go b/backend/internal/api/port_operation_interfaces_test.go new file mode 100644 index 0000000..566ce4e --- /dev/null +++ b/backend/internal/api/port_operation_interfaces_test.go @@ -0,0 +1,40 @@ +package api + +import ( + "net/http" + "path/filepath" + "testing" + + "tera-clone/services/tera-api/internal/adapters/mock" + "tera-clone/services/tera-api/internal/domain" +) + +func TestOperationTwoTelnetCanBeStagedAppliedSavedAndReloadedBesideSSH(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + store, err := mock.NewStoreWithConfigFile(fixedClock, path) + if err != nil { + t.Fatal(err) + } + handler := NewRouter(store) + // Use the same separate PATCH + apply requests sent by the two UI tabs. + getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"operation_1":{"mode":"reverse_ssh","port":10001,"max_clients":2,"authentication":"local","newline":"cr"}}`, "mock-admin") + getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") + getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"operation_2":{"mode":"reverse_telnet","port":10001,"max_clients":4,"authentication":"local","newline":"cr"}}`, "mock-admin") + getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") + assertDualOperation(t, getPort(t, handler, "CH01")) + getJSONAs(t, handler, http.MethodPost, "/api/v1/config/save", "", "mock-admin") + reloaded, err := mock.NewStoreWithConfigFile(fixedClock, path) + if err != nil { + t.Fatal(err) + } + assertDualOperation(t, getPort(t, NewRouter(reloaded), "CH01")) +} + +func assertDualOperation(t *testing.T, port domain.Port) { + t.Helper() + if port.Operation1.Mode != "reverse_ssh" || port.Operation2.Mode != "reverse_telnet" || + port.Operation1.Port != 10001 || port.Operation2.Port != 10001 || + port.Operation1.MaxClients != 2 || port.Operation2.MaxClients != 2 { + t.Fatalf("CH01 operations not preserved: op1=%+v op2=%+v", port.Operation1, port.Operation2) + } +} diff --git a/demo-guide/07-system-improvements-20260914.md b/demo-guide/07-system-improvements-20260914.md index 5d4057d..098f24d 100644 --- a/demo-guide/07-system-improvements-20260914.md +++ b/demo-guide/07-system-improvements-20260914.md @@ -11,9 +11,11 @@ | 설정 변경 | **포트 설정**에서 다른 채널의 개별 보기 선택 → 해당 채널 설정 변경 | 기존 채널의 설정을 PATCH로 변경. 개별 보기로 이동하면 이전의 다중 변경 대상이 따라붙지 않음. 여러 채널 적용은 ‘변경 대상’을 명시적으로 선택 | | 포트 설정 탭 | Port 1 설정을 열고 포트 선택에서 26 클릭 | 기존 탭의 같은 위치에서 ‘포트 26 설정’으로 바뀌며 탭이 추가되지 않음. 새로고침해도 포트 설정 탭은 하나만 유지 | | 변경 대상 체크박스 | 확대된 카드에서 ‘변경 대상’ 옆 체크박스를 선택·해제 | 체크와 카드 선택색이 함께 바뀜. 카드 위쪽 숫자는 개별 설정 전환, 아래 체크박스는 여러 포트의 변경 대상 선택 | +| 포트 선택 별칭 | 포트 상태 화면에서 별칭을 설정한 후 포트 설정으로 이동 | 선택 카드의 번호·상태 아래에 같은 별칭 표시. 긴 이름은 `…`, 마우스를 올리면 전체 이름 확인 | | 순간적인 응답 누락 | CH20·CH26을 연결한 채 대시보드와 상태 이벤트를 관찰 | 마지막 CR 응답 후 15초 이내의 일시적인 누락은 UP 유지. 계속 응답이 없으면 Unknown(미확인). CR 무응답만으로 케이블이 끊겼다고 판단하거나 DOWN 알림을 발생시키지 않음 | | 동작 1 | **포트 설정 → CH20 → 동작 설정 1 · eth0**에서 Reverse SSH, 최대 세션 2 설정 | Listen Port는 `10020` 고정. PC PuTTY에서 관리 주소 `192.168.0.223`, SSH, 포트 `10020`으로 접속. 장비에 등록된 로그인 계정 사용 | | 동작 2 | **동작 설정 2 · eth1**에서 Reverse Telnet 설정 | Listen Port `10020`, 최대 세션 2가 읽기 전용으로 표시. 두 설정을 합쳐 2개까지 접속 가능 | +| 동작 2 충돌 수정 | CH01의 동작 1을 SSH로 두고 동작 2를 Telnet으로 설정 → 적용 | 두 인터페이스의 Listen Port `10001`이 함께 허용됨. `operation_1 and operation_2` 포트 충돌 오류가 발생하지 않아야 함. 데이터 로그의 형식 이름은 ASCII·HEX로 표시 | | 합산 제한 | 위 SSH 연결을 유지하고 eth1 주소의 `10020`에 Telnet으로 접속. 그 뒤 세 번째 연결 시도 | 앞의 두 연결은 유지되고 세 번째는 `Too many clients`로 거절. 포트 상태에서 세션 1·2 IP 확인. 하나를 종료하면 해당 칸이 비워지고 다시 연결 가능 | | eth1 접근 조건 | PC에서 현재 eth1 주소까지 통신 가능한지 먼저 확인 | 지금 PC가 관리망에만 연결돼 있고 eth1 경로가 없으면 SSH/Telnet 혼합 시험은 아직 수행할 수 없음. 이것을 세션 제한 실패로 판정하지 않음. 관리망의 SSH 두 창으로 총 세션·IP 표시부터 시험 가능 | | 수신 로그 | **CH20 → 데이터 로그**에서 Receive 기록, ASCII 선택. CH20 상대 장비의 기존 인터페이스 입력 화면에서 `d` 입력 | `Invalid interface`가 실제 수신되어 포트 로그에 표시. 줄바꿈과 ASCII 대소문자 유지. HEX로 바꾸고 같은 동작을 반복하면 A~F 대문자 표시 | diff --git a/final-build/README.md b/final-build/README.md index bcfc517..9dd3731 100644 --- a/final-build/README.md +++ b/final-build/README.md @@ -1,6 +1,12 @@ # 최종 장비 빌드 -**릴리스 20260914-port-selection-3 · Linux ARM64 · 192.168.0.223 배포 완료** +**릴리스 20260914-port-selector-alias-5 · Linux ARM64 · 192.168.0.223 배포 완료** + +포트 선택 카드에도 별칭을 표시합니다. 긴 이름은 말줄임표로 표시하고 마우스를 올리면 전체 이름을 확인할 수 있습니다. 이 추가 웹 수정의 백업은 `/userdata/tera-backups/port-selector-alias-20260914`입니다. + +추가 수정: 동작 1(eth0)과 동작 2(eth1)의 동일 Listen Port를 설정 저장 검사에서도 허용합니다. CH01 동작 1 SSH + 동작 2 Telnet의 저장·적용·설정 재로딩을 회귀 테스트로 확인했습니다. 같은 인터페이스의 포트 중복은 계속 거절합니다. 로그 형식 표시 이름은 ASCII·HEX 대문자이며 저장 값과 수신 원문은 유지합니다. 이번 API·웹의 이전 실행 파일은 `/userdata/tera-backups/operation2-telnet-20260914`에 백업했습니다. + +실제 장비 검증: 정상 로그인한 API로 CH01 동작 2 Telnet을 적용·저장했습니다. 실행/영구 저장 리비전 29, eth0 SSH·eth1 Telnet의 10001 리스너와 공유 최대 세션 4를 확인했습니다. 실제 데이터 로그 HTML에서 ASCII·HEX 표시도 확인했습니다. 두 네트워크를 통한 실제 SSH/Telnet 클라이언트 동시 접속은 이번에 실행하지 않았습니다. 장비 준비 상태 12/12, HTTP 80·HTTPS 443 서비스, 설치 파일 8개와 final-build 폴더의 SHA256 일치를 확인했습니다. API·웹은 이번 수정본으로 빌드·배포했고, logd는 같은 날 배포한 시스템 개선 빌드입니다. 나머지 데몬은 현재 장비 설치본과 맞춘 파일입니다. diff --git a/final-build/SHA256SUMS b/final-build/SHA256SUMS index 6c8f69c..3a4df98 100644 --- a/final-build/SHA256SUMS +++ b/final-build/SHA256SUMS @@ -1,5 +1,5 @@ -2ef9c70584f275deebac08711809be556f76f3601417f08cc62a0bc234b17cd2 frontend/tera-clone-demo-web -a7225f0786111cd3c714fe7f23a34778085c86b923d08bbc5e67dc6e8e611860 backend/tera-clone-demo-api +0109367727841cc79d9cabdcb9687e97a0b51788e994c39243a72ed789de1807 frontend/tera-clone-demo-web +112bcdadf04155209b1a361200163c57232427dd628fdd31d8e060b1ab49461f backend/tera-clone-demo-api b5856899698bfafb1f382a3dd45db02b034ba785d56a075ea964c937b57a48b3 backend/tera-logd ac3149f54d93ce252527606bda35dc739f1f3ad40bfb77409f16b3514d437006 backend/tera-clone-demo-alarmd 2864dbde4fcde16898a3b187716b325883365a1fd39f16264a5186877f94c50c backend/tera-netd diff --git a/final-build/backend/tera-clone-demo-api b/final-build/backend/tera-clone-demo-api index f157cee..06aa267 100644 Binary files a/final-build/backend/tera-clone-demo-api and b/final-build/backend/tera-clone-demo-api differ diff --git a/final-build/frontend/tera-clone-demo-web b/final-build/frontend/tera-clone-demo-web index 783162c..899f0b1 100644 Binary files a/final-build/frontend/tera-clone-demo-web and b/final-build/frontend/tera-clone-demo-web differ diff --git a/final-build/release-manifest.json b/final-build/release-manifest.json index c5531ea..5e74c46 100644 --- a/final-build/release-manifest.json +++ b/final-build/release-manifest.json @@ -1,20 +1,20 @@ { - "release_id": "20260914-port-selection-3", + "release_id": "20260914-port-selector-alias-5", "platform": "linux-arm64", "scope": "application-and-daemon-binaries-not-full-firmware", - "generated_at": "2026-09-14T05:22:41.7644664+00:00", + "generated_at": "2026-09-14T07:17:33.9524858+00:00", "files": [ { "path": "frontend/tera-clone-demo-web", "bytes": 13238434, - "sha256": "2ef9c70584f275deebac08711809be556f76f3601417f08cc62a0bc234b17cd2", + "sha256": "0109367727841cc79d9cabdcb9687e97a0b51788e994c39243a72ed789de1807", "install_path": "/usr/bin/tera-clone-demo-web", "provenance": "built-and-deployed" }, { "path": "backend/tera-clone-demo-api", "bytes": 10027170, - "sha256": "a7225f0786111cd3c714fe7f23a34778085c86b923d08bbc5e67dc6e8e611860", + "sha256": "112bcdadf04155209b1a361200163c57232427dd628fdd31d8e060b1ab49461f", "install_path": "/usr/bin/tera-api", "provenance": "built-and-deployed" }, @@ -62,7 +62,7 @@ } ], "source": { - "base_commit": "8d5936102974bf1fa2a2b10f63b04beac6cdaeef", + "base_commit": "8c2fe00", "changes": "Release source is included with this manifest in the Git commit; baseline daemons have individual provenance above." }, "deployment": { @@ -71,16 +71,17 @@ "ready": true, "completed": 12, "total": 12, - "backup": "/userdata/tera-backups/port-selection-20260914", - "verified": "Eight binary SHA256 values match installed files; HTTP/HTTPS listeners and served workspace-tabs.js verified." + "backup": "/userdata/tera-backups/operation2-telnet-20260914", + "verified": "Eight binary SHA256 values match installed files; HTTP/HTTPS listeners and served workspace-tabs.js verified.", + "web_alias_backup": "/userdata/tera-backups/port-selector-alias-20260914" }, "validation": { - "backend": "Changed API, serial and logruntime tests passed; earlier system improvements passed the full backend suite.", - "frontend": "Full Go suite passed, then final page fixtures regenerated and checked.", + "backend": "mock store, API and portservice suites passed; CH01 SSH plus Telnet stage/apply/save/reload regression and same-interface conflict guards passed.", + "frontend": "Rendered page check passed: ASCII and HEX labels retain lowercase API values.", "javascript": "19 workspace, navigation and dashboard tests passed.", - "browser": "Local Edge fixtures: fixed positions, one settings tab, legacy migration, checkbox, refresh, 1440/1024/800/480 layouts, icon removal.", - "live": "Read-only CH20/CH26 CR cache samples observed; transient CH26 miss followed by normal reply.", - "limitations": "No cable-unplug, destructive action or power-cycle acceptance test; authenticated live UI was not tested in this run." + "browser": "Local Edge: port selector aliases, truncation/hover/accessibility, one-tab navigation, checkboxes and responsive layout; ASCII/HEX display with lowercase values passed.", + "live": "Authenticated live API: CH01 operation 2 Telnet applied and saved (running/persistent revision 29). eth0 SSH and eth1 Telnet both listening on 10001 with shared max_clients=4. Live HTML ASCII/HEX labels confirmed. Final web hash and readiness 12/12 verified.", + "limitations": "No cable-unplug, destructive action, power-cycle or SSH/Telnet clients across both physical networks acceptance test." }, "changes": [ "Keep channel positions fixed from 1 to 48", @@ -88,6 +89,9 @@ "Larger cards with target checkboxes", "Remove serial console icon", "Share CR response classification with 15-second grace; sustained silence is unknown, not cable down", - "Include earlier system/session/log/reauthentication improvements" + "Include earlier system/session/log/reauthentication improvements", + "Allow same listener port across operation 1 eth0 and operation 2 eth1 in configuration validation", + "Display log format labels as ASCII and HEX", + "Show aliases in port selector cards with ellipsis and full-name hover" ] } diff --git a/frontend/internal/mock/mock_data.go b/frontend/internal/mock/mock_data.go index 817a1e7..b4b529b 100644 --- a/frontend/internal/mock/mock_data.go +++ b/frontend/internal/mock/mock_data.go @@ -441,7 +441,10 @@ func portPage(path string) (PageData, bool) { toggleField("사용자 표시", "showUsername", false), toggleField("IP 주소 표시", "showIP", false), toggleField("포트 표시", "showPort", false), - selectField("로그 형식", "format", "ascii", []string{"ascii", "hex"}), + selectOptionsField("로그 형식", "format", "ascii", []Option{ + {Label: "ASCII", Value: "ascii"}, + {Label: "HEX", Value: "hex"}, + }), }, "적용", ""), }), true default: diff --git a/frontend/web/static/css/style.css b/frontend/web/static/css/style.css index 0776077..56f31b2 100644 --- a/frontend/web/static/css/style.css +++ b/frontend/web/static/css/style.css @@ -626,7 +626,8 @@ body[data-current-path^="/ports/"] .port-selector-panel .port-link-card { border-radius: 5px; } body[data-current-path^="/ports/"] .port-selector-panel .port-link-view { - min-height: 58px; + min-height: 72px; + grid-template-columns: minmax(0, 1fr); padding: 8px 4px; gap: 3px; font-size: 12px; @@ -643,6 +644,14 @@ body[data-current-path^="/ports/"] .port-selector-panel .port-link-state { body[data-current-path^="/ports/"] .port-selector-panel .port-link-caption { display: none; } +body[data-current-path^="/ports/"] .port-link-alias { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 11px; + color: #60738d; +} body[data-current-path^="/ports/"] .port-selector-panel .port-link-apply { min-height: 34px; padding: 7px 3px; diff --git a/frontend/web/static/js/integration.js b/frontend/web/static/js/integration.js index ae8bbd3..0a24409 100644 --- a/frontend/web/static/js/integration.js +++ b/frontend/web/static/js/integration.js @@ -761,6 +761,19 @@ if (state && state.textContent !== view.label) { state.textContent = view.label; } + const alias = String(port.alias || ""); + const aliasElement = element.querySelector("[data-port-alias]"); + if (aliasElement) { + if (aliasElement.textContent !== (alias || "—")) { aliasElement.textContent = alias || "—"; } + if (aliasElement.title !== alias) { aliasElement.title = alias; } + const button = element.matches("[data-port-view]") ? element : element.querySelector("[data-port-view]"); + if (button) { + const description = "Port " + number + (alias ? " · " + alias : "") + " · " + view.label; + if (button.title !== description) { button.title = description; } + const accessibleLabel = description + " 개별 보기"; + if (button.getAttribute("aria-label") !== accessibleLabel) { button.setAttribute("aria-label", accessibleLabel); } + } + } }); } diff --git a/frontend/web/templates/layout.html b/frontend/web/templates/layout.html index b42aaa2..13d4f73 100644 --- a/frontend/web/templates/layout.html +++ b/frontend/web/templates/layout.html @@ -7,7 +7,7 @@