Compare commits

...

12 Commits

9 changed files with 402 additions and 369 deletions
Binary file not shown.
+27 -29
View File
@@ -13,28 +13,19 @@ public sealed class HdbCliRunner
/// </summary> /// </summary>
public static List<HdbUserstoreKey> ListKeys() public static List<HdbUserstoreKey> ListKeys()
{ {
var result = RunAndCapture(HdbClientLocator.HdbUserstore, "list"); var result = RunAndCapture(HdbClientLocator.HdbUserstore, ["list"]);
if (!result.Success) if (!result.Success)
return []; return [];
return HdbUserstoreKey.ParseFrom(result.Output); return HdbUserstoreKey.ParseFrom(result.Output);
} }
/// <summary>
/// Tests a key connection by running a trivial SELECT against DUMMY.
/// Returns true on success.
/// </summary>
public static bool TestKey(string key) public static bool TestKey(string key)
{ {
var result = RunAndCapture( var result = RunAndCapture(HdbClientLocator.HdbSql,
HdbClientLocator.HdbSql, ["-U", key, "SELECT 'ok' FROM DUMMY"]);
$"-U \"{key}\" \"SELECT 'ok' FROM DUMMY\"");
return result.Success && result.Output.Contains("ok"); return result.Success && result.Output.Contains("ok");
} }
/// <summary>
/// Lists eligible schemas for the given userstore key.
/// </summary>
public static (bool Success, List<string> Schemas, string Error) ListSchemas(string key) public static (bool Success, List<string> Schemas, string Error) ListSchemas(string key)
{ {
const string query = const string query =
@@ -42,7 +33,7 @@ public sealed class HdbCliRunner
"WHERE SCHEMA_OWNER = 'SYSTEM' " + "WHERE SCHEMA_OWNER = 'SYSTEM' " +
"AND SCHEMA_NAME NOT IN ('_SYS_SECURITY', 'IFSERV', 'B1if', 'SYSTEM', 'RSP');"; "AND SCHEMA_NAME NOT IN ('_SYS_SECURITY', 'IFSERV', 'B1if', 'SYSTEM', 'RSP');";
var result = RunAndCapture(HdbClientLocator.HdbSql, $"-U \"{key}\" \"{query}\""); var result = RunAndCapture(HdbClientLocator.HdbSql, ["-U", key, query]);
if (!result.Success) if (!result.Success)
return (false, [], result.Output); return (false, [], result.Output);
@@ -73,9 +64,11 @@ public sealed class HdbCliRunner
Action<string> onOutputLine, Action<string> onOutputLine,
CancellationToken ct) CancellationToken ct)
{ {
// Use ArgumentList (not Arguments) so args are passed directly to the process
// without shell quoting — double quotes inside SQL won't break the argument boundary.
return await RunStreamingAsync( return await RunStreamingAsync(
HdbClientLocator.HdbSql, HdbClientLocator.HdbSql,
$"-U \"{key}\" \"{sql}\"", ["-U", key, sql],
onOutputLine, onOutputLine,
ct); ct);
} }
@@ -90,23 +83,29 @@ public sealed class HdbCliRunner
Action<string> onOutputLine, Action<string> onOutputLine,
CancellationToken ct) CancellationToken ct)
{ {
return await RunStreamingAsync(executable, arguments, onOutputLine, ct); // Shell commands (tar, pigz) need shell parsing for things like -I "pigz -p N",
// so we use a bash -c wrapper to ensure correct tokenisation.
return await RunStreamingAsync(
"/bin/bash",
["-c", arguments],
onOutputLine,
ct);
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
private static (bool Success, string Output) RunAndCapture(string exe, string args) private static (bool Success, string Output) RunAndCapture(string exe, string[] args)
{ {
try try
{ {
var psi = new ProcessStartInfo(exe) var psi = new ProcessStartInfo(exe)
{ {
Arguments = args,
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
UseShellExecute = false, UseShellExecute = false,
CreateNoWindow = true, CreateNoWindow = true,
}; };
foreach (var a in args) psi.ArgumentList.Add(a);
using var proc = Process.Start(psi); using var proc = Process.Start(psi);
if (proc is null) if (proc is null)
@@ -116,10 +115,9 @@ public sealed class HdbCliRunner
var stderr = proc.StandardError.ReadToEnd(); var stderr = proc.StandardError.ReadToEnd();
proc.WaitForExit(); proc.WaitForExit();
if (proc.ExitCode == 0) return proc.ExitCode == 0
return (true, stdout); ? (true, stdout)
else : (false, string.IsNullOrWhiteSpace(stderr) ? stdout : stderr);
return (false, string.IsNullOrWhiteSpace(stderr) ? stdout : stderr);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -129,18 +127,18 @@ public sealed class HdbCliRunner
private static async Task<int> RunStreamingAsync( private static async Task<int> RunStreamingAsync(
string exe, string exe,
string args, string[] args,
Action<string> onOutputLine, Action<string> onOutputLine,
CancellationToken ct) CancellationToken ct)
{ {
var psi = new ProcessStartInfo(exe) var psi = new ProcessStartInfo(exe)
{ {
Arguments = args,
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
UseShellExecute = false, UseShellExecute = false,
CreateNoWindow = true, CreateNoWindow = true,
}; };
foreach (var a in args) psi.ArgumentList.Add(a);
Process? proc; Process? proc;
try try
+11 -11
View File
@@ -357,7 +357,7 @@ public sealed class SchemaService(string userKey)
private async Task<string> GetTenantNameAsync() private async Task<string> GetTenantNameAsync()
{ {
var sql = SqlQueryBuilder.GetTenantName(); var sql = SqlQueryBuilder.GetTenantName();
var (success, output) = RunCapture(HdbClientLocator.HdbSql, $"-U \"{_key}\" \"{sql}\""); var (success, output) = RunCapture(HdbClientLocator.HdbSql, ["-U", _key, sql]);
if (!success) return ""; if (!success) return "";
foreach (var line in output.Split('\n')) foreach (var line in output.Split('\n'))
@@ -373,19 +373,19 @@ public sealed class SchemaService(string userKey)
return ""; return "";
} }
private static (bool, string) RunCapture(string exe, string args) private static (bool, string) RunCapture(string exe, string[] args)
{ {
try try
{ {
var psi = new SysDiag.ProcessStartInfo(exe) var psi = new SysDiag.ProcessStartInfo(exe)
{ {
Arguments = args, RedirectStandardOutput = true,
RedirectStandardOutput = true, RedirectStandardError = true,
RedirectStandardError = true, UseShellExecute = false,
UseShellExecute = false, CreateNoWindow = true,
CreateNoWindow = true, };
}; foreach (var a in args) psi.ArgumentList.Add(a);
using var proc = SysDiag.Process.Start(psi); using var proc = SysDiag.Process.Start(psi);
if (proc is null) return (false, ""); if (proc is null) return (false, "");
var stdout = proc.StandardOutput.ReadToEnd(); var stdout = proc.StandardOutput.ReadToEnd();
var stderr = proc.StandardError.ReadToEnd(); var stderr = proc.StandardError.ReadToEnd();
+49 -40
View File
@@ -3,27 +3,23 @@ using Spectre.Console.Rendering;
namespace HanaTui.Tui.Components; namespace HanaTui.Tui.Components;
/// <summary>
/// A timestamped log entry.
/// </summary>
public sealed class LogEntry public sealed class LogEntry
{ {
public DateTime Time { get; init; } = DateTime.Now; public DateTime Time { get; init; } = DateTime.Now;
public string Text { get; init; } = ""; public string Text { get; init; } = "";
public LogLevel Level { get; init; } = LogLevel.Info; public LogLevel Level { get; init; } = LogLevel.Info;
} }
public enum LogLevel { Info, Sql, Done, Warn, Error } public enum LogLevel { Info, Sql, Done, Warn, Error }
/// <summary> /// <summary>
/// Maintains a bounded list of log entries and renders them as a /// Thread-safe bounded log. Build() returns an IRenderable panel.
/// Spectre.Console IRenderable panel, showing the most recent N lines. /// Dynamic content is rendered via Text() objects — never interpolated into markup strings.
/// Thread-safe.
/// </summary> /// </summary>
public sealed class LogPanel public sealed class LogPanel
{ {
private const int MaxEntries = 500; private const int MaxEntries = 500;
private const int VisibleLines = 20; // shown inside the panel private const int VisibleLines = 20;
private readonly List<LogEntry> _entries = new(MaxEntries); private readonly List<LogEntry> _entries = new(MaxEntries);
private readonly object _lock = new(); private readonly object _lock = new();
@@ -31,19 +27,13 @@ public sealed class LogPanel
public void Add(string text) public void Add(string text)
{ {
var level = DetectLevel(text); var level = DetectLevel(text);
var entry = new LogEntry { Time = DateTime.Now, Text = text, Level = level };
lock (_lock) lock (_lock)
{ {
if (_entries.Count >= MaxEntries) if (_entries.Count >= MaxEntries) _entries.RemoveAt(0);
_entries.RemoveAt(0); _entries.Add(new LogEntry { Time = DateTime.Now, Text = text, Level = level });
_entries.Add(entry);
} }
} }
/// <summary>
/// Returns a renderable panel showing the last N log lines.
/// </summary>
public IRenderable Build() public IRenderable Build()
{ {
List<LogEntry> snapshot; List<LogEntry> snapshot;
@@ -56,28 +46,37 @@ public sealed class LogPanel
var rows = new Grid(); var rows = new Grid();
rows.AddColumn(new GridColumn().NoWrap()); rows.AddColumn(new GridColumn().NoWrap());
// Pad to VisibleLines so the panel doesn't resize each tick // Pad so the panel height stays stable
var padCount = VisibleLines - snapshot.Count; for (int i = 0; i < VisibleLines - snapshot.Count; i++)
for (int i = 0; i < padCount; i++)
rows.AddRow(new Text("")); rows.AddRow(new Text(""));
foreach (var entry in snapshot) foreach (var entry in snapshot)
{ {
var timeStr = entry.Time.ToString("HH:mm:ss"); var (tagLabel, tagColor, textColor) = entry.Level switch
var (tag, color) = entry.Level switch
{ {
LogLevel.Sql => ("[SQL ]", "blue"), LogLevel.Sql => ("SQL ", Color.Blue, Color.White),
LogLevel.Done => ("[DONE]", "green"), LogLevel.Done => ("DONE", Color.Green, Color.White),
LogLevel.Warn => ("[WARN]", "yellow"), LogLevel.Warn => ("WARN", Color.Yellow, Color.White),
LogLevel.Error => ("[ERR ]", "red"), LogLevel.Error => ("ERR ", Color.Red, Color.White),
_ => ("[INFO]", "grey"), _ => ("INFO", Color.Grey, Color.Silver),
}; };
// Escape any markup-like content in the raw text // Strip the [TAG] prefix the service already prepends — we re-render it with color
var safeText = Markup.Escape(entry.Text); var bodyText = StripKnownPrefix(entry.Text);
rows.AddRow(new Markup( // Compose the row from Text objects — zero markup parsing of dynamic content
$"[dim]{timeStr}[/] [{color}]{tag}[/] {safeText}")); var lineGrid = new Grid();
lineGrid.AddColumn(new GridColumn().NoWrap().Width(10)); // time
lineGrid.AddColumn(new GridColumn().NoWrap().Width(8)); // tag
lineGrid.AddColumn(new GridColumn().NoWrap()); // body
lineGrid.AddRow(
new Text(entry.Time.ToString("HH:mm:ss"), new Style(Color.Grey, decoration: Decoration.Dim)),
new Text($"[{tagLabel}]", new Style(tagColor)),
new Text(" " + bodyText, new Style(textColor))
);
rows.AddRow(lineGrid);
} }
return new Panel(rows) return new Panel(rows)
@@ -88,16 +87,26 @@ public sealed class LogPanel
}; };
} }
// -------------------------------------------------------------------------
private static LogLevel DetectLevel(string text) private static LogLevel DetectLevel(string text)
{ {
if (text.Contains("[SQL ]") || text.Contains("[SQL]")) if (text.Contains("[SQL ]") || text.Contains("[SQL]")) return LogLevel.Sql;
return LogLevel.Sql; if (text.Contains("[DONE]")) return LogLevel.Done;
if (text.Contains("[DONE]")) if (text.Contains("[WARN]")) return LogLevel.Warn;
return LogLevel.Done; if (text.Contains("[ERROR]") || text.Contains("[ERR ]") || text.Contains("[ERR]"))
if (text.Contains("[WARN]")) return LogLevel.Error;
return LogLevel.Warn;
if (text.Contains("[ERROR]") || text.Contains("[ERR]"))
return LogLevel.Error;
return LogLevel.Info; return LogLevel.Info;
} }
private static readonly string[] KnownPrefixes =
["[INFO] ", "[SQL ] ", "[SQL] ", "[DONE] ", "[WARN] ", "[ERROR] ", "[ERR ] ", "[ERR] "];
private static string StripKnownPrefix(string text)
{
foreach (var p in KnownPrefixes)
if (text.StartsWith(p, StringComparison.Ordinal))
return text[p.Length..];
return text;
}
} }
+124 -112
View File
@@ -6,130 +6,142 @@ namespace HanaTui.Tui.Components;
/// <summary> /// <summary>
/// Renders the system stats panel (CPU bars, RAM bar, Swap bar, elapsed time). /// Renders the system stats panel (CPU bars, RAM bar, Swap bar, elapsed time).
/// Returns a Spectre.Console IRenderable so it can be embedded in a Live layout. /// Uses only hardcoded markup tags — no dynamic data ever enters a markup string.
/// </summary> /// </summary>
public static class StatsPanel public static class StatsPanel
{ {
/// <summary> public static IRenderable Build(SystemSnapshot snap, TimeSpan elapsed, int panelWidth)
/// Build the stats panel renderable from the current snapshot. {
/// </summary> var barWidth = Math.Max(10, panelWidth - 16);
public static IRenderable Build(SystemSnapshot snap, TimeSpan elapsed, int panelWidth)
var grid = new Grid();
grid.AddColumn(new GridColumn().NoWrap());
// --- CPU ---
grid.AddRow(new Markup("[bold yellow]CPU[/]"));
if (snap.CpuPercents.Length == 0)
{ {
var barWidth = Math.Max(10, panelWidth - 16); // leave room for label + % grid.AddRow(new Markup("[grey]No data[/]"));
}
var grid = new Grid(); else
grid.AddColumn(new GridColumn().NoWrap()); {
for (int i = 0; i < snap.CpuPercents.Length; i++)
// --- CPU Section --- {
grid.AddRow(new Markup("[bold yellow]CPU[/]")); var label = snap.CpuLabels.Length > i ? snap.CpuLabels[i] : $"cpu{i}";
var displayLabel = label == "cpu" ? "Total" : label.Replace("cpu", "Core");
if (snap.CpuPercents.Length == 0) var pct = snap.CpuPercents[i];
{ // Build as Columns: label | bar | pct — no interpolation of dynamic values into markup
grid.AddRow(new Markup("[grey]No data[/]")); grid.AddRow(BuildBarRow(displayLabel, pct, barWidth));
} }
else
{
for (int i = 0; i < snap.CpuPercents.Length; i++)
{
var label = snap.CpuLabels.Length > i ? snap.CpuLabels[i] : $"cpu{i}";
var displayLabel = label == "cpu" ? "Total" : label.Replace("cpu", "Core");
var pct = snap.CpuPercents[i];
var bar = BuildBar(pct, barWidth, CpuColor(pct));
grid.AddRow(new Markup($" [dim]{displayLabel,-6}[/] {bar} [bold]{pct,5:F1}%[/]"));
}
}
grid.AddRow(new Text(""));
// --- Memory Section ---
grid.AddRow(new Markup("[bold cyan]MEMORY[/]"));
if (snap.MemTotalKb > 0)
{
var memPct = snap.MemUsedPercent;
var memBar = BuildBar(memPct, barWidth, MemColor(memPct));
grid.AddRow(new Markup(
$" [dim]Used [/] {memBar} [bold]{memPct,5:F1}%[/]"));
grid.AddRow(new Markup(
$" [dim]{SystemSnapshot.FormatGb(snap.MemUsedKb)} / {SystemSnapshot.FormatGb(snap.MemTotalKb)}[/]"));
}
else
{
grid.AddRow(new Markup("[grey]No data[/]"));
}
grid.AddRow(new Text(""));
// --- Swap Section ---
grid.AddRow(new Markup("[bold cyan]SWAP[/]"));
if (snap.SwapTotalKb > 0)
{
var swapPct = snap.SwapUsedPercent;
var swapBar = BuildBar(swapPct, barWidth, MemColor(swapPct));
grid.AddRow(new Markup(
$" [dim]Used [/] {swapBar} [bold]{swapPct,5:F1}%[/]"));
grid.AddRow(new Markup(
$" [dim]{SystemSnapshot.FormatGb(snap.SwapUsedKb)} / {SystemSnapshot.FormatGb(snap.SwapTotalKb)}[/]"));
}
else
{
grid.AddRow(new Markup("[grey]No swap[/]"));
}
grid.AddRow(new Text(""));
// --- Elapsed ---
grid.AddRow(new Markup(
$"[bold]Elapsed:[/] [yellow]{FormatElapsed(elapsed)}[/]"));
var panel = new Panel(grid)
{
Header = new PanelHeader("[bold] SYSTEM STATS [/]"),
Border = BoxBorder.Rounded,
Padding = new Padding(1, 0),
};
return panel;
} }
// ------------------------------------------------------------------------- grid.AddRow(new Text(""));
// Helpers
// -------------------------------------------------------------------------
/// <summary> // --- Memory ---
/// Renders a horizontal block bar of the given width. grid.AddRow(new Markup("[bold cyan]MEMORY[/]"));
/// e.g. [████████░░░░] if (snap.MemTotalKb > 0)
/// </summary>
private static string BuildBar(double percent, int width, string color)
{ {
var filled = (int)Math.Round(percent / 100.0 * width); var memPct = snap.MemUsedPercent;
filled = Math.Clamp(filled, 0, width); grid.AddRow(BuildBarRow("Used", memPct, barWidth));
var empty = width - filled; grid.AddRow(new Text($" {SystemSnapshot.FormatGb(snap.MemUsedKb)} / {SystemSnapshot.FormatGb(snap.MemTotalKb)}",
new Style(Color.Grey)));
var bar = new string('\u2588', filled) + new string('\u2591', empty); }
return $"[[{color}]{bar}[/]]"; else
{
grid.AddRow(new Markup("[grey]No data[/]"));
} }
private static string CpuColor(double pct) => pct switch grid.AddRow(new Text(""));
// --- Swap ---
grid.AddRow(new Markup("[bold cyan]SWAP[/]"));
if (snap.SwapTotalKb > 0)
{ {
> 90 => "red", var swapPct = snap.SwapUsedPercent;
> 70 => "yellow", grid.AddRow(BuildBarRow("Used", swapPct, barWidth));
> 40 => "green", grid.AddRow(new Text($" {SystemSnapshot.FormatGb(snap.SwapUsedKb)} / {SystemSnapshot.FormatGb(snap.SwapTotalKb)}",
_ => "blue", new Style(Color.Grey)));
}
else
{
grid.AddRow(new Markup("[grey]No swap[/]"));
}
grid.AddRow(new Text(""));
// --- Elapsed ---
// Two separate renderables composed — no interpolation of elapsed into markup
var elapsedGrid = new Grid();
elapsedGrid.AddColumn(new GridColumn().NoWrap());
elapsedGrid.AddColumn(new GridColumn().NoWrap());
elapsedGrid.AddRow(
new Markup("[bold]Elapsed:[/]"),
new Text(" " + FormatElapsed(elapsed), new Style(Color.Yellow)));
grid.AddRow(elapsedGrid);
return new Panel(grid)
{
Header = new PanelHeader("[bold] SYSTEM STATS [/]"),
Border = BoxBorder.Rounded,
Padding = new Padding(1, 0),
}; };
}
private static string MemColor(double pct) => pct switch // -------------------------------------------------------------------------
{ // Helpers
> 90 => "red", // -------------------------------------------------------------------------
> 70 => "yellow",
_ => "cyan",
};
private static string FormatElapsed(TimeSpan t) /// <summary>
{ /// Builds a single bar row as a Grid with three columns:
if (t.TotalHours >= 1) /// label (dim) | bar (block chars, plain Text with color) | pct (bold)
return $"{(int)t.TotalHours}h {t.Minutes:D2}m {t.Seconds:D2}s"; /// Nothing here goes through markup parsing with dynamic content.
if (t.TotalMinutes >= 1) /// </summary>
return $"{t.Minutes}m {t.Seconds:D2}s"; private static IRenderable BuildBarRow(string label, double pct, int barWidth)
return $"{t.Seconds}s"; {
} var filled = (int)Math.Round(pct / 100.0 * barWidth);
filled = Math.Clamp(filled, 0, barWidth);
var empty = barWidth - filled;
var filledColor = CpuColor(pct);
var filledStr = new string('\u2588', filled);
var emptyStr = new string('\u2591', empty);
var row = new Grid();
// FIX: Add all three columns back!
row.AddColumn(new GridColumn().NoWrap().Width(10)); // Column 1: Label
row.AddColumn(new GridColumn().NoWrap()); // Column 2: Bar
row.AddColumn(new GridColumn().NoWrap().Width(8)); // Column 3: Pct
var colorMarkup = filledColor.ToMarkup();
// Construct a seamless markup string
var barMarkup = $"[grey][[[/][{colorMarkup}]{filledStr}[/][grey dim]{emptyStr}[/][grey]]][/]";
row.AddRow(
new Text($" {label,-5}", new Style(Color.Grey, decoration: Decoration.Dim)),
new Markup(barMarkup),
new Text($" {pct,5:F1}%", new Style(Color.White, decoration: Decoration.Bold))
);
return row;
}
private static Color CpuColor(double pct) => pct switch
{
> 90 => Color.Red,
> 70 => Color.Yellow,
> 40 => Color.Green,
_ => Color.Blue,
};
private static string FormatElapsed(TimeSpan t)
{
if (t.TotalHours >= 1)
return $"{(int)t.TotalHours}h {t.Minutes:D2}m {t.Seconds:D2}s";
if (t.TotalMinutes >= 1)
return $"{t.Minutes}m {t.Seconds:D2}s";
return $"{t.Seconds}s";
}
} }
+48 -28
View File
@@ -1,5 +1,6 @@
using HanaTui.Hana; using HanaTui.Hana;
using Spectre.Console; using Spectre.Console;
using SysText = System.Text;
namespace HanaTui.Tui; namespace HanaTui.Tui;
@@ -9,6 +10,17 @@ namespace HanaTui.Tui;
/// </summary> /// </summary>
public static class KeySelectionScreen public static class KeySelectionScreen
{ {
private sealed class KeyChoice
{
public string Display { get; init; } = "";
public string? KeyName { get; init; } // null = exit, "" = manual entry
public static readonly KeyChoice Manual = new() { Display = "[ Enter key name manually ]", KeyName = "" };
public static readonly KeyChoice Exit = new() { Display = "[ Exit ]", KeyName = null };
public override string ToString() => Display;
}
public static string? Run() public static string? Run()
{ {
AnsiConsole.Clear(); AnsiConsole.Clear();
@@ -25,14 +37,11 @@ public static class KeySelectionScreen
return null; return null;
} }
// Show client path for reference
var clientDir = HdbClientLocator.ClientDirectory; var clientDir = HdbClientLocator.ClientDirectory;
if (clientDir is not null) if (clientDir is not null)
AnsiConsole.MarkupLine($"[dim]HDB client: {clientDir}[/]\n"); AnsiConsole.MarkupLine($"[dim]HDB client: {Markup.Escape(clientDir)}[/]\n");
// Load keys with a spinner
List<HdbUserstoreKey> keys = []; List<HdbUserstoreKey> keys = [];
AnsiConsole.Status() AnsiConsole.Status()
.Spinner(Spinner.Known.Dots) .Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("blue")) .SpinnerStyle(Style.Parse("blue"))
@@ -47,49 +56,60 @@ public static class KeySelectionScreen
AnsiConsole.MarkupLine("You can still enter a key name manually.\n"); AnsiConsole.MarkupLine("You can still enter a key name manually.\n");
} }
// Build selection choices // Build typed choices. Display is plain text — never parsed as markup.
var choices = new List<string>(); var choices = keys
foreach (var k in keys) .Select(k => new KeyChoice { Display = BuildKeyDisplay(k), KeyName = k.Name })
{ .ToList();
var detail = BuildKeyDetail(k); choices.Add(KeyChoice.Manual);
choices.Add(detail); choices.Add(KeyChoice.Exit);
}
choices.Add("[dim][ Enter key name manually ][/]");
choices.Add("[red][ Exit ][/]");
var prompt = new SelectionPrompt<string>() var prompt = new SelectionPrompt<KeyChoice>()
.Title("[bold]Select HDBUSERSTORE key:[/]") .Title("[bold]Select HDBUSERSTORE key:[/]")
.PageSize(15) .PageSize(15)
.HighlightStyle(Style.Parse("bold dodgerblue1")) .HighlightStyle(Style.Parse("bold dodgerblue1"))
.UseConverter(c => Markup.Escape(c.Display))
.AddChoices(choices); .AddChoices(choices);
var selected = AnsiConsole.Prompt(prompt); var selected = AnsiConsole.Prompt(prompt);
if (selected.Contains("Exit")) if (selected.KeyName is null)
return null; return null; // Exit
if (selected.Contains("manually")) if (selected.KeyName == "")
{ {
var manual = AnsiConsole.Ask<string>("[bold]Enter HDBUSERSTORE key name:[/]").Trim(); var manual = AnsiConsole.Ask<string>("[bold]Enter HDBUSERSTORE key name:[/]").Trim();
return string.IsNullOrWhiteSpace(manual) ? null : manual; return string.IsNullOrWhiteSpace(manual) ? null : manual;
} }
// Extract the key name from the formatted string (it's always the first word) return selected.KeyName;
var keyName = selected.Split(' ')[0].Trim();
return keyName;
} }
private static string BuildKeyDetail(HdbUserstoreKey k) private static string BuildKeyDisplay(HdbUserstoreKey k)
{ {
var parts = new List<string> { k.Name }; var sb = new SysText.StringBuilder(k.Name);
if (!string.IsNullOrEmpty(k.Host)) if (!string.IsNullOrEmpty(k.Host))
parts.Add($"[dim]{k.Host}:{k.Port}[/]"); {
if (!string.IsNullOrEmpty(k.Tenant)) sb.Append(" ");
parts.Add($"[dim]@{k.Tenant}[/]"); sb.Append(k.Host);
if (!string.IsNullOrEmpty(k.User)) if (!string.IsNullOrEmpty(k.Port))
parts.Add($"[dim]user={k.User}[/]"); {
sb.Append(':');
sb.Append(k.Port);
}
if (!string.IsNullOrEmpty(k.Tenant))
{
sb.Append('@');
sb.Append(k.Tenant);
}
}
return string.Join(" ", parts); if (!string.IsNullOrEmpty(k.User))
{
sb.Append(" user=");
sb.Append(k.User);
}
return sb.ToString();
} }
} }
+5 -5
View File
@@ -32,15 +32,15 @@ public static class MainMenuScreen
if (key is not null) if (key is not null)
{ {
var conn = key.Host + ":" + key.Port +
(string.IsNullOrEmpty(key.Tenant) ? "" : "@" + key.Tenant) +
" user=" + key.User;
AnsiConsole.MarkupLine( AnsiConsole.MarkupLine(
$" Key: [bold yellow]{key.Name}[/] " + $" Key: [bold yellow]{Markup.Escape(key.Name)}[/] [dim]{Markup.Escape(conn)}[/]");
$"[dim]{key.Host}:{key.Port}" +
(string.IsNullOrEmpty(key.Tenant) ? "" : $"@{key.Tenant}") +
$" user={key.User}[/]");
} }
else else
{ {
AnsiConsole.MarkupLine($" Key: [bold yellow]{keyName}[/]"); AnsiConsole.MarkupLine($" Key: [bold yellow]{Markup.Escape(keyName)}[/]");
} }
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
+34 -12
View File
@@ -14,6 +14,21 @@ public static class OperationForms
// Shared helpers // Shared helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/// <summary>
/// A choice item that keeps the real value separate from the display label.
/// The display label is pre-escaped plain text — never interpreted as markup.
/// </summary>
private sealed class SchemaChoice
{
public string Display { get; init; } = "";
public string? Value { get; init; } // null = cancel, "" = manual entry
public static readonly SchemaChoice Manual = new() { Display = "[ Enter manually ]", Value = "" };
public static readonly SchemaChoice Cancel = new() { Display = "[ Cancel ]", Value = null };
public override string ToString() => Display;
}
/// <summary> /// <summary>
/// Fetches the schema list with a spinner, then shows an arrow-key picker. /// Fetches the schema list with a spinner, then shows an arrow-key picker.
/// Returns the selected schema, or null if cancelled. /// Returns the selected schema, or null if cancelled.
@@ -38,28 +53,35 @@ public static class OperationForms
if (error is not null) if (error is not null)
AnsiConsole.MarkupLine($"[yellow][[WARN]] Could not fetch schemas: {Markup.Escape(error)}[/]"); AnsiConsole.MarkupLine($"[yellow][[WARN]] Could not fetch schemas: {Markup.Escape(error)}[/]");
var choices = new List<string>(schemas); // Build typed choices. Display is plain text; ToString() is what SelectionPrompt shows.
choices.Add("[dim][ Enter manually ][/]"); var choices = schemas
choices.Add("[dim][ Cancel ][/]"); .Select(s => new SchemaChoice { Display = s, Value = s })
.ToList();
choices.Add(SchemaChoice.Manual);
choices.Add(SchemaChoice.Cancel);
var prompt = new SelectionPrompt<string>() // UseConverter returns Display which is already plain text.
.Title($"[bold]{title}[/]") // We also set the prompt to NOT interpret converter output as markup by
// escaping it — belt-and-suspenders.
var prompt = new SelectionPrompt<SchemaChoice>()
.Title($"[bold]{Markup.Escape(title)}[/]")
.PageSize(15) .PageSize(15)
.HighlightStyle(Style.Parse("bold dodgerblue1")) .HighlightStyle(Style.Parse("bold dodgerblue1"))
.UseConverter(c => Markup.Escape(c.Display))
.AddChoices(choices); .AddChoices(choices);
var selected = AnsiConsole.Prompt(prompt); var selected = AnsiConsole.Prompt(prompt);
if (selected.Contains("Cancel")) if (selected.Value is null)
return null; return null; // Cancel
if (selected.Contains("manually")) if (selected.Value == "")
{ {
var manual = AnsiConsole.Ask<string>("Enter schema name:").Trim(); var manual = AnsiConsole.Ask<string>("Enter schema name:").Trim();
return string.IsNullOrWhiteSpace(manual) ? null : manual; return string.IsNullOrWhiteSpace(manual) ? null : manual;
} }
return selected.Trim(); return selected.Value;
} }
private static int PickThreads(string label = "Number of threads") private static int PickThreads(string label = "Number of threads")
@@ -81,7 +103,7 @@ public static class OperationForms
private static void PrintOperationHeader(string title) private static void PrintOperationHeader(string title)
{ {
AnsiConsole.Clear(); AnsiConsole.Clear();
var rule = new Rule($"[bold dodgerblue1]{title}[/]").RuleStyle(Style.Parse("dodgerblue1")); var rule = new Rule($"[bold dodgerblue1]{Markup.Escape(title)}[/]").RuleStyle(Style.Parse("dodgerblue1"));
AnsiConsole.Write(rule); AnsiConsole.Write(rule);
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
} }
@@ -93,8 +115,8 @@ public static class OperationForms
table.AddColumn("[bold]Parameter[/]"); table.AddColumn("[bold]Parameter[/]");
table.AddColumn("[bold]Value[/]"); table.AddColumn("[bold]Value[/]");
foreach (var (k, v) in fields) foreach (var (k, v) in fields)
table.AddRow($"[dim]{k}[/]", $"[yellow]{Markup.Escape(v)}[/]"); table.AddRow($"[dim]{Markup.Escape(k)}[/]", $"[yellow]{Markup.Escape(v)}[/]");
AnsiConsole.MarkupLine($"[bold]{title}[/]"); AnsiConsole.MarkupLine($"[bold]{Markup.Escape(title)}[/]");
AnsiConsole.Write(table); AnsiConsole.Write(table);
AnsiConsole.WriteLine(); AnsiConsole.WriteLine();
+104 -132
View File
@@ -10,19 +10,13 @@ namespace HanaTui.Tui;
/// Left: System stats (CPU, RAM, Swap, elapsed) /// Left: System stats (CPU, RAM, Swap, elapsed)
/// Right: Streaming operation log /// Right: Streaming operation log
/// ///
/// Abort behavior: /// Abort: first Q warns, second Q within 3s sends SIGTERM then SIGKILL.
/// First Q -> warning shown in log /// Post-completion: 10s countdown, any key cancels and holds; Enter/Esc returns.
/// Second Q (within 3s) -> sends cancellation (SIGTERM -> wait 5s -> SIGKILL)
///
/// Post-completion:
/// Stats freeze, log stays.
/// "Returning in 10s... [any key to stay]" countdown.
/// If key pressed: stays until Enter/Esc.
/// </summary> /// </summary>
public static class TaskRunnerScreen public static class TaskRunnerScreen
{ {
private const int CountdownSeconds = 10; private const int CountdownSeconds = 10;
private const double AbortWindowSeconds = 3.0; private const double AbortWindowSeconds = 3.0;
public static async Task RunAsync( public static async Task RunAsync(
string operationTitle, string operationTitle,
@@ -30,20 +24,16 @@ public static class TaskRunnerScreen
{ {
AnsiConsole.Clear(); AnsiConsole.Clear();
var logPanel = new LogPanel(); var logPanel = new LogPanel();
var stats = new SystemStats(); var stats = new SystemStats();
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
var startTime = DateTime.Now; var startTime = DateTime.Now;
var operationDone = false; var operationDone = false;
var operationSuccess = false; var operationSuccess = false;
DateTime? firstQTime = null;
// Abort state machine // Key listener
DateTime? firstQPressTime = null;
// -----------------------------------------------------------------------
// Key listener task — runs concurrently with the Live render loop
// -----------------------------------------------------------------------
var keyTask = Task.Run(async () => var keyTask = Task.Run(async () =>
{ {
while (!operationDone) while (!operationDone)
@@ -51,25 +41,24 @@ public static class TaskRunnerScreen
if (Console.KeyAvailable) if (Console.KeyAvailable)
{ {
var key = Console.ReadKey(intercept: true); var key = Console.ReadKey(intercept: true);
if (key.Key == ConsoleKey.Q || key.KeyChar == 'q' || key.KeyChar == 'Q') if (key.Key == ConsoleKey.Q || key.KeyChar is 'q' or 'Q')
{ {
if (cts.IsCancellationRequested) break; // already cancelled if (cts.IsCancellationRequested) break;
if (firstQPressTime is null) if (firstQTime is null)
{ {
firstQPressTime = DateTime.Now; firstQTime = DateTime.Now;
logPanel.Add("[WARN] Press Q again within 3 seconds to abort the operation."); logPanel.Add("[WARN] Press Q again within 3 seconds to abort.");
} }
else if ((DateTime.Now - firstQPressTime.Value).TotalSeconds <= AbortWindowSeconds) else if ((DateTime.Now - firstQTime.Value).TotalSeconds <= AbortWindowSeconds)
{ {
logPanel.Add("[WARN] Aborting operation..."); logPanel.Add("[WARN] Aborting operation...");
await cts.CancelAsync(); await cts.CancelAsync();
} }
else else
{ {
// Window expired, treat as first press again firstQTime = DateTime.Now;
firstQPressTime = DateTime.Now; logPanel.Add("[WARN] Press Q again within 3 seconds to abort.");
logPanel.Add("[WARN] Press Q again within 3 seconds to abort the operation.");
} }
} }
} }
@@ -77,16 +66,12 @@ public static class TaskRunnerScreen
} }
}, CancellationToken.None); }, CancellationToken.None);
// ----------------------------------------------------------------------- // Operation task
// Main operation task
// -----------------------------------------------------------------------
var operationTask = Task.Run(async () => var operationTask = Task.Run(async () =>
{ {
try try
{ {
operationSuccess = await operation( operationSuccess = await operation(line => logPanel.Add(line), cts.Token);
line => logPanel.Add(line),
cts.Token);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -99,93 +84,77 @@ public static class TaskRunnerScreen
} }
}, CancellationToken.None); }, CancellationToken.None);
// -----------------------------------------------------------------------
// Live render loop // Live render loop
// ----------------------------------------------------------------------- await AnsiConsole.Live(BuildLayout(operationTitle, logPanel, stats, startTime, RunState.Running))
await AnsiConsole.Live(BuildLayout(logPanel, stats, startTime, "[yellow]Running...[/]"))
.AutoClear(false) .AutoClear(false)
.StartAsync(async ctx => .StartAsync(async ctx =>
{ {
while (!operationDone) while (!operationDone)
{ {
var elapsed = DateTime.Now - startTime; ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, RunState.Running));
ctx.UpdateTarget(
BuildLayout(logPanel, stats, startTime, "[yellow]Running...[/]"));
await Task.Delay(800, CancellationToken.None); await Task.Delay(800, CancellationToken.None);
} }
// Final render with result var elapsed = DateTime.Now - startTime;
var elapsed2 = DateTime.Now - startTime; RunState finalState;
var statusMsg = cts.IsCancellationRequested
? "[red]Aborted[/]"
: operationSuccess
? "[green]Completed successfully[/]"
: "[red]Failed[/]";
if (cts.IsCancellationRequested) if (cts.IsCancellationRequested)
logPanel.Add($"[WARN] Operation aborted after {FormatElapsed(elapsed2)}."); {
finalState = RunState.Aborted;
logPanel.Add($"[WARN] Operation aborted after {FormatElapsed(elapsed)}.");
}
else if (operationSuccess) else if (operationSuccess)
logPanel.Add($"[DONE] Operation completed in {FormatElapsed(elapsed2)}."); {
finalState = RunState.Success;
logPanel.Add($"[DONE] Operation completed in {FormatElapsed(elapsed)}.");
}
else else
logPanel.Add($"[ERR ] Operation failed after {FormatElapsed(elapsed2)}."); {
finalState = RunState.Failed;
logPanel.Add($"[ERR ] Operation failed after {FormatElapsed(elapsed)}.");
}
ctx.UpdateTarget(BuildLayout(logPanel, stats, startTime, statusMsg)); ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, finalState));
}); });
// Clean up
stats.Dispose(); stats.Dispose();
await operationTask; // ensure it's fully done await operationTask;
operationDone = true; operationDone = true;
await keyTask; await keyTask;
// ----------------------------------------------------------------------- await PostCompletionWaitAsync(operationTitle, logPanel, stats, startTime,
// Post-completion: countdown or hold cts.IsCancellationRequested ? RunState.Aborted :
// ----------------------------------------------------------------------- operationSuccess ? RunState.Success : RunState.Failed);
await PostCompletionWaitAsync(logPanel, stats, startTime, operationSuccess, cts.IsCancellationRequested);
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Post-completion hold screen
// ----------------------------------------------------------------------- private enum RunState { Running, Success, Failed, Aborted }
private static async Task PostCompletionWaitAsync( private static async Task PostCompletionWaitAsync(
LogPanel logPanel, string title, LogPanel logPanel, SystemStats stats, DateTime startTime, RunState state)
SystemStats stats,
DateTime startTime,
bool success,
bool aborted)
{ {
var statusMsg = aborted ? "[red]Aborted[/]" :
success ? "[green]Completed[/]" : "[red]Failed[/]";
var countdownCancelled = false; var countdownCancelled = false;
var returnNow = false; var returnNow = false;
var staying = false;
var startCountdown = DateTime.Now;
// Start 10-second countdown in background
var countdownTask = Task.Run(async () => var countdownTask = Task.Run(async () =>
{ {
for (int i = CountdownSeconds; i > 0 && !countdownCancelled; i--) for (int i = CountdownSeconds; i > 0 && !countdownCancelled; i--)
{
await Task.Delay(1000, CancellationToken.None); await Task.Delay(1000, CancellationToken.None);
} if (!countdownCancelled) returnNow = true;
if (!countdownCancelled)
returnNow = true;
}, CancellationToken.None); }, CancellationToken.None);
await AnsiConsole.Live(BuildLayout(logPanel, stats, startTime, statusMsg)) await AnsiConsole.Live(BuildLayout(title, logPanel, stats, startTime, state))
.AutoClear(false) .AutoClear(false)
.StartAsync(async ctx => .StartAsync(async ctx =>
{ {
var secondsLeft = CountdownSeconds;
var staying = false;
while (!returnNow) while (!returnNow)
{ {
if (Console.KeyAvailable) if (Console.KeyAvailable)
{ {
var key = Console.ReadKey(intercept: true); var key = Console.ReadKey(intercept: true);
countdownCancelled = true; countdownCancelled = true;
staying = true;
if (key.Key is ConsoleKey.Enter or ConsoleKey.Escape) if (key.Key is ConsoleKey.Enter or ConsoleKey.Escape)
{ {
@@ -193,27 +162,26 @@ public static class TaskRunnerScreen
break; break;
} }
// Any other key: cancel countdown, show "hold" message if (!staying)
logPanel.Add("[INFO] Countdown cancelled. Press [Enter] or [Esc] to return to menu."); {
staying = true;
logPanel.Add("[INFO] Countdown cancelled. Press Enter or Esc to return to menu.");
}
} }
// Update footer countdown string footerText;
var elapsed = DateTime.Now - startTime;
string footer;
if (!staying) if (!staying)
{ {
secondsLeft = CountdownSeconds - var secsLeft = Math.Max(0, CountdownSeconds -
(int)(DateTime.Now - startTime).TotalSeconds; // approximate (int)(DateTime.Now - startCountdown).TotalSeconds);
// recompute properly footerText = $"Returning to menu in {secsLeft}s... (any key to stay)";
footer = $"[dim]Returning to menu in {Math.Max(0, secondsLeft)}s... " +
"[any key to stay][/]";
} }
else else
{ {
footer = "[dim]Press [Enter] or [Esc] to return to menu.[/]"; footerText = "Press Enter or Esc to return to menu.";
} }
ctx.UpdateTarget(BuildLayoutWithFooter(logPanel, stats, startTime, statusMsg, footer)); ctx.UpdateTarget(BuildLayout(title, logPanel, stats, startTime, state, footerText));
await Task.Delay(200, CancellationToken.None); await Task.Delay(200, CancellationToken.None);
} }
}); });
@@ -222,74 +190,78 @@ public static class TaskRunnerScreen
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Layout builders // Layout
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
private static IRenderable BuildLayout( private static IRenderable BuildLayout(
string title,
LogPanel logPanel, LogPanel logPanel,
SystemStats stats, SystemStats stats,
DateTime startTime, DateTime startTime,
string statusMsg) RunState state,
{ string? footerText = null)
return BuildLayoutWithFooter(logPanel, stats, startTime, statusMsg, footer: null);
}
private static IRenderable BuildLayoutWithFooter(
LogPanel logPanel,
SystemStats stats,
DateTime startTime,
string statusMsg,
string? footer)
{ {
var elapsed = DateTime.Now - startTime; var elapsed = DateTime.Now - startTime;
var snap = stats.CurrentSnapshot; var snap = stats.CurrentSnapshot;
// Stats panel (left) — fixed width ~36 chars const int statsPanelWidth = 40;
var statsPanelWidth = 36;
var statsRenderable = StatsPanel.Build(snap, elapsed, statsPanelWidth - 4); var statsRenderable = StatsPanel.Build(snap, elapsed, statsPanelWidth - 4);
var logRenderable = logPanel.Build();
// Log panel (right) — fills remaining space var layout = new Layout("root").SplitColumns(
var logRenderable = logPanel.Build(); new Layout("stats").Size(statsPanelWidth),
new Layout("log"));
var layout = new Layout("root")
.SplitColumns(
new Layout("stats").Size(statsPanelWidth),
new Layout("log"));
layout["stats"].Update(statsRenderable); layout["stats"].Update(statsRenderable);
layout["log"].Update(logRenderable); layout["log"].Update(logRenderable);
// Wrap in a grid so we can add a footer row
var outerGrid = new Grid(); var outerGrid = new Grid();
outerGrid.AddColumn(new GridColumn()); outerGrid.AddColumn(new GridColumn());
// Title row // Title row — state label uses Text objects, not markup interpolation
outerGrid.AddRow(new Markup( var titleGrid = new Grid();
$"[bold dodgerblue1] Running:[/] [yellow]{Markup.Escape(ExtractTitle(statusMsg))}[/] " + titleGrid.AddColumn(new GridColumn().NoWrap());
$"Status: {statusMsg}")); titleGrid.AddColumn(new GridColumn().NoWrap());
titleGrid.AddColumn(new GridColumn().NoWrap());
titleGrid.AddColumn(new GridColumn().NoWrap());
titleGrid.AddRow(
new Markup("[bold dodgerblue1] Operation:[/]"),
new Text(" " + title, new Style(Color.Yellow)),
new Markup(" [bold dodgerblue1]Status:[/]"),
StateLabel(state)
);
outerGrid.AddRow(titleGrid);
outerGrid.AddRow(layout); outerGrid.AddRow(layout);
if (footer is not null) // Footer row — plain Text, no markup parsing of dynamic content
outerGrid.AddRow(new Markup($"\n {footer} [dim][[Q]] Abort[/]")); var footerGrid = new Grid();
else footerGrid.AddColumn(new GridColumn().NoWrap());
outerGrid.AddRow(new Markup("\n [dim][[Q]] Press once to warn, twice to abort[/]")); footerGrid.AddColumn(new GridColumn().NoWrap());
var footerLeft = footerText is not null
? new Text(" " + footerText, new Style(Color.Grey, decoration: Decoration.Dim))
: (IRenderable)new Text(" Press Q to abort (twice within 3s)", new Style(Color.Grey, decoration: Decoration.Dim));
var footerRight = new Text(" [Q] abort", new Style(Color.Grey, decoration: Decoration.Dim));
footerGrid.AddRow(footerLeft, footerRight);
outerGrid.AddRow(footerGrid);
return outerGrid; return outerGrid;
} }
private static string ExtractTitle(string statusMsg) private static IRenderable StateLabel(RunState state) => state switch
{ {
// statusMsg is markup like "[yellow]Running...[/]" — we just return a static title RunState.Running => new Markup(" [yellow]Running...[/]"),
return "HANA Operation"; RunState.Success => new Markup(" [green]Completed[/]"),
} RunState.Failed => new Markup(" [red]Failed[/]"),
RunState.Aborted => new Markup(" [red]Aborted[/]"),
_ => new Text(""),
};
private static string FormatElapsed(TimeSpan t) private static string FormatElapsed(TimeSpan t)
{ {
if (t.TotalHours >= 1) if (t.TotalHours >= 1) return $"{(int)t.TotalHours}h {t.Minutes:D2}m {t.Seconds:D2}s";
return $"{(int)t.TotalHours}h {t.Minutes:D2}m {t.Seconds:D2}s"; if (t.TotalMinutes >= 1) return $"{t.Minutes}m {t.Seconds:D2}s";
if (t.TotalMinutes >= 1)
return $"{t.Minutes}m {t.Seconds:D2}s";
return $"{t.Seconds}s"; return $"{t.Seconds}s";
} }
} }