Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
1456 lines
43 KiB
JavaScript
1456 lines
43 KiB
JavaScript
import process from 'node:process';
|
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Box, Text, render, useApp, useInput, useStdout } from 'ink';
|
|
import { selectDiscoveredSite } from './environment.mjs';
|
|
import { formatStatusReport, formatStatusSummary } from './format.mjs';
|
|
import { runRepairRelease, runReset, runSupportBundle } from './lifecycle.mjs';
|
|
import { collectStatus } from './status.mjs';
|
|
import { listAppliancePods, readPodLogsSince, readPodLogsTail } from './workloads.mjs';
|
|
|
|
const ACTION_GROUPS = [
|
|
{
|
|
title: 'Operations',
|
|
actions: ['Status', 'Workloads'],
|
|
},
|
|
{
|
|
title: 'System',
|
|
actions: ['Repair Release', 'Support Bundle', 'Reset'],
|
|
},
|
|
];
|
|
const ACTIONS = ACTION_GROUPS.flatMap((group) => group.actions);
|
|
|
|
const YES_NO_OPTIONS = ['yes', 'no'];
|
|
const BRAND_PRIMARY = 'magentaBright';
|
|
const BRAND_SECONDARY = 'cyanBright';
|
|
const TEXT_MUTED = 'white';
|
|
const COLOR_OK = 'greenBright';
|
|
const COLOR_WARN = 'yellowBright';
|
|
const COLOR_ERROR = 'redBright';
|
|
const WORKLOAD_REFRESH_MS = 5000;
|
|
const LOG_POLL_MS = 1500;
|
|
const LOG_VIEW_HEIGHT = 16;
|
|
const LOG_CHUNK_LINES = 120;
|
|
const LOG_MAX_LINES = 400;
|
|
|
|
function clampIndex(value, length) {
|
|
if (length <= 0) {
|
|
return 0;
|
|
}
|
|
if (value < 0) {
|
|
return length - 1;
|
|
}
|
|
if (value >= length) {
|
|
return 0;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function cycleOption(options, current, delta) {
|
|
if (!options.length) {
|
|
return current;
|
|
}
|
|
const index = options.indexOf(current);
|
|
const start = index >= 0 ? index : 0;
|
|
const next = (start + delta + options.length) % options.length;
|
|
return options[next];
|
|
}
|
|
|
|
function lineColor(line = '') {
|
|
const normalized = String(line).toLowerCase();
|
|
|
|
if (
|
|
normalized.includes('error') ||
|
|
normalized.includes('failed') ||
|
|
normalized.includes('unhealthy') ||
|
|
normalized.includes('blocked') ||
|
|
normalized.includes('timed out') ||
|
|
normalized.includes('crashloop') ||
|
|
normalized.includes('imagepullbackoff') ||
|
|
normalized.includes('not ready')
|
|
) {
|
|
return COLOR_ERROR;
|
|
}
|
|
|
|
if (
|
|
normalized.includes('warning') ||
|
|
normalized.includes('pending') ||
|
|
normalized.includes('reconciling') ||
|
|
normalized.includes('installing') ||
|
|
normalized.includes('unknown') ||
|
|
normalized.includes('unavailable')
|
|
) {
|
|
return COLOR_WARN;
|
|
}
|
|
|
|
if (
|
|
normalized.includes('healthy') ||
|
|
normalized.includes('ready') ||
|
|
normalized.includes('api reachable: true') ||
|
|
normalized.includes('no blocker detected') ||
|
|
normalized.includes('completed successfully') ||
|
|
normalized.includes('done:')
|
|
) {
|
|
return COLOR_OK;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function splitLabelValue(line = '') {
|
|
const text = String(line);
|
|
const separatorIndex = text.indexOf(': ');
|
|
if (separatorIndex <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
label: text.slice(0, separatorIndex + 1),
|
|
value: text.slice(separatorIndex + 2),
|
|
};
|
|
}
|
|
|
|
function truncateText(value, width) {
|
|
const text = String(value ?? '');
|
|
if (text.length <= width) {
|
|
return text.padEnd(width);
|
|
}
|
|
if (width <= 1) {
|
|
return text.slice(0, width);
|
|
}
|
|
return `${text.slice(0, width - 1)}…`;
|
|
}
|
|
|
|
function alignRight(value, width) {
|
|
return String(value ?? '').padStart(width);
|
|
}
|
|
|
|
const WORKLOAD_COLUMNS = {
|
|
pod: 31,
|
|
namespace: 12,
|
|
status: 18,
|
|
ready: 7,
|
|
restarts: 8,
|
|
age: 5,
|
|
};
|
|
|
|
function renderLines(lines, keyPrefix, options = {}) {
|
|
const { fallbackColor, bold = false } = options;
|
|
return lines.map((line, index) =>
|
|
{
|
|
const text = line || ' ';
|
|
const parts = splitLabelValue(text);
|
|
const valueColor = lineColor(text) || fallbackColor;
|
|
|
|
if (!parts) {
|
|
return React.createElement(
|
|
Text,
|
|
{
|
|
key: `${keyPrefix}-${index}`,
|
|
color: valueColor,
|
|
bold: bold && index === 0,
|
|
},
|
|
text,
|
|
);
|
|
}
|
|
|
|
return React.createElement(
|
|
Text,
|
|
{
|
|
key: `${keyPrefix}-${index}`,
|
|
bold: bold && index === 0,
|
|
},
|
|
React.createElement(Text, { color: BRAND_SECONDARY }, `${parts.label} `),
|
|
React.createElement(Text, { color: valueColor }, parts.value || ''),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
function mapProgressEvent(event) {
|
|
if (event.type === 'phase') {
|
|
return `[${event.phase}] ${event.line || ''}`.trim();
|
|
}
|
|
if (event.type === 'error') {
|
|
return `ERROR: ${event.line}`;
|
|
}
|
|
if (event.type === 'done') {
|
|
return `DONE: ${event.line}`;
|
|
}
|
|
return event.line || '';
|
|
}
|
|
|
|
function makeResetDefaults(env) {
|
|
return {
|
|
challenge: '',
|
|
expected: `WIPE ${env.site?.siteId || env.suggestedSiteId || 'appliance-single-node'}`,
|
|
};
|
|
}
|
|
|
|
function makeRepairReleaseDefaults() {
|
|
return {
|
|
releaseName: 'alga-core',
|
|
cleanupWorkloads: 'yes',
|
|
};
|
|
}
|
|
|
|
function makeSupportBundleDefaults() {
|
|
return {
|
|
outputDir: process.cwd(),
|
|
};
|
|
}
|
|
|
|
function summarizeStatus(status) {
|
|
if (!status) {
|
|
return ['Status unavailable'];
|
|
}
|
|
return formatStatusSummary(status);
|
|
}
|
|
|
|
function shortStatusReport(status) {
|
|
if (!status) {
|
|
return ['Unable to collect status'];
|
|
}
|
|
const report = formatStatusReport(status);
|
|
return [
|
|
...report.summary,
|
|
'',
|
|
'Talos:',
|
|
...report.host,
|
|
'',
|
|
'Kubernetes:',
|
|
...report.cluster,
|
|
'',
|
|
'Flux:',
|
|
...report.flux,
|
|
'',
|
|
'Workloads:',
|
|
...report.workloads,
|
|
];
|
|
}
|
|
|
|
function trimLogLines(lines, maxLines = LOG_MAX_LINES) {
|
|
if (lines.length <= maxLines) {
|
|
return lines;
|
|
}
|
|
return lines.slice(lines.length - maxLines);
|
|
}
|
|
|
|
function applyLogTail(nextLines, previous = []) {
|
|
const merged = [];
|
|
for (const line of nextLines) {
|
|
merged.push({
|
|
id: `${line.timestamp || 'no-ts'}|${line.text}`,
|
|
timestamp: line.timestamp,
|
|
text: line.text,
|
|
});
|
|
}
|
|
|
|
if (!merged.length) {
|
|
return trimLogLines(previous);
|
|
}
|
|
|
|
return trimLogLines(merged);
|
|
}
|
|
|
|
function appendLiveLogLines(previous, incoming) {
|
|
if (!incoming.length) {
|
|
return previous;
|
|
}
|
|
|
|
const knownIds = new Set(previous.map((line) => line.id));
|
|
const next = [...previous];
|
|
for (const line of incoming) {
|
|
const row = {
|
|
id: `${line.timestamp || 'no-ts'}|${line.text}`,
|
|
timestamp: line.timestamp,
|
|
text: line.text,
|
|
};
|
|
if (!knownIds.has(row.id)) {
|
|
knownIds.add(row.id);
|
|
next.push(row);
|
|
}
|
|
}
|
|
return trimLogLines(next);
|
|
}
|
|
|
|
function prependOlderLines(previous, expandedTail) {
|
|
if (!expandedTail.length) {
|
|
return previous;
|
|
}
|
|
const normalized = expandedTail.map((line) => ({
|
|
id: `${line.timestamp || 'no-ts'}|${line.text}`,
|
|
timestamp: line.timestamp,
|
|
text: line.text,
|
|
}));
|
|
const existing = new Set(previous.map((line) => line.id));
|
|
const older = normalized.filter((line) => !existing.has(line.id));
|
|
if (!older.length) {
|
|
return previous;
|
|
}
|
|
return trimLogLines([...older, ...previous]);
|
|
}
|
|
|
|
function FieldList({ fields, values, selectedIndex }) {
|
|
return React.createElement(
|
|
Box,
|
|
{ flexDirection: 'column' },
|
|
...fields.map((field, index) => {
|
|
const selected = index === selectedIndex;
|
|
const pointer = selected ? '>' : ' ';
|
|
const rawValue = values[field.key] ?? '';
|
|
const suffix = field.type === 'secret' ? String(rawValue).replace(/./g, '*') : rawValue;
|
|
return React.createElement(
|
|
Text,
|
|
{ key: field.key, bold: selected },
|
|
React.createElement(Text, { color: selected ? BRAND_SECONDARY : BRAND_PRIMARY }, `${pointer} ${field.label}: `),
|
|
React.createElement(Text, { color: selected ? undefined : TEXT_MUTED }, suffix),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
function Header({ env, status }) {
|
|
const lines = summarizeStatus(status);
|
|
return React.createElement(
|
|
Box,
|
|
{ borderStyle: 'round', borderColor: BRAND_PRIMARY, paddingX: 1, flexDirection: 'column' },
|
|
React.createElement(Text, { bold: true, color: BRAND_PRIMARY }, 'Alga PSA Operator'),
|
|
React.createElement(
|
|
Text,
|
|
{ color: BRAND_SECONDARY },
|
|
`Site: ${env.site?.siteId || 'unselected'} Node IP: ${env.nodeIp || 'unknown'}`,
|
|
),
|
|
React.createElement(
|
|
Text,
|
|
{ color: TEXT_MUTED },
|
|
`Release: ${status?.release?.selectedReleaseVersion || 'unknown'}`,
|
|
),
|
|
...renderLines(lines.slice(0, 2), 'header-summary', { fallbackColor: TEXT_MUTED }),
|
|
);
|
|
}
|
|
|
|
function ActionNav({ selectedIndex, compactLayout }) {
|
|
return React.createElement(
|
|
Box,
|
|
{
|
|
borderStyle: 'round',
|
|
borderColor: BRAND_SECONDARY,
|
|
paddingX: 1,
|
|
flexDirection: 'column',
|
|
width: compactLayout ? undefined : 26,
|
|
minWidth: compactLayout ? undefined : 26,
|
|
flexShrink: 0,
|
|
},
|
|
React.createElement(Text, { bold: true, color: BRAND_PRIMARY }, 'Actions'),
|
|
...ACTION_GROUPS.flatMap((group) => {
|
|
const section = [
|
|
React.createElement(Text, { key: `${group.title}-title`, color: BRAND_SECONDARY, bold: true }, group.title),
|
|
];
|
|
|
|
for (const label of group.actions) {
|
|
const index = ACTIONS.indexOf(label);
|
|
const selected = index === selectedIndex;
|
|
const color = label === 'Reset'
|
|
? (selected ? COLOR_ERROR : COLOR_WARN)
|
|
: (selected ? BRAND_SECONDARY : TEXT_MUTED);
|
|
|
|
section.push(
|
|
React.createElement(
|
|
Text,
|
|
{
|
|
key: label,
|
|
color,
|
|
bold: selected,
|
|
},
|
|
`${selected ? '>' : ' '} ${label}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
return section;
|
|
}),
|
|
);
|
|
}
|
|
|
|
function HelpStrip({ view, busy, formName }) {
|
|
let text = '↑/↓ move Enter select q quit';
|
|
if (view === 'site-select') {
|
|
text = '↑/↓ select site Enter confirm q quit';
|
|
} else if (view === 'form') {
|
|
text = `${formName}: ↑/↓ field ←/→ option type edit Backspace delete Enter confirm Esc back`;
|
|
} else if (view === 'confirm') {
|
|
text = `${formName}: Enter run Esc cancel`;
|
|
} else if (view === 'workloads') {
|
|
text = 'Workloads: ↑/↓ select pod Enter logs r refresh Esc home q quit';
|
|
} else if (view === 'logs') {
|
|
text = 'Logs: ↑/↓ scroll PgUp/PgDn page Enter older near top Esc back q quit';
|
|
} else if (view === 'running' || busy) {
|
|
text = 'Running action...';
|
|
}
|
|
|
|
return React.createElement(
|
|
Box,
|
|
{ borderStyle: 'round', borderColor: BRAND_SECONDARY, paddingX: 1 },
|
|
React.createElement(Text, { color: TEXT_MUTED }, text),
|
|
);
|
|
}
|
|
|
|
function WorkloadsPane({ workloads, workloadIndex, workloadNotice, loadingWorkloads }) {
|
|
const rows = workloads?.pods || [];
|
|
const start = Math.max(0, Math.min(workloadIndex - 8, Math.max(0, rows.length - 16)));
|
|
const visibleRows = rows.slice(start, start + 16);
|
|
const headerLine = [
|
|
truncateText('Pod', WORKLOAD_COLUMNS.pod),
|
|
truncateText('Namespace', WORKLOAD_COLUMNS.namespace),
|
|
truncateText('Status', WORKLOAD_COLUMNS.status),
|
|
alignRight('Ready', WORKLOAD_COLUMNS.ready),
|
|
alignRight('Restarts', WORKLOAD_COLUMNS.restarts),
|
|
alignRight('Age', WORKLOAD_COLUMNS.age),
|
|
].join(' ');
|
|
return React.createElement(
|
|
Box,
|
|
{ borderStyle: 'round', borderColor: BRAND_PRIMARY, paddingX: 1, flexDirection: 'column', flexGrow: 1 },
|
|
React.createElement(Text, { bold: true, color: BRAND_PRIMARY }, 'Workloads'),
|
|
React.createElement(
|
|
Text,
|
|
{ color: TEXT_MUTED },
|
|
`Namespace: ${(workloads?.namespaces || []).join(', ') || 'msp'}`,
|
|
),
|
|
React.createElement(Text, { color: TEXT_MUTED }, `Updated: ${workloads?.fetchedAt || 'pending...'}`),
|
|
loadingWorkloads ? React.createElement(Text, { color: COLOR_WARN }, 'Refreshing workload inventory...') : null,
|
|
React.createElement(Text, null, ''),
|
|
React.createElement(Text, { color: BRAND_SECONDARY, bold: true }, headerLine),
|
|
...(rows.length
|
|
? visibleRows.map((pod, index) => {
|
|
const absolute = start + index;
|
|
const selected = absolute === workloadIndex;
|
|
const pointer = selected ? '>' : ' ';
|
|
|
|
return React.createElement(
|
|
Text,
|
|
{
|
|
key: pod.key,
|
|
backgroundColor: selected ? BRAND_SECONDARY : undefined,
|
|
color: selected ? 'black' : undefined,
|
|
bold: selected,
|
|
},
|
|
React.createElement(Text, { color: selected ? 'black' : BRAND_SECONDARY }, `${pointer} `),
|
|
React.createElement(Text, { color: selected ? 'black' : TEXT_MUTED }, `${truncateText(pod.name, WORKLOAD_COLUMNS.pod)} `),
|
|
React.createElement(Text, { color: selected ? 'black' : TEXT_MUTED }, `${truncateText(pod.namespace, WORKLOAD_COLUMNS.namespace)} `),
|
|
React.createElement(Text, { color: selected ? 'black' : lineColor(pod.status) || TEXT_MUTED }, `${truncateText(pod.status, WORKLOAD_COLUMNS.status)} `),
|
|
React.createElement(Text, { color: selected ? 'black' : TEXT_MUTED }, `${alignRight(pod.ready, WORKLOAD_COLUMNS.ready)} `),
|
|
React.createElement(Text, { color: selected ? 'black' : TEXT_MUTED }, `${alignRight(pod.restarts, WORKLOAD_COLUMNS.restarts)} `),
|
|
React.createElement(Text, { color: selected ? 'black' : TEXT_MUTED }, alignRight(pod.age, WORKLOAD_COLUMNS.age)),
|
|
);
|
|
})
|
|
: [React.createElement(Text, { key: 'empty-workloads', color: TEXT_MUTED }, 'No appliance pods found.')]),
|
|
...(workloads?.errors || []).map((errorLine, index) =>
|
|
React.createElement(Text, { key: `error-${index}`, color: COLOR_WARN }, errorLine),
|
|
),
|
|
workloadNotice ? React.createElement(Text, { color: COLOR_WARN }, workloadNotice) : null,
|
|
);
|
|
}
|
|
|
|
function LogPane({ selectedPod, logState, logNotice, loadingOlder, loadingLogs }) {
|
|
const lines = logState?.lines || [];
|
|
const top = Math.max(0, logState?.top ?? 0);
|
|
const viewLines = lines.slice(top, top + LOG_VIEW_HEIGHT);
|
|
const followMode = !!logState?.follow;
|
|
|
|
return React.createElement(
|
|
Box,
|
|
{ borderStyle: 'round', borderColor: BRAND_PRIMARY, paddingX: 1, flexDirection: 'column', flexGrow: 1 },
|
|
React.createElement(Text, { bold: true, color: BRAND_PRIMARY }, `Logs: ${selectedPod?.namespace || ''}/${selectedPod?.name || ''}`),
|
|
React.createElement(
|
|
Text,
|
|
{ color: TEXT_MUTED },
|
|
`Lines: ${lines.length} Follow: ${followMode ? 'on' : 'paused'} Top: ${top + 1}`,
|
|
),
|
|
loadingLogs ? React.createElement(Text, { color: COLOR_WARN }, 'Loading logs...') : null,
|
|
loadingOlder ? React.createElement(Text, { color: COLOR_WARN }, 'Loading older log chunk...') : null,
|
|
React.createElement(Text, null, ''),
|
|
...(viewLines.length
|
|
? viewLines.map((line, index) =>
|
|
React.createElement(
|
|
Text,
|
|
{
|
|
key: `${line.id}-${index}`,
|
|
color: TEXT_MUTED,
|
|
},
|
|
line.text,
|
|
),
|
|
)
|
|
: [React.createElement(Text, { key: 'empty-logs', color: TEXT_MUTED }, 'No logs yet for selected pod.')]),
|
|
logNotice ? React.createElement(Text, { color: COLOR_WARN }, logNotice) : null,
|
|
);
|
|
}
|
|
|
|
function ProgressPane({ lines, maxLines = 12 }) {
|
|
const shown = lines.slice(-maxLines);
|
|
return React.createElement(
|
|
Box,
|
|
{
|
|
borderStyle: 'round',
|
|
borderColor: BRAND_SECONDARY,
|
|
paddingX: 1,
|
|
flexDirection: 'column',
|
|
minHeight: Math.min(maxLines + 2, 28),
|
|
},
|
|
React.createElement(Text, { bold: true, color: BRAND_PRIMARY }, 'Live Progress / Install Log'),
|
|
...(shown.length
|
|
? shown.map((line, index) =>
|
|
React.createElement(
|
|
Text,
|
|
{ key: `${index}-${line.slice(0, 8)}`, color: lineColor(line) || TEXT_MUTED },
|
|
line,
|
|
),
|
|
)
|
|
: [React.createElement(Text, { key: 'empty', color: TEXT_MUTED }, 'No lifecycle output yet.')]),
|
|
);
|
|
}
|
|
|
|
function RunningSummaryPane({ env, status, formType }) {
|
|
const canonical = status?.canonical || status || {};
|
|
const rollup = canonical.rollup || {};
|
|
const operation = canonical.activeOperations?.[0]?.message || rollup.message || 'Waiting for lifecycle output...';
|
|
const statusUrl = canonical.urls?.statusUrl || (env.nodeIp ? `http://${env.nodeIp}:8080` : 'unknown');
|
|
const loginUrl = canonical.urls?.loginUrl || env.appUrl || (env.nodeIp ? `http://${env.nodeIp}:3000` : 'unknown');
|
|
const blocker = canonical.topBlockers?.[0] || status?.topBlocker || null;
|
|
return React.createElement(
|
|
Box,
|
|
{ borderStyle: 'round', borderColor: BRAND_PRIMARY, paddingX: 1, flexDirection: 'column' },
|
|
React.createElement(Text, { bold: true, color: BRAND_PRIMARY }, `${formType || 'Operation'} status`),
|
|
React.createElement(Text, { color: BRAND_SECONDARY }, `Status page: ${statusUrl}`),
|
|
React.createElement(Text, { color: TEXT_MUTED }, `Login URL: ${loginUrl}`),
|
|
React.createElement(Text, { color: rollup.state === 'failed_action_required' ? COLOR_ERROR : BRAND_SECONDARY }, `Install state: ${rollup.state || canonical.status || 'unknown'}`),
|
|
React.createElement(Text, { color: TEXT_MUTED }, `Current operation: ${operation}`),
|
|
blocker
|
|
? React.createElement(Text, { color: COLOR_WARN }, `Blocker: ${blocker.reason || blocker.layer}`)
|
|
: React.createElement(Text, { color: TEXT_MUTED }, 'Blocker: none detected yet'),
|
|
);
|
|
}
|
|
|
|
function StatusPane({ status }) {
|
|
const lines = shortStatusReport(status);
|
|
return React.createElement(
|
|
Box,
|
|
{
|
|
borderStyle: 'round',
|
|
borderColor: BRAND_PRIMARY,
|
|
paddingX: 1,
|
|
flexDirection: 'column',
|
|
minWidth: 0,
|
|
width: undefined,
|
|
flexBasis: 44,
|
|
flexGrow: 1,
|
|
flexShrink: 1,
|
|
},
|
|
React.createElement(Text, { bold: true, color: BRAND_PRIMARY }, 'Status Dashboard'),
|
|
...renderLines(lines.slice(0, 20), 'dashboard', { fallbackColor: TEXT_MUTED }),
|
|
);
|
|
}
|
|
|
|
function MainPane({
|
|
view,
|
|
env,
|
|
status,
|
|
formType,
|
|
formValues,
|
|
formIndex,
|
|
siteIndex,
|
|
pendingAction,
|
|
notice,
|
|
result,
|
|
error,
|
|
workloads,
|
|
workloadIndex,
|
|
workloadNotice,
|
|
loadingWorkloads,
|
|
selectedPod,
|
|
logState,
|
|
logNotice,
|
|
loadingOlder,
|
|
loadingLogs,
|
|
}) {
|
|
if (view === 'site-select') {
|
|
const sites = env.siteIds || [];
|
|
return React.createElement(
|
|
Box,
|
|
{ borderStyle: 'round', borderColor: BRAND_PRIMARY, paddingX: 1, flexDirection: 'column', flexGrow: 1 },
|
|
React.createElement(Text, { bold: true, color: BRAND_PRIMARY }, 'Select Appliance Site'),
|
|
...sites.map((siteId, index) =>
|
|
React.createElement(
|
|
Text,
|
|
{
|
|
key: siteId,
|
|
color: index === siteIndex ? BRAND_SECONDARY : TEXT_MUTED,
|
|
bold: index === siteIndex,
|
|
},
|
|
`${index === siteIndex ? '>' : ' '} ${siteId}`,
|
|
),
|
|
),
|
|
React.createElement(Text, null, ''),
|
|
React.createElement(
|
|
Text,
|
|
{ color: TEXT_MUTED },
|
|
pendingAction
|
|
? `Select a discovered site to continue to ${pendingAction}.`
|
|
: 'Select a discovered site to continue.',
|
|
),
|
|
);
|
|
}
|
|
|
|
if (view === 'status') {
|
|
const report = formatStatusReport(status);
|
|
return React.createElement(
|
|
Box,
|
|
{ borderStyle: 'round', borderColor: BRAND_PRIMARY, paddingX: 1, flexDirection: 'column', flexGrow: 1 },
|
|
React.createElement(Text, { bold: true, color: BRAND_PRIMARY }, 'Status View'),
|
|
...renderLines(report.summary, 'status-summary', { fallbackColor: TEXT_MUTED }),
|
|
React.createElement(Text, null, ''),
|
|
React.createElement(Text, { bold: true, color: BRAND_SECONDARY }, 'Release'),
|
|
...renderLines(report.release, 'status-release', { fallbackColor: TEXT_MUTED }),
|
|
React.createElement(Text, null, ''),
|
|
React.createElement(Text, { bold: true, color: BRAND_SECONDARY }, 'Config Paths'),
|
|
...renderLines(report.paths, 'status-paths', { fallbackColor: TEXT_MUTED }),
|
|
);
|
|
}
|
|
|
|
if (view === 'workloads') {
|
|
return React.createElement(WorkloadsPane, {
|
|
workloads,
|
|
workloadIndex,
|
|
workloadNotice,
|
|
loadingWorkloads,
|
|
});
|
|
}
|
|
|
|
if (view === 'logs') {
|
|
return React.createElement(LogPane, {
|
|
selectedPod,
|
|
logState,
|
|
logNotice,
|
|
loadingOlder,
|
|
loadingLogs,
|
|
});
|
|
}
|
|
|
|
if (view === 'form') {
|
|
const fields = formFields(formType, env);
|
|
const title = `${formType} Form`;
|
|
|
|
return React.createElement(
|
|
Box,
|
|
{ borderStyle: 'round', borderColor: BRAND_PRIMARY, paddingX: 1, flexDirection: 'column', flexGrow: 1 },
|
|
React.createElement(Text, { bold: true, color: BRAND_PRIMARY }, title),
|
|
formType === 'Reset'
|
|
? React.createElement(
|
|
Box,
|
|
{ flexDirection: 'column' },
|
|
React.createElement(Text, { color: COLOR_ERROR, bold: true }, `Target appliance: ${env.site.siteId}`),
|
|
React.createElement(
|
|
Text,
|
|
{ color: COLOR_ERROR },
|
|
'Wipes namespace msp, namespace alga-system, and /var/mnt/alga-data/local-path-provisioner data.',
|
|
),
|
|
)
|
|
: null,
|
|
formType === 'Repair Release'
|
|
? React.createElement(
|
|
Text,
|
|
{ color: COLOR_WARN },
|
|
'Repairs a stuck alga-core release by cleaning failed workloads and reconciling the HelmRelease.',
|
|
)
|
|
: null,
|
|
React.createElement(FieldList, { fields, values: formValues, selectedIndex: formIndex }),
|
|
notice ? React.createElement(Text, { color: COLOR_WARN }, notice) : null,
|
|
);
|
|
}
|
|
|
|
if (view === 'confirm') {
|
|
return React.createElement(
|
|
Box,
|
|
{ borderStyle: 'round', borderColor: BRAND_PRIMARY, paddingX: 1, flexDirection: 'column', flexGrow: 1 },
|
|
React.createElement(Text, { bold: true, color: BRAND_PRIMARY }, `${formType} Confirmation`),
|
|
...Object.entries(formValues).map(([key, value]) =>
|
|
React.createElement(Text, { key, color: TEXT_MUTED }, `${key}: ${String(value)}`),
|
|
),
|
|
formType === 'Repair Release'
|
|
? React.createElement(Text, { color: COLOR_WARN }, 'Repair will clean up failed alga-core workloads before reconciling the release.')
|
|
: null,
|
|
React.createElement(Text, { color: TEXT_MUTED }, 'Press Enter to run, Esc to cancel.'),
|
|
);
|
|
}
|
|
|
|
if (view === 'running') {
|
|
return React.createElement(
|
|
Box,
|
|
{ borderStyle: 'round', borderColor: BRAND_PRIMARY, paddingX: 1, flexDirection: 'column', flexGrow: 1 },
|
|
React.createElement(Text, { bold: true, color: BRAND_PRIMARY }, `${formType} Running`),
|
|
React.createElement(Text, { color: TEXT_MUTED }, 'Streaming lifecycle progress in the panel below...'),
|
|
);
|
|
}
|
|
|
|
return React.createElement(
|
|
Box,
|
|
{ borderStyle: 'round', borderColor: BRAND_PRIMARY, paddingX: 1, flexDirection: 'column', flexGrow: 1 },
|
|
React.createElement(Text, { bold: true, color: BRAND_PRIMARY }, 'Home'),
|
|
React.createElement(Text, { color: TEXT_MUTED }, `Selected appliance: ${env.site?.siteId || 'unselected'}`),
|
|
React.createElement(Text, { color: TEXT_MUTED }, `Node IP: ${env.nodeIp || 'unknown'}`),
|
|
React.createElement(Text, { color: TEXT_MUTED }, `Current release: ${status?.release?.selectedReleaseVersion || 'unknown'}`),
|
|
React.createElement(Text, { color: TEXT_MUTED }, 'Use arrow keys and Enter to launch a flow.'),
|
|
notice ? React.createElement(Text, { color: COLOR_WARN }, notice) : null,
|
|
result
|
|
? React.createElement(Text, { color: result.ok ? COLOR_OK : COLOR_ERROR, bold: true }, result.message)
|
|
: null,
|
|
error ? React.createElement(Text, { color: COLOR_ERROR }, error) : null,
|
|
);
|
|
}
|
|
|
|
function formFields(formType, env) {
|
|
if (formType === 'Reset') {
|
|
return [{ key: 'challenge', label: `Type ${makeResetDefaults(env).expected}`, type: 'text' }];
|
|
}
|
|
|
|
if (formType === 'Repair Release') {
|
|
return [
|
|
{ key: 'releaseName', label: 'Release Name', type: 'text' },
|
|
{ key: 'cleanupWorkloads', label: 'Cleanup Failed Workloads', type: 'select', options: YES_NO_OPTIONS },
|
|
];
|
|
}
|
|
|
|
if (formType === 'Support Bundle') {
|
|
return [{ key: 'outputDir', label: 'Output Directory', type: 'text' }];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function initFormValues(formType, env) {
|
|
if (formType === 'Reset') {
|
|
return makeResetDefaults(env);
|
|
}
|
|
if (formType === 'Repair Release') {
|
|
return makeRepairReleaseDefaults();
|
|
}
|
|
if (formType === 'Support Bundle') {
|
|
return makeSupportBundleDefaults();
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function normalizeChallenge(value) {
|
|
return String(value || '')
|
|
.trim()
|
|
.replace(/\s+/g, ' ');
|
|
}
|
|
|
|
function TuiApp({ initialEnv, actions, onExit }) {
|
|
const { exit } = useApp();
|
|
const { stdout } = useStdout();
|
|
const [env, setEnv] = useState(initialEnv);
|
|
const [status, setStatus] = useState(null);
|
|
const [view, setView] = useState('home');
|
|
const [actionIndex, setActionIndex] = useState(0);
|
|
const [siteIndex, setSiteIndex] = useState(0);
|
|
const [pendingAction, setPendingAction] = useState('');
|
|
const [formType, setFormType] = useState('');
|
|
const [formValues, setFormValues] = useState({});
|
|
const [formIndex, setFormIndex] = useState(0);
|
|
const [progressLines, setProgressLines] = useState([]);
|
|
const [busy, setBusy] = useState(false);
|
|
const [notice, setNotice] = useState('');
|
|
const [result, setResult] = useState(null);
|
|
const [error, setError] = useState('');
|
|
const [workloads, setWorkloads] = useState({ fetchedAt: null, namespaces: [], pods: [], errors: [] });
|
|
const [loadingWorkloads, setLoadingWorkloads] = useState(false);
|
|
const [workloadNotice, setWorkloadNotice] = useState('');
|
|
const [workloadIndex, setWorkloadIndex] = useState(0);
|
|
const [logState, setLogState] = useState({
|
|
lines: [],
|
|
top: 0,
|
|
follow: true,
|
|
tailLines: LOG_CHUNK_LINES,
|
|
lastTimestamp: null,
|
|
});
|
|
const [loadingLogs, setLoadingLogs] = useState(false);
|
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
const [logNotice, setLogNotice] = useState('');
|
|
const [viewportWidth, setViewportWidth] = useState(stdout?.columns || process.stdout.columns || 140);
|
|
const selectedPodRef = useRef(null);
|
|
|
|
const compactLayout = viewportWidth < 140;
|
|
const showProgressPane = busy || view === 'running' || progressLines.length > 0;
|
|
const selectedPod = workloads.pods[workloadIndex] || null;
|
|
|
|
useEffect(() => {
|
|
if (!stdout || typeof stdout.on !== 'function') {
|
|
return undefined;
|
|
}
|
|
|
|
let resizeTimer = null;
|
|
const handleResize = () => {
|
|
if (resizeTimer) {
|
|
clearTimeout(resizeTimer);
|
|
}
|
|
|
|
resizeTimer = setTimeout(() => {
|
|
setViewportWidth(stdout.columns || process.stdout.columns || 140);
|
|
}, 80);
|
|
};
|
|
|
|
stdout.on('resize', handleResize);
|
|
return () => {
|
|
if (resizeTimer) {
|
|
clearTimeout(resizeTimer);
|
|
}
|
|
stdout.off('resize', handleResize);
|
|
};
|
|
}, [stdout]);
|
|
|
|
const refreshStatus = useMemo(
|
|
() => async () => {
|
|
if (!env.site) {
|
|
setStatus(null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const next = await actions.collectStatus(env);
|
|
setStatus(next);
|
|
setError('');
|
|
} catch (err) {
|
|
setError(err.message || String(err));
|
|
}
|
|
},
|
|
[actions, env],
|
|
);
|
|
|
|
const refreshWorkloads = useMemo(
|
|
() => async () => {
|
|
if (!env.site) {
|
|
setWorkloads({ fetchedAt: null, namespaces: [], pods: [], errors: [] });
|
|
return;
|
|
}
|
|
|
|
setLoadingWorkloads(true);
|
|
try {
|
|
const next = await actions.listAppliancePods(env);
|
|
setWorkloads(next);
|
|
setWorkloadNotice('');
|
|
setWorkloadIndex((current) => {
|
|
if (selectedPodRef.current) {
|
|
const byRef = next.pods.findIndex((entry) => entry.key === selectedPodRef.current.key);
|
|
if (byRef >= 0) {
|
|
return byRef;
|
|
}
|
|
}
|
|
return clampIndex(current, next.pods.length);
|
|
});
|
|
} catch (err) {
|
|
setWorkloadNotice(err.message || String(err));
|
|
} finally {
|
|
setLoadingWorkloads(false);
|
|
}
|
|
},
|
|
[actions, env],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!env.siteSelectionRequired && env.site) {
|
|
refreshStatus();
|
|
}
|
|
}, [env, refreshStatus]);
|
|
|
|
useEffect(() => {
|
|
selectedPodRef.current = selectedPod;
|
|
}, [selectedPod]);
|
|
|
|
useEffect(() => {
|
|
if (view !== 'workloads') {
|
|
return undefined;
|
|
}
|
|
|
|
refreshWorkloads();
|
|
const timer = setInterval(() => {
|
|
refreshWorkloads();
|
|
}, WORKLOAD_REFRESH_MS);
|
|
return () => clearInterval(timer);
|
|
}, [refreshWorkloads, view]);
|
|
|
|
useEffect(() => {
|
|
if (view !== 'running') {
|
|
return undefined;
|
|
}
|
|
|
|
refreshStatus();
|
|
const timer = setInterval(() => {
|
|
refreshStatus();
|
|
}, 5000);
|
|
return () => clearInterval(timer);
|
|
}, [refreshStatus, view]);
|
|
|
|
async function executeFormAction() {
|
|
setBusy(true);
|
|
setView('running');
|
|
setNotice('');
|
|
setResult(null);
|
|
|
|
const append = (line) => {
|
|
if (!line || !line.trim()) {
|
|
return;
|
|
}
|
|
setProgressLines((prev) => [...prev.slice(-199), line]);
|
|
};
|
|
|
|
try {
|
|
let output;
|
|
if (formType === 'Reset') {
|
|
output = await actions.runReset(env, {
|
|
onProgress: (event) => append(mapProgressEvent(event)),
|
|
});
|
|
} else if (formType === 'Repair Release') {
|
|
output = await actions.runRepairRelease(env, {
|
|
...formValues,
|
|
cleanupWorkloads: formValues.cleanupWorkloads !== 'no',
|
|
onProgress: (event) => append(mapProgressEvent(event)),
|
|
});
|
|
} else if (formType === 'Support Bundle') {
|
|
output = await actions.runSupportBundle(env, {
|
|
...formValues,
|
|
onProgress: (event) => append(mapProgressEvent(event)),
|
|
});
|
|
} else {
|
|
output = { ok: true };
|
|
}
|
|
|
|
if (!output.ok) {
|
|
const layer = output.failureLayer ? ` Failure layer: ${output.failureLayer}.` : '';
|
|
setResult({
|
|
ok: false,
|
|
message: `${formType} failed.${layer}`.trim(),
|
|
});
|
|
} else {
|
|
setResult({ ok: true, message: `${formType} completed successfully.` });
|
|
}
|
|
} catch (err) {
|
|
setResult({ ok: false, message: `${formType} failed: ${err.message || String(err)}` });
|
|
} finally {
|
|
setBusy(false);
|
|
setView('home');
|
|
setFormType('');
|
|
setFormValues({});
|
|
setFormIndex(0);
|
|
await refreshStatus();
|
|
}
|
|
}
|
|
|
|
async function openLogViewerForPod(pod) {
|
|
if (!pod) {
|
|
setLogNotice('No pod selected.');
|
|
return;
|
|
}
|
|
|
|
setLoadingLogs(true);
|
|
setLogNotice('');
|
|
try {
|
|
const fetched = await actions.readPodLogsTail(env, pod, { tailLines: LOG_CHUNK_LINES });
|
|
if (!fetched.ok) {
|
|
setLogState({
|
|
lines: [],
|
|
top: 0,
|
|
follow: true,
|
|
tailLines: LOG_CHUNK_LINES,
|
|
lastTimestamp: null,
|
|
});
|
|
setLogNotice(fetched.error || 'Unable to load pod logs.');
|
|
} else {
|
|
const lines = applyLogTail(fetched.lines);
|
|
const lastTimestamp = fetched.lines.at(-1)?.timestamp || null;
|
|
setLogState({
|
|
lines,
|
|
top: Math.max(0, lines.length - LOG_VIEW_HEIGHT),
|
|
follow: true,
|
|
tailLines: LOG_CHUNK_LINES,
|
|
lastTimestamp,
|
|
});
|
|
}
|
|
setView('logs');
|
|
} catch (err) {
|
|
setLogNotice(err.message || String(err));
|
|
} finally {
|
|
setLoadingLogs(false);
|
|
}
|
|
}
|
|
|
|
async function loadOlderLogs() {
|
|
if (!selectedPod || loadingOlder || loadingLogs) {
|
|
return;
|
|
}
|
|
setLoadingOlder(true);
|
|
setLogNotice('');
|
|
try {
|
|
const nextTail = (logState.tailLines || LOG_CHUNK_LINES) + LOG_CHUNK_LINES;
|
|
const fetched = await actions.readPodLogsTail(env, selectedPod, { tailLines: nextTail });
|
|
if (!fetched.ok) {
|
|
setLogNotice(fetched.error || 'Unable to load older logs.');
|
|
return;
|
|
}
|
|
setLogState((previous) => {
|
|
const merged = prependOlderLines(previous.lines, fetched.lines);
|
|
const shifted = Math.max(0, merged.length - previous.lines.length);
|
|
const lastTimestamp = fetched.lines.at(-1)?.timestamp || previous.lastTimestamp || null;
|
|
return {
|
|
...previous,
|
|
lines: merged,
|
|
tailLines: nextTail,
|
|
top: previous.top + shifted,
|
|
lastTimestamp,
|
|
};
|
|
});
|
|
} catch (err) {
|
|
setLogNotice(err.message || String(err));
|
|
} finally {
|
|
setLoadingOlder(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (view !== 'logs' || !selectedPod || !logState.follow) {
|
|
return undefined;
|
|
}
|
|
|
|
const timer = setInterval(async () => {
|
|
try {
|
|
const fetched = await actions.readPodLogsSince(env, selectedPod, { sinceTime: logState.lastTimestamp });
|
|
if (!fetched.ok || !fetched.lines.length) {
|
|
return;
|
|
}
|
|
setLogState((previous) => {
|
|
const lines = appendLiveLogLines(previous.lines, fetched.lines);
|
|
const top = Math.max(0, lines.length - LOG_VIEW_HEIGHT);
|
|
return {
|
|
...previous,
|
|
lines,
|
|
top,
|
|
lastTimestamp: fetched.lines.at(-1)?.timestamp || previous.lastTimestamp || null,
|
|
};
|
|
});
|
|
} catch {
|
|
// Keep live follow resilient; surface hard failures only on direct user actions.
|
|
}
|
|
}, LOG_POLL_MS);
|
|
|
|
return () => clearInterval(timer);
|
|
}, [actions, env, logState.follow, logState.lastTimestamp, selectedPod, view]);
|
|
|
|
function openAction(action) {
|
|
setNotice('');
|
|
setResult(null);
|
|
|
|
if (!env.site) {
|
|
if (!(env.siteIds || []).length) {
|
|
setNotice('No appliance sites discovered. Run setup from the appliance web UI first.');
|
|
setView('home');
|
|
return;
|
|
}
|
|
setPendingAction(action);
|
|
setSiteIndex(0);
|
|
setView('site-select');
|
|
return;
|
|
}
|
|
|
|
if (action === 'Status') {
|
|
setView('status');
|
|
return;
|
|
}
|
|
|
|
if (action === 'Workloads') {
|
|
setView('workloads');
|
|
return;
|
|
}
|
|
|
|
setFormType(action);
|
|
setFormValues(initFormValues(action, env));
|
|
setFormIndex(0);
|
|
setView('form');
|
|
}
|
|
|
|
useInput((input, key) => {
|
|
if (key.ctrl && input === 'c') {
|
|
onExit(0);
|
|
exit();
|
|
return;
|
|
}
|
|
|
|
if (busy || view === 'running') {
|
|
return;
|
|
}
|
|
|
|
if (input === 'q') {
|
|
onExit(0);
|
|
exit();
|
|
return;
|
|
}
|
|
|
|
if (view === 'site-select') {
|
|
const sites = env.siteIds || [];
|
|
if (!sites.length) {
|
|
setView('home');
|
|
return;
|
|
}
|
|
if (key.escape) {
|
|
setPendingAction('');
|
|
setView('home');
|
|
return;
|
|
}
|
|
if (key.upArrow || input === 'k') {
|
|
setSiteIndex((value) => clampIndex(value - 1, sites.length));
|
|
return;
|
|
}
|
|
if (key.downArrow || input === 'j') {
|
|
setSiteIndex((value) => clampIndex(value + 1, sites.length));
|
|
return;
|
|
}
|
|
if (key.return) {
|
|
const selected = sites[siteIndex] || sites[0];
|
|
if (!selected) {
|
|
setNotice('No discovered site selected.');
|
|
return;
|
|
}
|
|
const selectedEnv = selectDiscoveredSite(env, selected);
|
|
setEnv(selectedEnv);
|
|
const action = pendingAction;
|
|
setPendingAction('');
|
|
if (action === 'Status') {
|
|
setView('status');
|
|
return;
|
|
}
|
|
if (action === 'Workloads') {
|
|
setView('workloads');
|
|
return;
|
|
}
|
|
if (action) {
|
|
setFormType(action);
|
|
setFormValues(initFormValues(action, selectedEnv));
|
|
setFormIndex(0);
|
|
setView('form');
|
|
return;
|
|
}
|
|
setView('home');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (view === 'status') {
|
|
if (key.escape || input === 'h') {
|
|
setView('home');
|
|
}
|
|
if (input === 'r') {
|
|
refreshStatus();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (view === 'workloads') {
|
|
if (key.escape || input === 'h') {
|
|
setView('home');
|
|
return;
|
|
}
|
|
if (key.upArrow || input === 'k') {
|
|
setWorkloadIndex((value) => clampIndex(value - 1, workloads.pods.length));
|
|
return;
|
|
}
|
|
if (key.downArrow || input === 'j') {
|
|
setWorkloadIndex((value) => clampIndex(value + 1, workloads.pods.length));
|
|
return;
|
|
}
|
|
if (input === 'r') {
|
|
refreshWorkloads();
|
|
return;
|
|
}
|
|
if (key.return) {
|
|
openLogViewerForPod(selectedPod);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (view === 'logs') {
|
|
if (key.escape || input === 'h') {
|
|
setView('workloads');
|
|
return;
|
|
}
|
|
|
|
if (key.pageUp) {
|
|
setLogState((previous) => {
|
|
const top = Math.max(0, previous.top - LOG_VIEW_HEIGHT);
|
|
return {
|
|
...previous,
|
|
top,
|
|
follow: top + LOG_VIEW_HEIGHT >= previous.lines.length,
|
|
};
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (key.pageDown) {
|
|
setLogState((previous) => {
|
|
const maxTop = Math.max(0, previous.lines.length - LOG_VIEW_HEIGHT);
|
|
const top = Math.min(maxTop, previous.top + LOG_VIEW_HEIGHT);
|
|
return {
|
|
...previous,
|
|
top,
|
|
follow: top >= maxTop,
|
|
};
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (key.upArrow || input === 'k') {
|
|
setLogState((previous) => {
|
|
const top = Math.max(0, previous.top - 1);
|
|
return {
|
|
...previous,
|
|
top,
|
|
follow: false,
|
|
};
|
|
});
|
|
if ((logState.top || 0) <= 1) {
|
|
loadOlderLogs();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (key.downArrow || input === 'j') {
|
|
setLogState((previous) => {
|
|
const maxTop = Math.max(0, previous.lines.length - LOG_VIEW_HEIGHT);
|
|
const top = Math.min(maxTop, previous.top + 1);
|
|
return {
|
|
...previous,
|
|
top,
|
|
follow: top >= maxTop,
|
|
};
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (key.return && (logState.top || 0) <= 1) {
|
|
loadOlderLogs();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (view === 'home') {
|
|
if (key.upArrow || input === 'k') {
|
|
setActionIndex((value) => clampIndex(value - 1, ACTIONS.length));
|
|
return;
|
|
}
|
|
if (key.downArrow || input === 'j') {
|
|
setActionIndex((value) => clampIndex(value + 1, ACTIONS.length));
|
|
return;
|
|
}
|
|
if (input === 'r') {
|
|
refreshStatus();
|
|
return;
|
|
}
|
|
if (key.return) {
|
|
openAction(ACTIONS[actionIndex]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (view === 'form') {
|
|
const fields = formFields(formType, env);
|
|
|
|
if (key.escape) {
|
|
setView('home');
|
|
setFormType('');
|
|
setFormValues({});
|
|
setFormIndex(0);
|
|
return;
|
|
}
|
|
|
|
if (key.upArrow || input === 'k') {
|
|
setFormIndex((value) => clampIndex(value - 1, fields.length));
|
|
return;
|
|
}
|
|
if (key.downArrow || key.tab || input === 'j') {
|
|
setFormIndex((value) => clampIndex(value + 1, fields.length));
|
|
return;
|
|
}
|
|
|
|
const field = fields[formIndex];
|
|
if (!field) {
|
|
return;
|
|
}
|
|
|
|
if (field.type === 'select' && (key.leftArrow || key.rightArrow || input === 'h' || input === 'l')) {
|
|
const delta = key.rightArrow || input === 'l' ? 1 : -1;
|
|
setFormValues((prev) => ({
|
|
...prev,
|
|
[field.key]: cycleOption(field.options || [], prev[field.key], delta),
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (key.return) {
|
|
if (formType === 'Reset') {
|
|
const expected = makeResetDefaults(env).expected;
|
|
if (normalizeChallenge(formValues.challenge) !== normalizeChallenge(expected)) {
|
|
setNotice(`Reset confirmation mismatch. Type ${expected}.`);
|
|
return;
|
|
}
|
|
}
|
|
setNotice('');
|
|
setView('confirm');
|
|
return;
|
|
}
|
|
|
|
if (field.type === 'text') {
|
|
if (key.backspace || key.delete) {
|
|
setFormValues((prev) => ({
|
|
...prev,
|
|
[field.key]: String(prev[field.key] || '').slice(0, -1),
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (input && !key.meta && !key.ctrl) {
|
|
setFormValues((prev) => ({
|
|
...prev,
|
|
[field.key]: `${String(prev[field.key] || '')}${input}`,
|
|
}));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (view === 'confirm') {
|
|
if (key.escape) {
|
|
setView('form');
|
|
return;
|
|
}
|
|
|
|
if (key.return) {
|
|
executeFormAction();
|
|
}
|
|
}
|
|
});
|
|
|
|
if (view === 'running') {
|
|
return React.createElement(
|
|
Box,
|
|
{ flexDirection: 'column', width: '100%' },
|
|
React.createElement(RunningSummaryPane, { env, status, formType }),
|
|
React.createElement(Box, { marginTop: 1 }, React.createElement(ProgressPane, { lines: progressLines, maxLines: 24 })),
|
|
React.createElement(Box, { marginTop: 1 }, React.createElement(HelpStrip, { view, busy, formName: formType || 'Operation' })),
|
|
);
|
|
}
|
|
|
|
return React.createElement(
|
|
Box,
|
|
{ flexDirection: 'column', width: '100%' },
|
|
React.createElement(Header, { env, status }),
|
|
React.createElement(
|
|
Box,
|
|
{ flexDirection: compactLayout ? 'column' : 'row', marginTop: 1, width: '100%' },
|
|
React.createElement(ActionNav, { selectedIndex: actionIndex, compactLayout }),
|
|
React.createElement(
|
|
Box,
|
|
{
|
|
marginLeft: compactLayout ? 0 : 1,
|
|
marginTop: compactLayout ? 1 : 0,
|
|
flexGrow: 1,
|
|
flexBasis: compactLayout ? undefined : 0,
|
|
flexShrink: 1,
|
|
minWidth: 0,
|
|
},
|
|
React.createElement(MainPane, {
|
|
view,
|
|
env,
|
|
status,
|
|
formType,
|
|
formValues,
|
|
formIndex,
|
|
siteIndex,
|
|
pendingAction,
|
|
notice,
|
|
result,
|
|
error,
|
|
workloads,
|
|
workloadIndex,
|
|
workloadNotice,
|
|
loadingWorkloads,
|
|
selectedPod,
|
|
logState,
|
|
logNotice,
|
|
loadingOlder,
|
|
loadingLogs,
|
|
}),
|
|
),
|
|
React.createElement(
|
|
Box,
|
|
{
|
|
marginLeft: compactLayout ? 0 : 1,
|
|
marginTop: compactLayout ? 1 : 0,
|
|
flexGrow: compactLayout ? 0 : 1,
|
|
flexBasis: compactLayout ? undefined : 0,
|
|
flexShrink: 1,
|
|
minWidth: 0,
|
|
},
|
|
React.createElement(StatusPane, { status }),
|
|
),
|
|
),
|
|
showProgressPane
|
|
? React.createElement(Box, { marginTop: 1 }, React.createElement(ProgressPane, { lines: progressLines, maxLines: 10 }))
|
|
: null,
|
|
React.createElement(Box, { marginTop: 1 }, React.createElement(HelpStrip, { view, busy, formName: formType || 'Home' })),
|
|
);
|
|
}
|
|
|
|
function createTuiActions(overrides = {}) {
|
|
return {
|
|
collectStatus: overrides.collectStatus || collectStatus,
|
|
runRepairRelease: overrides.runRepairRelease || runRepairRelease,
|
|
runReset: overrides.runReset || runReset,
|
|
runSupportBundle: overrides.runSupportBundle || runSupportBundle,
|
|
listAppliancePods: overrides.listAppliancePods || listAppliancePods,
|
|
readPodLogsTail: overrides.readPodLogsTail || readPodLogsTail,
|
|
readPodLogsSince: overrides.readPodLogsSince || readPodLogsSince,
|
|
};
|
|
}
|
|
|
|
export async function runTui(env, options = {}) {
|
|
const actions = createTuiActions(options);
|
|
let exitCode = 0;
|
|
const enterAlt = process.stdout.isTTY;
|
|
|
|
if (enterAlt) {
|
|
process.stdout.write('\u001B[?1049h\u001B[H');
|
|
}
|
|
|
|
try {
|
|
const app = render(
|
|
React.createElement(TuiApp, {
|
|
initialEnv: env,
|
|
actions,
|
|
onExit: (code) => {
|
|
exitCode = code;
|
|
},
|
|
}),
|
|
{
|
|
exitOnCtrlC: true,
|
|
},
|
|
);
|
|
|
|
await app.waitUntilExit();
|
|
return exitCode;
|
|
} finally {
|
|
if (enterAlt) {
|
|
process.stdout.write('\u001B[?1049l');
|
|
}
|
|
}
|
|
}
|
|
|
|
export { TuiApp, createTuiActions, formFields, initFormValues, mapProgressEvent };
|