diff --git a/backend/internal/api/serial_runtime_test.go b/backend/internal/api/serial_runtime_test.go
index 1e536ce..cadcdf6 100644
--- a/backend/internal/api/serial_runtime_test.go
+++ b/backend/internal/api/serial_runtime_test.go
@@ -284,7 +284,7 @@ func TestRuntimePortStatusRequiresUpBeforeDownAndPrioritizesSession(t *testing.T
{name: "boot", runtime: &domain.PortRuntime{}, want: "unknown"},
{name: "unconfirmed miss", runtime: &domain.PortRuntime{CRProbeState: serial.CRProbeStateNoResponse}, want: "unknown"},
{name: "confirmed response", runtime: &domain.PortRuntime{CRProbeEnabled: true, CRProbeUp: true, CRProbeEverUp: true}, want: "up"},
- {name: "no response is not cable down", runtime: &domain.PortRuntime{CRProbeEnabled: true, CRProbeEverUp: true, CRProbeState: serial.CRProbeStateNoResponse}, want: "unknown"},
+ {name: "no response after confirmed up", runtime: &domain.PortRuntime{CRProbeEnabled: true, CRProbeEverUp: true, CRProbeState: serial.CRProbeStateNoResponse}, want: "down"},
{name: "no RX timestamp", runtime: &domain.PortRuntime{RXAgeMillis: 0}, want: "unknown"},
{name: "confirmed hardware down", runtime: &domain.PortRuntime{LinkAvailable: true, LinkEverUp: true, LinkState: "down", CRProbeEnabled: true, CRProbeUp: true}, want: "down"},
{name: "unknown session is not promoted", runtime: &domain.PortRuntime{SessionActive: true}, want: "unknown"},
diff --git a/backend/internal/logruntime/scheduler_test.go b/backend/internal/logruntime/scheduler_test.go
index 745c499..fd62683 100644
--- a/backend/internal/logruntime/scheduler_test.go
+++ b/backend/internal/logruntime/scheduler_test.go
@@ -154,8 +154,8 @@ func TestSerialObserverUsesCRProbeForUnifiedConnectionTransitions(t *testing.T)
Channel: "CH06", Enabled: true, State: serial.CRProbeStateNoResponse, Up: false, EverUp: true,
}}}
values, err = observer.Observe(context.Background(), now.Add(time.Second))
- if err != nil || len(values) != 1 || values[0].State != "unknown" || values[0].Signal != "cr" {
- t.Fatalf("CR no response must remain indeterminate = %+v, %v", values, err)
+ if err != nil || len(values) != 1 || values[0].State != "down" || values[0].Signal != "cr" {
+ t.Fatalf("CR no response after UP must be DOWN = %+v, %v", values, err)
}
observer.Probes = fakeCRProbeController{values: []serial.CRProbeState{{
Channel: "CH06", Enabled: true, EverUp: true, LastResponseAt: &now,
diff --git a/backend/internal/serial/connection_status.go b/backend/internal/serial/connection_status.go
index eac5f06..fcf3b92 100644
--- a/backend/internal/serial/connection_status.go
+++ b/backend/internal/serial/connection_status.go
@@ -3,7 +3,7 @@ package serial
import "time"
// Allow several normal four-second sweeps to recover from a slow/noisy reply.
-// A CR timeout alone cannot establish physical cable disconnection.
+// After a confirmed UP, sustained response loss is reported as DOWN.
const CRResponseGracePeriod = 15 * time.Second
func CRConnectionStatus(probe CRProbeState, now time.Time) string {
@@ -19,5 +19,8 @@ func CRConnectionStatus(probe CRProbeState, now time.Time) string {
return "up"
}
}
+ if probe.EverUp {
+ return "down"
+ }
return "unknown"
}
diff --git a/backend/internal/serial/connection_status_test.go b/backend/internal/serial/connection_status_test.go
index 9bae46f..27446c8 100644
--- a/backend/internal/serial/connection_status_test.go
+++ b/backend/internal/serial/connection_status_test.go
@@ -5,7 +5,7 @@ import (
"time"
)
-func TestCRConnectionStatusToleratesTransientMissWithoutInventingCableDown(t *testing.T) {
+func TestCRConnectionStatusReportsDownAfterConfirmedResponseExpires(t *testing.T) {
now := time.Date(2026, 9, 14, 1, 0, 0, 0, time.UTC)
probe := CRProbeState{Enabled: true, EverUp: true, LastResponseAt: &now}
for _, state := range []string{CRProbeStateNoResponse, CRProbeStateBusy, CRProbeStateSuppressed, CRProbeStateError} {
@@ -15,8 +15,8 @@ func TestCRConnectionStatusToleratesTransientMissWithoutInventingCableDown(t *te
t.Fatalf("transient %s at %s = %s", state, age, got)
}
}
- if got := CRConnectionStatus(probe, now.Add(CRResponseGracePeriod+time.Millisecond)); got != "unknown" {
- t.Fatalf("stale %s = %s; no response cannot prove cable down", state, got)
+ if got := CRConnectionStatus(probe, now.Add(CRResponseGracePeriod+time.Millisecond)); got != "down" {
+ t.Fatalf("stale %s = %s; previously confirmed port must become down", state, got)
}
}
probe.EverUp = false
@@ -25,10 +25,30 @@ func TestCRConnectionStatusToleratesTransientMissWithoutInventingCableDown(t *te
}
probe.Up = true
if CRConnectionStatus(probe, now) != "up" {
- t.Fatal("confirmed reply ignored")
+ t.Fatal("confirmed reply ignored")
}
probe.Enabled = false
if CRConnectionStatus(probe, now) != "unknown" {
t.Fatal("disabled probe promoted")
}
}
+
+func TestCRConnectionStatusUnknownUpDownUp(t *testing.T) {
+ now := time.Now()
+ probe := CRProbeState{Enabled: true, State: CRProbeStateNoResponse}
+ if got := CRConnectionStatus(probe, now); got != "unknown" {
+ t.Fatalf("initial = %s", got)
+ }
+ probe.Up, probe.EverUp, probe.LastResponseAt = true, true, &now
+ if got := CRConnectionStatus(probe, now); got != "up" {
+ t.Fatalf("response = %s", got)
+ }
+ probe.Up = false
+ if got := CRConnectionStatus(probe, now.Add(CRResponseGracePeriod+time.Second)); got != "down" {
+ t.Fatalf("response lost = %s", got)
+ }
+ probe.Up = true
+ if got := CRConnectionStatus(probe, now.Add(time.Minute)); got != "up" {
+ t.Fatalf("response recovered = %s", got)
+ }
+}
diff --git a/final-build/README.md b/final-build/README.md
index ac8a08d..f7c3fdf 100644
--- a/final-build/README.md
+++ b/final-build/README.md
@@ -1,6 +1,8 @@
# 최종 장비 빌드
-**릴리스 20260921-snmp-diagnostics-1 · Linux ARM64 · 192.168.0.223 배포 완료**
+**릴리스 20260921-port-down-1 · Linux ARM64 · 192.168.0.223 배포 완료**
+
+2026-09-21 추가 수정: 최초 연결 미확인 상태는 UNKNOWN으로 유지하고, UP 확인 이후 응답이 끊기면 15초 유예 후 DOWN으로 판정합니다. 응답이 복구되면 UP으로 돌아옵니다. API 상태와 링크 이벤트가 같은 판정을 사용하며 웹에서는 DOWN을 빨간색으로 표시합니다. serial/logruntime/API 패키지 테스트와 JavaScript 문법 검사를 통과했습니다. 배포 API·웹 해시 및 integration.js 일치를 확인했습니다. 이번 검증에서 실제 케이블을 분리하지는 않았습니다. 이번 릴리스 커밋에는 DOWN 판정 관련 소스·회귀 테스트를 함께 포함하며, 이전 배포분의 나머지 소스 수정은 작업 트리에 남아 있습니다.
2026-09-21: SNMP v3 사용자 이름의 대소문자를 유지하도록 API를 수정했습니다. PC에서 v3 GET과 SHA-1/AES Trap 수신을 확인했습니다. Trap 시험에는 PC에서 장비 `192.168.0.223`의 UDP 2162 수신을 허용해야 합니다. 진단도구 상단의 초록색 `C:\>`와 `CMD` 표기를 제거하고 본문 프롬프트를 `$`로 변경했습니다. API·웹 배포 해시와 서비스 준비 상태 12/12, 배포된 integration.js/i18n.js/style.css 원문 일치를 확인했습니다. 관련 SNMP 회귀 테스트와 JavaScript 문법 검사를 통과했습니다.
diff --git a/final-build/SHA256SUMS b/final-build/SHA256SUMS
index 41c153b..1aea215 100644
--- a/final-build/SHA256SUMS
+++ b/final-build/SHA256SUMS
@@ -1,5 +1,5 @@
-ee1403bd9897df8b94e76058c2b4f198e12d251cf9ff97cb0e346263991469d4 frontend/tera-clone-demo-web
-c45e8b57365ac7070c96997ce32a3d8775e7c1ea29ffe1c29ddc886b857caa9c backend/tera-clone-demo-api
+96c2f7e0e09ff3357cb62fdfd5b1bc4495c99215d7561a41717b747ee3695a21 frontend/tera-clone-demo-web
+c2a947b6573a4c56468c28d156b3ce35b626656c6a9f5b5acdda53ad10da9375 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 119617d..4443c0e 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 f0b55ff..0c15a2d 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 4727b68..bc858fe 100644
--- a/final-build/release-manifest.json
+++ b/final-build/release-manifest.json
@@ -1,20 +1,20 @@
{
- "release_id": "20260921-snmp-diagnostics-1",
+ "release_id": "20260921-port-down-1",
"platform": "linux-arm64",
"scope": "application-and-daemon-binaries-not-full-firmware",
- "generated_at": "2026-09-21T13:44:32.1572567+09:00",
+ "generated_at": "2026-09-21T15:05:03.204065+09:00",
"files": [
{
"path": "frontend/tera-clone-demo-web",
"bytes": 19064981,
- "sha256": "ee1403bd9897df8b94e76058c2b4f198e12d251cf9ff97cb0e346263991469d4",
+ "sha256": "96c2f7e0e09ff3357cb62fdfd5b1bc4495c99215d7561a41717b747ee3695a21",
"install_path": "/usr/bin/tera-clone-demo-web",
"provenance": "built-and-deployed"
},
{
"path": "backend/tera-clone-demo-api",
- "bytes": 14458265,
- "sha256": "c45e8b57365ac7070c96997ce32a3d8775e7c1ea29ffe1c29ddc886b857caa9c",
+ "bytes": 14458233,
+ "sha256": "c2a947b6573a4c56468c28d156b3ce35b626656c6a9f5b5acdda53ad10da9375",
"install_path": "/usr/bin/tera-api",
"provenance": "built-and-deployed"
},
@@ -63,7 +63,7 @@
],
"source": {
"base_commit": "6ed3580",
- "changes": "Built from the working tree based on base_commit, including pending shared-session, tab, log UI, SNMP username case and diagnostics prompt changes. This release commit packages binaries and metadata only; working-tree source changes are not included."
+ "changes": "Working-tree build based on 6ed3580 with earlier deployed changes and the port DOWN fix. This release includes the DOWN classification source and regression tests; other earlier working-tree source changes remain outside this commit."
},
"deployment": {
"device": "192.168.0.223",
@@ -73,7 +73,7 @@
"total": 12,
"backup": "/userdata/tera-backups/telnet-session-ips-20260916-124023",
"web_labels_backup": "/userdata/tera-backups/compact-port-selector-20260916-125224",
- "verified": "API and web installed SHA256 verified on device; readiness 12/12. Served integration.js, i18n.js and style.css match local source. SNMP v3 GET and Trap reception confirmed.",
+ "verified": "Installed API and web SHA256 verified, service readiness 12/12, served integration.js matches source.",
"web_alias_backup": "/userdata/tera-backups/port-selector-alias-20260914",
"web_title_backup": "/userdata/tera-backups/direct-mode-title-20260914",
"web_modal_enter_backup": "/userdata/tera-backups/modal-enter-send-20260917-112546",
@@ -82,7 +82,9 @@
"web_storage_summary_backup": "/userdata/tera-backups/log-storage-summary-20260917-114241",
"web_tab_controls_backup": "/userdata/tera-backups/tab-controls-20260917-115134",
"api_snmp_username_backup": "/userdata/tera-backups/snmp-username-20260921-102935",
- "web_diagnostics_backup": "/userdata/tera-backups/tab-controls-20260921-134221"
+ "web_diagnostics_backup": "/userdata/tera-backups/tab-controls-20260921-134221",
+ "port_down_api_backup": "/userdata/tera-backups/snmp-username-20260921-150246",
+ "port_down_web_backup": "/userdata/tera-backups/tab-controls-20260921-150326"
},
"validation": {
"backend": "directstream, portservice, mock store, API, tera-sessiond and tera-api suites passed. Mixed browser/terminal four-session limit, fifth-session rejection, fanout, shared writes, slot reuse, per-session release and combined count covered.",
@@ -91,9 +93,10 @@
"browser": "Local Edge: port selector aliases, truncation/hover/accessibility, one-tab navigation, checkboxes and responsive layout; ASCII/HEX display with lowercase values passed.",
"live": "ARM64 tests executed on the target: WebSocket plus Unix gateway attachments, same-number operation 1/2 SSH+Telnet, SSH+SSH and Telnet+Telnet over test listeners with a simulated UART all passed. Deployed API, sessiond and web hashes, shared socket and readiness verified.",
"limitations": "No PuTTY GUI or simultaneous clients through both physical Ethernet networks acceptance test; integration tests use loopback listeners and a simulated UART. No cable-unplug or power-cycle test.",
- "latest": "SNMP mock-store and controller regression tests passed; 17 workspace/tab tests passed; diagnostics JavaScript syntax checks passed. User confirmed SNMP v3 GET and authenticated encrypted Trap reception on PC. Trap reception required a PC firewall exception for device UDP 2162."
+ "latest": "serial, logruntime and API package tests passed; UNKNOWN -> UP -> DOWN -> UP regression passed with 15-second grace. JavaScript syntax passed. Physical cable unplug not performed for this release."
},
"changes": [
+ "After confirmed UP, sustained CR response loss becomes DOWN after 15 seconds; never-confirmed ports remain UNKNOWN; applies to API and link events",
"Preserve SNMPv3 username case through storage and runtime application",
"Remove green C-drive prompt and CMD label from diagnostics; use dollar prompts",
"Alt-click inactive tab close retains the selected tab; Alt-click selected tab close retains the leftmost tab",
diff --git a/frontend/web/static/js/integration.js b/frontend/web/static/js/integration.js
index 2da4e67..b9f7fd5 100644
--- a/frontend/web/static/js/integration.js
+++ b/frontend/web/static/js/integration.js
@@ -662,12 +662,12 @@
return { active: true, tone: "session", label: "Session_" + sessionCount, sessionCount: sessionCount, summary: "포트 세션 " + sessionCount + "명 연결 중" };
}
if (status === "down") {
- return { active: false, tone: "error", label: "DOWN", summary: "링크 신호 끊김 확인" };
+ return { active: false, tone: "error", label: "DOWN", summary: "연결 확인 후 장비 응답 중단 또는 링크 신호 끊김" };
}
if (status === "up") {
return { active: true, tone: "connected", label: "UP", summary: "상대 장비 응답 확인 · 일시적 응답 누락은 15초간 재확인" };
}
- return { active: false, tone: "waiting", label: "Unknown", summary: "장비 응답 미확인 · 응답이 없다는 이유만으로 케이블 단절을 판정할 수 없습니다." };
+ return { active: false, tone: "waiting", label: "Unknown", summary: "아직 장비 연결이 확인되지 않았습니다." };
}
function normalizePortView(view) {
diff --git a/frontend/web/templates/layout.html b/frontend/web/templates/layout.html
index 3dc1a20..35df65d 100644
--- a/frontend/web/templates/layout.html
+++ b/frontend/web/templates/layout.html
@@ -188,7 +188,7 @@
-
+