Browse Source

Fix operation 2 listener validation and show selector aliases

main
gudae 2 months ago
parent
commit
d790e06737
  1. 28
      backend/internal/adapters/mock/port_listener_conflicts_test.go
  2. 11
      backend/internal/adapters/mock/store.go
  3. 40
      backend/internal/api/port_operation_interfaces_test.go
  4. 2
      demo-guide/07-system-improvements-20260914.md
  5. 8
      final-build/README.md
  6. 4
      final-build/SHA256SUMS
  7. BIN
      final-build/backend/tera-clone-demo-api
  8. BIN
      final-build/frontend/tera-clone-demo-web
  9. 30
      final-build/release-manifest.json
  10. 5
      frontend/internal/mock/mock_data.go
  11. 11
      frontend/web/static/css/style.css
  12. 13
      frontend/web/static/js/integration.js
  13. 2
      frontend/web/templates/layout.html
  14. 1
      frontend/web/templates/partials/block_port-selector.html

28
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)
}
})
}
}

11
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

40
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)
}
}

2
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 대문자 표시 |

8
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는 같은 날 배포한 시스템 개선 빌드입니다. 나머지 데몬은 현재 장비 설치본과 맞춘 파일입니다.

4
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

BIN
final-build/backend/tera-clone-demo-api

Binary file not shown.

BIN
final-build/frontend/tera-clone-demo-web

Binary file not shown.

30
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"
]
}

5
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:

11
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;

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

2
frontend/web/templates/layout.html

@ -7,7 +7,7 @@
<title>{{.Title}} - {{.AppName}}</title>
<link rel="stylesheet" href="/static/css/style.css?v=20260914-fixed-port-positions-2">
</head>
<body data-ui-version="20260914-port-selection-3" data-workspace-id="{{.WorkspaceID}}" data-current-user="{{.CurrentUser}}" data-current-path="{{.CurrentPath}}" data-current-role="{{.CurrentRole}}" data-current-tab-key="{{(index .WorkspaceTabs 0).Key}}" data-current-tab-label="{{(index .WorkspaceTabs 0).Label}}">
<body data-ui-version="20260914-port-selector-alias-5" data-workspace-id="{{.WorkspaceID}}" data-current-user="{{.CurrentUser}}" data-current-path="{{.CurrentPath}}" data-current-role="{{.CurrentRole}}" data-current-tab-key="{{(index .WorkspaceTabs 0).Key}}" data-current-tab-label="{{(index .WorkspaceTabs 0).Label}}">
<div class="app-shell">
<aside class="sidebar">
<div class="brand">

1
frontend/web/templates/partials/block_port-selector.html

@ -9,6 +9,7 @@
<button type="button" class="port-link port-link-view" data-port-view="{{.Number}}" aria-label="Port {{.Number}} 개별 보기" title="Port {{.Number}} 개별 보기">
<div class="port-link-number">{{.Number}}</div>
<div class="port-link-state">{{if .Up}}UP{{else}}Unknown{{end}}</div>
<div class="port-link-alias" data-port-alias>—</div>
<div class="port-link-caption">개별 보기</div>
</button>
<label class="port-link-apply" title="Port {{.Number}} 변경 대상 선택">

Loading…
Cancel
Save