@matthesketh/fleet 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tui/components/AppList.js +1 -1
- package/dist/tui/components/Confirm.js +3 -4
- package/dist/tui/components/Header.js +37 -8
- package/dist/tui/components/KeyHint.js +4 -5
- package/dist/tui/hooks/use-terminal-size.d.ts +1 -0
- package/dist/tui/hooks/use-terminal-size.js +1 -0
- package/dist/tui/router.js +81 -9
- package/dist/tui/state.js +15 -0
- package/dist/tui/tests/flicker.test.d.ts +1 -0
- package/dist/tui/tests/flicker.test.js +105 -0
- package/dist/tui/tests/keyboard-integration.test.d.ts +1 -0
- package/dist/tui/tests/keyboard-integration.test.js +117 -0
- package/dist/tui/tests/test-app.d.ts +4 -0
- package/dist/tui/tests/test-app.js +79 -0
- package/dist/tui/types.d.ts +13 -0
- package/dist/tui/views/AppDetail.js +41 -26
- package/dist/tui/views/Dashboard.js +34 -9
- package/dist/tui/views/HealthView.js +36 -12
- package/dist/tui/views/LogsView.js +14 -9
- package/dist/tui/views/SecretEdit.js +8 -4
- package/dist/tui/views/SecretsView.js +49 -36
- package/package.json +17 -1
|
@@ -25,7 +25,7 @@ export function AppList({ items, onSelect, renderItem }) {
|
|
|
25
25
|
return (_jsx(Box, { flexDirection: "column", children: items.map((item, i) => {
|
|
26
26
|
const selected = i === selectedIndex;
|
|
27
27
|
if (renderItem) {
|
|
28
|
-
return (_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, children: selected ? '> ' : ' ' }), renderItem(item, selected)] }, item.name));
|
|
28
|
+
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: selected ? colors.primary : colors.muted, children: selected ? '> ' : ' ' }), renderItem(item, selected)] }, item.name));
|
|
29
29
|
}
|
|
30
30
|
return (_jsxs(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: [selected ? '> ' : ' ', item.label ?? item.name] }, item.name));
|
|
31
31
|
}) }));
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { Text, Box } from 'ink';
|
|
3
|
+
import { Modal } from '@matthesketh/ink-modal';
|
|
3
4
|
import { useAppState } from '../state.js';
|
|
4
5
|
import { colors } from '../theme.js';
|
|
5
6
|
export function Confirm() {
|
|
6
7
|
const { confirmAction } = useAppState();
|
|
7
|
-
|
|
8
|
-
return null;
|
|
9
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.warning, paddingX: 2, paddingY: 1, marginY: 1, children: [_jsx(Text, { bold: true, color: colors.warning, children: confirmAction.label }), _jsx(Text, { color: colors.muted, children: confirmAction.description }), _jsxs(Box, { marginTop: 1, gap: 2, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: colors.success, children: "y" }), " confirm"] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: colors.error, children: "n" }), " cancel"] })] })] }));
|
|
8
|
+
return (_jsx(Modal, { visible: confirmAction !== null, title: confirmAction?.label, borderColor: colors.warning, width: 50, footer: _jsxs(Box, { gap: 2, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: colors.success, children: "y" }), " confirm"] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: colors.error, children: "n" }), " cancel"] })] }), children: _jsx(Text, { color: colors.muted, children: confirmAction?.description ?? '' }) }));
|
|
10
9
|
}
|
|
@@ -1,16 +1,45 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { Tabs } from '@matthesketh/ink-tabs';
|
|
4
|
+
import { Breadcrumb } from '@matthesketh/ink-breadcrumb';
|
|
3
5
|
import { useAppState } from '../state.js';
|
|
4
6
|
import { colors } from '../theme.js';
|
|
5
|
-
const
|
|
6
|
-
{
|
|
7
|
-
{
|
|
8
|
-
{
|
|
7
|
+
const TAB_ITEMS = [
|
|
8
|
+
{ id: 'dashboard', label: 'Dashboard' },
|
|
9
|
+
{ id: 'health', label: 'Health' },
|
|
10
|
+
{ id: 'secrets', label: 'Secrets' },
|
|
9
11
|
];
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
const TOP_VIEWS = new Set(['dashboard', 'health', 'secrets']);
|
|
13
|
+
function resolveActiveTab(view, previousView) {
|
|
14
|
+
if (TOP_VIEWS.has(view))
|
|
15
|
+
return view;
|
|
16
|
+
if (view === 'app-detail' || view === 'logs')
|
|
17
|
+
return 'dashboard';
|
|
18
|
+
if (view === 'secret-edit')
|
|
19
|
+
return 'secrets';
|
|
20
|
+
return previousView ?? 'dashboard';
|
|
21
|
+
}
|
|
22
|
+
function buildBreadcrumb(view, selectedApp) {
|
|
23
|
+
switch (view) {
|
|
24
|
+
case 'dashboard':
|
|
25
|
+
return ['Dashboard'];
|
|
26
|
+
case 'health':
|
|
27
|
+
return ['Health'];
|
|
28
|
+
case 'secrets':
|
|
29
|
+
return ['Secrets'];
|
|
30
|
+
case 'app-detail':
|
|
31
|
+
return ['Dashboard', selectedApp ?? '...'];
|
|
32
|
+
case 'logs':
|
|
33
|
+
return ['Dashboard', selectedApp ?? '...', 'Logs'];
|
|
34
|
+
case 'secret-edit':
|
|
35
|
+
return ['Secrets', selectedApp ?? '...', 'Edit'];
|
|
36
|
+
default:
|
|
37
|
+
return ['Dashboard'];
|
|
38
|
+
}
|
|
12
39
|
}
|
|
13
40
|
export function Header({ vaultSealed }) {
|
|
14
|
-
const { currentView, redacted } = useAppState();
|
|
15
|
-
|
|
41
|
+
const { currentView, previousView, selectedApp, redacted } = useAppState();
|
|
42
|
+
const activeTab = resolveActiveTab(currentView, previousView);
|
|
43
|
+
const breadcrumb = buildBreadcrumb(currentView, selectedApp);
|
|
44
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "single", borderBottom: true, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, alignItems: "center", children: [_jsx(Text, { bold: true, color: colors.primary, children: "Fleet" }), _jsx(Tabs, { tabs: TAB_ITEMS, activeId: activeTab, accentColor: colors.primary })] }), _jsxs(Box, { gap: 1, children: [redacted && _jsx(Text, { color: "magenta", bold: true, children: "[REDACTED]" }), _jsx(Text, { color: vaultSealed ? colors.warning : colors.success, children: vaultSealed ? '[SEALED]' : '[UNSEALED]' })] })] }), breadcrumb.length > 1 && (_jsx(Box, { paddingX: 1, children: _jsx(Breadcrumb, { path: breadcrumb, activeColor: colors.primary }) }))] }));
|
|
16
45
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { jsx as _jsx
|
|
2
|
-
import {
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { StatusBar } from '@matthesketh/ink-status-bar';
|
|
3
3
|
import { useAppState } from '../state.js';
|
|
4
|
-
import { colors } from '../theme.js';
|
|
5
4
|
const viewHints = {
|
|
6
5
|
dashboard: [
|
|
7
6
|
{ key: 'j/k', label: 'navigate' },
|
|
8
7
|
{ key: 'Enter', label: 'select' },
|
|
9
8
|
{ key: 'Tab', label: 'switch view' },
|
|
9
|
+
{ key: '?', label: 'help' },
|
|
10
10
|
{ key: 'x', label: 'redact' },
|
|
11
11
|
{ key: 'q', label: 'quit' },
|
|
12
12
|
],
|
|
@@ -31,7 +31,6 @@ const viewHints = {
|
|
|
31
31
|
{ key: 'a', label: 'add' },
|
|
32
32
|
{ key: 'd', label: 'delete' },
|
|
33
33
|
{ key: 'r', label: 'reveal' },
|
|
34
|
-
{ key: 'x', label: 'redact' },
|
|
35
34
|
{ key: 'Esc', label: 'back' },
|
|
36
35
|
{ key: 'q', label: 'quit' },
|
|
37
36
|
],
|
|
@@ -51,5 +50,5 @@ export function KeyHint() {
|
|
|
51
50
|
const hints = confirmAction
|
|
52
51
|
? [{ key: 'y', label: 'confirm' }, { key: 'n', label: 'cancel' }]
|
|
53
52
|
: viewHints[currentView] ?? [];
|
|
54
|
-
return
|
|
53
|
+
return _jsx(StatusBar, { items: hints });
|
|
55
54
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useTerminalSize, useAvailableHeight } from '@matthesketh/ink-viewport';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useTerminalSize, useAvailableHeight } from '@matthesketh/ink-viewport';
|
package/dist/tui/router.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { useReducer, useState, useEffect } from 'react';
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useReducer, useState, useEffect, useCallback } from 'react';
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { InputDispatcher } from '@matthesketh/ink-input-dispatcher';
|
|
5
|
+
import { Viewport } from '@matthesketh/ink-viewport';
|
|
6
|
+
import { ToastProvider } from '@matthesketh/ink-toast';
|
|
7
|
+
import { ToastContainer } from '@matthesketh/ink-toast';
|
|
8
|
+
import { KeyBindingHelp } from '@matthesketh/ink-keybinding-help';
|
|
9
|
+
import { reducer, initialState, AppStateContext, AppDispatchContext, nextTopView } from './state.js';
|
|
6
10
|
import { Header } from './components/Header.js';
|
|
7
11
|
import { KeyHint } from './components/KeyHint.js';
|
|
8
12
|
import { Confirm } from './components/Confirm.js';
|
|
@@ -13,6 +17,35 @@ import { SecretEdit } from './views/SecretEdit.js';
|
|
|
13
17
|
import { HealthView } from './views/HealthView.js';
|
|
14
18
|
import { LogsView } from './views/LogsView.js';
|
|
15
19
|
import { isSealed, isInitialized } from '../core/secrets.js';
|
|
20
|
+
const HELP_GROUPS = [
|
|
21
|
+
{
|
|
22
|
+
title: 'Navigation',
|
|
23
|
+
bindings: [
|
|
24
|
+
{ key: 'j/k', description: 'move up/down' },
|
|
25
|
+
{ key: 'Enter', description: 'select / confirm' },
|
|
26
|
+
{ key: 'Tab', description: 'switch view' },
|
|
27
|
+
{ key: 'Esc', description: 'go back' },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
title: 'Actions',
|
|
32
|
+
bindings: [
|
|
33
|
+
{ key: 'x', description: 'toggle redaction' },
|
|
34
|
+
{ key: 'f', description: 'follow logs' },
|
|
35
|
+
{ key: 'q', description: 'quit' },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
title: 'Secrets',
|
|
40
|
+
bindings: [
|
|
41
|
+
{ key: 'u', description: 'unseal vault' },
|
|
42
|
+
{ key: 'l', description: 'seal vault' },
|
|
43
|
+
{ key: 'a', description: 'add secret' },
|
|
44
|
+
{ key: 'd', description: 'delete secret' },
|
|
45
|
+
{ key: 'r', description: 'reveal / hide' },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
];
|
|
16
49
|
function ViewRouter() {
|
|
17
50
|
const state = React.useContext(AppStateContext);
|
|
18
51
|
switch (state.currentView) {
|
|
@@ -32,13 +65,11 @@ function ViewRouter() {
|
|
|
32
65
|
return _jsx(Dashboard, {});
|
|
33
66
|
}
|
|
34
67
|
}
|
|
35
|
-
|
|
36
|
-
useKeyboard();
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
68
|
+
const CHROME_ROWS = 6;
|
|
39
69
|
export function App() {
|
|
40
70
|
const [state, dispatch] = useReducer(reducer, initialState);
|
|
41
71
|
const [vaultSealed, setVaultSealed] = useState(true);
|
|
72
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
42
73
|
useEffect(() => {
|
|
43
74
|
try {
|
|
44
75
|
if (isInitialized()) {
|
|
@@ -61,5 +92,46 @@ export function App() {
|
|
|
61
92
|
}, 5000);
|
|
62
93
|
return () => clearInterval(interval);
|
|
63
94
|
}, []);
|
|
64
|
-
|
|
95
|
+
const globalHandler = useCallback((input, key) => {
|
|
96
|
+
if (showHelp) {
|
|
97
|
+
setShowHelp(false);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
if (state.confirmAction) {
|
|
101
|
+
if (input === 'y' || input === 'Y') {
|
|
102
|
+
state.confirmAction.onConfirm();
|
|
103
|
+
dispatch({ type: 'CANCEL_CONFIRM' });
|
|
104
|
+
}
|
|
105
|
+
else if (input === 'n' || input === 'N' || key.escape) {
|
|
106
|
+
dispatch({ type: 'CANCEL_CONFIRM' });
|
|
107
|
+
}
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
if (input === '?' && state.currentView !== 'secret-edit') {
|
|
111
|
+
setShowHelp(true);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
if (input === 'q' && state.currentView !== 'secret-edit') {
|
|
115
|
+
process.exit(0);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (input === 'x' && state.currentView !== 'secret-edit') {
|
|
119
|
+
dispatch({ type: 'TOGGLE_REDACT' });
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (key.tab) {
|
|
123
|
+
const topViews = ['dashboard', 'health', 'secrets'];
|
|
124
|
+
const base = topViews.includes(state.currentView)
|
|
125
|
+
? state.currentView
|
|
126
|
+
: state.previousView ?? 'dashboard';
|
|
127
|
+
dispatch({ type: 'NAVIGATE', view: nextTopView(base) });
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
if (key.escape && state.previousView) {
|
|
131
|
+
dispatch({ type: 'GO_BACK' });
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}, [state.confirmAction, state.currentView, state.previousView, showHelp]);
|
|
136
|
+
return (_jsx(AppStateContext.Provider, { value: state, children: _jsx(AppDispatchContext.Provider, { value: dispatch, children: _jsx(ToastProvider, { children: _jsx(InputDispatcher, { globalHandler: globalHandler, children: _jsxs(Viewport, { chrome: CHROME_ROWS, children: [_jsx(Header, { vaultSealed: vaultSealed }), _jsx(Box, { flexGrow: 1, flexDirection: "column", children: showHelp ? (_jsx(KeyBindingHelp, { groups: HELP_GROUPS, title: "Fleet TUI \u2014 Keyboard Shortcuts" })) : (_jsxs(_Fragment, { children: [_jsx(ViewRouter, {}), _jsx(Confirm, {}), state.error && (_jsx(Box, { paddingX: 1, children: _jsx(Box, { borderStyle: "round", borderColor: "red", paddingX: 1, children: _jsx(Text, { color: "red", children: state.error }) }) }))] })) }), _jsx(ToastContainer, {}), _jsx(KeyHint, {})] }) }) }) }) }));
|
|
65
137
|
}
|
package/dist/tui/state.js
CHANGED
|
@@ -9,6 +9,11 @@ export const initialState = {
|
|
|
9
9
|
loading: false,
|
|
10
10
|
error: null,
|
|
11
11
|
confirmAction: null,
|
|
12
|
+
dashboardIndex: 0,
|
|
13
|
+
healthIndex: 0,
|
|
14
|
+
secretsIndex: 0,
|
|
15
|
+
secretsSubView: 'app-list',
|
|
16
|
+
appDetailIndex: 0,
|
|
12
17
|
};
|
|
13
18
|
export function reducer(state, action) {
|
|
14
19
|
switch (action.type) {
|
|
@@ -26,6 +31,7 @@ export function reducer(state, action) {
|
|
|
26
31
|
currentView: state.previousView ?? 'dashboard',
|
|
27
32
|
previousView: null,
|
|
28
33
|
selectedSecret: null,
|
|
34
|
+
secretsSubView: 'app-list',
|
|
29
35
|
error: null,
|
|
30
36
|
confirmAction: null,
|
|
31
37
|
};
|
|
@@ -43,6 +49,15 @@ export function reducer(state, action) {
|
|
|
43
49
|
return { ...state, confirmAction: action.action };
|
|
44
50
|
case 'CANCEL_CONFIRM':
|
|
45
51
|
return { ...state, confirmAction: null };
|
|
52
|
+
case 'SET_INDEX': {
|
|
53
|
+
const key = `${action.view}Index`;
|
|
54
|
+
if (key in state) {
|
|
55
|
+
return { ...state, [key]: action.index };
|
|
56
|
+
}
|
|
57
|
+
return state;
|
|
58
|
+
}
|
|
59
|
+
case 'SET_SECRETS_SUBVIEW':
|
|
60
|
+
return { ...state, secretsSubView: action.subView, secretsIndex: 0 };
|
|
46
61
|
default:
|
|
47
62
|
return state;
|
|
48
63
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { TestApp } from './test-app.js';
|
|
5
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6
|
+
const apps = ['app-alpha', 'app-bravo', 'app-charlie'];
|
|
7
|
+
describe('no-flicker guarantees', () => {
|
|
8
|
+
it('tab switch produces no intermediate frames', async () => {
|
|
9
|
+
const { stdin, frames } = render(_jsx(TestApp, { items: apps }));
|
|
10
|
+
await delay(100);
|
|
11
|
+
const beforeCount = frames.length;
|
|
12
|
+
stdin.write('\t');
|
|
13
|
+
await delay(50);
|
|
14
|
+
// grab only the frames produced by this action
|
|
15
|
+
const newFrames = frames.slice(beforeCount);
|
|
16
|
+
// every new frame must show the destination view, never a blank or half-state
|
|
17
|
+
for (const frame of newFrames) {
|
|
18
|
+
expect(frame).toContain('view:');
|
|
19
|
+
// should not show a frame with view:dashboard after we switched to health
|
|
20
|
+
expect(frame).toContain('view:health');
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
it('enter on dashboard does not flash dashboard before showing detail', async () => {
|
|
24
|
+
const { stdin, frames } = render(_jsx(TestApp, { items: apps }));
|
|
25
|
+
await delay(100);
|
|
26
|
+
stdin.write('j');
|
|
27
|
+
await delay(50);
|
|
28
|
+
const beforeCount = frames.length;
|
|
29
|
+
stdin.write('\r');
|
|
30
|
+
await delay(50);
|
|
31
|
+
const newFrames = frames.slice(beforeCount);
|
|
32
|
+
// should have at least one frame with the detail view
|
|
33
|
+
const detailFrames = newFrames.filter(f => f.includes('view:app-detail'));
|
|
34
|
+
expect(detailFrames.length).toBeGreaterThan(0);
|
|
35
|
+
// no frame should show view:dashboard after the enter press
|
|
36
|
+
// (which would indicate SELECT_APP rendered before NAVIGATE)
|
|
37
|
+
for (const frame of newFrames) {
|
|
38
|
+
if (frame.includes('view:')) {
|
|
39
|
+
expect(frame).toContain('view:app-detail');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
it('arrow key navigation produces exactly one visual change', async () => {
|
|
44
|
+
const { stdin, frames } = render(_jsx(TestApp, { items: apps }));
|
|
45
|
+
await delay(100);
|
|
46
|
+
const beforeCount = frames.length;
|
|
47
|
+
stdin.write('\x1B[B'); // down arrow
|
|
48
|
+
await delay(50);
|
|
49
|
+
const newFrames = frames.slice(beforeCount);
|
|
50
|
+
// all new frames should show the cursor on app-bravo
|
|
51
|
+
for (const frame of newFrames) {
|
|
52
|
+
expect(frame).toContain('> app-bravo');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
it('escape from sub-view does not flash intermediate state', async () => {
|
|
56
|
+
const { stdin, frames } = render(_jsx(TestApp, { items: apps }));
|
|
57
|
+
await delay(100);
|
|
58
|
+
// go to detail
|
|
59
|
+
stdin.write('\r');
|
|
60
|
+
await delay(50);
|
|
61
|
+
expect(frames[frames.length - 1]).toContain('view:app-detail');
|
|
62
|
+
const beforeCount = frames.length;
|
|
63
|
+
stdin.write('\x1B');
|
|
64
|
+
await delay(50);
|
|
65
|
+
const newFrames = frames.slice(beforeCount);
|
|
66
|
+
for (const frame of newFrames) {
|
|
67
|
+
expect(frame).toContain('view:dashboard');
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
it('rapid key presses do not produce garbled frames', async () => {
|
|
71
|
+
const { stdin, frames } = render(_jsx(TestApp, { items: apps }));
|
|
72
|
+
await delay(100);
|
|
73
|
+
// rapid j-j-j
|
|
74
|
+
stdin.write('j');
|
|
75
|
+
stdin.write('j');
|
|
76
|
+
stdin.write('j');
|
|
77
|
+
await delay(100);
|
|
78
|
+
const lastFrame = frames[frames.length - 1];
|
|
79
|
+
// should be clamped at the last item
|
|
80
|
+
expect(lastFrame).toContain('> app-charlie');
|
|
81
|
+
// should still show the view label
|
|
82
|
+
expect(lastFrame).toContain('view:dashboard');
|
|
83
|
+
});
|
|
84
|
+
it('help overlay toggle produces no blank frames', async () => {
|
|
85
|
+
const { stdin, frames } = render(_jsx(TestApp, { items: apps }));
|
|
86
|
+
await delay(100);
|
|
87
|
+
const beforeCount = frames.length;
|
|
88
|
+
stdin.write('?');
|
|
89
|
+
await delay(50);
|
|
90
|
+
const helpFrames = frames.slice(beforeCount);
|
|
91
|
+
for (const frame of helpFrames) {
|
|
92
|
+
expect(frame).toContain('view:dashboard');
|
|
93
|
+
expect(frame).toContain('help-overlay');
|
|
94
|
+
}
|
|
95
|
+
const beforeDismiss = frames.length;
|
|
96
|
+
stdin.write('x');
|
|
97
|
+
await delay(50);
|
|
98
|
+
const dismissFrames = frames.slice(beforeDismiss);
|
|
99
|
+
for (const frame of dismissFrames) {
|
|
100
|
+
expect(frame).toContain('view:dashboard');
|
|
101
|
+
// help should be gone
|
|
102
|
+
expect(frame).not.toContain('help-overlay');
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { TestApp } from './test-app.js';
|
|
5
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6
|
+
const apps = ['app-alpha', 'app-bravo', 'app-charlie'];
|
|
7
|
+
describe('keyboard integration', () => {
|
|
8
|
+
it('tab switches between top-level views', async () => {
|
|
9
|
+
const { stdin, lastFrame } = render(_jsx(TestApp, { items: apps }));
|
|
10
|
+
await delay(100);
|
|
11
|
+
expect(lastFrame()).toContain('view:dashboard');
|
|
12
|
+
stdin.write('\t');
|
|
13
|
+
await delay(50);
|
|
14
|
+
expect(lastFrame()).toContain('view:health');
|
|
15
|
+
stdin.write('\t');
|
|
16
|
+
await delay(50);
|
|
17
|
+
expect(lastFrame()).toContain('view:secrets');
|
|
18
|
+
stdin.write('\t');
|
|
19
|
+
await delay(50);
|
|
20
|
+
expect(lastFrame()).toContain('view:dashboard');
|
|
21
|
+
});
|
|
22
|
+
it('arrow down moves selection in dashboard', async () => {
|
|
23
|
+
const { stdin, lastFrame } = render(_jsx(TestApp, { items: apps }));
|
|
24
|
+
await delay(100);
|
|
25
|
+
expect(lastFrame()).toContain('> app-alpha');
|
|
26
|
+
stdin.write('\x1B[B');
|
|
27
|
+
await delay(50);
|
|
28
|
+
expect(lastFrame()).toContain('> app-bravo');
|
|
29
|
+
stdin.write('\x1B[B');
|
|
30
|
+
await delay(50);
|
|
31
|
+
expect(lastFrame()).toContain('> app-charlie');
|
|
32
|
+
});
|
|
33
|
+
it('arrow up moves selection up in dashboard', async () => {
|
|
34
|
+
const { stdin, lastFrame } = render(_jsx(TestApp, { items: apps }));
|
|
35
|
+
await delay(100);
|
|
36
|
+
stdin.write('\x1B[B');
|
|
37
|
+
await delay(50);
|
|
38
|
+
stdin.write('\x1B[B');
|
|
39
|
+
await delay(50);
|
|
40
|
+
expect(lastFrame()).toContain('> app-charlie');
|
|
41
|
+
stdin.write('\x1B[A');
|
|
42
|
+
await delay(50);
|
|
43
|
+
expect(lastFrame()).toContain('> app-bravo');
|
|
44
|
+
});
|
|
45
|
+
it('j/k keys also navigate the list', async () => {
|
|
46
|
+
const { stdin, lastFrame } = render(_jsx(TestApp, { items: apps }));
|
|
47
|
+
await delay(100);
|
|
48
|
+
stdin.write('j');
|
|
49
|
+
await delay(50);
|
|
50
|
+
expect(lastFrame()).toContain('> app-bravo');
|
|
51
|
+
stdin.write('k');
|
|
52
|
+
await delay(50);
|
|
53
|
+
expect(lastFrame()).toContain('> app-alpha');
|
|
54
|
+
});
|
|
55
|
+
it('enter selects an app and navigates to detail', async () => {
|
|
56
|
+
const { stdin, lastFrame } = render(_jsx(TestApp, { items: apps }));
|
|
57
|
+
await delay(100);
|
|
58
|
+
stdin.write('j');
|
|
59
|
+
await delay(50);
|
|
60
|
+
stdin.write('\r');
|
|
61
|
+
await delay(50);
|
|
62
|
+
expect(lastFrame()).toContain('view:app-detail');
|
|
63
|
+
expect(lastFrame()).toContain('detail:app-bravo');
|
|
64
|
+
});
|
|
65
|
+
it('escape goes back from sub-view', async () => {
|
|
66
|
+
const { stdin, lastFrame } = render(_jsx(TestApp, { items: apps }));
|
|
67
|
+
await delay(100);
|
|
68
|
+
stdin.write('\r');
|
|
69
|
+
await delay(50);
|
|
70
|
+
expect(lastFrame()).toContain('view:app-detail');
|
|
71
|
+
stdin.write('\x1B');
|
|
72
|
+
await delay(50);
|
|
73
|
+
expect(lastFrame()).toContain('view:dashboard');
|
|
74
|
+
});
|
|
75
|
+
it('arrow keys are clamped at list boundaries', async () => {
|
|
76
|
+
const { stdin, lastFrame } = render(_jsx(TestApp, { items: apps }));
|
|
77
|
+
await delay(100);
|
|
78
|
+
stdin.write('\x1B[A');
|
|
79
|
+
await delay(50);
|
|
80
|
+
expect(lastFrame()).toContain('> app-alpha');
|
|
81
|
+
stdin.write('\x1B[B');
|
|
82
|
+
await delay(50);
|
|
83
|
+
stdin.write('\x1B[B');
|
|
84
|
+
await delay(50);
|
|
85
|
+
stdin.write('\x1B[B');
|
|
86
|
+
await delay(50);
|
|
87
|
+
expect(lastFrame()).toContain('> app-charlie');
|
|
88
|
+
});
|
|
89
|
+
it('? toggles help overlay and any key dismisses it', async () => {
|
|
90
|
+
const { stdin, lastFrame } = render(_jsx(TestApp, { items: apps }));
|
|
91
|
+
await delay(100);
|
|
92
|
+
stdin.write('?');
|
|
93
|
+
await delay(50);
|
|
94
|
+
expect(lastFrame()).toContain('help-overlay');
|
|
95
|
+
stdin.write('x');
|
|
96
|
+
await delay(50);
|
|
97
|
+
expect(lastFrame()).not.toContain('help-overlay');
|
|
98
|
+
});
|
|
99
|
+
it('tab works from a sub-view, using previousView as base', async () => {
|
|
100
|
+
const { stdin, lastFrame } = render(_jsx(TestApp, { items: apps }));
|
|
101
|
+
await delay(100);
|
|
102
|
+
stdin.write('\r');
|
|
103
|
+
await delay(50);
|
|
104
|
+
expect(lastFrame()).toContain('view:app-detail');
|
|
105
|
+
stdin.write('\t');
|
|
106
|
+
await delay(50);
|
|
107
|
+
expect(lastFrame()).toContain('view:health');
|
|
108
|
+
});
|
|
109
|
+
it('escape does nothing on top-level view with no previousView', async () => {
|
|
110
|
+
const { stdin, lastFrame } = render(_jsx(TestApp, { items: apps }));
|
|
111
|
+
await delay(100);
|
|
112
|
+
expect(lastFrame()).toContain('view:dashboard');
|
|
113
|
+
stdin.write('\x1B');
|
|
114
|
+
await delay(50);
|
|
115
|
+
expect(lastFrame()).toContain('view:dashboard');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useReducer, useState, useCallback } from 'react';
|
|
3
|
+
import { Text, Box } from 'ink';
|
|
4
|
+
import { InputDispatcher, useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
|
|
5
|
+
import { reducer, initialState, nextTopView } from '../state.js';
|
|
6
|
+
function MockDashboard({ state, dispatch, items, }) {
|
|
7
|
+
const handler = (input, key) => {
|
|
8
|
+
if (items.length === 0)
|
|
9
|
+
return false;
|
|
10
|
+
if (input === 'j' || key.downArrow) {
|
|
11
|
+
dispatch({ type: 'SET_INDEX', view: 'dashboard', index: Math.min(state.dashboardIndex + 1, items.length - 1) });
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
if (input === 'k' || key.upArrow) {
|
|
15
|
+
dispatch({ type: 'SET_INDEX', view: 'dashboard', index: Math.max(state.dashboardIndex - 1, 0) });
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
if (key.return) {
|
|
19
|
+
dispatch({ type: 'SELECT_APP', app: items[state.dashboardIndex] });
|
|
20
|
+
dispatch({ type: 'NAVIGATE', view: 'app-detail' });
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
};
|
|
25
|
+
useRegisterHandler(handler);
|
|
26
|
+
return (_jsx(Box, { flexDirection: "column", children: items.map((item, i) => (_jsxs(Text, { children: [i === state.dashboardIndex ? '> ' : ' ', item] }, item))) }));
|
|
27
|
+
}
|
|
28
|
+
export function TestApp({ items }) {
|
|
29
|
+
const [state, dispatch] = useReducer(reducer, initialState);
|
|
30
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
31
|
+
const globalHandler = useCallback((input, key) => {
|
|
32
|
+
if (showHelp) {
|
|
33
|
+
setShowHelp(false);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
if (state.confirmAction) {
|
|
37
|
+
if (input === 'y' || input === 'Y') {
|
|
38
|
+
state.confirmAction.onConfirm();
|
|
39
|
+
dispatch({ type: 'CANCEL_CONFIRM' });
|
|
40
|
+
}
|
|
41
|
+
else if (input === 'n' || input === 'N' || key.escape) {
|
|
42
|
+
dispatch({ type: 'CANCEL_CONFIRM' });
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
if (input === '?') {
|
|
47
|
+
setShowHelp(true);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
if (key.tab) {
|
|
51
|
+
const topViews = ['dashboard', 'health', 'secrets'];
|
|
52
|
+
const base = topViews.includes(state.currentView)
|
|
53
|
+
? state.currentView
|
|
54
|
+
: state.previousView ?? 'dashboard';
|
|
55
|
+
dispatch({ type: 'NAVIGATE', view: nextTopView(base) });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
if (key.escape && state.previousView) {
|
|
59
|
+
dispatch({ type: 'GO_BACK' });
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}, [state.confirmAction, state.currentView, state.previousView, showHelp]);
|
|
64
|
+
const renderView = () => {
|
|
65
|
+
switch (state.currentView) {
|
|
66
|
+
case 'dashboard':
|
|
67
|
+
return _jsx(MockDashboard, { state: state, dispatch: dispatch, items: items });
|
|
68
|
+
case 'health':
|
|
69
|
+
return _jsx(Text, { children: "health-view" });
|
|
70
|
+
case 'secrets':
|
|
71
|
+
return _jsx(Text, { children: "secrets-view" });
|
|
72
|
+
case 'app-detail':
|
|
73
|
+
return _jsxs(Text, { children: ["detail:", state.selectedApp] });
|
|
74
|
+
default:
|
|
75
|
+
return _jsx(MockDashboard, { state: state, dispatch: dispatch, items: items });
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
return (_jsx(InputDispatcher, { globalHandler: globalHandler, children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["view:", state.currentView] }), showHelp ? _jsx(Text, { children: "help-overlay" }) : renderView()] }) }));
|
|
79
|
+
}
|
package/dist/tui/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type View = 'dashboard' | 'app-detail' | 'health' | 'secrets' | 'secret-edit' | 'logs';
|
|
2
|
+
export type SecretsSubView = 'app-list' | 'secret-list';
|
|
2
3
|
export interface TuiState {
|
|
3
4
|
currentView: View;
|
|
4
5
|
previousView: View | null;
|
|
@@ -8,6 +9,11 @@ export interface TuiState {
|
|
|
8
9
|
loading: boolean;
|
|
9
10
|
error: string | null;
|
|
10
11
|
confirmAction: ConfirmAction | null;
|
|
12
|
+
dashboardIndex: number;
|
|
13
|
+
healthIndex: number;
|
|
14
|
+
secretsIndex: number;
|
|
15
|
+
secretsSubView: SecretsSubView;
|
|
16
|
+
appDetailIndex: number;
|
|
11
17
|
}
|
|
12
18
|
export interface ConfirmAction {
|
|
13
19
|
label: string;
|
|
@@ -38,4 +44,11 @@ export type Action = {
|
|
|
38
44
|
action: ConfirmAction;
|
|
39
45
|
} | {
|
|
40
46
|
type: 'CANCEL_CONFIRM';
|
|
47
|
+
} | {
|
|
48
|
+
type: 'SET_INDEX';
|
|
49
|
+
view: string;
|
|
50
|
+
index: number;
|
|
51
|
+
} | {
|
|
52
|
+
type: 'SET_SECRETS_SUBVIEW';
|
|
53
|
+
subView: SecretsSubView;
|
|
41
54
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useState } from 'react';
|
|
3
|
-
import { Box, Text
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
4
|
import Spinner from 'ink-spinner';
|
|
5
|
+
import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
|
|
5
6
|
import { useAppState, useAppDispatch, useRedact } from '../state.js';
|
|
6
7
|
import { runFleetCommand } from '../exec-bridge.js';
|
|
7
8
|
import { colors } from '../theme.js';
|
|
@@ -14,28 +15,49 @@ const ACTIONS = [
|
|
|
14
15
|
{ key: '5', label: 'Logs', command: ['logs'] },
|
|
15
16
|
];
|
|
16
17
|
export function AppDetail() {
|
|
17
|
-
const { selectedApp, redacted } = useAppState();
|
|
18
|
+
const { selectedApp, redacted, appDetailIndex } = useAppState();
|
|
18
19
|
const dispatch = useAppDispatch();
|
|
19
20
|
const redact = useRedact();
|
|
20
|
-
const [actionIndex, setActionIndex] = useState(0);
|
|
21
21
|
const [running, setRunning] = useState(false);
|
|
22
22
|
const [result, setResult] = useState(null);
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const [app, setApp] = useState(undefined);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (selectedApp) {
|
|
26
|
+
try {
|
|
27
|
+
const reg = load();
|
|
28
|
+
setApp(findApp(reg, selectedApp));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
setApp(undefined);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}, [selectedApp]);
|
|
35
|
+
function executeAction(action) {
|
|
36
|
+
if (!selectedApp)
|
|
27
37
|
return;
|
|
38
|
+
setRunning(true);
|
|
39
|
+
setResult(null);
|
|
40
|
+
runFleetCommand([...action.command, selectedApp]).then(res => {
|
|
41
|
+
setResult(res);
|
|
42
|
+
setRunning(false);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const handler = (input, key) => {
|
|
46
|
+
if (running)
|
|
47
|
+
return false;
|
|
28
48
|
if (input === 'j' || key.downArrow) {
|
|
29
|
-
|
|
49
|
+
dispatch({ type: 'SET_INDEX', view: 'appDetail', index: Math.min(appDetailIndex + 1, ACTIONS.length - 1) });
|
|
50
|
+
return true;
|
|
30
51
|
}
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
if (input === 'k' || key.upArrow) {
|
|
53
|
+
dispatch({ type: 'SET_INDEX', view: 'appDetail', index: Math.max(appDetailIndex - 1, 0) });
|
|
54
|
+
return true;
|
|
33
55
|
}
|
|
34
|
-
|
|
35
|
-
const action = ACTIONS[
|
|
56
|
+
if (key.return) {
|
|
57
|
+
const action = ACTIONS[appDetailIndex];
|
|
36
58
|
if (action.command[0] === 'logs') {
|
|
37
59
|
dispatch({ type: 'NAVIGATE', view: 'logs' });
|
|
38
|
-
return;
|
|
60
|
+
return true;
|
|
39
61
|
}
|
|
40
62
|
if (action.destructive) {
|
|
41
63
|
dispatch({
|
|
@@ -50,23 +72,16 @@ export function AppDetail() {
|
|
|
50
72
|
else {
|
|
51
73
|
executeAction(action);
|
|
52
74
|
}
|
|
75
|
+
return true;
|
|
53
76
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return;
|
|
58
|
-
setRunning(true);
|
|
59
|
-
setResult(null);
|
|
60
|
-
runFleetCommand([...action.command, selectedApp]).then(res => {
|
|
61
|
-
setResult(res);
|
|
62
|
-
setRunning(false);
|
|
63
|
-
});
|
|
64
|
-
}
|
|
77
|
+
return false;
|
|
78
|
+
};
|
|
79
|
+
useRegisterHandler(handler);
|
|
65
80
|
if (!app) {
|
|
66
81
|
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { color: colors.error, children: ["App not found: ", selectedApp] }) }));
|
|
67
82
|
}
|
|
68
83
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: colors.primary, children: redact(app.displayName || app.name) }), _jsxs(Box, { marginY: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: colors.muted, children: "Type: " }), app.type] }), _jsxs(Text, { children: [_jsx(Text, { color: colors.muted, children: "Service: " }), redacted ? '***' : app.serviceName] }), _jsxs(Text, { children: [_jsx(Text, { color: colors.muted, children: "Compose: " }), redacted ? '***' : app.composePath] }), app.domains.length > 0 && (_jsxs(Text, { children: [_jsx(Text, { color: colors.muted, children: "Domains: " }), redacted ? '***' : app.domains.join(', ')] })), app.port && (_jsxs(Text, { children: [_jsx(Text, { color: colors.muted, children: "Port: " }), app.port] })), _jsxs(Text, { children: [_jsx(Text, { color: colors.muted, children: "Containers:" }), " ", redacted ? '***' : app.containers.join(', ')] }), app.gitRepo && (_jsxs(Text, { children: [_jsx(Text, { color: colors.muted, children: "Git: " }), redacted ? '***' : app.gitRepo] }))] }), _jsx(Text, { bold: true, children: "Actions" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: ACTIONS.map((action, i) => {
|
|
69
|
-
const selected = i ===
|
|
84
|
+
const selected = i === appDetailIndex;
|
|
70
85
|
return (_jsxs(Text, { children: [_jsx(Text, { color: colors.primary, children: selected ? '> ' : ' ' }), _jsxs(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: ["[", action.key, "] ", action.label] }), action.destructive && _jsx(Text, { color: colors.warning, children: " !" })] }, action.key));
|
|
71
86
|
}) }), running && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsx(Spinner, { type: "dots" }), " Running..."] }) })), result && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: result.ok ? colors.success : colors.error, children: result.ok ? 'Done' : 'Failed' }), result.output && (_jsx(Text, { color: colors.muted, children: result.output.trim().slice(0, 500) }))] }))] }));
|
|
72
87
|
}
|
|
@@ -1,15 +1,42 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from 'react';
|
|
2
3
|
import { Box, Text } from 'ink';
|
|
3
4
|
import Spinner from 'ink-spinner';
|
|
4
|
-
import {
|
|
5
|
+
import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
|
|
6
|
+
import { ScrollableList } from '@matthesketh/ink-scrollable-list';
|
|
7
|
+
import { useAvailableHeight } from '@matthesketh/ink-viewport';
|
|
8
|
+
import { useAppState, useAppDispatch, useRedact } from '../state.js';
|
|
5
9
|
import { useFleetData } from '../hooks/use-fleet-data.js';
|
|
6
|
-
import { AppList } from '../components/AppList.js';
|
|
7
|
-
import { StatusBadge } from '../components/StatusBadge.js';
|
|
8
10
|
import { colors } from '../theme.js';
|
|
9
11
|
export function Dashboard() {
|
|
12
|
+
const state = useAppState();
|
|
10
13
|
const dispatch = useAppDispatch();
|
|
11
14
|
const { status, loading, error } = useFleetData();
|
|
12
15
|
const redact = useRedact();
|
|
16
|
+
const availableHeight = useAvailableHeight();
|
|
17
|
+
const items = useMemo(() => status?.apps.map(app => ({ ...app, name: app.name })) ?? [], [status]);
|
|
18
|
+
const handler = (input, key) => {
|
|
19
|
+
if (items.length === 0)
|
|
20
|
+
return false;
|
|
21
|
+
if (input === 'j' || key.downArrow) {
|
|
22
|
+
dispatch({ type: 'SET_INDEX', view: 'dashboard', index: Math.min(state.dashboardIndex + 1, items.length - 1) });
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
if (input === 'k' || key.upArrow) {
|
|
26
|
+
dispatch({ type: 'SET_INDEX', view: 'dashboard', index: Math.max(state.dashboardIndex - 1, 0) });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (key.return) {
|
|
30
|
+
const item = items[state.dashboardIndex];
|
|
31
|
+
if (item) {
|
|
32
|
+
dispatch({ type: 'SELECT_APP', app: item.name });
|
|
33
|
+
dispatch({ type: 'NAVIGATE', view: 'app-detail' });
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
};
|
|
39
|
+
useRegisterHandler(handler);
|
|
13
40
|
if (loading && !status) {
|
|
14
41
|
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { children: [_jsx(Spinner, { type: "dots" }), " Loading fleet status..."] }) }));
|
|
15
42
|
}
|
|
@@ -18,12 +45,10 @@ export function Dashboard() {
|
|
|
18
45
|
}
|
|
19
46
|
if (!status)
|
|
20
47
|
return _jsx(Text, { color: colors.muted, children: "No data" });
|
|
21
|
-
const
|
|
22
|
-
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsxs(Text, { bold: true, children: [status.totalApps, " apps"] }), _jsxs(Text, { color: colors.success, children: [status.healthy, " healthy"] }), status.unhealthy > 0 && (_jsxs(Text, { color: colors.error, children: [status.unhealthy, " unhealthy"] })), loading && _jsx(Text, { color: colors.muted, children: _jsx(Spinner, { type: "dots" }) })] }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, children: [
|
|
23
|
-
dispatch({ type: 'SELECT_APP', app: item.name });
|
|
24
|
-
dispatch({ type: 'NAVIGATE', view: 'app-detail' });
|
|
25
|
-
}, renderItem: (item, selected) => {
|
|
48
|
+
const listHeight = Math.max(5, availableHeight - 4);
|
|
49
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsxs(Text, { bold: true, children: [status.totalApps, " apps"] }), _jsxs(Text, { color: colors.success, children: [status.healthy, " healthy"] }), status.unhealthy > 0 && (_jsxs(Text, { color: colors.error, children: [status.unhealthy, " unhealthy"] })), loading && _jsx(Text, { color: colors.muted, children: _jsx(Spinner, { type: "dots" }) })] }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, children: ['APP'.padEnd(24), 'SYSTEMD'.padEnd(14), 'CONTAINERS'.padEnd(14), 'HEALTH'.padEnd(12)] }) }), _jsx(ScrollableList, { items: items, selectedIndex: Math.min(state.dashboardIndex, items.length - 1), maxVisible: listHeight, renderItem: (item, selected) => {
|
|
26
50
|
const app = status.apps.find(a => a.name === item.name);
|
|
27
|
-
|
|
51
|
+
const displayName = redact(app.name);
|
|
52
|
+
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: selected ? colors.primary : colors.muted, children: selected ? '> ' : ' ' }), _jsx(Box, { width: 24, children: _jsx(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: displayName.length > 22 ? displayName.slice(0, 19) + '...' : displayName }) }), _jsx(Box, { width: 14, children: _jsx(Text, { children: app.systemd.slice(0, 12) }) }), _jsx(Box, { width: 14, children: _jsx(Text, { children: app.containers }) }), _jsx(Box, { width: 12, children: _jsx(Text, { children: app.health.slice(0, 10) }) })] }));
|
|
28
53
|
} })] }));
|
|
29
54
|
}
|
|
@@ -1,28 +1,52 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from 'react';
|
|
2
3
|
import { Box, Text } from 'ink';
|
|
3
4
|
import Spinner from 'ink-spinner';
|
|
5
|
+
import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
|
|
6
|
+
import { ScrollableList } from '@matthesketh/ink-scrollable-list';
|
|
7
|
+
import { useAvailableHeight } from '@matthesketh/ink-viewport';
|
|
4
8
|
import { useHealth } from '../hooks/use-health.js';
|
|
5
9
|
import { StatusBadge } from '../components/StatusBadge.js';
|
|
6
|
-
import { useRedact } from '../state.js';
|
|
10
|
+
import { useAppState, useAppDispatch, useRedact } from '../state.js';
|
|
7
11
|
import { colors } from '../theme.js';
|
|
8
12
|
export function HealthView() {
|
|
13
|
+
const state = useAppState();
|
|
14
|
+
const dispatch = useAppDispatch();
|
|
9
15
|
const { results, loading, error } = useHealth();
|
|
10
16
|
const redact = useRedact();
|
|
17
|
+
const availableHeight = useAvailableHeight();
|
|
18
|
+
const counts = useMemo(() => ({
|
|
19
|
+
healthy: results.filter(r => r.overall === 'healthy').length,
|
|
20
|
+
degraded: results.filter(r => r.overall === 'degraded').length,
|
|
21
|
+
down: results.filter(r => r.overall === 'down').length,
|
|
22
|
+
}), [results]);
|
|
23
|
+
const handler = (input, key) => {
|
|
24
|
+
if (results.length === 0)
|
|
25
|
+
return false;
|
|
26
|
+
if (input === 'j' || key.downArrow) {
|
|
27
|
+
dispatch({ type: 'SET_INDEX', view: 'health', index: Math.min(state.healthIndex + 1, results.length - 1) });
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
if (input === 'k' || key.upArrow) {
|
|
31
|
+
dispatch({ type: 'SET_INDEX', view: 'health', index: Math.max(state.healthIndex - 1, 0) });
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
};
|
|
36
|
+
useRegisterHandler(handler);
|
|
11
37
|
if (loading && results.length === 0) {
|
|
12
38
|
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { children: [_jsx(Spinner, { type: "dots" }), " Running health checks..."] }) }));
|
|
13
39
|
}
|
|
14
40
|
if (error && results.length === 0) {
|
|
15
41
|
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { color: colors.error, children: ["Error: ", error] }) }));
|
|
16
42
|
}
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
? result.http
|
|
25
|
-
|
|
26
|
-
return (_jsxs(Box, { children: [_jsxs(Text, { children: [' ', redact(result.app).padEnd(24)] }), _jsx(Box, { width: 12, children: _jsx(StatusBadge, { value: result.systemd.state, type: "systemd" }) }), _jsx(Text, { children: containerStr.padEnd(20) }), _jsx(Box, { width: 10, children: _jsx(Text, { color: result.http?.ok ? colors.success : result.http ? colors.error : colors.muted, children: httpStr }) }), _jsx(StatusBadge, { value: result.overall, type: "health" })] }, result.app));
|
|
27
|
-
})] }));
|
|
43
|
+
const listHeight = Math.max(5, availableHeight - 4);
|
|
44
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsx(Text, { bold: true, children: "Health Monitor" }), _jsxs(Text, { color: colors.success, children: [counts.healthy, " healthy"] }), counts.degraded > 0 && _jsxs(Text, { color: colors.warning, children: [counts.degraded, " degraded"] }), counts.down > 0 && _jsxs(Text, { color: colors.error, children: [counts.down, " down"] }), loading && _jsx(Text, { color: colors.muted, children: _jsx(Spinner, { type: "dots" }) })] }), _jsxs(Text, { bold: true, children: [' APP'.padEnd(26), 'SYSTEMD'.padEnd(12), 'CONTAINERS'.padEnd(20), 'HTTP'.padEnd(10), "OVERALL"] }), _jsx(ScrollableList, { items: results, selectedIndex: Math.min(state.healthIndex, results.length - 1), maxVisible: listHeight, renderItem: (result, selected) => {
|
|
45
|
+
const runningCount = result.containers.filter(c => c.running).length;
|
|
46
|
+
const containerStr = `${runningCount}/${result.containers.length}`;
|
|
47
|
+
const httpStr = result.http
|
|
48
|
+
? result.http.ok ? `${result.http.status}` : 'err'
|
|
49
|
+
: 'n/a';
|
|
50
|
+
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: selected ? colors.primary : colors.muted, children: selected ? '> ' : ' ' }), _jsx(Text, { children: redact(result.app).padEnd(24) }), _jsx(Box, { width: 12, children: _jsx(StatusBadge, { value: result.systemd.state, type: "systemd" }) }), _jsx(Text, { children: containerStr.padEnd(20) }), _jsx(Box, { width: 10, children: _jsx(Text, { color: result.http?.ok ? colors.success : result.http ? colors.error : colors.muted, children: httpStr }) }), _jsx(StatusBadge, { value: result.overall, type: "health" })] }));
|
|
51
|
+
} })] }));
|
|
28
52
|
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect, useRef } from 'react';
|
|
3
|
-
import { Box, Text
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
4
|
import Spinner from 'ink-spinner';
|
|
5
|
+
import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
|
|
6
|
+
import { useAvailableHeight } from '@matthesketh/ink-viewport';
|
|
5
7
|
import { useAppState, useAppDispatch, useRedact } from '../state.js';
|
|
6
8
|
import { runFleetCommand, streamFleetCommand } from '../exec-bridge.js';
|
|
7
9
|
import { colors } from '../theme.js';
|
|
8
|
-
const MAX_LINES =
|
|
10
|
+
const MAX_LINES = 200;
|
|
9
11
|
export function LogsView() {
|
|
10
12
|
const { selectedApp } = useAppState();
|
|
11
13
|
const dispatch = useAppDispatch();
|
|
12
14
|
const redact = useRedact();
|
|
15
|
+
const availableHeight = useAvailableHeight();
|
|
13
16
|
const [lines, setLines] = useState([]);
|
|
14
17
|
const [following, setFollowing] = useState(false);
|
|
15
18
|
const [loading, setLoading] = useState(true);
|
|
@@ -34,10 +37,9 @@ export function LogsView() {
|
|
|
34
37
|
}
|
|
35
38
|
};
|
|
36
39
|
}, [selectedApp]);
|
|
37
|
-
|
|
40
|
+
const handler = (input, key) => {
|
|
38
41
|
if (input === 'f') {
|
|
39
42
|
if (following) {
|
|
40
|
-
// Stop following
|
|
41
43
|
if (streamRef.current) {
|
|
42
44
|
streamRef.current.kill();
|
|
43
45
|
streamRef.current = null;
|
|
@@ -45,7 +47,6 @@ export function LogsView() {
|
|
|
45
47
|
setFollowing(false);
|
|
46
48
|
}
|
|
47
49
|
else if (selectedApp) {
|
|
48
|
-
// Start following
|
|
49
50
|
setFollowing(true);
|
|
50
51
|
const handle = streamFleetCommand(['logs', selectedApp, '-f']);
|
|
51
52
|
streamRef.current = handle;
|
|
@@ -53,19 +54,23 @@ export function LogsView() {
|
|
|
53
54
|
setLines(prev => [...prev.slice(-MAX_LINES + 1), line]);
|
|
54
55
|
});
|
|
55
56
|
}
|
|
57
|
+
return true;
|
|
56
58
|
}
|
|
57
|
-
|
|
59
|
+
if (key.escape) {
|
|
58
60
|
if (streamRef.current) {
|
|
59
61
|
streamRef.current.kill();
|
|
60
62
|
streamRef.current = null;
|
|
61
63
|
}
|
|
62
64
|
dispatch({ type: 'GO_BACK' });
|
|
65
|
+
return true;
|
|
63
66
|
}
|
|
64
|
-
|
|
67
|
+
return false;
|
|
68
|
+
};
|
|
69
|
+
useRegisterHandler(handler);
|
|
65
70
|
if (loading) {
|
|
66
71
|
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { children: [_jsx(Spinner, { type: "dots" }), " Loading logs for ", selectedApp, "..."] }) }));
|
|
67
72
|
}
|
|
68
|
-
|
|
69
|
-
const visibleLines = lines.slice(-
|
|
73
|
+
const visibleCount = Math.max(5, availableHeight - 3);
|
|
74
|
+
const visibleLines = lines.slice(-visibleCount);
|
|
70
75
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsxs(Text, { bold: true, color: colors.primary, children: ["Logs: ", redact(selectedApp ?? '')] }), following && (_jsxs(Text, { color: colors.success, children: [_jsx(Spinner, { type: "dots" }), " following"] }))] }), _jsx(Box, { flexDirection: "column", children: visibleLines.map((line, i) => (_jsx(Text, { wrap: "truncate", children: line }, i))) })] }));
|
|
71
76
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
|
-
import { Box, Text
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
4
|
import TextInput from 'ink-text-input';
|
|
5
|
+
import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
|
|
5
6
|
import { useAppState, useAppDispatch } from '../state.js';
|
|
6
7
|
import { useSecrets } from '../hooks/use-secrets.js';
|
|
7
8
|
import { getSecret as getCoreSecret } from '../../core/secrets-ops.js';
|
|
@@ -26,7 +27,7 @@ export function SecretEdit() {
|
|
|
26
27
|
// ignore
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
|
-
}, []);
|
|
30
|
+
}, [isNew, selectedApp, selectedSecret]);
|
|
30
31
|
const save = () => {
|
|
31
32
|
if (!selectedApp || !keyName)
|
|
32
33
|
return;
|
|
@@ -41,11 +42,14 @@ export function SecretEdit() {
|
|
|
41
42
|
setStatus(`Error: ${result.error}`);
|
|
42
43
|
}
|
|
43
44
|
};
|
|
44
|
-
|
|
45
|
+
const handler = (_input, key) => {
|
|
45
46
|
if (key.escape) {
|
|
46
47
|
dispatch({ type: 'GO_BACK' });
|
|
48
|
+
return true;
|
|
47
49
|
}
|
|
48
|
-
|
|
50
|
+
return false;
|
|
51
|
+
};
|
|
52
|
+
useRegisterHandler(handler);
|
|
49
53
|
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { bold: true, color: colors.primary, children: [isNew ? 'Add Secret' : 'Edit Secret', " - ", selectedApp] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", gap: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: colors.muted, children: "Key: " }), isNew && phase === 'key' ? (_jsx(TextInput, { value: keyName, onChange: setKeyName, onSubmit: () => {
|
|
50
54
|
if (keyName)
|
|
51
55
|
setPhase('value');
|
|
@@ -1,72 +1,84 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect,
|
|
3
|
-
import { Box, Text
|
|
2
|
+
import { useEffect, useCallback } from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { useRegisterHandler } from '@matthesketh/ink-input-dispatcher';
|
|
5
|
+
import { ScrollableList } from '@matthesketh/ink-scrollable-list';
|
|
6
|
+
import { useAvailableHeight } from '@matthesketh/ink-viewport';
|
|
4
7
|
import { useAppState, useAppDispatch, useRedact } from '../state.js';
|
|
5
8
|
import { useSecrets } from '../hooks/use-secrets.js';
|
|
6
9
|
import { colors } from '../theme.js';
|
|
7
10
|
export function SecretsView() {
|
|
8
|
-
const
|
|
11
|
+
const state = useAppState();
|
|
9
12
|
const dispatch = useAppDispatch();
|
|
10
13
|
const redact = useRedact();
|
|
11
14
|
const secrets = useSecrets();
|
|
12
|
-
const
|
|
13
|
-
const
|
|
15
|
+
const availableHeight = useAvailableHeight();
|
|
16
|
+
const { secretsSubView: subView, secretsIndex: selectedIndex, selectedApp } = state;
|
|
17
|
+
const refresh = secrets.refresh;
|
|
14
18
|
useEffect(() => {
|
|
15
|
-
|
|
16
|
-
}, []);
|
|
19
|
+
refresh();
|
|
20
|
+
}, [refresh]);
|
|
17
21
|
useEffect(() => {
|
|
18
22
|
if (subView === 'secret-list' && selectedApp) {
|
|
19
23
|
secrets.loadAppSecrets(selectedApp);
|
|
20
24
|
}
|
|
21
|
-
}, [subView, selectedApp]);
|
|
22
|
-
|
|
25
|
+
}, [subView, selectedApp, secrets.loadAppSecrets]);
|
|
26
|
+
const handler = useCallback((input, key) => {
|
|
23
27
|
if (subView === 'app-list') {
|
|
24
28
|
if (input === 'j' || key.downArrow) {
|
|
25
|
-
|
|
29
|
+
dispatch({ type: 'SET_INDEX', view: 'secrets', index: Math.min(selectedIndex + 1, secrets.apps.length - 1) });
|
|
30
|
+
return true;
|
|
26
31
|
}
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
if (input === 'k' || key.upArrow) {
|
|
33
|
+
dispatch({ type: 'SET_INDEX', view: 'secrets', index: Math.max(selectedIndex - 1, 0) });
|
|
34
|
+
return true;
|
|
29
35
|
}
|
|
30
|
-
|
|
36
|
+
if (key.return && secrets.apps[selectedIndex]) {
|
|
31
37
|
dispatch({ type: 'SELECT_APP', app: secrets.apps[selectedIndex].app });
|
|
32
|
-
|
|
33
|
-
|
|
38
|
+
dispatch({ type: 'SET_SECRETS_SUBVIEW', subView: 'secret-list' });
|
|
39
|
+
return true;
|
|
34
40
|
}
|
|
35
|
-
|
|
41
|
+
if (input === 'u') {
|
|
36
42
|
const result = secrets.unseal();
|
|
37
43
|
if (!result.ok) {
|
|
38
44
|
dispatch({ type: 'SET_ERROR', error: result.error ?? 'Unseal failed' });
|
|
39
45
|
}
|
|
40
46
|
secrets.refresh();
|
|
47
|
+
return true;
|
|
41
48
|
}
|
|
42
|
-
|
|
49
|
+
if (input === 'l') {
|
|
43
50
|
const result = secrets.seal();
|
|
44
51
|
if (!result.ok) {
|
|
45
52
|
dispatch({ type: 'SET_ERROR', error: result.error ?? 'Seal failed' });
|
|
46
53
|
}
|
|
47
54
|
secrets.refresh();
|
|
55
|
+
return true;
|
|
48
56
|
}
|
|
49
57
|
}
|
|
50
58
|
else if (subView === 'secret-list') {
|
|
51
59
|
if (input === 'j' || key.downArrow) {
|
|
52
|
-
|
|
60
|
+
dispatch({ type: 'SET_INDEX', view: 'secrets', index: Math.min(selectedIndex + 1, secrets.secrets.length - 1) });
|
|
61
|
+
return true;
|
|
53
62
|
}
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
if (input === 'k' || key.upArrow) {
|
|
64
|
+
dispatch({ type: 'SET_INDEX', view: 'secrets', index: Math.max(selectedIndex - 1, 0) });
|
|
65
|
+
return true;
|
|
56
66
|
}
|
|
57
|
-
|
|
67
|
+
if (key.return && secrets.secrets[selectedIndex] && selectedApp) {
|
|
58
68
|
dispatch({ type: 'SELECT_SECRET', key: secrets.secrets[selectedIndex].key });
|
|
59
69
|
dispatch({ type: 'NAVIGATE', view: 'secret-edit' });
|
|
70
|
+
return true;
|
|
60
71
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
if (key.escape) {
|
|
73
|
+
dispatch({ type: 'SET_SECRETS_SUBVIEW', subView: 'app-list' });
|
|
74
|
+
return true;
|
|
64
75
|
}
|
|
65
|
-
|
|
76
|
+
if (input === 'a' && selectedApp) {
|
|
66
77
|
dispatch({ type: 'SELECT_SECRET', key: null });
|
|
67
78
|
dispatch({ type: 'NAVIGATE', view: 'secret-edit' });
|
|
79
|
+
return true;
|
|
68
80
|
}
|
|
69
|
-
|
|
81
|
+
if (input === 'd' && selectedApp && secrets.secrets[selectedIndex]) {
|
|
70
82
|
const secretKey = secrets.secrets[selectedIndex].key;
|
|
71
83
|
dispatch({
|
|
72
84
|
type: 'CONFIRM',
|
|
@@ -85,8 +97,9 @@ export function SecretsView() {
|
|
|
85
97
|
},
|
|
86
98
|
},
|
|
87
99
|
});
|
|
100
|
+
return true;
|
|
88
101
|
}
|
|
89
|
-
|
|
102
|
+
if (input === 'r' && selectedApp && secrets.secrets[selectedIndex]) {
|
|
90
103
|
const secretKey = secrets.secrets[selectedIndex].key;
|
|
91
104
|
if (secrets.revealedValues[secretKey]) {
|
|
92
105
|
secrets.hideSecret(secretKey);
|
|
@@ -94,15 +107,15 @@ export function SecretsView() {
|
|
|
94
107
|
else {
|
|
95
108
|
secrets.revealSecret(selectedApp, secretKey);
|
|
96
109
|
}
|
|
110
|
+
return true;
|
|
97
111
|
}
|
|
98
112
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
})) })] }))] }));
|
|
113
|
+
return false;
|
|
114
|
+
}, [subView, selectedIndex, selectedApp, secrets, dispatch, redact]);
|
|
115
|
+
useRegisterHandler(handler);
|
|
116
|
+
const listHeight = Math.max(5, availableHeight - 5);
|
|
117
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, paddingX: 1, gap: 2, children: [_jsx(Text, { bold: true, children: "Vault:" }), !secrets.initialized ? (_jsx(Text, { color: colors.error, children: "Not initialized" })) : secrets.sealed ? (_jsx(Text, { color: colors.warning, bold: true, children: "SEALED" })) : (_jsx(Text, { color: colors.success, bold: true, children: "UNSEALED" })), _jsxs(Text, { color: colors.muted, children: [secrets.apps.length, " apps | ", secrets.apps.reduce((sum, a) => sum + a.keyCount, 0), " keys"] })] }), secrets.error && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.error, children: secrets.error }) })), subView === 'app-list' ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Apps with secrets:" }), _jsx(ScrollableList, { items: secrets.apps, selectedIndex: Math.min(selectedIndex, secrets.apps.length - 1), maxVisible: listHeight, emptyText: " No secrets managed", renderItem: (app, selected) => (_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, children: selected ? '> ' : ' ' }), _jsx(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: redact(app.app).padEnd(24) }), _jsx(Text, { color: colors.muted, children: app.type.padEnd(14) }), _jsxs(Text, { children: [String(app.keyCount).padEnd(8), " keys"] })] })) })] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: colors.primary, children: redact(selectedApp ?? '') }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(ScrollableList, { items: secrets.secrets, selectedIndex: Math.min(selectedIndex, secrets.secrets.length - 1), maxVisible: listHeight, emptyText: " No secrets found", renderItem: (secret, selected) => {
|
|
118
|
+
const revealed = secrets.revealedValues[secret.key];
|
|
119
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, children: selected ? '> ' : ' ' }), _jsx(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: secret.key.padEnd(30) }), _jsx(Text, { color: revealed ? colors.warning : colors.muted, children: revealed ?? secret.maskedValue })] }));
|
|
120
|
+
} }) })] }))] }));
|
|
108
121
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matthesketh/fleet",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Docker production management CLI + MCP server for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"LICENSE",
|
|
14
14
|
"README.md"
|
|
15
15
|
],
|
|
16
|
+
"workspaces": ["packages/*"],
|
|
16
17
|
"scripts": {
|
|
17
18
|
"build": "tsc",
|
|
18
19
|
"dev": "tsx src/index.ts",
|
|
@@ -48,6 +49,21 @@
|
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
50
51
|
"@modelcontextprotocol/sdk": "1.8.0",
|
|
52
|
+
"@matthesketh/ink-breadcrumb": "*",
|
|
53
|
+
"@matthesketh/ink-gauge": "*",
|
|
54
|
+
"@matthesketh/ink-input-dispatcher": "*",
|
|
55
|
+
"@matthesketh/ink-keybinding-help": "*",
|
|
56
|
+
"@matthesketh/ink-log-viewer": "*",
|
|
57
|
+
"@matthesketh/ink-modal": "*",
|
|
58
|
+
"@matthesketh/ink-pipeline": "*",
|
|
59
|
+
"@matthesketh/ink-rule": "*",
|
|
60
|
+
"@matthesketh/ink-scrollable-list": "*",
|
|
61
|
+
"@matthesketh/ink-split-pane": "*",
|
|
62
|
+
"@matthesketh/ink-status-bar": "*",
|
|
63
|
+
"@matthesketh/ink-table": "*",
|
|
64
|
+
"@matthesketh/ink-tabs": "*",
|
|
65
|
+
"@matthesketh/ink-toast": "*",
|
|
66
|
+
"@matthesketh/ink-viewport": "*",
|
|
51
67
|
"ink": "^5.2.1",
|
|
52
68
|
"ink-spinner": "^5.0.0",
|
|
53
69
|
"ink-text-input": "^6.0.0",
|