diff --git a/.gitignore b/.gitignore index 1746e32..c9db9ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ bin obj +hanatui.dbg diff --git a/hanatui/PLAN.md b/hanatui/PLAN.md new file mode 100644 index 0000000..e24dcbb --- /dev/null +++ b/hanatui/PLAN.md @@ -0,0 +1,215 @@ +# HANA TUI - Implementation Plan + +## Overview + +A single-binary AOT-compiled C# TUI for managing SAP HANA schemas. Built on **.NET 10** +with **Spectre.Console** for rich UI, running against the existing `hdbsql`/`hdbuserstore` +tools already installed on the system. + +--- + +## Project Setup + +Update `hanatui.csproj`: +- `true` +- `true` +- `true` +- `true` +- NuGet: `Spectre.Console` (core only, no `.Cli`) + +Build command: +```bash +dotnet publish -r linux-x64 -c Release -o bin/publish +``` +Output: `bin/publish/hanatui` (~15-20 MB single native binary, no runtime dependency) + +--- + +## File Structure + +``` +hanatui/ +├── hanatui.csproj +├── PLAN.md +├── Program.cs +└── src/ + ├── Hana/ + │ ├── HdbClientLocator.cs # Finds hdbclient path (/usr/sap/hdbclient etc.) + │ ├── HdbCliRunner.cs # Spawns hdbsql/hdbuserstore, streams output + │ ├── HdbUserstoreKey.cs # Model: key name, host, port, tenant, user + │ ├── SchemaService.cs # Lists schemas, builds/runs SQL queries + │ └── SqlQueryBuilder.cs # Builds EXPORT/IMPORT/DROP/BACKUP SQL strings + ├── System/ + │ ├── SystemStats.cs # Reads /proc/stat + /proc/meminfo, computes deltas + │ └── CpuSample.cs # Struct for CPU snapshot (used for delta calc) + └── Tui/ + ├── KeySelectionScreen.cs # Startup: arrow-key key picker + ├── MainMenuScreen.cs # Main menu with current key shown in header + ├── OperationForms.cs # Per-op guided input forms (Export/Import/etc.) + ├── TaskRunnerScreen.cs # Live split-panel: stats left, log right + └── Components/ + ├── StatsPanel.cs # CPU bars per core + total, RAM bar, Swap bar + └── LogPanel.cs # Scrolling timestamped log lines +``` + +--- + +## Screen Flow + +``` +[Launch] + └─> KeySelectionScreen + hdbuserstore list → parse keys → arrow-key picker (or manual entry) + └─> MainMenuScreen (shows active key + host in header) + └─> OperationForms (per operation) + 1. Fetch schemas (spinner while loading) + 2. Schema picker (arrow-key selectable list) + 3. Additional inputs (path, threads, flags) + 4. Confirmation summary before running + └─> TaskRunnerScreen + ┌──────────────────┬───────────────────┐ + │ SYSTEM STATS │ OPERATION LOG │ + │ CPU bars │ streaming lines │ + │ Total [████░░] │ with timestamps │ + │ Core0 [███░░░] │ │ + │ Core1 [█████░] │ │ + │ ... │ │ + │ RAM bar │ │ + │ [██████░░] 58% │ │ + │ 12.4 / 21.3 GB │ │ + │ Swap bar │ │ + │ [██░░░░░░] 18% │ │ + │ Elapsed: 4m 32s │ │ + └──────────────────┴───────────────────┘ + [q] → "Press Q again to abort" + [q again] → SIGTERM → wait 5s → SIGKILL + + On complete/abort: + - Stats stop refreshing, log stays visible + - "Returning in 10s... [any key to stay]" + - If key pressed → cancel countdown, stay on result screen + - Enter/Esc → back to main menu +``` + +--- + +## Operations + +| # | Operation | Key inputs | Notes | +|---|--------------------|-------------------------------------------------|------------------------------| +| 1 | Export Schema | Schema (picker), target path, threads, compress | Shells pigz if available | +| 2 | Import Schema | Source schema name, source path, threads, replace | Decompresses .tar.gz if needed | +| 3 | Import & Rename | Source name, new name, source path, threads, replace | Adds RENAME clause | +| 4 | Copy Schema | Schema (picker), target name, temp path, threads, replace | Export then Import-Rename | +| 5 | Drop Schema | Schema (picker), type YES to confirm | Destructive — extra confirm | +| 6 | Rename DB | Schema (picker), new company name | Updates CINF + OADM tables | +| 7 | Backup Tenant | Target path, threads, compress | BACKUP DATA USING FILE | + +--- + +## System Stats Details + +| Metric | Source | Method | +|--------------|------------------|-----------------------------------------------| +| CPU % total | `/proc/stat` | Delta between two reads 500ms apart | +| CPU % core N | `/proc/stat` | Per-core lines (`cpu0`, `cpu1`, ...) | +| RAM total | `/proc/meminfo` | `MemTotal` | +| RAM used | `/proc/meminfo` | `MemTotal - MemAvailable` | +| Swap total | `/proc/meminfo` | `SwapTotal` | +| Swap used | `/proc/meminfo` | `SwapTotal - SwapFree` | + +- Refresh interval: 800ms via `System.Threading.Timer` +- Bar width adapts to terminal width +- Bar format: `[████████░░] 82%` + +--- + +## Abort Behavior + +1. First `q` press during running task: + - Shows warning in log panel: `[WARN] Press Q again to abort the operation` +2. Second `q` press within ~3 seconds: + - Sends SIGTERM to `hdbsql` child process + - Waits up to 5 seconds for graceful exit + - If still running after 5s: sends SIGKILL + - Logs each step to the log panel + +--- + +## Post-Operation Return Behavior + +- After success or abort: stats panel freezes, log remains +- Footer shows: `Returning to menu in 10s... [any key to stay]` +- If any key pressed: countdown cancels, footer changes to `[Enter/Esc] Return to menu` +- User reads the result at their own pace + +--- + +## AOT Compatibility + +| Concern | Mitigation | +|-------------------------------|------------------------------------------------------| +| No runtime reflection | All code uses concrete types, no Activator | +| Spectre.Console prompts | SelectionPrompt, TextPrompt — AOT-safe | +| Spectre.Console Live/Layout | AnsiConsole.Live() — AOT-compatible | +| Process spawning | System.Diagnostics.Process — fully AOT-safe | +| /proc file reading | File.ReadAllText — no issues | +| Compression | Process (pigz/tar) — no native .NET compression | +| No JsonSerializer | Not needed | +| No dynamic/Activator | Enforced throughout | + +--- + +## Key Discovery (hdbuserstore) + +Command: `hdbuserstore list` + +Sample output: +``` +DATA FILE : /home/user/.hdb/hostname/SSFS_HDB.DAT +KEY FILE : /home/user/.hdb/hostname/SSFS_HDB.KEY + +KEY CRONKEY + ENV : hostname:30015@NDB + USER: SYSTEM + +KEY DEVKEY + ENV : devhost:30015@DEV + USER: SYSTEM +``` + +Parse: lines starting with `KEY ` → key name. Following `ENV :` and `USER:` lines → details. + +--- + +## SQL Queries Used + +```sql +-- List schemas +SELECT SCHEMA_NAME FROM SCHEMAS +WHERE SCHEMA_OWNER = 'SYSTEM' +AND SCHEMA_NAME NOT IN ('_SYS_SECURITY', 'IFSERV', 'B1if', 'SYSTEM', 'RSP'); + +-- Export +EXPORT "SCHEMA"."*" AS BINARY INTO '/path' WITH REPLACE THREADS N NO DEPENDENCIES; + +-- Import +IMPORT "SCHEMA"."*" AS BINARY FROM '/path' WITH IGNORE EXISTING THREADS N; + +-- Import-Rename +IMPORT "SCHEMA"."*" AS BINARY FROM '/path' +WITH IGNORE EXISTING RENAME SCHEMA "OLD" TO "NEW" THREADS N; + +-- Drop +DROP SCHEMA "SCHEMA" CASCADE; + +-- Rename company +UPDATE "SCHEMA".CINF SET "CompnyName" = 'NAME'; +UPDATE "SCHEMA".OADM SET "CompnyName" = 'NAME', "PrintHeadr" = 'NAME'; + +-- Backup tenant +BACKUP DATA USING FILE ('/path/prefix'); + +-- Get tenant name +SELECT DATABASE_NAME FROM SYS.M_DATABASES; +``` diff --git a/hanatui/Program.cs b/hanatui/Program.cs index 1bc52a6..0614188 100644 --- a/hanatui/Program.cs +++ b/hanatui/Program.cs @@ -1 +1,145 @@ -Console.WriteLine("Hello, World!"); +using HanaTui.Hana; +using HanaTui.Tui; + +// ----------------------------------------------------------------------- +// Entry point +// ----------------------------------------------------------------------- + +// Enable UTF-8 output for block characters in bars +Console.OutputEncoding = System.Text.Encoding.UTF8; + +// Key selection loop — allows returning to key picker via "Change Key" +string? keyName = null; +HdbUserstoreKey? keyObj = null; + +while (true) +{ + // Step 1: Select or re-select a key + keyName = KeySelectionScreen.Run(); + if (keyName is null) + { + // User chose Exit + break; + } + + // Resolve key metadata if available + keyObj = HdbCliRunner.ListKeys().Find(k => k.Name.Equals(keyName, StringComparison.OrdinalIgnoreCase)); + + // Step 2: Main menu loop for this key + var exitToKeySelection = false; + while (!exitToKeySelection) + { + var operation = MainMenuScreen.Run(keyObj, keyName); + + switch (operation) + { + case MainMenuScreen.Operation.Quit: + Environment.Exit(0); + break; + + case MainMenuScreen.Operation.ChangeKey: + exitToKeySelection = true; + break; + + case MainMenuScreen.Operation.Export: + await HandleExportAsync(keyName); + break; + + case MainMenuScreen.Operation.Import: + await HandleImportAsync(keyName, renameMode: false); + break; + + case MainMenuScreen.Operation.ImportRename: + await HandleImportAsync(keyName, renameMode: true); + break; + + case MainMenuScreen.Operation.Copy: + await HandleCopyAsync(keyName); + break; + + case MainMenuScreen.Operation.Drop: + await HandleDropAsync(keyName); + break; + + case MainMenuScreen.Operation.RenameDb: + await HandleRenameDbAsync(keyName); + break; + + case MainMenuScreen.Operation.Backup: + await HandleBackupAsync(keyName); + break; + } + } +} + +return; + +// ----------------------------------------------------------------------- +// Operation handlers +// ----------------------------------------------------------------------- + +static async Task HandleExportAsync(string key) +{ + var p = OperationForms.ExportForm(key); + if (p is null) return; + + var svc = new SchemaService(key); + await TaskRunnerScreen.RunAsync( + $"Export Schema '{p.Schema}'", + (log, ct) => svc.ExportAsync(p, log, ct)); +} + +static async Task HandleImportAsync(string key, bool renameMode) +{ + var p = OperationForms.ImportForm(key, renameMode); + if (p is null) return; + + var svc = new SchemaService(key); + await TaskRunnerScreen.RunAsync( + renameMode ? $"Import & Rename '{p.SourceSchema}'" : $"Import '{p.SourceSchema}'", + (log, ct) => svc.ImportAsync(p, log, ct)); +} + +static async Task HandleCopyAsync(string key) +{ + var p = OperationForms.CopyForm(key); + if (p is null) return; + + var svc = new SchemaService(key); + await TaskRunnerScreen.RunAsync( + $"Copy '{p.SourceSchema}' -> '{p.TargetSchema}'", + (log, ct) => svc.CopyAsync(p, log, ct)); +} + +static async Task HandleDropAsync(string key) +{ + var p = OperationForms.DropForm(key); + if (p is null) return; + + var svc = new SchemaService(key); + await TaskRunnerScreen.RunAsync( + $"Drop Schema '{p.Schema}'", + (log, ct) => svc.DropAsync(p, log, ct)); +} + +static async Task HandleRenameDbAsync(string key) +{ + var p = OperationForms.RenameDbForm(key); + if (p is null) return; + + var svc = new SchemaService(key); + await TaskRunnerScreen.RunAsync( + $"Rename DB '{p.Schema}'", + (log, ct) => svc.RenameDbAsync(p, log, ct)); +} + +static async Task HandleBackupAsync(string key) +{ + var p = OperationForms.BackupForm(key); + if (p is null) return; + + var svc = new SchemaService(key); + await TaskRunnerScreen.RunAsync( + "Backup Tenant", + (log, ct) => svc.BackupAsync(p, log, ct)); +} diff --git a/hanatui/build.sh b/hanatui/build.sh new file mode 100755 index 0000000..e77c0c8 --- /dev/null +++ b/hanatui/build.sh @@ -0,0 +1,3 @@ +#! /bin/bash + +dotnet publish -r linux-x64 -c Release -o ./publish diff --git a/hanatui/hanatui.csproj b/hanatui/hanatui.csproj index ed9781c..58cb97a 100644 --- a/hanatui/hanatui.csproj +++ b/hanatui/hanatui.csproj @@ -5,6 +5,16 @@ net10.0 enable enable + true + true + true + true + HanaTui + hanatui + + + + diff --git a/hanatui/publish/hanatui b/hanatui/publish/hanatui new file mode 100755 index 0000000..adeb5e7 Binary files /dev/null and b/hanatui/publish/hanatui differ diff --git a/hanatui/src/Hana/HdbCliRunner.cs b/hanatui/src/Hana/HdbCliRunner.cs new file mode 100644 index 0000000..ab49ba4 --- /dev/null +++ b/hanatui/src/Hana/HdbCliRunner.cs @@ -0,0 +1,203 @@ +using System.Diagnostics; + +namespace HanaTui.Hana; + +/// +/// Low-level wrapper for spawning hdbsql and hdbuserstore child processes. +/// All output is streamed line-by-line via callbacks for live log display. +/// +public sealed class HdbCliRunner +{ + /// + /// Lists all hdbuserstore keys. Returns empty list on failure. + /// + public static List ListKeys() + { + var result = RunAndCapture(HdbClientLocator.HdbUserstore, "list"); + if (!result.Success) + return []; + + return HdbUserstoreKey.ParseFrom(result.Output); + } + + /// + /// Tests a key connection by running a trivial SELECT against DUMMY. + /// Returns true on success. + /// + public static bool TestKey(string key) + { + var result = RunAndCapture( + HdbClientLocator.HdbSql, + $"-U \"{key}\" \"SELECT 'ok' FROM DUMMY\""); + return result.Success && result.Output.Contains("ok"); + } + + /// + /// Lists eligible schemas for the given userstore key. + /// + public static (bool Success, List Schemas, string Error) ListSchemas(string key) + { + const string query = + "SELECT SCHEMA_NAME FROM SCHEMAS " + + "WHERE SCHEMA_OWNER = 'SYSTEM' " + + "AND SCHEMA_NAME NOT IN ('_SYS_SECURITY', 'IFSERV', 'B1if', 'SYSTEM', 'RSP');"; + + var result = RunAndCapture(HdbClientLocator.HdbSql, $"-U \"{key}\" \"{query}\""); + if (!result.Success) + return (false, [], result.Output); + + var schemas = new List(); + foreach (var line in result.Output.Split('\n')) + { + var clean = line.Trim().Trim('"'); + if (string.IsNullOrWhiteSpace(clean)) continue; + if (clean.StartsWith("SCHEMA_NAME", StringComparison.OrdinalIgnoreCase)) continue; + if (clean.Contains("rows selected", StringComparison.OrdinalIgnoreCase)) continue; + if (clean.Contains("row selected", StringComparison.OrdinalIgnoreCase)) continue; + if (clean.Contains("overall time", StringComparison.OrdinalIgnoreCase)) continue; + if (clean.StartsWith('-')) continue; + schemas.Add(clean); + } + + return (true, schemas, ""); + } + + /// + /// Runs hdbsql with the given key and SQL query, streaming output lines + /// to the provided callback. Returns the process exit code. + /// The CancellationToken is checked — if cancelled, the process is terminated. + /// + public static async Task RunSqlStreamingAsync( + string key, + string sql, + Action onOutputLine, + CancellationToken ct) + { + return await RunStreamingAsync( + HdbClientLocator.HdbSql, + $"-U \"{key}\" \"{sql}\"", + onOutputLine, + ct); + } + + /// + /// Runs an arbitrary shell command, streaming output lines to the callback. + /// Used for compression steps (tar/pigz). + /// + public static async Task RunCommandStreamingAsync( + string executable, + string arguments, + Action onOutputLine, + CancellationToken ct) + { + return await RunStreamingAsync(executable, arguments, onOutputLine, ct); + } + + // ------------------------------------------------------------------------- + + private static (bool Success, string Output) RunAndCapture(string exe, string args) + { + try + { + var psi = new ProcessStartInfo(exe) + { + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var proc = Process.Start(psi); + if (proc is null) + return (false, "Failed to start process"); + + var stdout = proc.StandardOutput.ReadToEnd(); + var stderr = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + + if (proc.ExitCode == 0) + return (true, stdout); + else + return (false, string.IsNullOrWhiteSpace(stderr) ? stdout : stderr); + } + catch (Exception ex) + { + return (false, ex.Message); + } + } + + private static async Task RunStreamingAsync( + string exe, + string args, + Action onOutputLine, + CancellationToken ct) + { + var psi = new ProcessStartInfo(exe) + { + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + Process? proc; + try + { + proc = Process.Start(psi); + } + catch (Exception ex) + { + onOutputLine($"[ERROR] Failed to start process: {ex.Message}"); + return -1; + } + + if (proc is null) + { + onOutputLine("[ERROR] Process could not be started."); + return -1; + } + + // Register cancellation: SIGTERM first, then SIGKILL + using var ctr = ct.Register(() => + { + _ = Task.Run(async () => + { + try + { + onOutputLine("[WARN] Sending SIGTERM to process..."); + proc.Kill(entireProcessTree: false); // sends SIGTERM on Linux + await Task.Delay(5000, CancellationToken.None); + if (!proc.HasExited) + { + onOutputLine("[WARN] Process did not exit, sending SIGKILL..."); + proc.Kill(entireProcessTree: true); + } + } + catch { /* process may already be gone */ } + }, CancellationToken.None); + }); + + // Stream stdout + var stdoutTask = Task.Run(async () => + { + string? line; + while ((line = await proc.StandardOutput.ReadLineAsync(CancellationToken.None)) is not null) + onOutputLine(line); + }, CancellationToken.None); + + // Stream stderr as well (SAP HANA outputs progress to stderr) + var stderrTask = Task.Run(async () => + { + string? line; + while ((line = await proc.StandardError.ReadLineAsync(CancellationToken.None)) is not null) + onOutputLine(line); + }, CancellationToken.None); + + await Task.WhenAll(stdoutTask, stderrTask); + await proc.WaitForExitAsync(CancellationToken.None); + + return proc.ExitCode; + } +} diff --git a/hanatui/src/Hana/HdbClientLocator.cs b/hanatui/src/Hana/HdbClientLocator.cs new file mode 100644 index 0000000..ee184d6 --- /dev/null +++ b/hanatui/src/Hana/HdbClientLocator.cs @@ -0,0 +1,79 @@ +namespace HanaTui.Hana; + +/// +/// Locates the SAP HANA client binaries (hdbsql, hdbuserstore) on the system. +/// Mirrors the path detection logic from keymanager.sh. +/// +public static class HdbClientLocator +{ + private static readonly string[] KnownPaths = + [ + "/usr/sap/hdbclient", + "/usr/sap/NDB/HDB00/exe", + ]; + + private static string? _resolvedPath; + + /// + /// Returns the full path to hdbsql, or just "hdbsql" if it's on PATH. + /// Throws if the client cannot be found anywhere. + /// + public static string HdbSql => Resolve("hdbsql"); + + /// + /// Returns the full path to hdbuserstore, or just "hdbuserstore" if it's on PATH. + /// + public static string HdbUserstore => Resolve("hdbuserstore"); + + /// + /// Returns the resolved client directory, or null if only found via PATH. + /// + public static string? ClientDirectory => GetClientDirectory(); + + private static string Resolve(string binary) + { + var dir = GetClientDirectory(); + if (dir is not null) + return Path.Combine(dir, binary); + + // Fall back to PATH — let the OS resolve it + return binary; + } + + private static string? GetClientDirectory() + { + if (_resolvedPath is not null) + return _resolvedPath; + + foreach (var path in KnownPaths) + { + if (Directory.Exists(path)) + { + _resolvedPath = path; + return _resolvedPath; + } + } + + // Not found in known paths — return null (caller will use PATH) + return null; + } + + /// + /// Checks whether hdbsql is available either in a known path or on PATH. + /// + public static bool IsAvailable() + { + if (GetClientDirectory() is not null) + return true; + + // Check if it's available on PATH by attempting to locate it + var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? ""; + foreach (var dir in pathEnv.Split(':')) + { + if (File.Exists(Path.Combine(dir, "hdbsql"))) + return true; + } + + return false; + } +} diff --git a/hanatui/src/Hana/HdbUserstoreKey.cs b/hanatui/src/Hana/HdbUserstoreKey.cs new file mode 100644 index 0000000..119ad38 --- /dev/null +++ b/hanatui/src/Hana/HdbUserstoreKey.cs @@ -0,0 +1,93 @@ +namespace HanaTui.Hana; + +/// +/// Represents a single parsed entry from hdbuserstore list output. +/// +public sealed class HdbUserstoreKey +{ + public string Name { get; init; } = ""; + public string Environment { get; init; } = ""; // raw ENV value e.g. host:30015@NDB + public string User { get; init; } = ""; + + // Parsed from Environment + public string Host { get; init; } = ""; + public string Port { get; init; } = ""; + public string Tenant { get; init; } = ""; + + public override string ToString() => Name; + + /// + /// Parse a list of keys from the raw stdout of "hdbuserstore list". + /// + public static List ParseFrom(string output) + { + var keys = new List(); + var lines = output.Split('\n', StringSplitOptions.None); + + string currentName = ""; + string currentEnv = ""; + string currentUser = ""; + + foreach (var rawLine in lines) + { + var line = rawLine.TrimEnd(); + + if (line.StartsWith("KEY ", StringComparison.Ordinal)) + { + // Flush previous key + if (!string.IsNullOrEmpty(currentName)) + keys.Add(Build(currentName, currentEnv, currentUser)); + + currentName = line[4..].Trim(); + currentEnv = ""; + currentUser = ""; + } + else if (line.TrimStart().StartsWith("ENV :", StringComparison.Ordinal)) + { + currentEnv = line.TrimStart()[5..].Trim(); + } + else if (line.TrimStart().StartsWith("USER:", StringComparison.Ordinal)) + { + currentUser = line.TrimStart()[5..].Trim(); + } + } + + // Flush last key + if (!string.IsNullOrEmpty(currentName)) + keys.Add(Build(currentName, currentEnv, currentUser)); + + return keys; + } + + private static HdbUserstoreKey Build(string name, string env, string user) + { + // ENV format: host:port@TENANT or host:port + string host = "", port = "", tenant = ""; + + var atIdx = env.IndexOf('@'); + var hostPort = atIdx >= 0 ? env[..atIdx] : env; + if (atIdx >= 0) + tenant = env[(atIdx + 1)..]; + + var colonIdx = hostPort.IndexOf(':'); + if (colonIdx >= 0) + { + host = hostPort[..colonIdx]; + port = hostPort[(colonIdx + 1)..]; + } + else + { + host = hostPort; + } + + return new HdbUserstoreKey + { + Name = name, + Environment = env, + User = user, + Host = host, + Port = port, + Tenant = tenant, + }; + } +} diff --git a/hanatui/src/Hana/SchemaService.cs b/hanatui/src/Hana/SchemaService.cs new file mode 100644 index 0000000..b56d269 --- /dev/null +++ b/hanatui/src/Hana/SchemaService.cs @@ -0,0 +1,485 @@ +using SysDiag = System.Diagnostics; + +namespace HanaTui.Hana; + +/// +/// Parameters for an Export operation. +/// +public sealed class ExportParams +{ + public string Schema { get; init; } = ""; + public string TargetPath { get; init; } = ""; + public int Threads { get; init; } = 1; + public bool Compress { get; init; } +} + +/// +/// Parameters for an Import operation. +/// +public sealed class ImportParams +{ + public string SourceSchema { get; init; } = ""; + public string? NewSchemaName { get; init; } // non-null = rename mode + public string SourcePath { get; init; } = ""; + public int Threads { get; init; } = 1; + public bool Replace { get; init; } +} + +/// +/// Parameters for a Copy operation. +/// +public sealed class CopyParams +{ + public string SourceSchema { get; init; } = ""; + public string TargetSchema { get; init; } = ""; + public string TempPath { get; init; } = ""; + public int Threads { get; init; } = 1; + public bool Replace { get; init; } +} + +/// +/// Parameters for Drop Schema. +/// +public sealed class DropParams +{ + public string Schema { get; init; } = ""; +} + +/// +/// Parameters for Rename Database (company name). +/// +public sealed class RenameDbParams +{ + public string Schema { get; init; } = ""; + public string NewCompanyName { get; init; } = ""; +} + +/// +/// Parameters for Backup Tenant. +/// +public sealed class BackupParams +{ + public string TargetPath { get; init; } = ""; + public int Threads { get; init; } = 1; + public bool Compress { get; init; } +} + +/// +/// High-level service that runs HANA operations using HdbCliRunner. +/// Each method accepts an operation params object and a log callback. +/// The CancellationToken is forwarded so the TaskRunner can abort. +/// +public sealed class SchemaService(string userKey) +{ + private readonly string _key = userKey; + + // ------------------------------------------------------------------------- + // Export + // ------------------------------------------------------------------------- + + public async Task ExportAsync( + ExportParams p, + Action log, + CancellationToken ct) + { + log($"[INFO] Starting export of schema '{p.Schema}'..."); + + string exportDir = p.TargetPath; + string archiveFile = ""; + + if (p.Compress) + { + string targetDir; + if (p.TargetPath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) + { + archiveFile = p.TargetPath; + targetDir = Path.GetDirectoryName(p.TargetPath) ?? "."; + } + else + { + targetDir = p.TargetPath; + archiveFile = Path.Combine(targetDir, + $"{p.Schema}_export_{DateTime.Now:yyyyMMdd_HHmmss}.tar.gz"); + } + + Directory.CreateDirectory(targetDir); + exportDir = CreateTempDir(targetDir, $"export_{p.Schema}"); + log($"[INFO] Using temp export directory: {exportDir}"); + } + else + { + Directory.CreateDirectory(exportDir); + } + + var sql = SqlQueryBuilder.ExportSchema(p.Schema, exportDir, p.Threads); + log($"[SQL ] {sql}"); + + var exitCode = await HdbCliRunner.RunSqlStreamingAsync(_key, sql, log, ct); + + if (exitCode != 0) + { + log("[ERROR] Export failed."); + if (p.Compress) SafeDelete(exportDir); + return false; + } + + log("[DONE] Export completed successfully."); + + if (p.Compress && !ct.IsCancellationRequested) + { + var ok = await CompressAsync(exportDir, archiveFile, p.Threads, log, ct); + if (ok) SafeDelete(exportDir); + } + + return true; + } + + // ------------------------------------------------------------------------- + // Import + // ------------------------------------------------------------------------- + + public async Task ImportAsync( + ImportParams p, + Action log, + CancellationToken ct) + { + var mode = p.NewSchemaName is not null ? "Import & Rename" : "Import"; + log($"[INFO] Starting {mode} of schema '{p.SourceSchema}'..."); + + string importDir = p.SourcePath; + bool cleanupTemp = false; + + if (File.Exists(p.SourcePath) && + p.SourcePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) + { + importDir = CreateTempDir("/tmp", $"import_{p.SourceSchema}"); + cleanupTemp = true; + log($"[INFO] Decompressing archive to {importDir}..."); + + var ok = await DecompressAsync(p.SourcePath, importDir, p.Threads, log, ct); + if (!ok) + { + SafeDelete(importDir); + return false; + } + } + else if (!Directory.Exists(importDir)) + { + log("[ERROR] Source path is neither a directory nor a valid .tar.gz archive."); + return false; + } + + string sql = p.NewSchemaName is not null + ? SqlQueryBuilder.ImportSchemaWithRename( + p.SourceSchema, p.NewSchemaName, importDir, p.Threads, p.Replace) + : SqlQueryBuilder.ImportSchema(p.SourceSchema, importDir, p.Threads, p.Replace); + + log($"[SQL ] {sql}"); + + var exitCode = await HdbCliRunner.RunSqlStreamingAsync(_key, sql, log, ct); + + if (cleanupTemp) + { + log("[INFO] Cleaning up temporary files..."); + SafeDelete(importDir); + } + + if (exitCode != 0) + { + log("[ERROR] Import failed."); + return false; + } + + log("[DONE] Import completed successfully."); + return true; + } + + // ------------------------------------------------------------------------- + // Copy + // ------------------------------------------------------------------------- + + public async Task CopyAsync( + CopyParams p, + Action log, + CancellationToken ct) + { + log($"[INFO] Starting copy: '{p.SourceSchema}' -> '{p.TargetSchema}'"); + + // Step 1: Export + log($"[INFO] Step 1/2: Exporting '{p.SourceSchema}'..."); + Directory.CreateDirectory(p.TempPath); + var tempExportDir = CreateTempDir(p.TempPath, $"copy_export_{p.SourceSchema}"); + log($"[INFO] Temp export dir: {tempExportDir}"); + + var exportSql = SqlQueryBuilder.ExportSchema(p.SourceSchema, tempExportDir, p.Threads); + log($"[SQL ] {exportSql}"); + + var exportCode = await HdbCliRunner.RunSqlStreamingAsync(_key, exportSql, log, ct); + if (exportCode != 0) + { + log("[ERROR] Export phase failed. Aborting copy."); + SafeDelete(tempExportDir); + return false; + } + log("[DONE] Export phase completed."); + + if (ct.IsCancellationRequested) { SafeDelete(tempExportDir); return false; } + + // Step 2: Import with rename + log($"[INFO] Step 2/2: Importing as '{p.TargetSchema}'..."); + var importSql = SqlQueryBuilder.ImportSchemaWithRename( + p.SourceSchema, p.TargetSchema, tempExportDir, p.Threads, p.Replace); + log($"[SQL ] {importSql}"); + + var importCode = await HdbCliRunner.RunSqlStreamingAsync(_key, importSql, log, ct); + + log("[INFO] Cleaning up temporary files..."); + SafeDelete(tempExportDir); + + if (importCode != 0) + { + log("[ERROR] Import phase failed."); + return false; + } + + log($"[DONE] Successfully copied schema '{p.SourceSchema}' to '{p.TargetSchema}'."); + return true; + } + + // ------------------------------------------------------------------------- + // Drop + // ------------------------------------------------------------------------- + + public async Task DropAsync( + DropParams p, + Action log, + CancellationToken ct) + { + log($"[INFO] Dropping schema '{p.Schema}'..."); + var sql = SqlQueryBuilder.DropSchema(p.Schema); + log($"[SQL ] {sql}"); + + var exitCode = await HdbCliRunner.RunSqlStreamingAsync(_key, sql, log, ct); + + if (exitCode != 0) + { + log("[ERROR] Failed to drop schema."); + return false; + } + + log("[DONE] Schema successfully dropped."); + return true; + } + + // ------------------------------------------------------------------------- + // Rename DB (company name) + // ------------------------------------------------------------------------- + + public async Task RenameDbAsync( + RenameDbParams p, + Action log, + CancellationToken ct) + { + log($"[INFO] Renaming company in schema '{p.Schema}' to '{p.NewCompanyName}'..."); + + var q1 = SqlQueryBuilder.RenameCompanyInCinf(p.Schema, p.NewCompanyName); + log($"[SQL ] {q1}"); + var r1 = await HdbCliRunner.RunSqlStreamingAsync(_key, q1, log, ct); + if (r1 != 0) { log("[WARN] CINF update may have failed (table may not exist)."); } + + if (ct.IsCancellationRequested) return false; + + var q2 = SqlQueryBuilder.RenameCompanyInOadm(p.Schema, p.NewCompanyName); + log($"[SQL ] {q2}"); + var r2 = await HdbCliRunner.RunSqlStreamingAsync(_key, q2, log, ct); + if (r2 != 0) { log("[WARN] OADM update may have failed (table may not exist)."); } + + log("[DONE] Database rename completed."); + return true; + } + + // ------------------------------------------------------------------------- + // Backup Tenant + // ------------------------------------------------------------------------- + + public async Task BackupAsync( + BackupParams p, + Action log, + CancellationToken ct) + { + log("[INFO] Fetching tenant name..."); + + var tenantResult = await GetTenantNameAsync(); + if (string.IsNullOrEmpty(tenantResult)) + { + log("[ERROR] Could not retrieve HANA tenant name."); + return false; + } + + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + string backupDir = p.TargetPath; + string archiveFile = ""; + + if (p.Compress) + { + backupDir = CreateTempDir(p.TargetPath, $"{tenantResult}_backup_{timestamp}"); + archiveFile = Path.Combine(p.TargetPath, $"{tenantResult}_backup_{timestamp}.tar.gz"); + log($"[INFO] Using temp backup directory: {backupDir}"); + } + + Directory.CreateDirectory(backupDir); + var backupPrefix = Path.Combine(backupDir, $"backup_{tenantResult}_{timestamp}"); + + log($"[INFO] Starting backup of tenant '{tenantResult}'..."); + var sql = SqlQueryBuilder.BackupTenant(backupPrefix); + log($"[SQL ] {sql}"); + + var exitCode = await HdbCliRunner.RunSqlStreamingAsync(_key, sql, log, ct); + + if (exitCode != 0) + { + log("[ERROR] Backup failed."); + if (p.Compress) SafeDelete(backupDir); + return false; + } + + log("[DONE] Backup completed successfully."); + + if (p.Compress && !ct.IsCancellationRequested) + { + var ok = await CompressDir(backupDir, archiveFile, p.Threads, log, ct); + if (ok) SafeDelete(backupDir); + } + + return true; + } + + private async Task GetTenantNameAsync() + { + var sql = SqlQueryBuilder.GetTenantName(); + var (success, output) = RunCapture(HdbClientLocator.HdbSql, $"-U \"{_key}\" \"{sql}\""); + if (!success) return ""; + + foreach (var line in output.Split('\n')) + { + var clean = line.Trim().Trim('"').Trim(); + if (string.IsNullOrWhiteSpace(clean)) continue; + if (clean.StartsWith("DATABASE_NAME", StringComparison.OrdinalIgnoreCase)) continue; + if (clean.StartsWith('-')) continue; + if (clean.Contains("row", StringComparison.OrdinalIgnoreCase)) continue; + return clean; + } + + return ""; + } + + private static (bool, string) RunCapture(string exe, string args) + { + try + { + var psi = new SysDiag.ProcessStartInfo(exe) + { + Arguments = args, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + using var proc = SysDiag.Process.Start(psi); + if (proc is null) return (false, ""); + var stdout = proc.StandardOutput.ReadToEnd(); + var stderr = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + return (proc.ExitCode == 0, proc.ExitCode == 0 ? stdout : stderr); + } + catch (Exception ex) { return (false, ex.Message); } + } + + private static async Task CompressAsync( + string sourceDir, string archiveFile, int threads, Action log, CancellationToken ct) + { + log($"[INFO] Compressing to {archiveFile}..."); + return await CompressDir(sourceDir, archiveFile, threads, log, ct); + } + + private static async Task CompressDir( + string sourceDir, string archiveFile, int threads, Action log, CancellationToken ct) + { + Directory.CreateDirectory(Path.GetDirectoryName(archiveFile) ?? "."); + + bool hasPigz = IsOnPath("pigz"); + string exe, args; + + if (hasPigz) + { + exe = "tar"; + args = $"-I \"pigz -p {threads}\" -cf \"{archiveFile}\" -C \"{Path.GetDirectoryName(sourceDir)}\" \"{Path.GetFileName(sourceDir)}\""; + } + else + { + exe = "tar"; + args = $"-czf \"{archiveFile}\" -C \"{Path.GetDirectoryName(sourceDir)}\" \"{Path.GetFileName(sourceDir)}\""; + } + + log($"[INFO] Using: {exe} {args}"); + var code = await HdbCliRunner.RunCommandStreamingAsync(exe, args, log, ct); + + if (code == 0) + { + log("[DONE] Compression successful."); + return true; + } + + log("[ERROR] Compression failed."); + return false; + } + + private static async Task DecompressAsync( + string archiveFile, string targetDir, int threads, Action log, CancellationToken ct) + { + bool hasPigz = IsOnPath("pigz"); + string exe = "tar"; + string args; + + if (hasPigz) + args = $"-I \"pigz -p {threads}\" -xf \"{archiveFile}\" -C \"{targetDir}\" --strip-components=1"; + else + args = $"-xzf \"{archiveFile}\" -C \"{targetDir}\" --strip-components=1"; + + var code = await HdbCliRunner.RunCommandStreamingAsync(exe, args, log, ct); + + if (code == 0) { log("[DONE] Decompression successful."); return true; } + log("[ERROR] Decompression failed."); + return false; + } + + private static string CreateTempDir(string parent, string prefix) + { + // Generate a unique temp dir similar to mktemp -d + string path; + do + { + path = Path.Combine(parent, $"{prefix}_{Path.GetRandomFileName()[..6]}"); + } while (Directory.Exists(path)); + + Directory.CreateDirectory(path); + return path; + } + + private static void SafeDelete(string path) + { + try { if (Directory.Exists(path)) Directory.Delete(path, true); } + catch { /* best effort */ } + } + + private static bool IsOnPath(string binary) + { + var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? ""; + foreach (var dir in pathEnv.Split(':')) + { + if (File.Exists(Path.Combine(dir, binary))) + return true; + } + return false; + } +} diff --git a/hanatui/src/Hana/SqlQueryBuilder.cs b/hanatui/src/Hana/SqlQueryBuilder.cs new file mode 100644 index 0000000..01966e8 --- /dev/null +++ b/hanatui/src/Hana/SqlQueryBuilder.cs @@ -0,0 +1,43 @@ +namespace HanaTui.Hana; + +/// +/// Builds the SQL / shell command strings for each operation. +/// All SQL is built here; nothing else constructs query strings. +/// +public static class SqlQueryBuilder +{ + public static string ExportSchema(string schema, string exportDir, int threads) + => $"EXPORT \"{schema}\".\"*\" AS BINARY INTO '{exportDir}' WITH REPLACE THREADS {threads} NO DEPENDENCIES;"; + + public static string ImportSchema(string schema, string importDir, int threads, bool replace) + { + var opts = replace ? "REPLACE" : "IGNORE EXISTING"; + return $"IMPORT \"{schema}\".\"*\" AS BINARY FROM '{importDir}' WITH {opts} THREADS {threads};"; + } + + public static string ImportSchemaWithRename( + string sourceSchema, string targetSchema, string importDir, int threads, bool replace) + { + var opts = replace ? "REPLACE" : "IGNORE EXISTING"; + return $"IMPORT \"{sourceSchema}\".\"*\" AS BINARY FROM '{importDir}' " + + $"WITH {opts} RENAME SCHEMA \"{sourceSchema}\" TO \"{targetSchema}\" THREADS {threads};"; + } + + public static string DropSchema(string schema) + => $"DROP SCHEMA \"{schema}\" CASCADE"; + + public static string RenameCompanyInCinf(string schema, string newName) + => $"UPDATE \"{schema}\".CINF SET \"CompnyName\" = '{Escape(newName)}';"; + + public static string RenameCompanyInOadm(string schema, string newName) + => $"UPDATE \"{schema}\".OADM SET \"CompnyName\" = '{Escape(newName)}', \"PrintHeadr\" = '{Escape(newName)}';"; + + public static string GetTenantName() + => "SELECT DATABASE_NAME FROM SYS.M_DATABASES;"; + + public static string BackupTenant(string backupPrefix) + => $"BACKUP DATA USING FILE ('{backupPrefix}')"; + + // SQL single-quote escape + private static string Escape(string value) => value.Replace("'", "''"); +} diff --git a/hanatui/src/System/CpuSample.cs b/hanatui/src/System/CpuSample.cs new file mode 100644 index 0000000..c38010d --- /dev/null +++ b/hanatui/src/System/CpuSample.cs @@ -0,0 +1,75 @@ +namespace HanaTui.System; + +/// +/// A single snapshot of /proc/stat CPU counters for one CPU line. +/// Used to compute usage % by diffing two samples. +/// +public readonly struct CpuSample +{ + public readonly string Label; // "cpu", "cpu0", "cpu1", ... + public readonly long User; + public readonly long Nice; + public readonly long System; + public readonly long Idle; + public readonly long IoWait; + public readonly long Irq; + public readonly long SoftIrq; + public readonly long Steal; + + public long Total => User + Nice + System + Idle + IoWait + Irq + SoftIrq + Steal; + public long Active => Total - Idle - IoWait; + + public CpuSample(string label, long user, long nice, long system, + long idle, long ioWait, long irq, long softIrq, long steal) + { + Label = label; + User = user; + Nice = nice; + System = system; + Idle = idle; + IoWait = ioWait; + Irq = irq; + SoftIrq = softIrq; + Steal = steal; + } + + /// + /// Compute usage % between two consecutive samples (0.0 - 100.0). + /// + public static double ComputePercent(CpuSample prev, CpuSample curr) + { + var totalDelta = curr.Total - prev.Total; + var activeDelta = curr.Active - prev.Active; + if (totalDelta <= 0) return 0.0; + return Math.Clamp(activeDelta * 100.0 / totalDelta, 0.0, 100.0); + } + + /// + /// Parse all CPU lines from /proc/stat content. + /// Returns only lines that start with "cpu". + /// + public static List ParseProcStat(string content) + { + var result = new List(); + foreach (var line in content.Split('\n')) + { + if (!line.StartsWith("cpu", StringComparison.Ordinal)) continue; + + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 8) continue; + + result.Add(new CpuSample( + label: parts[0], + user: long.TryParse(parts[1], out var u) ? u : 0, + nice: long.TryParse(parts[2], out var n) ? n : 0, + system: long.TryParse(parts[3], out var s) ? s : 0, + idle: long.TryParse(parts[4], out var id) ? id : 0, + ioWait: long.TryParse(parts[5], out var io) ? io : 0, + irq: long.TryParse(parts[6], out var irq) ? irq : 0, + softIrq: long.TryParse(parts[7], out var si) ? si : 0, + steal: parts.Length > 8 && long.TryParse(parts[8], out var st) ? st : 0 + )); + } + return result; + } +} diff --git a/hanatui/src/System/SystemStats.cs b/hanatui/src/System/SystemStats.cs new file mode 100644 index 0000000..a406c21 --- /dev/null +++ b/hanatui/src/System/SystemStats.cs @@ -0,0 +1,150 @@ +using HanaTui.System; + +namespace HanaTui.System; + +/// +/// A snapshot of all system metrics at a point in time. +/// +public sealed class SystemSnapshot +{ + // CPU: index 0 = total ("cpu"), 1+ = per-core ("cpu0", "cpu1", ...) + public double[] CpuPercents { get; init; } = []; + public string[] CpuLabels { get; init; } = []; + + public long MemTotalKb { get; init; } + public long MemAvailableKb { get; init; } + public long MemUsedKb => MemTotalKb - MemAvailableKb; + + public long SwapTotalKb { get; init; } + public long SwapFreeKb { get; init; } + public long SwapUsedKb => SwapTotalKb - SwapFreeKb; + + public double MemUsedPercent => + MemTotalKb > 0 ? MemUsedKb * 100.0 / MemTotalKb : 0; + + public double SwapUsedPercent => + SwapTotalKb > 0 ? SwapUsedKb * 100.0 / SwapTotalKb : 0; + + public static string FormatGb(long kb) => + $"{kb / 1024.0 / 1024.0:F1} GB"; +} + +/// +/// Polls /proc/stat and /proc/meminfo on a background timer. +/// Thread-safe snapshot available via CurrentSnapshot. +/// +public sealed class SystemStats : IDisposable +{ + private const int RefreshMs = 800; + + private List _prevSamples = []; + private SystemSnapshot _snapshot = new(); + private readonly object _lock = new(); + private readonly Timer _timer; + private bool _disposed; + + public SystemStats() + { + // Take initial sample immediately (no % yet, need two samples) + _prevSamples = ReadCpuSamples(); + _timer = new Timer(Tick, null, RefreshMs, RefreshMs); + } + + /// + /// Returns the most recent system snapshot. Safe to call from any thread. + /// + public SystemSnapshot CurrentSnapshot + { + get { lock (_lock) { return _snapshot; } } + } + + private void Tick(object? _) + { + try + { + var currSamples = ReadCpuSamples(); + var mem = ReadMemInfo(); + + // Compute CPU % by diffing prev vs curr + var percents = new double[currSamples.Count]; + var labels = new string[currSamples.Count]; + + for (int i = 0; i < currSamples.Count; i++) + { + labels[i] = currSamples[i].Label; + // Find matching prev sample by label + var prev = _prevSamples.Find(s => s.Label == currSamples[i].Label); + percents[i] = CpuSample.ComputePercent(prev, currSamples[i]); + } + + var snap = new SystemSnapshot + { + CpuPercents = percents, + CpuLabels = labels, + MemTotalKb = mem.MemTotal, + MemAvailableKb = mem.MemAvailable, + SwapTotalKb = mem.SwapTotal, + SwapFreeKb = mem.SwapFree, + }; + + lock (_lock) + { + _prevSamples = currSamples; + _snapshot = snap; + } + } + catch + { + // Silently swallow errors in background stats thread + } + } + + private static List ReadCpuSamples() + { + try + { + var content = File.ReadAllText("/proc/stat"); + return CpuSample.ParseProcStat(content); + } + catch + { + return []; + } + } + + private static (long MemTotal, long MemAvailable, long SwapTotal, long SwapFree) ReadMemInfo() + { + long memTotal = 0, memAvailable = 0, swapTotal = 0, swapFree = 0; + try + { + foreach (var line in File.ReadLines("/proc/meminfo")) + { + if (line.StartsWith("MemTotal:", StringComparison.Ordinal)) + memTotal = ParseKb(line); + else if (line.StartsWith("MemAvailable:", StringComparison.Ordinal)) + memAvailable = ParseKb(line); + else if (line.StartsWith("SwapTotal:", StringComparison.Ordinal)) + swapTotal = ParseKb(line); + else if (line.StartsWith("SwapFree:", StringComparison.Ordinal)) + swapFree = ParseKb(line); + } + } + catch { /* no /proc/meminfo — probably not Linux */ } + + return (memTotal, memAvailable, swapTotal, swapFree); + } + + private static long ParseKb(string line) + { + // Format: "MemTotal: 32768 kB" + var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return parts.Length >= 2 && long.TryParse(parts[1], out var v) ? v : 0; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _timer.Dispose(); + } +} diff --git a/hanatui/src/Tui/Components/LogPanel.cs b/hanatui/src/Tui/Components/LogPanel.cs new file mode 100644 index 0000000..e2202eb --- /dev/null +++ b/hanatui/src/Tui/Components/LogPanel.cs @@ -0,0 +1,103 @@ +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace HanaTui.Tui.Components; + +/// +/// A timestamped log entry. +/// +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 } + +/// +/// Maintains a bounded list of log entries and renders them as a +/// Spectre.Console IRenderable panel, showing the most recent N lines. +/// Thread-safe. +/// +public sealed class LogPanel +{ + private const int MaxEntries = 500; + private const int VisibleLines = 20; // shown inside the panel + + private readonly List _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); + } + } + + /// + /// Returns a renderable panel showing the last N log lines. + /// + public IRenderable Build() + { + List 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; + } +} diff --git a/hanatui/src/Tui/Components/StatsPanel.cs b/hanatui/src/Tui/Components/StatsPanel.cs new file mode 100644 index 0000000..8a5920e --- /dev/null +++ b/hanatui/src/Tui/Components/StatsPanel.cs @@ -0,0 +1,135 @@ +using HanaTui.System; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace HanaTui.Tui.Components; + +/// +/// 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. +/// +public static class StatsPanel +{ + /// + /// Build the stats panel renderable from the current snapshot. + /// + 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 + // ------------------------------------------------------------------------- + + /// + /// Renders a horizontal block bar of the given width. + /// e.g. [████████░░░░] + /// + 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"; + } +} diff --git a/hanatui/src/Tui/KeySelectionScreen.cs b/hanatui/src/Tui/KeySelectionScreen.cs new file mode 100644 index 0000000..cb86dcc --- /dev/null +++ b/hanatui/src/Tui/KeySelectionScreen.cs @@ -0,0 +1,95 @@ +using HanaTui.Hana; +using Spectre.Console; + +namespace HanaTui.Tui; + +/// +/// Startup screen: lists hdbuserstore keys and lets the user pick one. +/// Returns the selected key name, or null if the user chose to exit. +/// +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 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(); + 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() + .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("[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 { 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); + } +} diff --git a/hanatui/src/Tui/MainMenuScreen.cs b/hanatui/src/Tui/MainMenuScreen.cs new file mode 100644 index 0000000..1afd7d4 --- /dev/null +++ b/hanatui/src/Tui/MainMenuScreen.cs @@ -0,0 +1,77 @@ +using HanaTui.Hana; +using Spectre.Console; + +namespace HanaTui.Tui; + +/// +/// The main operation menu. Returns the selected operation, or null to exit. +/// +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 + { + ["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() + .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; + } +} diff --git a/hanatui/src/Tui/OperationForms.cs b/hanatui/src/Tui/OperationForms.cs new file mode 100644 index 0000000..6e5bb71 --- /dev/null +++ b/hanatui/src/Tui/OperationForms.cs @@ -0,0 +1,313 @@ +using HanaTui.Hana; +using Spectre.Console; + +namespace HanaTui.Tui; + +/// +/// Guided input forms for each operation. +/// Each method collects all needed parameters, then returns a typed params object. +/// Returns null if the user cancelled. +/// +public static class OperationForms +{ + // ------------------------------------------------------------------------- + // Shared helpers + // ------------------------------------------------------------------------- + + /// + /// Fetches the schema list with a spinner, then shows an arrow-key picker. + /// Returns the selected schema, or null if cancelled. + /// + private static string? PickSchema(string userKey, string title) + { + List 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(schemas); + choices.Add("[dim][ Enter manually ][/]"); + choices.Add("[dim][ Cancel ][/]"); + + var prompt = new SelectionPrompt() + .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("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($"{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 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("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 + { + ["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("Source schema name [dim](as it was exported)[/]:").Trim(); + if (string.IsNullOrWhiteSpace(sourceSchema)) return null; + + string? newSchemaName = null; + if (renameMode) + { + newSchemaName = AnsiConsole.Ask("New target schema name:").Trim(); + if (string.IsNullOrWhiteSpace(newSchemaName)) return null; + } + + var sourcePath = AnsiConsole.Ask("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 + { + ["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("Target schema name:").Trim(); + if (string.IsNullOrWhiteSpace(targetSchema)) return null; + + var tempPath = AnsiConsole.Ask("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 + { + ["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($"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("New company name:").Trim(); + if (string.IsNullOrWhiteSpace(newName)) return null; + + var confirmed = ShowSummaryAndConfirm("Rename DB Summary", new Dictionary + { + ["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("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 + { + ["Target path"] = targetPath, + ["Threads"] = threads.ToString(), + ["Compress"] = compress ? "Yes" : "No", + }); + + if (!confirmed) return null; + + return new BackupParams + { + TargetPath = targetPath, + Threads = threads, + Compress = compress, + }; + } +} diff --git a/hanatui/src/Tui/TaskRunnerScreen.cs b/hanatui/src/Tui/TaskRunnerScreen.cs new file mode 100644 index 0000000..56c2944 --- /dev/null +++ b/hanatui/src/Tui/TaskRunnerScreen.cs @@ -0,0 +1,295 @@ +using HanaTui.System; +using HanaTui.Tui.Components; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace HanaTui.Tui; + +/// +/// 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. +/// +public static class TaskRunnerScreen +{ + private const int CountdownSeconds = 10; + private const double AbortWindowSeconds = 3.0; + + public static async Task RunAsync( + string operationTitle, + Func, CancellationToken, Task> 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"; + } +}