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