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";
+ }
+}