add hanatui
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Rendering;
|
||||
|
||||
namespace HanaTui.Tui.Components;
|
||||
|
||||
/// <summary>
|
||||
/// A timestamped log entry.
|
||||
/// </summary>
|
||||
public sealed class LogEntry
|
||||
{
|
||||
public DateTime Time { get; init; } = DateTime.Now;
|
||||
public string Text { get; init; } = "";
|
||||
public LogLevel Level { get; init; } = LogLevel.Info;
|
||||
}
|
||||
|
||||
public enum LogLevel { Info, Sql, Done, Warn, Error }
|
||||
|
||||
/// <summary>
|
||||
/// Maintains a bounded list of log entries and renders them as a
|
||||
/// Spectre.Console IRenderable panel, showing the most recent N lines.
|
||||
/// Thread-safe.
|
||||
/// </summary>
|
||||
public sealed class LogPanel
|
||||
{
|
||||
private const int MaxEntries = 500;
|
||||
private const int VisibleLines = 20; // shown inside the panel
|
||||
|
||||
private readonly List<LogEntry> _entries = new(MaxEntries);
|
||||
private readonly object _lock = new();
|
||||
|
||||
public void Add(string text)
|
||||
{
|
||||
var level = DetectLevel(text);
|
||||
var entry = new LogEntry { Time = DateTime.Now, Text = text, Level = level };
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_entries.Count >= MaxEntries)
|
||||
_entries.RemoveAt(0);
|
||||
_entries.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a renderable panel showing the last N log lines.
|
||||
/// </summary>
|
||||
public IRenderable Build()
|
||||
{
|
||||
List<LogEntry> snapshot;
|
||||
lock (_lock)
|
||||
{
|
||||
var start = Math.Max(0, _entries.Count - VisibleLines);
|
||||
snapshot = _entries.GetRange(start, _entries.Count - start);
|
||||
}
|
||||
|
||||
var rows = new Grid();
|
||||
rows.AddColumn(new GridColumn().NoWrap());
|
||||
|
||||
// Pad to VisibleLines so the panel doesn't resize each tick
|
||||
var padCount = VisibleLines - snapshot.Count;
|
||||
for (int i = 0; i < padCount; i++)
|
||||
rows.AddRow(new Text(""));
|
||||
|
||||
foreach (var entry in snapshot)
|
||||
{
|
||||
var timeStr = entry.Time.ToString("HH:mm:ss");
|
||||
var (tag, color) = entry.Level switch
|
||||
{
|
||||
LogLevel.Sql => ("[SQL ]", "blue"),
|
||||
LogLevel.Done => ("[DONE]", "green"),
|
||||
LogLevel.Warn => ("[WARN]", "yellow"),
|
||||
LogLevel.Error => ("[ERR ]", "red"),
|
||||
_ => ("[INFO]", "grey"),
|
||||
};
|
||||
|
||||
// Escape any markup-like content in the raw text
|
||||
var safeText = Markup.Escape(entry.Text);
|
||||
|
||||
rows.AddRow(new Markup(
|
||||
$"[dim]{timeStr}[/] [{color}]{tag}[/] {safeText}"));
|
||||
}
|
||||
|
||||
return new Panel(rows)
|
||||
{
|
||||
Header = new PanelHeader("[bold] OPERATION LOG [/]"),
|
||||
Border = BoxBorder.Rounded,
|
||||
Padding = new Padding(1, 0),
|
||||
};
|
||||
}
|
||||
|
||||
private static LogLevel DetectLevel(string text)
|
||||
{
|
||||
if (text.Contains("[SQL ]") || text.Contains("[SQL]"))
|
||||
return LogLevel.Sql;
|
||||
if (text.Contains("[DONE]"))
|
||||
return LogLevel.Done;
|
||||
if (text.Contains("[WARN]"))
|
||||
return LogLevel.Warn;
|
||||
if (text.Contains("[ERROR]") || text.Contains("[ERR]"))
|
||||
return LogLevel.Error;
|
||||
return LogLevel.Info;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using HanaTui.System;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Rendering;
|
||||
|
||||
namespace HanaTui.Tui.Components;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
var barWidth = Math.Max(10, panelWidth - 16); // leave room for label + %
|
||||
|
||||
var grid = new Grid();
|
||||
grid.AddColumn(new GridColumn().NoWrap());
|
||||
|
||||
// --- CPU Section ---
|
||||
grid.AddRow(new Markup("[bold yellow]CPU[/]"));
|
||||
|
||||
if (snap.CpuPercents.Length == 0)
|
||||
{
|
||||
grid.AddRow(new Markup("[grey]No data[/]"));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Renders a horizontal block bar of the given width.
|
||||
/// e.g. [████████░░░░]
|
||||
/// </summary>
|
||||
private static string BuildBar(double percent, int width, string color)
|
||||
{
|
||||
var filled = (int)Math.Round(percent / 100.0 * width);
|
||||
filled = Math.Clamp(filled, 0, width);
|
||||
var empty = width - filled;
|
||||
|
||||
var bar = new string('\u2588', filled) + new string('\u2591', empty);
|
||||
return $"[[{color}]{bar}[/]]";
|
||||
}
|
||||
|
||||
private static string CpuColor(double pct) => pct switch
|
||||
{
|
||||
> 90 => "red",
|
||||
> 70 => "yellow",
|
||||
> 40 => "green",
|
||||
_ => "blue",
|
||||
};
|
||||
|
||||
private static string MemColor(double pct) => pct switch
|
||||
{
|
||||
> 90 => "red",
|
||||
> 70 => "yellow",
|
||||
_ => "cyan",
|
||||
};
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using HanaTui.Hana;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace HanaTui.Tui;
|
||||
|
||||
/// <summary>
|
||||
/// Startup screen: lists hdbuserstore keys and lets the user pick one.
|
||||
/// Returns the selected key name, or null if the user chose to exit.
|
||||
/// </summary>
|
||||
public static class KeySelectionScreen
|
||||
{
|
||||
public static string? Run()
|
||||
{
|
||||
AnsiConsole.Clear();
|
||||
AnsiConsole.Write(new FigletText("HANA TUI").Color(Color.DodgerBlue1));
|
||||
AnsiConsole.MarkupLine("[dim]SAP HANA Database Manager[/]\n");
|
||||
|
||||
if (!HdbClientLocator.IsAvailable())
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red][[ERROR]][/] hdbsql not found. " +
|
||||
"Ensure the SAP HANA client is installed and on your PATH, " +
|
||||
"or present in [dim]/usr/sap/hdbclient[/] or [dim]/usr/sap/NDB/HDB00/exe[/].");
|
||||
AnsiConsole.MarkupLine("\nPress any key to exit.");
|
||||
Console.ReadKey(intercept: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show client path for reference
|
||||
var clientDir = HdbClientLocator.ClientDirectory;
|
||||
if (clientDir is not null)
|
||||
AnsiConsole.MarkupLine($"[dim]HDB client: {clientDir}[/]\n");
|
||||
|
||||
// Load keys with a spinner
|
||||
List<HdbUserstoreKey> keys = [];
|
||||
|
||||
AnsiConsole.Status()
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
.SpinnerStyle(Style.Parse("blue"))
|
||||
.Start("[blue]Loading hdbuserstore keys...[/]", _ =>
|
||||
{
|
||||
keys = HdbCliRunner.ListKeys();
|
||||
});
|
||||
|
||||
if (keys.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow][[WARN]][/] No hdbuserstore keys found.");
|
||||
AnsiConsole.MarkupLine("You can still enter a key name manually.\n");
|
||||
}
|
||||
|
||||
// Build selection choices
|
||||
var choices = new List<string>();
|
||||
foreach (var k in keys)
|
||||
{
|
||||
var detail = BuildKeyDetail(k);
|
||||
choices.Add(detail);
|
||||
}
|
||||
choices.Add("[dim][ Enter key name manually ][/]");
|
||||
choices.Add("[red][ Exit ][/]");
|
||||
|
||||
var prompt = new SelectionPrompt<string>()
|
||||
.Title("[bold]Select HDBUSERSTORE key:[/]")
|
||||
.PageSize(15)
|
||||
.HighlightStyle(Style.Parse("bold dodgerblue1"))
|
||||
.AddChoices(choices);
|
||||
|
||||
var selected = AnsiConsole.Prompt(prompt);
|
||||
|
||||
if (selected.Contains("Exit"))
|
||||
return null;
|
||||
|
||||
if (selected.Contains("manually"))
|
||||
{
|
||||
var manual = AnsiConsole.Ask<string>("[bold]Enter HDBUSERSTORE key name:[/]").Trim();
|
||||
return string.IsNullOrWhiteSpace(manual) ? null : manual;
|
||||
}
|
||||
|
||||
// Extract the key name from the formatted string (it's always the first word)
|
||||
var keyName = selected.Split(' ')[0].Trim();
|
||||
return keyName;
|
||||
}
|
||||
|
||||
private static string BuildKeyDetail(HdbUserstoreKey k)
|
||||
{
|
||||
var parts = new List<string> { k.Name };
|
||||
|
||||
if (!string.IsNullOrEmpty(k.Host))
|
||||
parts.Add($"[dim]{k.Host}:{k.Port}[/]");
|
||||
if (!string.IsNullOrEmpty(k.Tenant))
|
||||
parts.Add($"[dim]@{k.Tenant}[/]");
|
||||
if (!string.IsNullOrEmpty(k.User))
|
||||
parts.Add($"[dim]user={k.User}[/]");
|
||||
|
||||
return string.Join(" ", parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using HanaTui.Hana;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace HanaTui.Tui;
|
||||
|
||||
/// <summary>
|
||||
/// The main operation menu. Returns the selected operation, or null to exit.
|
||||
/// </summary>
|
||||
public static class MainMenuScreen
|
||||
{
|
||||
public enum Operation
|
||||
{
|
||||
Export,
|
||||
Import,
|
||||
ImportRename,
|
||||
Copy,
|
||||
Drop,
|
||||
RenameDb,
|
||||
Backup,
|
||||
ChangeKey,
|
||||
Quit,
|
||||
}
|
||||
|
||||
public static Operation Run(HdbUserstoreKey? key, string keyName)
|
||||
{
|
||||
AnsiConsole.Clear();
|
||||
|
||||
// Header with key info
|
||||
var rule = new Rule("[bold dodgerblue1]HANA Database Manager[/]")
|
||||
.RuleStyle(Style.Parse("dodgerblue1"));
|
||||
AnsiConsole.Write(rule);
|
||||
|
||||
if (key is not null)
|
||||
{
|
||||
AnsiConsole.MarkupLine(
|
||||
$" Key: [bold yellow]{key.Name}[/] " +
|
||||
$"[dim]{key.Host}:{key.Port}" +
|
||||
(string.IsNullOrEmpty(key.Tenant) ? "" : $"@{key.Tenant}") +
|
||||
$" user={key.User}[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine($" Key: [bold yellow]{keyName}[/]");
|
||||
}
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
var choices = new Dictionary<string, Operation>
|
||||
{
|
||||
["1 Export Schema"] = Operation.Export,
|
||||
["2 Import Schema"] = Operation.Import,
|
||||
["3 Import & Rename Schema"] = Operation.ImportRename,
|
||||
["4 Copy Schema"] = Operation.Copy,
|
||||
["5 Drop Schema"] = Operation.Drop,
|
||||
["6 Rename Database (Company Name)"] = Operation.RenameDb,
|
||||
["7 Backup Tenant"] = Operation.Backup,
|
||||
["----------------------------------"] = Operation.Quit, // separator placeholder
|
||||
["k Change Key"] = Operation.ChangeKey,
|
||||
["q Quit"] = Operation.Quit,
|
||||
};
|
||||
|
||||
// Build SelectionPrompt without the separator entry
|
||||
var prompt = new SelectionPrompt<string>()
|
||||
.Title("[bold]Select operation:[/]")
|
||||
.PageSize(12)
|
||||
.HighlightStyle(Style.Parse("bold dodgerblue1"));
|
||||
|
||||
foreach (var key2 in choices.Keys)
|
||||
{
|
||||
if (key2.StartsWith("--")) continue;
|
||||
prompt.AddChoice(key2);
|
||||
}
|
||||
|
||||
var selected = AnsiConsole.Prompt(prompt);
|
||||
return choices.TryGetValue(selected, out var op) ? op : Operation.Quit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
using HanaTui.Hana;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace HanaTui.Tui;
|
||||
|
||||
/// <summary>
|
||||
/// Guided input forms for each operation.
|
||||
/// Each method collects all needed parameters, then returns a typed params object.
|
||||
/// Returns null if the user cancelled.
|
||||
/// </summary>
|
||||
public static class OperationForms
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the schema list with a spinner, then shows an arrow-key picker.
|
||||
/// Returns the selected schema, or null if cancelled.
|
||||
/// </summary>
|
||||
private static string? PickSchema(string userKey, string title)
|
||||
{
|
||||
List<string> schemas = [];
|
||||
string? error = null;
|
||||
|
||||
AnsiConsole.Status()
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
.SpinnerStyle(Style.Parse("blue"))
|
||||
.Start("[blue]Fetching schemas...[/]", _ =>
|
||||
{
|
||||
var (success, list, err) = HdbCliRunner.ListSchemas(userKey);
|
||||
if (success)
|
||||
schemas = list;
|
||||
else
|
||||
error = err;
|
||||
});
|
||||
|
||||
if (error is not null)
|
||||
AnsiConsole.MarkupLine($"[yellow][[WARN]] Could not fetch schemas: {Markup.Escape(error)}[/]");
|
||||
|
||||
var choices = new List<string>(schemas);
|
||||
choices.Add("[dim][ Enter manually ][/]");
|
||||
choices.Add("[dim][ Cancel ][/]");
|
||||
|
||||
var prompt = new SelectionPrompt<string>()
|
||||
.Title($"[bold]{title}[/]")
|
||||
.PageSize(15)
|
||||
.HighlightStyle(Style.Parse("bold dodgerblue1"))
|
||||
.AddChoices(choices);
|
||||
|
||||
var selected = AnsiConsole.Prompt(prompt);
|
||||
|
||||
if (selected.Contains("Cancel"))
|
||||
return null;
|
||||
|
||||
if (selected.Contains("manually"))
|
||||
{
|
||||
var manual = AnsiConsole.Ask<string>("Enter schema name:").Trim();
|
||||
return string.IsNullOrWhiteSpace(manual) ? null : manual;
|
||||
}
|
||||
|
||||
return selected.Trim();
|
||||
}
|
||||
|
||||
private static int PickThreads(string label = "Number of threads")
|
||||
{
|
||||
var maxThreads = Environment.ProcessorCount;
|
||||
var defaultThreads = Math.Max(1, maxThreads / 2);
|
||||
return AnsiConsole.Ask($"{label} [dim](default={defaultThreads}, max={maxThreads})[/]:",
|
||||
defaultThreads);
|
||||
}
|
||||
|
||||
private static bool ConfirmYesNo(string question, bool defaultYes = false)
|
||||
{
|
||||
var defaultLabel = defaultYes ? "Y/n" : "y/N";
|
||||
var answer = AnsiConsole.Ask<string>($"{question} [dim]({defaultLabel})[/]:", defaultYes ? "y" : "n");
|
||||
return answer.Trim().Equals("y", StringComparison.OrdinalIgnoreCase) ||
|
||||
answer.Trim().Equals("yes", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void PrintOperationHeader(string title)
|
||||
{
|
||||
AnsiConsole.Clear();
|
||||
var rule = new Rule($"[bold dodgerblue1]{title}[/]").RuleStyle(Style.Parse("dodgerblue1"));
|
||||
AnsiConsole.Write(rule);
|
||||
AnsiConsole.WriteLine();
|
||||
}
|
||||
|
||||
private static bool ShowSummaryAndConfirm(string title, Dictionary<string, string> fields)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
var table = new Table().BorderColor(Color.DodgerBlue1).Border(TableBorder.Rounded);
|
||||
table.AddColumn("[bold]Parameter[/]");
|
||||
table.AddColumn("[bold]Value[/]");
|
||||
foreach (var (k, v) in fields)
|
||||
table.AddRow($"[dim]{k}[/]", $"[yellow]{Markup.Escape(v)}[/]");
|
||||
AnsiConsole.MarkupLine($"[bold]{title}[/]");
|
||||
AnsiConsole.Write(table);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
return ConfirmYesNo("Proceed with this operation?", defaultYes: true);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Export
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static ExportParams? ExportForm(string userKey)
|
||||
{
|
||||
PrintOperationHeader("Export Schema");
|
||||
|
||||
var schema = PickSchema(userKey, "Select schema to export:");
|
||||
if (schema is null) return null;
|
||||
|
||||
var targetPath = AnsiConsole.Ask<string>("Target directory or file path [dim](.tar.gz for archive)[/]:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(targetPath)) return null;
|
||||
|
||||
var threads = PickThreads();
|
||||
var compress = ConfirmYesNo("Compress output as .tar.gz?");
|
||||
|
||||
var confirmed = ShowSummaryAndConfirm("Export Summary", new Dictionary<string, string>
|
||||
{
|
||||
["Schema"] = schema,
|
||||
["Target path"] = targetPath,
|
||||
["Threads"] = threads.ToString(),
|
||||
["Compress"] = compress ? "Yes" : "No",
|
||||
});
|
||||
|
||||
if (!confirmed) return null;
|
||||
|
||||
return new ExportParams
|
||||
{
|
||||
Schema = schema,
|
||||
TargetPath = targetPath,
|
||||
Threads = threads,
|
||||
Compress = compress,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Import
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static ImportParams? ImportForm(string userKey, bool renameMode)
|
||||
{
|
||||
var title = renameMode ? "Import & Rename Schema" : "Import Schema";
|
||||
PrintOperationHeader(title);
|
||||
|
||||
var sourceSchema = AnsiConsole.Ask<string>("Source schema name [dim](as it was exported)[/]:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(sourceSchema)) return null;
|
||||
|
||||
string? newSchemaName = null;
|
||||
if (renameMode)
|
||||
{
|
||||
newSchemaName = AnsiConsole.Ask<string>("New target schema name:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(newSchemaName)) return null;
|
||||
}
|
||||
|
||||
var sourcePath = AnsiConsole.Ask<string>("Source path [dim](directory or .tar.gz)[/]:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(sourcePath)) return null;
|
||||
|
||||
var threads = PickThreads();
|
||||
var replace = ConfirmYesNo("Replace existing objects?");
|
||||
|
||||
var summary = new Dictionary<string, string>
|
||||
{
|
||||
["Source schema"] = sourceSchema,
|
||||
["Source path"] = sourcePath,
|
||||
["Threads"] = threads.ToString(),
|
||||
["Replace"] = replace ? "Yes" : "No (IGNORE EXISTING)",
|
||||
};
|
||||
if (renameMode && newSchemaName is not null)
|
||||
summary["New schema name"] = newSchemaName;
|
||||
|
||||
var confirmed = ShowSummaryAndConfirm($"{title} Summary", summary);
|
||||
if (!confirmed) return null;
|
||||
|
||||
return new ImportParams
|
||||
{
|
||||
SourceSchema = sourceSchema,
|
||||
NewSchemaName = newSchemaName,
|
||||
SourcePath = sourcePath,
|
||||
Threads = threads,
|
||||
Replace = replace,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Copy
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static CopyParams? CopyForm(string userKey)
|
||||
{
|
||||
PrintOperationHeader("Copy Schema");
|
||||
|
||||
var sourceSchema = PickSchema(userKey, "Select source schema:");
|
||||
if (sourceSchema is null) return null;
|
||||
|
||||
var targetSchema = AnsiConsole.Ask<string>("Target schema name:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(targetSchema)) return null;
|
||||
|
||||
var tempPath = AnsiConsole.Ask<string>("Temporary export directory path:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(tempPath)) return null;
|
||||
|
||||
var threads = PickThreads();
|
||||
var replace = ConfirmYesNo("Replace existing objects in target schema?");
|
||||
|
||||
var confirmed = ShowSummaryAndConfirm("Copy Schema Summary", new Dictionary<string, string>
|
||||
{
|
||||
["Source schema"] = sourceSchema,
|
||||
["Target schema"] = targetSchema,
|
||||
["Temp path"] = tempPath,
|
||||
["Threads"] = threads.ToString(),
|
||||
["Replace"] = replace ? "Yes" : "No",
|
||||
});
|
||||
|
||||
if (!confirmed) return null;
|
||||
|
||||
return new CopyParams
|
||||
{
|
||||
SourceSchema = sourceSchema,
|
||||
TargetSchema = targetSchema,
|
||||
TempPath = tempPath,
|
||||
Threads = threads,
|
||||
Replace = replace,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Drop
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static DropParams? DropForm(string userKey)
|
||||
{
|
||||
PrintOperationHeader("Drop Schema");
|
||||
|
||||
var schema = PickSchema(userKey, "Select schema to drop:");
|
||||
if (schema is null) return null;
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine($"[bold red]WARNING:[/] You are about to [bold red]permanently drop[/] schema [bold yellow]{Markup.Escape(schema)}[/].");
|
||||
AnsiConsole.MarkupLine("[red]This cannot be undone.[/]\n");
|
||||
|
||||
var confirm = AnsiConsole.Ask<string>($"Type [bold red]YES[/] to confirm dropping [yellow]{Markup.Escape(schema)}[/]:").Trim();
|
||||
if (confirm != "YES")
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Operation cancelled.[/]");
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[dim]Press any key to return...[/]");
|
||||
Console.ReadKey(intercept: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DropParams { Schema = schema };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rename DB
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static RenameDbParams? RenameDbForm(string userKey)
|
||||
{
|
||||
PrintOperationHeader("Rename Database (Company Name)");
|
||||
|
||||
var schema = PickSchema(userKey, "Select schema:");
|
||||
if (schema is null) return null;
|
||||
|
||||
var newName = AnsiConsole.Ask<string>("New company name:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(newName)) return null;
|
||||
|
||||
var confirmed = ShowSummaryAndConfirm("Rename DB Summary", new Dictionary<string, string>
|
||||
{
|
||||
["Schema"] = schema,
|
||||
["New company name"] = newName,
|
||||
["Tables updated"] = "CINF, OADM",
|
||||
});
|
||||
|
||||
if (!confirmed) return null;
|
||||
|
||||
return new RenameDbParams { Schema = schema, NewCompanyName = newName };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Backup
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static BackupParams? BackupForm(string userKey)
|
||||
{
|
||||
PrintOperationHeader("Backup Tenant");
|
||||
|
||||
var targetPath = AnsiConsole.Ask<string>("Target directory path:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(targetPath)) return null;
|
||||
|
||||
var threads = PickThreads("Number of compression threads");
|
||||
var compress = ConfirmYesNo("Compress backup as .tar.gz?");
|
||||
|
||||
var confirmed = ShowSummaryAndConfirm("Backup Summary", new Dictionary<string, string>
|
||||
{
|
||||
["Target path"] = targetPath,
|
||||
["Threads"] = threads.ToString(),
|
||||
["Compress"] = compress ? "Yes" : "No",
|
||||
});
|
||||
|
||||
if (!confirmed) return null;
|
||||
|
||||
return new BackupParams
|
||||
{
|
||||
TargetPath = targetPath,
|
||||
Threads = threads,
|
||||
Compress = compress,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using HanaTui.System;
|
||||
using HanaTui.Tui.Components;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Rendering;
|
||||
|
||||
namespace HanaTui.Tui;
|
||||
|
||||
/// <summary>
|
||||
/// Runs an operation while showing a live split-panel:
|
||||
/// Left: System stats (CPU, RAM, Swap, elapsed)
|
||||
/// Right: Streaming operation log
|
||||
///
|
||||
/// Abort behavior:
|
||||
/// First Q -> warning shown in log
|
||||
/// 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>
|
||||
public static class TaskRunnerScreen
|
||||
{
|
||||
private const int CountdownSeconds = 10;
|
||||
private const double AbortWindowSeconds = 3.0;
|
||||
|
||||
public static async Task RunAsync(
|
||||
string operationTitle,
|
||||
Func<Action<string>, CancellationToken, Task<bool>> operation)
|
||||
{
|
||||
AnsiConsole.Clear();
|
||||
|
||||
var logPanel = new LogPanel();
|
||||
var stats = new SystemStats();
|
||||
var cts = new CancellationTokenSource();
|
||||
var startTime = DateTime.Now;
|
||||
|
||||
var operationDone = false;
|
||||
var operationSuccess = false;
|
||||
|
||||
// Abort state machine
|
||||
DateTime? firstQPressTime = null;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Key listener task — runs concurrently with the Live render loop
|
||||
// -----------------------------------------------------------------------
|
||||
var keyTask = Task.Run(async () =>
|
||||
{
|
||||
while (!operationDone)
|
||||
{
|
||||
if (Console.KeyAvailable)
|
||||
{
|
||||
var key = Console.ReadKey(intercept: true);
|
||||
if (key.Key == ConsoleKey.Q || key.KeyChar == 'q' || key.KeyChar == 'Q')
|
||||
{
|
||||
if (cts.IsCancellationRequested) break; // already cancelled
|
||||
|
||||
if (firstQPressTime is null)
|
||||
{
|
||||
firstQPressTime = DateTime.Now;
|
||||
logPanel.Add("[WARN] Press Q again within 3 seconds to abort the operation.");
|
||||
}
|
||||
else if ((DateTime.Now - firstQPressTime.Value).TotalSeconds <= AbortWindowSeconds)
|
||||
{
|
||||
logPanel.Add("[WARN] Aborting operation...");
|
||||
await cts.CancelAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Window expired, treat as first press again
|
||||
firstQPressTime = DateTime.Now;
|
||||
logPanel.Add("[WARN] Press Q again within 3 seconds to abort the operation.");
|
||||
}
|
||||
}
|
||||
}
|
||||
await Task.Delay(50, CancellationToken.None);
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Main operation task
|
||||
// -----------------------------------------------------------------------
|
||||
var operationTask = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
operationSuccess = await operation(
|
||||
line => logPanel.Add(line),
|
||||
cts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logPanel.Add($"[ERROR] Unexpected exception: {ex.Message}");
|
||||
operationSuccess = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
operationDone = true;
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Live render loop
|
||||
// -----------------------------------------------------------------------
|
||||
await AnsiConsole.Live(BuildLayout(logPanel, stats, startTime, "[yellow]Running...[/]"))
|
||||
.AutoClear(false)
|
||||
.StartAsync(async ctx =>
|
||||
{
|
||||
while (!operationDone)
|
||||
{
|
||||
var elapsed = DateTime.Now - startTime;
|
||||
ctx.UpdateTarget(
|
||||
BuildLayout(logPanel, stats, startTime, "[yellow]Running...[/]"));
|
||||
await Task.Delay(800, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Final render with result
|
||||
var elapsed2 = DateTime.Now - startTime;
|
||||
var statusMsg = cts.IsCancellationRequested
|
||||
? "[red]Aborted[/]"
|
||||
: operationSuccess
|
||||
? "[green]Completed successfully[/]"
|
||||
: "[red]Failed[/]";
|
||||
|
||||
if (cts.IsCancellationRequested)
|
||||
logPanel.Add($"[WARN] Operation aborted after {FormatElapsed(elapsed2)}.");
|
||||
else if (operationSuccess)
|
||||
logPanel.Add($"[DONE] Operation completed in {FormatElapsed(elapsed2)}.");
|
||||
else
|
||||
logPanel.Add($"[ERR ] Operation failed after {FormatElapsed(elapsed2)}.");
|
||||
|
||||
ctx.UpdateTarget(BuildLayout(logPanel, stats, startTime, statusMsg));
|
||||
});
|
||||
|
||||
// Clean up
|
||||
stats.Dispose();
|
||||
await operationTask; // ensure it's fully done
|
||||
operationDone = true;
|
||||
await keyTask;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Post-completion: countdown or hold
|
||||
// -----------------------------------------------------------------------
|
||||
await PostCompletionWaitAsync(logPanel, stats, startTime, operationSuccess, cts.IsCancellationRequested);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Post-completion hold screen
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static async Task PostCompletionWaitAsync(
|
||||
LogPanel logPanel,
|
||||
SystemStats stats,
|
||||
DateTime startTime,
|
||||
bool success,
|
||||
bool aborted)
|
||||
{
|
||||
var statusMsg = aborted ? "[red]Aborted[/]" :
|
||||
success ? "[green]Completed[/]" : "[red]Failed[/]";
|
||||
|
||||
var countdownCancelled = false;
|
||||
var returnNow = false;
|
||||
|
||||
// Start 10-second countdown in background
|
||||
var countdownTask = Task.Run(async () =>
|
||||
{
|
||||
for (int i = CountdownSeconds; i > 0 && !countdownCancelled; i--)
|
||||
{
|
||||
await Task.Delay(1000, CancellationToken.None);
|
||||
}
|
||||
if (!countdownCancelled)
|
||||
returnNow = true;
|
||||
}, CancellationToken.None);
|
||||
|
||||
await AnsiConsole.Live(BuildLayout(logPanel, stats, startTime, statusMsg))
|
||||
.AutoClear(false)
|
||||
.StartAsync(async ctx =>
|
||||
{
|
||||
var secondsLeft = CountdownSeconds;
|
||||
var staying = false;
|
||||
|
||||
while (!returnNow)
|
||||
{
|
||||
if (Console.KeyAvailable)
|
||||
{
|
||||
var key = Console.ReadKey(intercept: true);
|
||||
countdownCancelled = true;
|
||||
staying = true;
|
||||
|
||||
if (key.Key is ConsoleKey.Enter or ConsoleKey.Escape)
|
||||
{
|
||||
returnNow = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Any other key: cancel countdown, show "hold" message
|
||||
logPanel.Add("[INFO] Countdown cancelled. Press [Enter] or [Esc] to return to menu.");
|
||||
}
|
||||
|
||||
// Update footer countdown
|
||||
var elapsed = DateTime.Now - startTime;
|
||||
string footer;
|
||||
if (!staying)
|
||||
{
|
||||
secondsLeft = CountdownSeconds -
|
||||
(int)(DateTime.Now - startTime).TotalSeconds; // approximate
|
||||
// recompute properly
|
||||
footer = $"[dim]Returning to menu in {Math.Max(0, secondsLeft)}s... " +
|
||||
"[any key to stay][/]";
|
||||
}
|
||||
else
|
||||
{
|
||||
footer = "[dim]Press [Enter] or [Esc] to return to menu.[/]";
|
||||
}
|
||||
|
||||
ctx.UpdateTarget(BuildLayoutWithFooter(logPanel, stats, startTime, statusMsg, footer));
|
||||
await Task.Delay(200, CancellationToken.None);
|
||||
}
|
||||
});
|
||||
|
||||
await countdownTask;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Layout builders
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static IRenderable BuildLayout(
|
||||
LogPanel logPanel,
|
||||
SystemStats stats,
|
||||
DateTime startTime,
|
||||
string statusMsg)
|
||||
{
|
||||
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 snap = stats.CurrentSnapshot;
|
||||
|
||||
// Stats panel (left) — fixed width ~36 chars
|
||||
var statsPanelWidth = 36;
|
||||
var statsRenderable = StatsPanel.Build(snap, elapsed, statsPanelWidth - 4);
|
||||
|
||||
// Log panel (right) — fills remaining space
|
||||
var logRenderable = logPanel.Build();
|
||||
|
||||
var layout = new Layout("root")
|
||||
.SplitColumns(
|
||||
new Layout("stats").Size(statsPanelWidth),
|
||||
new Layout("log"));
|
||||
|
||||
layout["stats"].Update(statsRenderable);
|
||||
layout["log"].Update(logRenderable);
|
||||
|
||||
// Wrap in a grid so we can add a footer row
|
||||
var outerGrid = new Grid();
|
||||
outerGrid.AddColumn(new GridColumn());
|
||||
|
||||
// Title row
|
||||
outerGrid.AddRow(new Markup(
|
||||
$"[bold dodgerblue1] Running:[/] [yellow]{Markup.Escape(ExtractTitle(statusMsg))}[/] " +
|
||||
$"Status: {statusMsg}"));
|
||||
|
||||
outerGrid.AddRow(layout);
|
||||
|
||||
if (footer is not null)
|
||||
outerGrid.AddRow(new Markup($"\n {footer} [dim][[Q]] Abort[/]"));
|
||||
else
|
||||
outerGrid.AddRow(new Markup("\n [dim][[Q]] Press once to warn, twice to abort[/]"));
|
||||
|
||||
return outerGrid;
|
||||
}
|
||||
|
||||
private static string ExtractTitle(string statusMsg)
|
||||
{
|
||||
// statusMsg is markup like "[yellow]Running...[/]" — we just return a static title
|
||||
return "HANA Operation";
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user