Compare commits

...

15 Commits

Author SHA1 Message Date
tomi bcc6427eea fix stats formatting 3 on export option 2026-05-20 13:20:55 +02:00
tomi 3442f7f289 fix stats formatting 3 on export option 2026-05-20 12:58:59 +02:00
tomi 4ea3c01887 fix stats formatting 2 on export option 2026-05-20 12:55:30 +02:00
tomi a71ee8b81d fix stats formatting again on export option 2026-05-20 11:56:02 +02:00
tomi 47b57b5d3c fix query formatting on export option 2026-05-20 11:39:35 +02:00
tomi 6f285d6bd0 fix fourth core dump on export option 2026-05-20 11:34:37 +02:00
tomi 14d3ac2128 fix third core dump on export option 2026-05-20 11:31:35 +02:00
tomi 6f9d067e57 fix second core dump on export option 2026-05-20 11:29:16 +02:00
tomi 76064fa822 fix core dump on export option 2026-05-20 11:26:44 +02:00
tomi 4874bf096c fix formatting issue 2 with core dump 2026-05-20 11:22:55 +02:00
tomi 4cf0fdb6a0 upload binary 2026-05-20 11:20:28 +02:00
tomi 3e9d80fe98 fix color formatting core dump 2026-05-20 11:20:00 +02:00
tomi 27cf1b6314 add hanatui 2026-05-20 11:13:14 +02:00
tomi bb8ac5de08 add hanatui project 2026-05-20 10:29:35 +02:00
tomi 809c949a2c add gitignore 2026-05-20 10:26:26 +02:00
19 changed files with 2565 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
bin
obj
hanatui.dbg
+215
View File
@@ -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`:
- `<PublishAot>true</PublishAot>`
- `<AllowUnsafeBlocks>true</AllowUnsafeBlocks>`
- `<InvariantGlobalization>true</InvariantGlobalization>`
- `<StripSymbols>true</StripSymbols>`
- 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<T>, TextPrompt<T> — 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;
```
+145
View File
@@ -0,0 +1,145 @@
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));
}
+3
View File
@@ -0,0 +1,3 @@
#! /bin/bash
dotnet publish -r linux-x64 -c Release -o ./publish
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<StripSymbols>true</StripSymbols>
<RootNamespace>HanaTui</RootNamespace>
<AssemblyName>hanatui</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console" Version="0.49.1" />
</ItemGroup>
</Project>
BIN
View File
Binary file not shown.
+201
View File
@@ -0,0 +1,201 @@
using System.Diagnostics;
namespace HanaTui.Hana;
/// <summary>
/// Low-level wrapper for spawning hdbsql and hdbuserstore child processes.
/// All output is streamed line-by-line via callbacks for live log display.
/// </summary>
public sealed class HdbCliRunner
{
/// <summary>
/// Lists all hdbuserstore keys. Returns empty list on failure.
/// </summary>
public static List<HdbUserstoreKey> ListKeys()
{
var result = RunAndCapture(HdbClientLocator.HdbUserstore, ["list"]);
if (!result.Success)
return [];
return HdbUserstoreKey.ParseFrom(result.Output);
}
public static bool TestKey(string key)
{
var result = RunAndCapture(HdbClientLocator.HdbSql,
["-U", key, "SELECT 'ok' FROM DUMMY"]);
return result.Success && result.Output.Contains("ok");
}
public static (bool Success, List<string> 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<string>();
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, "");
}
/// <summary>
/// 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.
/// </summary>
public static async Task<int> RunSqlStreamingAsync(
string key,
string sql,
Action<string> onOutputLine,
CancellationToken ct)
{
// Use ArgumentList (not Arguments) so args are passed directly to the process
// without shell quoting — double quotes inside SQL won't break the argument boundary.
return await RunStreamingAsync(
HdbClientLocator.HdbSql,
["-U", key, sql],
onOutputLine,
ct);
}
/// <summary>
/// Runs an arbitrary shell command, streaming output lines to the callback.
/// Used for compression steps (tar/pigz).
/// </summary>
public static async Task<int> RunCommandStreamingAsync(
string executable,
string arguments,
Action<string> onOutputLine,
CancellationToken ct)
{
// Shell commands (tar, pigz) need shell parsing for things like -I "pigz -p N",
// so we use a bash -c wrapper to ensure correct tokenisation.
return await RunStreamingAsync(
"/bin/bash",
["-c", arguments],
onOutputLine,
ct);
}
// -------------------------------------------------------------------------
private static (bool Success, string Output) RunAndCapture(string exe, string[] args)
{
try
{
var psi = new ProcessStartInfo(exe)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
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();
return proc.ExitCode == 0
? (true, stdout)
: (false, string.IsNullOrWhiteSpace(stderr) ? stdout : stderr);
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
private static async Task<int> RunStreamingAsync(
string exe,
string[] args,
Action<string> onOutputLine,
CancellationToken ct)
{
var psi = new ProcessStartInfo(exe)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
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;
}
}
+79
View File
@@ -0,0 +1,79 @@
namespace HanaTui.Hana;
/// <summary>
/// Locates the SAP HANA client binaries (hdbsql, hdbuserstore) on the system.
/// Mirrors the path detection logic from keymanager.sh.
/// </summary>
public static class HdbClientLocator
{
private static readonly string[] KnownPaths =
[
"/usr/sap/hdbclient",
"/usr/sap/NDB/HDB00/exe",
];
private static string? _resolvedPath;
/// <summary>
/// Returns the full path to hdbsql, or just "hdbsql" if it's on PATH.
/// Throws if the client cannot be found anywhere.
/// </summary>
public static string HdbSql => Resolve("hdbsql");
/// <summary>
/// Returns the full path to hdbuserstore, or just "hdbuserstore" if it's on PATH.
/// </summary>
public static string HdbUserstore => Resolve("hdbuserstore");
/// <summary>
/// Returns the resolved client directory, or null if only found via PATH.
/// </summary>
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;
}
/// <summary>
/// Checks whether hdbsql is available either in a known path or on PATH.
/// </summary>
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;
}
}
+93
View File
@@ -0,0 +1,93 @@
namespace HanaTui.Hana;
/// <summary>
/// Represents a single parsed entry from hdbuserstore list output.
/// </summary>
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;
/// <summary>
/// Parse a list of keys from the raw stdout of "hdbuserstore list".
/// </summary>
public static List<HdbUserstoreKey> ParseFrom(string output)
{
var keys = new List<HdbUserstoreKey>();
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,
};
}
}
+485
View File
@@ -0,0 +1,485 @@
using SysDiag = System.Diagnostics;
namespace HanaTui.Hana;
/// <summary>
/// Parameters for an Export operation.
/// </summary>
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; }
}
/// <summary>
/// Parameters for an Import operation.
/// </summary>
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; }
}
/// <summary>
/// Parameters for a Copy operation.
/// </summary>
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; }
}
/// <summary>
/// Parameters for Drop Schema.
/// </summary>
public sealed class DropParams
{
public string Schema { get; init; } = "";
}
/// <summary>
/// Parameters for Rename Database (company name).
/// </summary>
public sealed class RenameDbParams
{
public string Schema { get; init; } = "";
public string NewCompanyName { get; init; } = "";
}
/// <summary>
/// Parameters for Backup Tenant.
/// </summary>
public sealed class BackupParams
{
public string TargetPath { get; init; } = "";
public int Threads { get; init; } = 1;
public bool Compress { get; init; }
}
/// <summary>
/// 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.
/// </summary>
public sealed class SchemaService(string userKey)
{
private readonly string _key = userKey;
// -------------------------------------------------------------------------
// Export
// -------------------------------------------------------------------------
public async Task<bool> ExportAsync(
ExportParams p,
Action<string> 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<bool> ImportAsync(
ImportParams p,
Action<string> 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<bool> CopyAsync(
CopyParams p,
Action<string> 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<bool> DropAsync(
DropParams p,
Action<string> 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<bool> RenameDbAsync(
RenameDbParams p,
Action<string> 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<bool> BackupAsync(
BackupParams p,
Action<string> 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<string> 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)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
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<bool> CompressAsync(
string sourceDir, string archiveFile, int threads, Action<string> log, CancellationToken ct)
{
log($"[INFO] Compressing to {archiveFile}...");
return await CompressDir(sourceDir, archiveFile, threads, log, ct);
}
private static async Task<bool> CompressDir(
string sourceDir, string archiveFile, int threads, Action<string> 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<bool> DecompressAsync(
string archiveFile, string targetDir, int threads, Action<string> 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;
}
}
+43
View File
@@ -0,0 +1,43 @@
namespace HanaTui.Hana;
/// <summary>
/// Builds the SQL / shell command strings for each operation.
/// All SQL is built here; nothing else constructs query strings.
/// </summary>
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("'", "''");
}
+75
View File
@@ -0,0 +1,75 @@
namespace HanaTui.System;
/// <summary>
/// A single snapshot of /proc/stat CPU counters for one CPU line.
/// Used to compute usage % by diffing two samples.
/// </summary>
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;
}
/// <summary>
/// Compute usage % between two consecutive samples (0.0 - 100.0).
/// </summary>
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);
}
/// <summary>
/// Parse all CPU lines from /proc/stat content.
/// Returns only lines that start with "cpu".
/// </summary>
public static List<CpuSample> ParseProcStat(string content)
{
var result = new List<CpuSample>();
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;
}
}
+150
View File
@@ -0,0 +1,150 @@
using HanaTui.System;
namespace HanaTui.System;
/// <summary>
/// A snapshot of all system metrics at a point in time.
/// </summary>
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";
}
/// <summary>
/// Polls /proc/stat and /proc/meminfo on a background timer.
/// Thread-safe snapshot available via CurrentSnapshot.
/// </summary>
public sealed class SystemStats : IDisposable
{
private const int RefreshMs = 800;
private List<CpuSample> _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);
}
/// <summary>
/// Returns the most recent system snapshot. Safe to call from any thread.
/// </summary>
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<CpuSample> 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();
}
}
+112
View File
@@ -0,0 +1,112 @@
using Spectre.Console;
using Spectre.Console.Rendering;
namespace HanaTui.Tui.Components;
public sealed class LogEntry
{
public DateTime Time { get; init; } = DateTime.Now;
public string Text { get; init; } = "";
public LogLevel Level { get; init; } = LogLevel.Info;
}
public enum LogLevel { Info, Sql, Done, Warn, Error }
/// <summary>
/// Thread-safe bounded log. Build() returns an IRenderable panel.
/// Dynamic content is rendered via Text() objects — never interpolated into markup strings.
/// </summary>
public sealed class LogPanel
{
private const int MaxEntries = 500;
private const int VisibleLines = 20;
private readonly List<LogEntry> _entries = new(MaxEntries);
private readonly object _lock = new();
public void Add(string text)
{
var level = DetectLevel(text);
lock (_lock)
{
if (_entries.Count >= MaxEntries) _entries.RemoveAt(0);
_entries.Add(new LogEntry { Time = DateTime.Now, Text = text, Level = level });
}
}
public IRenderable Build()
{
List<LogEntry> snapshot;
lock (_lock)
{
var start = Math.Max(0, _entries.Count - VisibleLines);
snapshot = _entries.GetRange(start, _entries.Count - start);
}
var rows = new Grid();
rows.AddColumn(new GridColumn().NoWrap());
// Pad so the panel height stays stable
for (int i = 0; i < VisibleLines - snapshot.Count; i++)
rows.AddRow(new Text(""));
foreach (var entry in snapshot)
{
var (tagLabel, tagColor, textColor) = entry.Level switch
{
LogLevel.Sql => ("SQL ", Color.Blue, Color.White),
LogLevel.Done => ("DONE", Color.Green, Color.White),
LogLevel.Warn => ("WARN", Color.Yellow, Color.White),
LogLevel.Error => ("ERR ", Color.Red, Color.White),
_ => ("INFO", Color.Grey, Color.Silver),
};
// Strip the [TAG] prefix the service already prepends — we re-render it with color
var bodyText = StripKnownPrefix(entry.Text);
// Compose the row from Text objects — zero markup parsing of dynamic content
var lineGrid = new Grid();
lineGrid.AddColumn(new GridColumn().NoWrap().Width(10)); // time
lineGrid.AddColumn(new GridColumn().NoWrap().Width(8)); // tag
lineGrid.AddColumn(new GridColumn().NoWrap()); // body
lineGrid.AddRow(
new Text(entry.Time.ToString("HH:mm:ss"), new Style(Color.Grey, decoration: Decoration.Dim)),
new Text($"[{tagLabel}]", new Style(tagColor)),
new Text(" " + bodyText, new Style(textColor))
);
rows.AddRow(lineGrid);
}
return new Panel(rows)
{
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 ]") || text.Contains("[ERR]"))
return LogLevel.Error;
return LogLevel.Info;
}
private static readonly string[] KnownPrefixes =
["[INFO] ", "[SQL ] ", "[SQL] ", "[DONE] ", "[WARN] ", "[ERROR] ", "[ERR ] ", "[ERR] "];
private static string StripKnownPrefix(string text)
{
foreach (var p in KnownPrefixes)
if (text.StartsWith(p, StringComparison.Ordinal))
return text[p.Length..];
return text;
}
}
+147
View File
@@ -0,0 +1,147 @@
using HanaTui.System;
using Spectre.Console;
using Spectre.Console.Rendering;
namespace HanaTui.Tui.Components;
/// <summary>
/// Renders the system stats panel (CPU bars, RAM bar, Swap bar, elapsed time).
/// Uses only hardcoded markup tags — no dynamic data ever enters a markup string.
/// </summary>
public static class StatsPanel
{
public static IRenderable Build(SystemSnapshot snap, TimeSpan elapsed, int panelWidth)
{
var barWidth = Math.Max(10, panelWidth - 16);
var grid = new Grid();
grid.AddColumn(new GridColumn().NoWrap());
// --- CPU ---
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];
// Build as Columns: label | bar | pct — no interpolation of dynamic values into markup
grid.AddRow(BuildBarRow(displayLabel, pct, barWidth));
}
}
grid.AddRow(new Text(""));
// --- Memory ---
grid.AddRow(new Markup("[bold cyan]MEMORY[/]"));
if (snap.MemTotalKb > 0)
{
var memPct = snap.MemUsedPercent;
grid.AddRow(BuildBarRow("Used", memPct, barWidth));
grid.AddRow(new Text($" {SystemSnapshot.FormatGb(snap.MemUsedKb)} / {SystemSnapshot.FormatGb(snap.MemTotalKb)}",
new Style(Color.Grey)));
}
else
{
grid.AddRow(new Markup("[grey]No data[/]"));
}
grid.AddRow(new Text(""));
// --- Swap ---
grid.AddRow(new Markup("[bold cyan]SWAP[/]"));
if (snap.SwapTotalKb > 0)
{
var swapPct = snap.SwapUsedPercent;
grid.AddRow(BuildBarRow("Used", swapPct, barWidth));
grid.AddRow(new Text($" {SystemSnapshot.FormatGb(snap.SwapUsedKb)} / {SystemSnapshot.FormatGb(snap.SwapTotalKb)}",
new Style(Color.Grey)));
}
else
{
grid.AddRow(new Markup("[grey]No swap[/]"));
}
grid.AddRow(new Text(""));
// --- Elapsed ---
// Two separate renderables composed — no interpolation of elapsed into markup
var elapsedGrid = new Grid();
elapsedGrid.AddColumn(new GridColumn().NoWrap());
elapsedGrid.AddColumn(new GridColumn().NoWrap());
elapsedGrid.AddRow(
new Markup("[bold]Elapsed:[/]"),
new Text(" " + FormatElapsed(elapsed), new Style(Color.Yellow)));
grid.AddRow(elapsedGrid);
return new Panel(grid)
{
Header = new PanelHeader("[bold] SYSTEM STATS [/]"),
Border = BoxBorder.Rounded,
Padding = new Padding(1, 0),
};
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/// <summary>
/// Builds a single bar row as a Grid with three columns:
/// label (dim) | bar (block chars, plain Text with color) | pct (bold)
/// Nothing here goes through markup parsing with dynamic content.
/// </summary>
private static IRenderable BuildBarRow(string label, double pct, int barWidth)
{
var filled = (int)Math.Round(pct / 100.0 * barWidth);
filled = Math.Clamp(filled, 0, barWidth);
var empty = barWidth - filled;
var filledColor = CpuColor(pct);
var filledStr = new string('\u2588', filled);
var emptyStr = new string('\u2591', empty);
var row = new Grid();
// FIX: Add all three columns back!
row.AddColumn(new GridColumn().NoWrap().Width(10)); // Column 1: Label
row.AddColumn(new GridColumn().NoWrap()); // Column 2: Bar
row.AddColumn(new GridColumn().NoWrap().Width(8)); // Column 3: Pct
var colorMarkup = filledColor.ToMarkup();
// Construct a seamless markup string
var barMarkup = $"[grey][[[/][{colorMarkup}]{filledStr}[/][grey dim]{emptyStr}[/][grey]]][/]";
row.AddRow(
new Text($" {label,-5}", new Style(Color.Grey, decoration: Decoration.Dim)),
new Markup(barMarkup),
new Text($" {pct,5:F1}%", new Style(Color.White, decoration: Decoration.Bold))
);
return row;
}
private static Color CpuColor(double pct) => pct switch
{
> 90 => Color.Red,
> 70 => Color.Yellow,
> 40 => Color.Green,
_ => Color.Blue,
};
private static string FormatElapsed(TimeSpan t)
{
if (t.TotalHours >= 1)
return $"{(int)t.TotalHours}h {t.Minutes:D2}m {t.Seconds:D2}s";
if (t.TotalMinutes >= 1)
return $"{t.Minutes}m {t.Seconds:D2}s";
return $"{t.Seconds}s";
}
}
+115
View File
@@ -0,0 +1,115 @@
using HanaTui.Hana;
using Spectre.Console;
using SysText = System.Text;
namespace HanaTui.Tui;
/// <summary>
/// Startup screen: lists hdbuserstore keys and lets the user pick one.
/// Returns the selected key name, or null if the user chose to exit.
/// </summary>
public static class KeySelectionScreen
{
private sealed class KeyChoice
{
public string Display { get; init; } = "";
public string? KeyName { get; init; } // null = exit, "" = manual entry
public static readonly KeyChoice Manual = new() { Display = "[ Enter key name manually ]", KeyName = "" };
public static readonly KeyChoice Exit = new() { Display = "[ Exit ]", KeyName = null };
public override string ToString() => Display;
}
public static string? Run()
{
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;
}
var clientDir = HdbClientLocator.ClientDirectory;
if (clientDir is not null)
AnsiConsole.MarkupLine($"[dim]HDB client: {Markup.Escape(clientDir)}[/]\n");
List<HdbUserstoreKey> keys = [];
AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("blue"))
.Start("[blue]Loading hdbuserstore keys...[/]", _ =>
{
keys = HdbCliRunner.ListKeys();
});
if (keys.Count == 0)
{
AnsiConsole.MarkupLine("[yellow][[WARN]][/] No hdbuserstore keys found.");
AnsiConsole.MarkupLine("You can still enter a key name manually.\n");
}
// Build typed choices. Display is plain text — never parsed as markup.
var choices = keys
.Select(k => new KeyChoice { Display = BuildKeyDisplay(k), KeyName = k.Name })
.ToList();
choices.Add(KeyChoice.Manual);
choices.Add(KeyChoice.Exit);
var prompt = new SelectionPrompt<KeyChoice>()
.Title("[bold]Select HDBUSERSTORE key:[/]")
.PageSize(15)
.HighlightStyle(Style.Parse("bold dodgerblue1"))
.UseConverter(c => Markup.Escape(c.Display))
.AddChoices(choices);
var selected = AnsiConsole.Prompt(prompt);
if (selected.KeyName is null)
return null; // Exit
if (selected.KeyName == "")
{
var manual = AnsiConsole.Ask<string>("[bold]Enter HDBUSERSTORE key name:[/]").Trim();
return string.IsNullOrWhiteSpace(manual) ? null : manual;
}
return selected.KeyName;
}
private static string BuildKeyDisplay(HdbUserstoreKey k)
{
var sb = new SysText.StringBuilder(k.Name);
if (!string.IsNullOrEmpty(k.Host))
{
sb.Append(" ");
sb.Append(k.Host);
if (!string.IsNullOrEmpty(k.Port))
{
sb.Append(':');
sb.Append(k.Port);
}
if (!string.IsNullOrEmpty(k.Tenant))
{
sb.Append('@');
sb.Append(k.Tenant);
}
}
if (!string.IsNullOrEmpty(k.User))
{
sb.Append(" user=");
sb.Append(k.User);
}
return sb.ToString();
}
}
+77
View File
@@ -0,0 +1,77 @@
using HanaTui.Hana;
using Spectre.Console;
namespace HanaTui.Tui;
/// <summary>
/// The main operation menu. Returns the selected operation, or null to exit.
/// </summary>
public static class MainMenuScreen
{
public enum Operation
{
Export,
Import,
ImportRename,
Copy,
Drop,
RenameDb,
Backup,
ChangeKey,
Quit,
}
public static Operation Run(HdbUserstoreKey? key, string keyName)
{
AnsiConsole.Clear();
// Header with key info
var rule = new Rule("[bold dodgerblue1]HANA Database Manager[/]")
.RuleStyle(Style.Parse("dodgerblue1"));
AnsiConsole.Write(rule);
if (key is not null)
{
var conn = key.Host + ":" + key.Port +
(string.IsNullOrEmpty(key.Tenant) ? "" : "@" + key.Tenant) +
" user=" + key.User;
AnsiConsole.MarkupLine(
$" Key: [bold yellow]{Markup.Escape(key.Name)}[/] [dim]{Markup.Escape(conn)}[/]");
}
else
{
AnsiConsole.MarkupLine($" Key: [bold yellow]{Markup.Escape(keyName)}[/]");
}
AnsiConsole.WriteLine();
var choices = new Dictionary<string, Operation>
{
["1 Export Schema"] = Operation.Export,
["2 Import Schema"] = Operation.Import,
["3 Import & Rename Schema"] = Operation.ImportRename,
["4 Copy Schema"] = Operation.Copy,
["5 Drop Schema"] = Operation.Drop,
["6 Rename Database (Company Name)"] = Operation.RenameDb,
["7 Backup Tenant"] = Operation.Backup,
["----------------------------------"] = Operation.Quit, // separator placeholder
["k Change Key"] = Operation.ChangeKey,
["q Quit"] = Operation.Quit,
};
// Build SelectionPrompt without the separator entry
var prompt = new SelectionPrompt<string>()
.Title("[bold]Select operation:[/]")
.PageSize(12)
.HighlightStyle(Style.Parse("bold dodgerblue1"));
foreach (var key2 in choices.Keys)
{
if (key2.StartsWith("--")) continue;
prompt.AddChoice(key2);
}
var selected = AnsiConsole.Prompt(prompt);
return choices.TryGetValue(selected, out var op) ? op : Operation.Quit;
}
}
+335
View File
@@ -0,0 +1,335 @@
using HanaTui.Hana;
using Spectre.Console;
namespace HanaTui.Tui;
/// <summary>
/// Guided input forms for each operation.
/// Each method collects all needed parameters, then returns a typed params object.
/// Returns null if the user cancelled.
/// </summary>
public static class OperationForms
{
// -------------------------------------------------------------------------
// Shared helpers
// -------------------------------------------------------------------------
/// <summary>
/// A choice item that keeps the real value separate from the display label.
/// The display label is pre-escaped plain text — never interpreted as markup.
/// </summary>
private sealed class SchemaChoice
{
public string Display { get; init; } = "";
public string? Value { get; init; } // null = cancel, "" = manual entry
public static readonly SchemaChoice Manual = new() { Display = "[ Enter manually ]", Value = "" };
public static readonly SchemaChoice Cancel = new() { Display = "[ Cancel ]", Value = null };
public override string ToString() => Display;
}
/// <summary>
/// Fetches the schema list with a spinner, then shows an arrow-key picker.
/// Returns the selected schema, or null if cancelled.
/// </summary>
private static string? PickSchema(string userKey, string title)
{
List<string> schemas = [];
string? error = null;
AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("blue"))
.Start("[blue]Fetching schemas...[/]", _ =>
{
var (success, list, err) = HdbCliRunner.ListSchemas(userKey);
if (success)
schemas = list;
else
error = err;
});
if (error is not null)
AnsiConsole.MarkupLine($"[yellow][[WARN]] Could not fetch schemas: {Markup.Escape(error)}[/]");
// Build typed choices. Display is plain text; ToString() is what SelectionPrompt shows.
var choices = schemas
.Select(s => new SchemaChoice { Display = s, Value = s })
.ToList();
choices.Add(SchemaChoice.Manual);
choices.Add(SchemaChoice.Cancel);
// UseConverter returns Display which is already plain text.
// We also set the prompt to NOT interpret converter output as markup by
// escaping it — belt-and-suspenders.
var prompt = new SelectionPrompt<SchemaChoice>()
.Title($"[bold]{Markup.Escape(title)}[/]")
.PageSize(15)
.HighlightStyle(Style.Parse("bold dodgerblue1"))
.UseConverter(c => Markup.Escape(c.Display))
.AddChoices(choices);
var selected = AnsiConsole.Prompt(prompt);
if (selected.Value is null)
return null; // Cancel
if (selected.Value == "")
{
var manual = AnsiConsole.Ask<string>("Enter schema name:").Trim();
return string.IsNullOrWhiteSpace(manual) ? null : manual;
}
return selected.Value;
}
private static int PickThreads(string label = "Number of threads")
{
var maxThreads = Environment.ProcessorCount;
var defaultThreads = Math.Max(1, maxThreads / 2);
return AnsiConsole.Ask($"{label} [dim](default={defaultThreads}, max={maxThreads})[/]:",
defaultThreads);
}
private static bool ConfirmYesNo(string question, bool defaultYes = false)
{
var defaultLabel = defaultYes ? "Y/n" : "y/N";
var answer = AnsiConsole.Ask<string>($"{question} [dim]({defaultLabel})[/]:", defaultYes ? "y" : "n");
return answer.Trim().Equals("y", StringComparison.OrdinalIgnoreCase) ||
answer.Trim().Equals("yes", StringComparison.OrdinalIgnoreCase);
}
private static void PrintOperationHeader(string title)
{
AnsiConsole.Clear();
var rule = new Rule($"[bold dodgerblue1]{Markup.Escape(title)}[/]").RuleStyle(Style.Parse("dodgerblue1"));
AnsiConsole.Write(rule);
AnsiConsole.WriteLine();
}
private static bool ShowSummaryAndConfirm(string title, Dictionary<string, string> fields)
{
AnsiConsole.WriteLine();
var table = new Table().BorderColor(Color.DodgerBlue1).Border(TableBorder.Rounded);
table.AddColumn("[bold]Parameter[/]");
table.AddColumn("[bold]Value[/]");
foreach (var (k, v) in fields)
table.AddRow($"[dim]{Markup.Escape(k)}[/]", $"[yellow]{Markup.Escape(v)}[/]");
AnsiConsole.MarkupLine($"[bold]{Markup.Escape(title)}[/]");
AnsiConsole.Write(table);
AnsiConsole.WriteLine();
return ConfirmYesNo("Proceed with this operation?", defaultYes: true);
}
// -------------------------------------------------------------------------
// Export
// -------------------------------------------------------------------------
public static ExportParams? ExportForm(string userKey)
{
PrintOperationHeader("Export Schema");
var schema = PickSchema(userKey, "Select schema to export:");
if (schema is null) return null;
var targetPath = AnsiConsole.Ask<string>("Target directory or file path [dim](.tar.gz for archive)[/]:").Trim();
if (string.IsNullOrWhiteSpace(targetPath)) return null;
var threads = PickThreads();
var compress = ConfirmYesNo("Compress output as .tar.gz?");
var confirmed = ShowSummaryAndConfirm("Export Summary", new Dictionary<string, string>
{
["Schema"] = schema,
["Target path"] = targetPath,
["Threads"] = threads.ToString(),
["Compress"] = compress ? "Yes" : "No",
});
if (!confirmed) return null;
return new ExportParams
{
Schema = schema,
TargetPath = targetPath,
Threads = threads,
Compress = compress,
};
}
// -------------------------------------------------------------------------
// Import
// -------------------------------------------------------------------------
public static ImportParams? ImportForm(string userKey, bool renameMode)
{
var title = renameMode ? "Import & Rename Schema" : "Import Schema";
PrintOperationHeader(title);
var sourceSchema = AnsiConsole.Ask<string>("Source schema name [dim](as it was exported)[/]:").Trim();
if (string.IsNullOrWhiteSpace(sourceSchema)) return null;
string? newSchemaName = null;
if (renameMode)
{
newSchemaName = AnsiConsole.Ask<string>("New target schema name:").Trim();
if (string.IsNullOrWhiteSpace(newSchemaName)) return null;
}
var sourcePath = AnsiConsole.Ask<string>("Source path [dim](directory or .tar.gz)[/]:").Trim();
if (string.IsNullOrWhiteSpace(sourcePath)) return null;
var threads = PickThreads();
var replace = ConfirmYesNo("Replace existing objects?");
var summary = new Dictionary<string, string>
{
["Source schema"] = sourceSchema,
["Source path"] = sourcePath,
["Threads"] = threads.ToString(),
["Replace"] = replace ? "Yes" : "No (IGNORE EXISTING)",
};
if (renameMode && newSchemaName is not null)
summary["New schema name"] = newSchemaName;
var confirmed = ShowSummaryAndConfirm($"{title} Summary", summary);
if (!confirmed) return null;
return new ImportParams
{
SourceSchema = sourceSchema,
NewSchemaName = newSchemaName,
SourcePath = sourcePath,
Threads = threads,
Replace = replace,
};
}
// -------------------------------------------------------------------------
// Copy
// -------------------------------------------------------------------------
public static CopyParams? CopyForm(string userKey)
{
PrintOperationHeader("Copy Schema");
var sourceSchema = PickSchema(userKey, "Select source schema:");
if (sourceSchema is null) return null;
var targetSchema = AnsiConsole.Ask<string>("Target schema name:").Trim();
if (string.IsNullOrWhiteSpace(targetSchema)) return null;
var tempPath = AnsiConsole.Ask<string>("Temporary export directory path:").Trim();
if (string.IsNullOrWhiteSpace(tempPath)) return null;
var threads = PickThreads();
var replace = ConfirmYesNo("Replace existing objects in target schema?");
var confirmed = ShowSummaryAndConfirm("Copy Schema Summary", new Dictionary<string, string>
{
["Source schema"] = sourceSchema,
["Target schema"] = targetSchema,
["Temp path"] = tempPath,
["Threads"] = threads.ToString(),
["Replace"] = replace ? "Yes" : "No",
});
if (!confirmed) return null;
return new CopyParams
{
SourceSchema = sourceSchema,
TargetSchema = targetSchema,
TempPath = tempPath,
Threads = threads,
Replace = replace,
};
}
// -------------------------------------------------------------------------
// Drop
// -------------------------------------------------------------------------
public static DropParams? DropForm(string userKey)
{
PrintOperationHeader("Drop Schema");
var schema = PickSchema(userKey, "Select schema to drop:");
if (schema is null) return null;
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[bold red]WARNING:[/] You are about to [bold red]permanently drop[/] schema [bold yellow]{Markup.Escape(schema)}[/].");
AnsiConsole.MarkupLine("[red]This cannot be undone.[/]\n");
var confirm = AnsiConsole.Ask<string>($"Type [bold red]YES[/] to confirm dropping [yellow]{Markup.Escape(schema)}[/]:").Trim();
if (confirm != "YES")
{
AnsiConsole.MarkupLine("[yellow]Operation cancelled.[/]");
AnsiConsole.WriteLine();
AnsiConsole.MarkupLine("[dim]Press any key to return...[/]");
Console.ReadKey(intercept: true);
return null;
}
return new DropParams { Schema = schema };
}
// -------------------------------------------------------------------------
// Rename DB
// -------------------------------------------------------------------------
public static RenameDbParams? RenameDbForm(string userKey)
{
PrintOperationHeader("Rename Database (Company Name)");
var schema = PickSchema(userKey, "Select schema:");
if (schema is null) return null;
var newName = AnsiConsole.Ask<string>("New company name:").Trim();
if (string.IsNullOrWhiteSpace(newName)) return null;
var confirmed = ShowSummaryAndConfirm("Rename DB Summary", new Dictionary<string, string>
{
["Schema"] = schema,
["New company name"] = newName,
["Tables updated"] = "CINF, OADM",
});
if (!confirmed) return null;
return new RenameDbParams { Schema = schema, NewCompanyName = newName };
}
// -------------------------------------------------------------------------
// Backup
// -------------------------------------------------------------------------
public static BackupParams? BackupForm(string userKey)
{
PrintOperationHeader("Backup Tenant");
var targetPath = AnsiConsole.Ask<string>("Target directory path:").Trim();
if (string.IsNullOrWhiteSpace(targetPath)) return null;
var threads = PickThreads("Number of compression threads");
var compress = ConfirmYesNo("Compress backup as .tar.gz?");
var confirmed = ShowSummaryAndConfirm("Backup Summary", new Dictionary<string, string>
{
["Target path"] = targetPath,
["Threads"] = threads.ToString(),
["Compress"] = compress ? "Yes" : "No",
});
if (!confirmed) return null;
return new BackupParams
{
TargetPath = targetPath,
Threads = threads,
Compress = compress,
};
}
}
+267
View File
@@ -0,0 +1,267 @@
using HanaTui.System;
using HanaTui.Tui.Components;
using Spectre.Console;
using Spectre.Console.Rendering;
namespace HanaTui.Tui;
/// <summary>
/// Runs an operation while showing a live split-panel:
/// Left: System stats (CPU, RAM, Swap, elapsed)
/// Right: Streaming operation log
///
/// Abort: first Q warns, second Q within 3s sends SIGTERM then SIGKILL.
/// Post-completion: 10s countdown, any key cancels and holds; Enter/Esc returns.
/// </summary>
public static class TaskRunnerScreen
{
private const int CountdownSeconds = 10;
private const double AbortWindowSeconds = 3.0;
public static async Task RunAsync(
string operationTitle,
Func<Action<string>, CancellationToken, Task<bool>> operation)
{
AnsiConsole.Clear();
var logPanel = new LogPanel();
var stats = new SystemStats();
var cts = new CancellationTokenSource();
var startTime = DateTime.Now;
var operationDone = false;
var operationSuccess = false;
DateTime? firstQTime = null;
// Key listener
var keyTask = Task.Run(async () =>
{
while (!operationDone)
{
if (Console.KeyAvailable)
{
var key = Console.ReadKey(intercept: true);
if (key.Key == ConsoleKey.Q || key.KeyChar is 'q' or 'Q')
{
if (cts.IsCancellationRequested) break;
if (firstQTime is null)
{
firstQTime = DateTime.Now;
logPanel.Add("[WARN] Press Q again within 3 seconds to abort.");
}
else if ((DateTime.Now - firstQTime.Value).TotalSeconds <= AbortWindowSeconds)
{
logPanel.Add("[WARN] Aborting operation...");
await cts.CancelAsync();
}
else
{
firstQTime = DateTime.Now;
logPanel.Add("[WARN] Press Q again within 3 seconds to abort.");
}
}
}
await Task.Delay(50, CancellationToken.None);
}
}, CancellationToken.None);
// 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(operationTitle, logPanel, stats, startTime, RunState.Running))
.AutoClear(false)
.StartAsync(async ctx =>
{
while (!operationDone)
{
ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, RunState.Running));
await Task.Delay(800, CancellationToken.None);
}
var elapsed = DateTime.Now - startTime;
RunState finalState;
if (cts.IsCancellationRequested)
{
finalState = RunState.Aborted;
logPanel.Add($"[WARN] Operation aborted after {FormatElapsed(elapsed)}.");
}
else if (operationSuccess)
{
finalState = RunState.Success;
logPanel.Add($"[DONE] Operation completed in {FormatElapsed(elapsed)}.");
}
else
{
finalState = RunState.Failed;
logPanel.Add($"[ERR ] Operation failed after {FormatElapsed(elapsed)}.");
}
ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, finalState));
});
stats.Dispose();
await operationTask;
operationDone = true;
await keyTask;
await PostCompletionWaitAsync(operationTitle, logPanel, stats, startTime,
cts.IsCancellationRequested ? RunState.Aborted :
operationSuccess ? RunState.Success : RunState.Failed);
}
// -----------------------------------------------------------------------
private enum RunState { Running, Success, Failed, Aborted }
private static async Task PostCompletionWaitAsync(
string title, LogPanel logPanel, SystemStats stats, DateTime startTime, RunState state)
{
var countdownCancelled = false;
var returnNow = false;
var staying = false;
var startCountdown = DateTime.Now;
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(title, logPanel, stats, startTime, state))
.AutoClear(false)
.StartAsync(async ctx =>
{
while (!returnNow)
{
if (Console.KeyAvailable)
{
var key = Console.ReadKey(intercept: true);
countdownCancelled = true;
if (key.Key is ConsoleKey.Enter or ConsoleKey.Escape)
{
returnNow = true;
break;
}
if (!staying)
{
staying = true;
logPanel.Add("[INFO] Countdown cancelled. Press Enter or Esc to return to menu.");
}
}
string footerText;
if (!staying)
{
var secsLeft = Math.Max(0, CountdownSeconds -
(int)(DateTime.Now - startCountdown).TotalSeconds);
footerText = $"Returning to menu in {secsLeft}s... (any key to stay)";
}
else
{
footerText = "Press Enter or Esc to return to menu.";
}
ctx.UpdateTarget(BuildLayout(title, logPanel, stats, startTime, state, footerText));
await Task.Delay(200, CancellationToken.None);
}
});
await countdownTask;
}
// -----------------------------------------------------------------------
// Layout
// -----------------------------------------------------------------------
private static IRenderable BuildLayout(
string title,
LogPanel logPanel,
SystemStats stats,
DateTime startTime,
RunState state,
string? footerText = null)
{
var elapsed = DateTime.Now - startTime;
var snap = stats.CurrentSnapshot;
const int statsPanelWidth = 40;
var statsRenderable = StatsPanel.Build(snap, elapsed, statsPanelWidth - 4);
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);
var outerGrid = new Grid();
outerGrid.AddColumn(new GridColumn());
// Title row — state label uses Text objects, not markup interpolation
var titleGrid = new Grid();
titleGrid.AddColumn(new GridColumn().NoWrap());
titleGrid.AddColumn(new GridColumn().NoWrap());
titleGrid.AddColumn(new GridColumn().NoWrap());
titleGrid.AddColumn(new GridColumn().NoWrap());
titleGrid.AddRow(
new Markup("[bold dodgerblue1] Operation:[/]"),
new Text(" " + title, new Style(Color.Yellow)),
new Markup(" [bold dodgerblue1]Status:[/]"),
StateLabel(state)
);
outerGrid.AddRow(titleGrid);
outerGrid.AddRow(layout);
// Footer row — plain Text, no markup parsing of dynamic content
var footerGrid = new Grid();
footerGrid.AddColumn(new GridColumn().NoWrap());
footerGrid.AddColumn(new GridColumn().NoWrap());
var footerLeft = footerText is not null
? new Text(" " + footerText, new Style(Color.Grey, decoration: Decoration.Dim))
: (IRenderable)new Text(" Press Q to abort (twice within 3s)", new Style(Color.Grey, decoration: Decoration.Dim));
var footerRight = new Text(" [Q] abort", new Style(Color.Grey, decoration: Decoration.Dim));
footerGrid.AddRow(footerLeft, footerRight);
outerGrid.AddRow(footerGrid);
return outerGrid;
}
private static IRenderable StateLabel(RunState state) => state switch
{
RunState.Running => new Markup(" [yellow]Running...[/]"),
RunState.Success => new Markup(" [green]Completed[/]"),
RunState.Failed => new Markup(" [red]Failed[/]"),
RunState.Aborted => new Markup(" [red]Aborted[/]"),
_ => new Text(""),
};
private static string FormatElapsed(TimeSpan t)
{
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";
}
}