Files
ETTerms/src/ETTerms/Infrastructure/SessionLogger.cs
T
etwenandClaude Fable 5 572e6589ec 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 <noreply@anthropic.com>
2026-07-02 09:17:19 +08:00

107 lines
4.0 KiB
C#

using System.Text;
using System.Text.RegularExpressions;
namespace ETTerms.Infrastructure;
/// <summary>
/// 單一分頁的終端機輸出側錄器。按下 Log 按鈕時建立,再按一次(或關閉分頁)時 Dispose。
///
/// - 檔案位置:&lt;exe 路徑&gt;\logs\
/// - 檔名格式:<c>[連線識別]_yyyyMMdd_HHmmss.log</c>,例如 <c>[PowerShell]_20260603_212400.log</c>
/// - 內容格式:每行 <c>[yyyy-MM-dd HH:mm:ss.fff] 文字</c>
///
/// 收到的位元組會去除 ANSI/VT escape 序列後依換行切行,每完成一行才寫出並補上收到當下的時間戳,
/// 未滿一行的殘段先留在緩衝區,待後續資料或 Dispose 時補寫。
/// </summary>
public sealed class SessionLogger : IDisposable
{
// 去除 CSI / OSC / 單字元 escape 等 ANSI 控制序列
private static readonly Regex AnsiRegex = new(
@"\x1B\][^\x07\x1B]*(\x07|\x1B\\)|\x1B[@-Z\\-_]|\x1B\[[0-?]*[ -/]*[@-~]",
RegexOptions.Compiled);
private readonly object _lock = new();
private readonly StreamWriter _writer;
private readonly StringBuilder _pending = new();
private readonly Decoder _decoder = Encoding.UTF8.GetDecoder(); // stateful:多位元組字元跨 chunk 不會解成亂碼
private bool _disposed;
/// <summary>實際寫入的完整檔案路徑。</summary>
public string FilePath { get; }
private SessionLogger(string filePath)
{
FilePath = filePath;
_writer = new StreamWriter(filePath, append: true, new UTF8Encoding(false)) { AutoFlush = true };
}
/// <summary>
/// 為某個連線建立側錄器。<paramref name="logName"/> 例如 "PowerShell" / "COM17" / "etwen@192.168.1.50"。
/// </summary>
public static SessionLogger Start(string logName)
{
string dir = Path.Combine(AppContext.BaseDirectory, "logs");
Directory.CreateDirectory(dir);
string safe = Sanitize(logName);
string fileName = $"[{safe}]_{DateTime.Now:yyyyMMdd_HHmmss}.log";
string path = Path.Combine(dir, fileName);
return new SessionLogger(path);
}
/// <summary>餵入終端機收到的原始位元組(UTF-8 解碼後側錄)。</summary>
public void Write(byte[] data)
{
if (_disposed || data.Length == 0) return;
lock (_lock)
{
if (_disposed) return;
var chars = new char[data.Length];
int n = _decoder.GetChars(data, 0, data.Length, chars, 0);
if (n == 0) return;
string clean = AnsiRegex.Replace(new string(chars, 0, n), "").Replace("\r", "");
if (clean.Length == 0) return;
string stamp = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] ";
int start = 0;
for (int i = 0; i < clean.Length; i++)
{
if (clean[i] != '\n') continue;
_pending.Append(clean, start, i - start);
_writer.WriteLine(stamp + _pending);
_pending.Clear();
start = i + 1;
}
if (start < clean.Length)
_pending.Append(clean, start, clean.Length - start);
}
}
public void Dispose()
{
lock (_lock)
{
if (_disposed) return;
_disposed = true;
try
{
if (_pending.Length > 0)
_writer.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {_pending}");
_writer.Dispose();
}
catch { /* 收尾失敗不可拖垮 App */ }
}
}
/// <summary>把連線識別字串清成合法檔名(移除 \ / : * ? " &lt; &gt; | 等)。</summary>
private static string Sanitize(string name)
{
if (string.IsNullOrWhiteSpace(name)) return "session";
var sb = new StringBuilder(name.Length);
var invalid = Path.GetInvalidFileNameChars();
foreach (char c in name)
sb.Append(Array.IndexOf(invalid, c) >= 0 ? '_' : c);
return sb.ToString();
}
}