From 572e6589ec20874d5f5a971b64f36960591778f3 Mon Sep 17 00:00:00 2001 From: ETWen Date: Thu, 2 Jul 2026 09:17:19 +0800 Subject: [PATCH] feat: v0.4.0 performance & stability overhaul Terminal: - cache font variants + single reusable brush in render hot path; resolve cell colors once per cell; dispose GDI resources - scrollback: List+RemoveRange -> O(1) ring buffer - follow new output only when already at bottom (no more yank-to-bottom while reading history) - alt-screen mouse wheel -> arrow keys (vim/htop scroll) - answer DSR (ESC[5n/6n) and DA (ESC[c) queries so TUIs no longer hang - remove dead code in OnKeyPress Encoding correctness (garbled CJK across chunk boundaries): - stateful UTF-8 Decoder in TTLInterpreter.OnData, SerialBridgeServer rx forwarding, and SessionLogger.Write Sessions: - ShellChannel: free proc-thread attribute list, close hProcess, notify '[ETTerms] shell process exited' in the tab - SshChannel: surface ErrorOccurred / ShellStream.Closed in the tab; Write no longer throws into the UI thread on a dead connection PDU: - new shared ETTerms.PduCore project replaces the two drifted copies of PduController (GUI + PduMcp); logging via injected delegates - batched SNMP GET (GetAllPortsStatus): 12-port poll is 3 UDP round-trips instead of 36 (StatusView polling + pdu_status tool) Scripting: - cap TTL receive buffer at 1MB; skip re-scan in wait when buffer length unchanged - merge ScriptRunner.RunAsync/RunGroupAsync; new TtlScript helper dedups script picking + group-command checks (3 copies -> 1) Misc: - ConnectionStore: parse LastUsedUtc with InvariantCulture/RoundtripKind - version 0.4.0; About changelog; CLAUDE.md notes (intentional group barrier behavior, SSH.NET reflection resize caveat) Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 7 +- ETTerms.slnx | 1 + src/ETTerms.PduCore/ETTerms.PduCore.csproj | 17 +++ src/ETTerms.PduCore/PduController.cs | 124 ++++++++++++++++++++ src/ETTerms.PduMcp/ETTerms.PduMcp.csproj | 7 +- src/ETTerms.PduMcp/PduController.cs | 92 --------------- src/ETTerms.PduMcp/PduRegistry.cs | 6 +- src/ETTerms.PduMcp/PduTools.cs | 16 ++- src/ETTerms/App/AboutView.cs | 11 ++ src/ETTerms/App/StatusView.cs | 16 ++- src/ETTerms/App/Workspace/WorkspaceView.cs | 36 +----- src/ETTerms/Connections/ConnectionStore.cs | 4 +- src/ETTerms/ETTerms.csproj | 10 +- src/ETTerms/Infrastructure/SessionLogger.cs | 9 +- src/ETTerms/Scripting/Pdu/PduController.cs | 76 ------------ src/ETTerms/Scripting/ScriptRunner.cs | 37 +----- src/ETTerms/Scripting/TTLInterpreter.cs | 27 ++++- src/ETTerms/Scripting/TtlScript.cs | 44 +++++++ src/ETTerms/Sessions/SerialBridgeServer.cs | 17 ++- src/ETTerms/Sessions/SessionPage.cs | 27 +---- src/ETTerms/Sessions/ShellChannel.cs | 26 +++- src/ETTerms/Sessions/SshChannel.cs | 24 +++- src/ETTerms/Terminal/AnsiParser.cs | 15 +++ src/ETTerms/Terminal/ScreenBuffer.cs | 22 ++-- src/ETTerms/Terminal/TerminalInput.cs | 13 ++ src/ETTerms/Terminal/TerminalView.cs | 79 ++++++++++--- 26 files changed, 444 insertions(+), 319 deletions(-) create mode 100644 src/ETTerms.PduCore/ETTerms.PduCore.csproj create mode 100644 src/ETTerms.PduCore/PduController.cs delete mode 100644 src/ETTerms.PduMcp/PduController.cs delete mode 100644 src/ETTerms/Scripting/Pdu/PduController.cs create mode 100644 src/ETTerms/Scripting/TtlScript.cs diff --git a/CLAUDE.md b/CLAUDE.md index 13786e4..c39176a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,8 @@ ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台, **進度:** Phase 1–5 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)、Phase 9 ✅(Serial MCP server:**GUI 持有 COM port,MCP 經本機 named pipe 橋接**,AI 收發的資料即時以 `[AI]` 標色顯示在 GUI)。打包待指示。 +**v0.4.0:** 效能 / 穩定性總整理(來自全 codebase 審視)。**(1) 終端機繪製熱路徑去配置** — `TerminalView` 快取 4 種 Font 變體(Bold/Underline 組合)與單一可變色 `SolidBrush`,run 合併時每 cell 顏色只解析一次,`Dispose` 收掉 GDI 資源;**(2) scrollback 改環形緩衝** — `ScreenBuffer` 從 `List+RemoveRange(0,…)`(滿了每行 O(n) 搬移)改 O(1) ring buffer;**(3) UTF-8 跨 chunk 亂碼修正(3 處)** — `TTLInterpreter.OnData`、`SerialBridgeServer` rx 轉發、`SessionLogger.Write` 改用 stateful `Decoder`(比照 `AnsiParser` 原本的正確做法);**(4) PDU SNMP 批次查詢** — 新共用專案 **`src/ETTerms.PduCore/`** 取代 GUI 與 PduMcp 兩份複製的 `PduController`(log 走建構子委派),新增 `GetAllPortsStatus()` 一個 SNMP GET 帶 12 個 varbind,12 port 輪詢從 36 個 UDP 來回縮成 3 個(StatusView 與 `pdu_status` 都改用);**(5) ShellChannel 資源修正** — `DeleteProcThreadAttributeList`+`FreeHGlobal` 釋放 attribute list、`CloseHandle(pi.hProcess)`,shell 自行 exit 時顯示灰色 `[ETTerms] shell process exited` 提示;**(6) SSH 斷線可見** — 訂 `ErrorOccurred`/`ShellStream.Closed` 顯示提示,`Write` 例外不再炸 UI thread;**(7) 終端機行為** — 新輸出只在已貼底時跟隨(看歷史不被拉回底部)、alt screen 滾輪轉方向鍵(vim/htop 可滾)、回應 DSR `ESC[6n`/`ESC[5n` 與 DA `ESC[c`(TUI 查游標位置不再卡住);**(8) TTL** — `_recv` 上限 1MB、wait 輪詢長度沒變不重掃;**(9) 去重複** — `ScriptRunner` 兩個 Run 合併、`TtlScript` 共用選檔/group 指令檢查(SessionPage/WorkspaceView 三份流程收斂)、`ConnectionStore` 讀取改 InvariantCulture+RoundtripKind。註:group `waitall` 一個成員失敗其他成員停在 barrier 是**刻意行為**(使用者要求整組停下),勿「修」。 + **v0.3.2:** **(1) 新增左側 `Status` rail view** — 位於 Terminal 與 Settings 之間(圖示 `⚡`,新增 `ActivityRail.RailView.Status` 列舉與 `Items`),獨立 `App/StatusView.cs`,沿用 `SettingsView` 的自繪 tab strip + panel 切換風格(避免 TabControl 白邊);`MainForm` 加 `_statusView` 欄位、佈局與 rail 切換可見性。目前只有 **PDU 分頁**,未來會再加分頁(`MakeTab(...)` 即可擴充)。**(2) PDU 搬到 Status 並改自動輪詢** — PDU 從 `SettingsView` 移到 `StatusView`,**移除手動 Refresh 按鈕**,連線成功後以 `System.Threading.Timer`(period 3000ms)在**背景執行緒**讀 SNMP、再 `BeginInvoke` 回 UI 更新表格(不卡 UI);`Interlocked` 旗標防止前一輪未讀完就重入;斷線 / 控制項 Dispose 時自動停掉 timer 與 `PduController`;表格下方顯示「last update HH:mm:ss」。`SettingsView` 移除 `BuildPduTab`/`RefreshPduGrid` 與 `ETTerms.Scripting.Pdu` import,現只剩 **Terminal / AI MCP** 兩分頁。**(2b) PDU 分頁加手動開關鈕** — 狀態表格新增 **Control** 欄(`DataGridViewButtonColumn`),每個 Port 一顆鈕,文字隨狀態切換(ON→「Turn OFF」、OFF→「Turn ON」、未知→「—」);點擊在背景執行緒呼叫 `PduController.SetPortOn/SetPortOff`(不卡 UI),等 ~400ms 後回讀刷新整表,失敗跳 `MessageBox`;未連線點擊會提示先 Connect,斷線時清空表格避免顯示過時狀態。**(3) 修 `ShellChannel` 啟動目錄 fallback** — 本機 Shell 的 `StartupDirectory` 若已不存在(外接碟拔除 / 資料夾被刪)會 `CreateProcess failed: 267 (ERROR_DIRECTORY)`;改為「為空 **或** `Directory.Exists` 為 false」即 fallback 到使用者家目錄(`Environment.SpecialFolder.UserProfile`,隨登入者變動,例如 `C:\Users\et_wen`),與 Windows PowerShell 行為一致。 **v0.3.1:** 終端機體驗修正(皆在 `src/ETTerms/Terminal/`)。**(1) 深色垂直捲軸** — 新增自繪 `DarkScrollBar`(細長、無箭頭、圓角滑塊,配合 KKTerm 深色主題),`TerminalView` 右側 `Dock=Right` 掛上,`ContentWidth` 扣掉捲軸寬避免文字被蓋,`UpdateScrollBar()` 在 `Feed` / 滾輪 / resize 時同步滑塊範圍與位置,滾輪與拖曳互通。**(2) 多行貼上修正** — `AnsiParser` 新增 `BracketedPaste`(DEC mode 2004);`TerminalView.Paste()` 在對方啟用 bracketed paste(PSReadLine / Kiro CLI 等)時以 `ESC[200~ … ESC[201~` 包夾整段,視為「單次貼上」而非逐行 Enter 立即送出;未啟用時退回原本逐字送出。**(3) 右鍵複製清反白** — 右鍵複製後清掉 `_hasSel` 並重繪,讓使用者知道已複製。 @@ -86,11 +88,14 @@ kiro-cli mcp add --name serial --command dotnet --args "run --project src\ETTerm - ⚠️ VT 相容性以常見情境(VT100 / 常見 ANSI)為主,冷門 escape 後補,不阻塞 GUI 進度。 - ⚠️ 本專案**無伺服端祕密 / 無 DB 密碼 / 無 EC2 / 無 VM**,因此不套用 AWS / VirtualBox 部署流程。 - ⚠️ Group 同步指令(`waitall` / `sendlnall` / `sendlngroup`)**只能在 Run Group 模式**使用;`▶ Script` 和 `▶ Run All` 須拒絕含這些指令的腳本。 +- ⚠️ Group `waitall` 的 barrier 行為:**一個成員失敗/停止,其他成員會停在 barrier 等** —— 這是刻意設計(整組一起停下來),不是 bug,勿改成 RemoveParticipant。 +- ⚠️ `SshChannel.Resize` 用反射挖 SSH.NET 私有 `_channel` 呼叫 `SendWindowChangeRequest`(該 API 未公開)。**升級 SSH.NET 版本時必須驗證 resize 仍有效**(連上後拉視窗大小看遠端 TUI 是否跟著變)。 ## 資料夾用途 - **`src/ETTerms/`** — 主應用程式(WinForms 視窗外殼 + 連線 / 終端機 / 腳本引擎)。 - **`src/ETTerms.SerialMcp/`** — ✅ stdio MCP server(給 AI agent 收發 serial)。獨立行程,但**不直接開 COM port**:經本機 named pipe 連到 GUI 的 `SerialBridgeServer`,由 GUI 代為讀寫實體 port;net8.0 console + `ModelContextProtocol` SDK。 -- **`src/ETTerms.PduMcp/`** — ✅ stdio MCP server(給 AI agent 控制 SNMP PDU,v0.3.0)。獨立行程,**直接打 SNMP**(內含精簡版 `PduController`),不經 GUI、GUI 不開著也能用;net8.0 console + `ModelContextProtocol` + `SnmpSharpNet`。 +- **`src/ETTerms.PduMcp/`** — ✅ stdio MCP server(給 AI agent 控制 SNMP PDU,v0.3.0)。獨立行程,**直接打 SNMP**,不經 GUI、GUI 不開著也能用;net8.0 console + `ModelContextProtocol`,PDU 邏輯用 `ETTerms.PduCore`。 +- **`src/ETTerms.PduCore/`** — ✅ PDU SNMP 控制共用庫(v0.4.0)。GUI 與 PduMcp 共用的唯一 `PduController`(先前兩份複製已移除);診斷 log 走建構子注入委派(GUI→AppLogger、MCP→stderr);含批次查詢 `GetAllPortsStatus()`。 - **`For_AI/`** — AI 協作素材與**參考專案**(`KKTerm-main` UI 參考、`MyTeraTerm` Script 參考)。整個資料夾 gitignored,僅供開發對照。 - 本專案**無 `secret/` 資料夾**:沒有伺服端祕密 / DB 密碼 / compile-time secret,連線密碼一律走 Windows Credential Manager。 diff --git a/ETTerms.slnx b/ETTerms.slnx index 2a1a08d..4ad397e 100644 --- a/ETTerms.slnx +++ b/ETTerms.slnx @@ -3,5 +3,6 @@ + diff --git a/src/ETTerms.PduCore/ETTerms.PduCore.csproj b/src/ETTerms.PduCore/ETTerms.PduCore.csproj new file mode 100644 index 0000000..6c6a5a9 --- /dev/null +++ b/src/ETTerms.PduCore/ETTerms.PduCore.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + ETTerms.PduCore + ETTerms.PduCore + + + + + NU1701 + + + + diff --git a/src/ETTerms.PduCore/PduController.cs b/src/ETTerms.PduCore/PduController.cs new file mode 100644 index 0000000..bbb14c9 --- /dev/null +++ b/src/ETTerms.PduCore/PduController.cs @@ -0,0 +1,124 @@ +using System.Net; +using SnmpSharpNet; + +namespace ETTerms.PduCore; + +/// +/// PDU controller for iPoMan II/III models via SNMP。 +/// GUI 與 ETTerms.PduMcp 共用的唯一實作(v0.4.0 起取代兩份複製的版本)。 +/// 診斷輸出走建構子注入的 log 委派:GUI 給 AppLogger、MCP server 給 stderr +/// (stdio MCP 的 stdout 專供 JSON-RPC,不可污染)。 +/// SNMP 走 UDP、非獨佔,多個行程可同時對同一台 PDU 操作。 +/// +public sealed class PduController : IDisposable +{ + private readonly string _ip; + private readonly Action? _logInfo; + private readonly Action? _logWarn; + + private const string Community = "private"; + private const int SnmpPort = 161; + private const int Timeout = 3000; + + public string Ip => _ip; + + public PduController(string ip, Action? logInfo = null, Action? logWarn = null) + { + _ip = ip; + _logInfo = logInfo; + _logWarn = logWarn; + } + + /// 讀 PDU 型號/名稱 OID;非空且含 "PDU" 代表連得上。 + public string? GetModelName() => SnmpGet(".1.3.6.1.4.1.2468.1.4.2.1.1.4"); + + public bool CheckConnection() + { + var name = GetModelName(); + _logInfo?.Invoke($"[PDU] CheckConnection {_ip}: name='{name ?? ""}'"); + return !string.IsNullOrEmpty(name) && name.Contains("PDU"); + } + + public bool SetPortOn(int port) => SnmpSet(PortControlOid(port), new Integer32(3)); + public bool SetPortOff(int port) => SnmpSet(PortControlOid(port), new Integer32(4)); + + /// true = on, false = off, null = unknown/unreachable. + public bool? GetPortState(int port) => ParseState(SnmpGet(PortStateOid(port))); + + public int? GetPortCurrent(int port) => int.TryParse(SnmpGet(PortCurrentOid(port)), out int v) ? v : null; + public double? GetPortPowerWatts(int port) => int.TryParse(SnmpGet(PortPowerOid(port)), out int v) ? v / 10.0 : null; + + /// + /// 一次讀回所有插座的狀態 / 電流(mA) / 功率(W)。 + /// 以 3 個批次 SNMP GET(每個 PDU 含 個 varbind)取代 + /// 逐 port 逐 OID 的 3×N 個請求 —— 12 port 從 36 個 UDP 來回縮成 3 個, + /// 逾時時的最壞情況也從 36×Timeout 縮到 3×Timeout。 + /// + public (bool? State, int? CurrentMilliAmps, double? PowerWatts)[] GetAllPortsStatus(int portCount) + { + var ports = Enumerable.Range(1, portCount).ToArray(); + var states = SnmpGetMany(ports.Select(PortStateOid).ToArray()); + var currents = SnmpGetMany(ports.Select(PortCurrentOid).ToArray()); + var powers = SnmpGetMany(ports.Select(PortPowerOid).ToArray()); + + var result = new (bool?, int?, double?)[portCount]; + for (int i = 0; i < portCount; i++) + { + result[i] = ( + ParseState(states[i]), + int.TryParse(currents[i], out int c) ? c : null, + int.TryParse(powers[i], out int p) ? p / 10.0 : null); + } + return result; + } + + private static bool? ParseState(string? r) => r == "3" ? true : (r == "2" || r == "4") ? false : null; + + private static string PortControlOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.4.1.2.{port}"; + private static string PortStateOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.2.{port}"; + private static string PortCurrentOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.3.{port}"; + private static string PortPowerOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.5.{port}"; + + private bool SnmpSet(string oid, AsnType value) + { + try + { + var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 }; + using var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1); + var pdu = new SnmpSharpNet.Pdu(PduType.Set); + pdu.VbList.Add(new Oid(oid), value); + var result = (SnmpV1Packet)target.Request(pdu, param); + return result?.Pdu.ErrorStatus == 0; + } + catch (Exception ex) { _logWarn?.Invoke($"[PDU] SNMP SET {oid} exception: {ex.Message}"); return false; } + } + + private string? SnmpGet(string oid) + { + var r = SnmpGetMany(new[] { oid }); + return r[0]; + } + + /// 一個 SNMP GET 帶多個 OID(varbind),回傳同序的值;失敗整批回 null。 + private string?[] SnmpGetMany(string[] oids) + { + var result = new string?[oids.Length]; + try + { + var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 }; + using var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1); + var pdu = new SnmpSharpNet.Pdu(PduType.Get); + foreach (var oid in oids) pdu.VbList.Add(new Oid(oid)); + var resp = (SnmpV1Packet)target.Request(pdu, param); + if (resp == null) { _logWarn?.Invoke($"[PDU] SNMP GET ({oids.Length} oids): no response (timeout)"); return result; } + if (resp.Pdu.ErrorStatus != 0) { _logWarn?.Invoke($"[PDU] SNMP GET ({oids.Length} oids): ErrorStatus={resp.Pdu.ErrorStatus}"); return result; } + // SNMP GET 回應的 varbind 順序與請求一致,直接依 index 對回去。 + int n = Math.Min(oids.Length, resp.Pdu.VbList.Count); + for (int i = 0; i < n; i++) result[i] = resp.Pdu.VbList[i].Value.ToString(); + } + catch (Exception ex) { _logWarn?.Invoke($"[PDU] SNMP GET ({oids.Length} oids) exception: {ex.Message}"); } + return result; + } + + public void Dispose() { } +} diff --git a/src/ETTerms.PduMcp/ETTerms.PduMcp.csproj b/src/ETTerms.PduMcp/ETTerms.PduMcp.csproj index 47115f0..a912930 100644 --- a/src/ETTerms.PduMcp/ETTerms.PduMcp.csproj +++ b/src/ETTerms.PduMcp/ETTerms.PduMcp.csproj @@ -12,9 +12,10 @@ - - NU1701 - + + + + diff --git a/src/ETTerms.PduMcp/PduController.cs b/src/ETTerms.PduMcp/PduController.cs deleted file mode 100644 index a521524..0000000 --- a/src/ETTerms.PduMcp/PduController.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Net; -using SnmpSharpNet; - -namespace ETTerms.PduMcp; - -/// -/// PDU controller for iPoMan II/III models via SNMP (slim copy of the GUI's -/// ETTerms.Scripting.Pdu.PduController, ported into the standalone MCP server). -/// -/// Differences from the GUI version: -/// • No dependency on ETTerms.Infrastructure.AppLogger — diagnostics go to stderr -/// (stdout is reserved for the MCP JSON-RPC stream). -/// • Adds so tools can surface the device identity. -/// -/// SNMP is connectionless (UDP) and non-exclusive, so this talks to the PDU directly -/// without bridging through the GUI. -/// -public sealed class PduController : IDisposable -{ - private readonly string _ip; - private const string Community = "private"; - private const int SnmpPort = 161; - private const int Timeout = 3000; - - public string Ip => _ip; - - public PduController(string ip) => _ip = ip; - - /// Reads the PDU model/name OID; non-empty containing "PDU" means reachable. - public string? GetModelName() => SnmpGet(".1.3.6.1.4.1.2468.1.4.2.1.1.4"); - - public bool CheckConnection() - { - var name = GetModelName(); - Log($"CheckConnection {_ip}: name='{name ?? ""}'"); - return !string.IsNullOrEmpty(name) && name.Contains("PDU"); - } - - public bool SetPortOn(int port) => SnmpSet(PortControlOid(port), new Integer32(3)); - public bool SetPortOff(int port) => SnmpSet(PortControlOid(port), new Integer32(4)); - - /// true = on, false = off, null = unknown/unreachable. - public bool? GetPortState(int port) - { - var r = SnmpGet(PortStateOid(port)); - return r == "3" ? true : (r == "2" || r == "4") ? false : null; - } - - public int? GetPortCurrent(int port) => int.TryParse(SnmpGet(PortCurrentOid(port)), out int v) ? v : null; - public double? GetPortPowerWatts(int port) => int.TryParse(SnmpGet(PortPowerOid(port)), out int v) ? v / 10.0 : null; - - private static string PortControlOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.4.1.2.{port}"; - private static string PortStateOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.2.{port}"; - private static string PortCurrentOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.3.{port}"; - private static string PortPowerOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.5.{port}"; - - private bool SnmpSet(string oid, AsnType value) - { - try - { - var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 }; - using var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1); - var pdu = new SnmpSharpNet.Pdu(PduType.Set); - pdu.VbList.Add(new Oid(oid), value); - var result = (SnmpV1Packet)target.Request(pdu, param); - return result?.Pdu.ErrorStatus == 0; - } - catch (Exception ex) { Log($"SNMP SET {oid} exception: {ex.Message}"); return false; } - } - - private string? SnmpGet(string oid) - { - try - { - var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 }; - using var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1); - var pdu = new SnmpSharpNet.Pdu(PduType.Get); - pdu.VbList.Add(new Oid(oid)); - var result = (SnmpV1Packet)target.Request(pdu, param); - if (result == null) { Log($"SNMP GET {oid}: no response (timeout)"); return null; } - if (result.Pdu.ErrorStatus != 0) { Log($"SNMP GET {oid}: ErrorStatus={result.Pdu.ErrorStatus}"); return null; } - foreach (var v in result.Pdu.VbList) return v.Value.ToString(); - } - catch (Exception ex) { Log($"SNMP GET {oid} exception: {ex.Message}"); } - return null; - } - - // stdio MCP server:stdout 專供 JSON-RPC,診斷一律走 stderr。 - private static void Log(string msg) => Console.Error.WriteLine($"[PDU] {msg}"); - - public void Dispose() { } -} diff --git a/src/ETTerms.PduMcp/PduRegistry.cs b/src/ETTerms.PduMcp/PduRegistry.cs index a1286ff..5eea9c1 100644 --- a/src/ETTerms.PduMcp/PduRegistry.cs +++ b/src/ETTerms.PduMcp/PduRegistry.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using ETTerms.PduCore; namespace ETTerms.PduMcp; @@ -16,7 +17,10 @@ public sealed class PduRegistry private readonly ConcurrentDictionary _pdus = new(StringComparer.OrdinalIgnoreCase); public PduController GetOrAdd(string ip) => - _pdus.GetOrAdd(ip.Trim(), static k => new PduController(k)); + _pdus.GetOrAdd(ip.Trim(), static k => new PduController(k, Log, Log)); + + // stdio MCP server:stdout 專供 JSON-RPC,診斷一律走 stderr。 + private static void Log(string msg) => Console.Error.WriteLine(msg); public bool TryGet(string ip, out PduController pdu) => _pdus.TryGetValue(ip.Trim(), out pdu!); diff --git a/src/ETTerms.PduMcp/PduTools.cs b/src/ETTerms.PduMcp/PduTools.cs index 837bed1..47d4eb8 100644 --- a/src/ETTerms.PduMcp/PduTools.cs +++ b/src/ETTerms.PduMcp/PduTools.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Text; using System.Text.Json; +using ETTerms.PduCore; using ModelContextProtocol.Server; namespace ETTerms.PduMcp; @@ -65,9 +66,20 @@ public static class PduTools { if (!PduRegistry.Instance.TryGet(ip, out var pdu)) return Err($"PDU {ip} not connected. Call pdu_connect first."); + // 批次 SNMP GET:12 port 只需 3 個 UDP 來回,而非逐 port 逐 OID 36 個。 + var all = pdu.GetAllPortsStatus(PduRegistry.PortCount); var ports = new List(); - for (int p = 1; p <= PduRegistry.PortCount; p++) - ports.Add(PortSnapshot(pdu, p)); + for (int p = 0; p < all.Length; p++) + { + var (state, current, power) = all[p]; + ports.Add(new + { + port = p + 1, + state = state == true ? "on" : state == false ? "off" : "unknown", + currentMilliAmps = current, + powerWatts = power + }); + } return Ok(new { ip, ports }); } diff --git a/src/ETTerms/App/AboutView.cs b/src/ETTerms/App/AboutView.cs index 1d33b78..abdedd3 100644 --- a/src/ETTerms/App/AboutView.cs +++ b/src/ETTerms/App/AboutView.cs @@ -183,6 +183,17 @@ public sealed class AboutView : UserControl private static readonly ChangelogEntry[] Changelog = [ + new("0.4.0", new DateOnly(2026, 7, 2), "Performance & stability overhaul", + [ + "The terminal is much smoother during heavy output (long boot logs, big file dumps) — drawing and scroll history were reworked to stay fast no matter how much text has scrolled by.", + "Fixed Chinese and other non-English text occasionally turning into garbled characters — in script 'wait' matching, in what the AI reads from the serial port, and in saved session logs.", + "While you are scrolled back reading history, new output no longer yanks the view down to the bottom.", + "The mouse wheel now scrolls inside full-screen tools like vim and htop instead of doing nothing.", + "Full-screen tools that ask the terminal where the cursor is now get an answer, fixing apps that could hang or draw at the wrong position.", + "You now see a clear message in the tab when an SSH connection drops or the local shell exits, instead of the session silently going dead.", + "PDU status refresh is much faster and lighter on the network (one bundled query instead of 36 separate ones).", + "Fixed small memory and handle leaks when opening many local shell tabs over a long session.", + ]), new("0.3.3", new DateOnly(2026, 6, 16), "Bugfix — terminal input no longer dies after activity", [ "Fixed: after the terminal had printed output (Serial or PowerShell), switching away and back — minimizing, or clicking another window — left the terminal unable to accept any typing or Enter, forcing you to reopen the session.", diff --git a/src/ETTerms/App/StatusView.cs b/src/ETTerms/App/StatusView.cs index f894884..3c54514 100644 --- a/src/ETTerms/App/StatusView.cs +++ b/src/ETTerms/App/StatusView.cs @@ -1,6 +1,7 @@ using System.Drawing; using System.Windows.Forms; -using ETTerms.Scripting.Pdu; +using ETTerms.Infrastructure; +using ETTerms.PduCore; namespace ETTerms.App; @@ -149,12 +150,9 @@ public sealed class StatusView : UserControl try { - var rows = new (bool? state, int? current, double? power)[12]; - for (int i = 0; i < 12; i++) - { - int port = i + 1; - rows[i] = (current.GetPortState(port), current.GetPortCurrent(port), current.GetPortPowerWatts(port)); - } + // 批次 SNMP GET:12 port 只需 3 個 UDP 來回(原本逐 port 逐 OID 36 個, + // 逾時時最壞一輪要 36×3 秒)。 + var rows = current.GetAllPortsStatus(12); if (!IsDisposed && IsHandleCreated) { @@ -192,7 +190,7 @@ public sealed class StatusView : UserControl } var ip = ipBox.Text.Trim(); - var p = new PduController(ip); + var p = new PduController(ip, AppLogger.Info, AppLogger.LogWarning); if (p.CheckConnection()) { pdu = p; @@ -255,7 +253,7 @@ public sealed class StatusView : UserControl return page; } - private static void ApplyPduRows(DataGridView grid, (bool? state, int? current, double? power)[] rows) + private static void ApplyPduRows(DataGridView grid, (bool? State, int? CurrentMilliAmps, double? PowerWatts)[] rows) { for (int i = 0; i < rows.Length && i < grid.Rows.Count; i++) { diff --git a/src/ETTerms/App/Workspace/WorkspaceView.cs b/src/ETTerms/App/Workspace/WorkspaceView.cs index e0d6eb8..10101c5 100644 --- a/src/ETTerms/App/Workspace/WorkspaceView.cs +++ b/src/ETTerms/App/Workspace/WorkspaceView.cs @@ -452,22 +452,9 @@ public sealed class WorkspaceView : UserControl return; } - string content, name; - using (var dlg = new OpenFileDialog { Filter = "TTL scripts (*.ttl)|*.ttl|All files (*.*)|*.*" }) - { - if (dlg.ShowDialog(this) != DialogResult.OK) return; - content = File.ReadAllText(dlg.FileName); - name = Path.GetFileName(dlg.FileName); - } - + if (!TtlScript.TryPick(this, out var content, out var name)) return; // Run All 拒絕含 waitall / sendlnall 的腳本 - if (ScriptContainsGroupCommands(content)) - { - MessageBox.Show(this, - "Script contains 'waitall' or 'sendlnall' which require Group execution.\nPlease use Run Group buttons instead.", - "Run All", MessageBoxButtons.OK, MessageBoxIcon.Warning); - return; - } + if (TtlScript.WarnIfGroupCommands(this, content, "Run All")) return; await Task.WhenAll(serials.Select(sp => sp.RunScriptAsync(content, name))); } @@ -486,29 +473,12 @@ public sealed class WorkspaceView : UserControl return; } - string content, name; - using (var dlg = new OpenFileDialog { Filter = "TTL scripts (*.ttl)|*.ttl|All files (*.*)|*.*" }) - { - if (dlg.ShowDialog(this) != DialogResult.OK) return; - content = File.ReadAllText(dlg.FileName); - name = Path.GetFileName(dlg.FileName); - } + if (!TtlScript.TryPick(this, out var content, out var name)) return; var sync = new GroupSyncContext(members.Count); await Task.WhenAll(members.Select((sp, i) => sp.RunGroupScriptAsync(content, name, sync, ((char)('A' + i)).ToString()))); } - private static bool ScriptContainsGroupCommands(string content) - { - foreach (var line in content.Split('\n')) - { - var t = line.Trim().ToLower(); - if (t.StartsWith("waitall") || t.StartsWith("sendlnall") || t.StartsWith("sendlngroup")) - return true; - } - return false; - } - protected override void Dispose(bool disposing) { if (disposing) diff --git a/src/ETTerms/Connections/ConnectionStore.cs b/src/ETTerms/Connections/ConnectionStore.cs index 917a760..955f86a 100644 --- a/src/ETTerms/Connections/ConnectionStore.cs +++ b/src/ETTerms/Connections/ConnectionStore.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using Microsoft.Data.Sqlite; using ETTerms.Infrastructure; @@ -59,7 +60,8 @@ public sealed class ConnectionStore Type = (ConnectionType)r.GetInt32(2), SortOrder = r.GetInt32(3), GroupName = r.IsDBNull(4) ? null : r.GetString(4), - LastUsedUtc = DateTime.Parse(r.GetString(5)), + // 與寫入端的 "o" (round-trip) 格式對稱,不受系統地區設定影響 + LastUsedUtc = DateTime.Parse(r.GetString(5), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), Ssh = blob?.Ssh, Serial = blob?.Serial }); diff --git a/src/ETTerms/ETTerms.csproj b/src/ETTerms/ETTerms.csproj index ec62a04..f4101d8 100644 --- a/src/ETTerms/ETTerms.csproj +++ b/src/ETTerms/ETTerms.csproj @@ -10,7 +10,7 @@ ETTerms - 0.3.3 + 0.4.0 ETTerms ETTerms Project @@ -28,13 +28,15 @@ - - NU1701 - + + + + +