fix fourth core dump on export option
This commit is contained in:
Binary file not shown.
@@ -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,30 +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 (tagLabel, 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),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Strip the leading [TAG] prefix from the raw text if present,
|
// Strip the [TAG] prefix the service already prepends — we re-render it with color
|
||||||
// since we re-render it with color. Then escape the remainder.
|
var bodyText = StripKnownPrefix(entry.Text);
|
||||||
var rawText = StripKnownPrefix(entry.Text);
|
|
||||||
var safeText = Markup.Escape(rawText);
|
|
||||||
|
|
||||||
rows.AddRow(new Markup(
|
// Compose the row from Text objects — zero markup parsing of dynamic content
|
||||||
$"[dim]{timeStr}[/] [{color}][[{tagLabel}]][/] {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)
|
||||||
@@ -90,16 +87,15 @@ 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]") || text.Contains("[ERR ]"))
|
|
||||||
return LogLevel.Error;
|
|
||||||
return LogLevel.Info;
|
return LogLevel.Info;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,11 +104,9 @@ public sealed class LogPanel
|
|||||||
|
|
||||||
private static string StripKnownPrefix(string text)
|
private static string StripKnownPrefix(string text)
|
||||||
{
|
{
|
||||||
foreach (var prefix in KnownPrefixes)
|
foreach (var p in KnownPrefixes)
|
||||||
{
|
if (text.StartsWith(p, StringComparison.Ordinal))
|
||||||
if (text.StartsWith(prefix, StringComparison.Ordinal))
|
return text[p.Length..];
|
||||||
return text[prefix.Length..];
|
|
||||||
}
|
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,21 +6,18 @@ 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>
|
|
||||||
/// Build the stats panel renderable from the current snapshot.
|
|
||||||
/// </summary>
|
|
||||||
public static IRenderable Build(SystemSnapshot snap, TimeSpan elapsed, int panelWidth)
|
public static IRenderable Build(SystemSnapshot snap, TimeSpan elapsed, int panelWidth)
|
||||||
{
|
{
|
||||||
var barWidth = Math.Max(10, panelWidth - 16); // leave room for label + %
|
var barWidth = Math.Max(10, panelWidth - 16);
|
||||||
|
|
||||||
var grid = new Grid();
|
var grid = new Grid();
|
||||||
grid.AddColumn(new GridColumn().NoWrap());
|
grid.AddColumn(new GridColumn().NoWrap());
|
||||||
|
|
||||||
// --- CPU Section ---
|
// --- CPU ---
|
||||||
grid.AddRow(new Markup("[bold yellow]CPU[/]"));
|
grid.AddRow(new Markup("[bold yellow]CPU[/]"));
|
||||||
|
|
||||||
if (snap.CpuPercents.Length == 0)
|
if (snap.CpuPercents.Length == 0)
|
||||||
@@ -34,23 +31,21 @@ public static class StatsPanel
|
|||||||
var label = snap.CpuLabels.Length > i ? snap.CpuLabels[i] : $"cpu{i}";
|
var label = snap.CpuLabels.Length > i ? snap.CpuLabels[i] : $"cpu{i}";
|
||||||
var displayLabel = label == "cpu" ? "Total" : label.Replace("cpu", "Core");
|
var displayLabel = label == "cpu" ? "Total" : label.Replace("cpu", "Core");
|
||||||
var pct = snap.CpuPercents[i];
|
var pct = snap.CpuPercents[i];
|
||||||
var bar = BuildBar(pct, barWidth, CpuColor(pct));
|
// Build as Columns: label | bar | pct — no interpolation of dynamic values into markup
|
||||||
grid.AddRow(new Markup($" [dim]{displayLabel,-6}[/] {bar} [bold]{pct,5:F1}%[/]"));
|
grid.AddRow(BuildBarRow(displayLabel, pct, barWidth));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.AddRow(new Text(""));
|
grid.AddRow(new Text(""));
|
||||||
|
|
||||||
// --- Memory Section ---
|
// --- Memory ---
|
||||||
grid.AddRow(new Markup("[bold cyan]MEMORY[/]"));
|
grid.AddRow(new Markup("[bold cyan]MEMORY[/]"));
|
||||||
if (snap.MemTotalKb > 0)
|
if (snap.MemTotalKb > 0)
|
||||||
{
|
{
|
||||||
var memPct = snap.MemUsedPercent;
|
var memPct = snap.MemUsedPercent;
|
||||||
var memBar = BuildBar(memPct, barWidth, MemColor(memPct));
|
grid.AddRow(BuildBarRow("Used", memPct, barWidth));
|
||||||
grid.AddRow(new Markup(
|
grid.AddRow(new Text($" {SystemSnapshot.FormatGb(snap.MemUsedKb)} / {SystemSnapshot.FormatGb(snap.MemTotalKb)}",
|
||||||
$" [dim]Used [/] {memBar} [bold]{memPct,5:F1}%[/]"));
|
new Style(Color.Grey)));
|
||||||
grid.AddRow(new Markup(
|
|
||||||
$" [dim]{SystemSnapshot.FormatGb(snap.MemUsedKb)} / {SystemSnapshot.FormatGb(snap.MemTotalKb)}[/]"));
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -59,16 +54,14 @@ public static class StatsPanel
|
|||||||
|
|
||||||
grid.AddRow(new Text(""));
|
grid.AddRow(new Text(""));
|
||||||
|
|
||||||
// --- Swap Section ---
|
// --- Swap ---
|
||||||
grid.AddRow(new Markup("[bold cyan]SWAP[/]"));
|
grid.AddRow(new Markup("[bold cyan]SWAP[/]"));
|
||||||
if (snap.SwapTotalKb > 0)
|
if (snap.SwapTotalKb > 0)
|
||||||
{
|
{
|
||||||
var swapPct = snap.SwapUsedPercent;
|
var swapPct = snap.SwapUsedPercent;
|
||||||
var swapBar = BuildBar(swapPct, barWidth, MemColor(swapPct));
|
grid.AddRow(BuildBarRow("Used", swapPct, barWidth));
|
||||||
grid.AddRow(new Markup(
|
grid.AddRow(new Text($" {SystemSnapshot.FormatGb(snap.SwapUsedKb)} / {SystemSnapshot.FormatGb(snap.SwapTotalKb)}",
|
||||||
$" [dim]Used [/] {swapBar} [bold]{swapPct,5:F1}%[/]"));
|
new Style(Color.Grey)));
|
||||||
grid.AddRow(new Markup(
|
|
||||||
$" [dim]{SystemSnapshot.FormatGb(snap.SwapUsedKb)} / {SystemSnapshot.FormatGb(snap.SwapTotalKb)}[/]"));
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -78,17 +71,21 @@ public static class StatsPanel
|
|||||||
grid.AddRow(new Text(""));
|
grid.AddRow(new Text(""));
|
||||||
|
|
||||||
// --- Elapsed ---
|
// --- Elapsed ---
|
||||||
grid.AddRow(new Markup(
|
// Two separate renderables composed — no interpolation of elapsed into markup
|
||||||
$"[bold]Elapsed:[/] [yellow]{FormatElapsed(elapsed)}[/]"));
|
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);
|
||||||
|
|
||||||
var panel = new Panel(grid)
|
return new Panel(grid)
|
||||||
{
|
{
|
||||||
Header = new PanelHeader("[bold] SYSTEM STATS [/]"),
|
Header = new PanelHeader("[bold] SYSTEM STATS [/]"),
|
||||||
Border = BoxBorder.Rounded,
|
Border = BoxBorder.Rounded,
|
||||||
Padding = new Padding(1, 0),
|
Padding = new Padding(1, 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
return panel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -96,36 +93,46 @@ public static class StatsPanel
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renders a horizontal block bar of the given width.
|
/// Builds a single bar row as a Grid with three columns:
|
||||||
/// e.g. [████████░░░░]
|
/// label (dim) | bar (block chars, plain Text with color) | pct (bold)
|
||||||
/// Result is a markup string safe for embedding inside a larger Markup call.
|
/// Nothing here goes through markup parsing with dynamic content.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string BuildBar(double percent, int width, string color)
|
private static IRenderable BuildBarRow(string label, double pct, int barWidth)
|
||||||
{
|
{
|
||||||
var filled = (int)Math.Round(percent / 100.0 * width);
|
var filled = (int)Math.Round(pct / 100.0 * barWidth);
|
||||||
filled = Math.Clamp(filled, 0, width);
|
filled = Math.Clamp(filled, 0, barWidth);
|
||||||
var empty = width - filled;
|
var empty = barWidth - filled;
|
||||||
|
|
||||||
// \u2588 = █ \u2591 = ░
|
var filledColor = CpuColor(pct);
|
||||||
// [[ and ]] are Spectre escape sequences for literal [ and ]
|
|
||||||
var filledStr = new string('\u2588', filled);
|
var filledStr = new string('\u2588', filled);
|
||||||
var emptyStr = new string('\u2591', empty);
|
var emptyStr = new string('\u2591', empty);
|
||||||
return $"[[[{color}]{filledStr}[/][dim]{emptyStr}[/]]]";
|
|
||||||
|
var row = new Grid();
|
||||||
|
row.AddColumn(new GridColumn().NoWrap().Width(9)); // label
|
||||||
|
row.AddColumn(new GridColumn().NoWrap()); // bar chars
|
||||||
|
row.AddColumn(new GridColumn().NoWrap().Width(7)); // pct
|
||||||
|
|
||||||
|
row.AddRow(
|
||||||
|
new Text($" {label,-5}", new Style(Color.Grey, decoration: Decoration.Dim)),
|
||||||
|
// Bar: colored filled + dim empty, wrapped in plain Text objects side by side
|
||||||
|
new Columns(
|
||||||
|
new Text("[", new Style(Color.Grey)),
|
||||||
|
new Text(filledStr, new Style(filledColor)),
|
||||||
|
new Text(emptyStr, new Style(Color.Grey, decoration: Decoration.Dim)),
|
||||||
|
new Text("]", new Style(Color.Grey))
|
||||||
|
),
|
||||||
|
new Text($" {pct,5:F1}%", new Style(Color.White, decoration: Decoration.Bold))
|
||||||
|
);
|
||||||
|
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CpuColor(double pct) => pct switch
|
private static Color CpuColor(double pct) => pct switch
|
||||||
{
|
{
|
||||||
> 90 => "red",
|
> 90 => Color.Red,
|
||||||
> 70 => "yellow",
|
> 70 => Color.Yellow,
|
||||||
> 40 => "green",
|
> 40 => Color.Green,
|
||||||
_ => "blue",
|
_ => Color.Blue,
|
||||||
};
|
|
||||||
|
|
||||||
private static string MemColor(double pct) => pct switch
|
|
||||||
{
|
|
||||||
> 90 => "red",
|
|
||||||
> 70 => "yellow",
|
|
||||||
_ => "cyan",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string FormatElapsed(TimeSpan t)
|
private static string FormatElapsed(TimeSpan t)
|
||||||
|
|||||||
+104
-132
@@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user