@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.
@@ -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 { Box, Text } from 'ink';
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
- if (!confirmAction)
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 TABS = [
6
- { view: 'dashboard', label: 'Dashboard' },
7
- { view: 'health', label: 'Health' },
8
- { view: 'secrets', label: 'Secrets' },
7
+ const TAB_ITEMS = [
8
+ { id: 'dashboard', label: 'Dashboard' },
9
+ { id: 'health', label: 'Health' },
10
+ { id: 'secrets', label: 'Secrets' },
9
11
  ];
10
- function VaultIndicator({ sealed }) {
11
- return (_jsx(Text, { color: sealed ? colors.warning : colors.success, children: sealed ? '[SEALED]' : '[UNSEALED]' }));
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
- return (_jsxs(Box, { borderStyle: "single", borderBottom: true, paddingX: 1, justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, color: colors.primary, children: "Fleet" }), _jsx(Text, { color: colors.muted, children: "|" }), TABS.map(tab => (_jsx(Text, { bold: currentView === tab.view || currentView === 'app-detail' && tab.view === 'dashboard' || currentView === 'secret-edit' && tab.view === 'secrets' || currentView === 'logs' && tab.view === 'dashboard', color: currentView === tab.view ? colors.primary : colors.muted, children: tab.label }, tab.view)))] }), _jsxs(Box, { gap: 1, children: [redacted && _jsx(Text, { color: "magenta", bold: true, children: "[REDACTED]" }), _jsx(VaultIndicator, { sealed: vaultSealed })] })] }));
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, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
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 (_jsx(Box, { borderStyle: "single", borderTop: true, paddingX: 1, gap: 2, children: hints.map(hint => (_jsxs(Box, { gap: 0, children: [_jsx(Text, { bold: true, color: colors.primary, children: hint.key }), _jsxs(Text, { color: colors.muted, children: [" ", hint.label] })] }, hint.key))) }));
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';
@@ -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 { reducer, initialState, AppStateContext, AppDispatchContext } from './state.js';
5
- import { useKeyboard } from './hooks/use-keyboard.js';
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
- function KeyboardHandler() {
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
- return (_jsx(AppStateContext.Provider, { value: state, children: _jsxs(AppDispatchContext.Provider, { value: dispatch, children: [_jsx(KeyboardHandler, {}), _jsxs(Box, { flexDirection: "column", height: process.stdout.rows || 24, children: [_jsx(Header, { vaultSealed: vaultSealed }), _jsxs(Box, { flexGrow: 1, flexDirection: "column", 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(KeyHint, {})] })] }) }));
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,4 @@
1
+ import React from 'react';
2
+ export declare function TestApp({ items }: {
3
+ items: string[];
4
+ }): React.JSX.Element;
@@ -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
+ }
@@ -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, useInput } from 'ink';
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 reg = load();
24
- const app = selectedApp ? findApp(reg, selectedApp) : undefined;
25
- useInput((input, key) => {
26
- if (running)
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
- setActionIndex(prev => Math.min(prev + 1, ACTIONS.length - 1));
49
+ dispatch({ type: 'SET_INDEX', view: 'appDetail', index: Math.min(appDetailIndex + 1, ACTIONS.length - 1) });
50
+ return true;
30
51
  }
31
- else if (input === 'k' || key.upArrow) {
32
- setActionIndex(prev => Math.max(prev - 1, 0));
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
- else if (key.return) {
35
- const action = ACTIONS[actionIndex];
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
- function executeAction(action) {
56
- if (!selectedApp)
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 === actionIndex;
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 { useAppDispatch, useRedact } from '../state.js';
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 items = status.apps.map(app => ({ ...app, name: app.name }));
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: [" ", 'APP'.padEnd(24), 'SYSTEMD'.padEnd(14), 'CONTAINERS'.padEnd(14), "HEALTH"] }) }), _jsx(AppList, { items: items, onSelect: (item) => {
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
- return (_jsxs(Box, { children: [_jsx(Text, { bold: selected, color: selected ? colors.primary : colors.text, children: redact(app.name).padEnd(22) }), _jsx(Text, { children: " " }), _jsx(Box, { width: 14, children: _jsx(StatusBadge, { value: app.systemd, type: "systemd" }) }), _jsx(Box, { width: 14, children: _jsx(Text, { children: app.containers }) }), _jsx(StatusBadge, { value: app.health, type: "health" })] }));
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 healthy = results.filter(r => r.overall === 'healthy').length;
18
- const degraded = results.filter(r => r.overall === 'degraded').length;
19
- const down = results.filter(r => r.overall === 'down').length;
20
- 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: [healthy, " healthy"] }), degraded > 0 && _jsxs(Text, { color: colors.warning, children: [degraded, " degraded"] }), down > 0 && _jsxs(Text, { color: colors.error, children: [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"] }), results.map(result => {
21
- const runningCount = result.containers.filter(c => c.running).length;
22
- const containerStr = `${runningCount}/${result.containers.length}`;
23
- const httpStr = result.http
24
- ? result.http.ok ? `${result.http.status}` : `err`
25
- : 'n/a';
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, useInput } from 'ink';
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 = 100;
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
- useInput((input, key) => {
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
- else if (key.escape) {
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
- // Show last N lines that fit in terminal
69
- const visibleLines = lines.slice(-30);
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, useInput } from 'ink';
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
- useInput((input, key) => {
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, useState } from 'react';
3
- import { Box, Text, useInput } from 'ink';
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 { selectedApp } = useAppState();
11
+ const state = useAppState();
9
12
  const dispatch = useAppDispatch();
10
13
  const redact = useRedact();
11
14
  const secrets = useSecrets();
12
- const [subView, setSubView] = useState('app-list');
13
- const [selectedIndex, setSelectedIndex] = useState(0);
15
+ const availableHeight = useAvailableHeight();
16
+ const { secretsSubView: subView, secretsIndex: selectedIndex, selectedApp } = state;
17
+ const refresh = secrets.refresh;
14
18
  useEffect(() => {
15
- secrets.refresh();
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
- useInput((input, key) => {
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
- setSelectedIndex(prev => Math.min(prev + 1, secrets.apps.length - 1));
29
+ dispatch({ type: 'SET_INDEX', view: 'secrets', index: Math.min(selectedIndex + 1, secrets.apps.length - 1) });
30
+ return true;
26
31
  }
27
- else if (input === 'k' || key.upArrow) {
28
- setSelectedIndex(prev => Math.max(prev - 1, 0));
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
- else if (key.return && secrets.apps[selectedIndex]) {
36
+ if (key.return && secrets.apps[selectedIndex]) {
31
37
  dispatch({ type: 'SELECT_APP', app: secrets.apps[selectedIndex].app });
32
- setSubView('secret-list');
33
- setSelectedIndex(0);
38
+ dispatch({ type: 'SET_SECRETS_SUBVIEW', subView: 'secret-list' });
39
+ return true;
34
40
  }
35
- else if (input === 'u') {
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
- else if (input === 'l') {
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
- setSelectedIndex(prev => Math.min(prev + 1, secrets.secrets.length - 1));
60
+ dispatch({ type: 'SET_INDEX', view: 'secrets', index: Math.min(selectedIndex + 1, secrets.secrets.length - 1) });
61
+ return true;
53
62
  }
54
- else if (input === 'k' || key.upArrow) {
55
- setSelectedIndex(prev => Math.max(prev - 1, 0));
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
- else if (key.return && secrets.secrets[selectedIndex] && selectedApp) {
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
- else if (key.escape) {
62
- setSubView('app-list');
63
- setSelectedIndex(0);
72
+ if (key.escape) {
73
+ dispatch({ type: 'SET_SECRETS_SUBVIEW', subView: 'app-list' });
74
+ return true;
64
75
  }
65
- else if (input === 'a' && selectedApp) {
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
- else if (input === 'd' && selectedApp && secrets.secrets[selectedIndex]) {
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
- else if (input === 'r' && selectedApp && secrets.secrets[selectedIndex]) {
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
- 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:" }), secrets.apps.length === 0 ? (_jsx(Text, { color: colors.muted, children: " No secrets managed" })) : (secrets.apps.map((app, i) => {
101
- const selected = i === selectedIndex;
102
- return (_jsxs(Text, { 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"] })] }, app.app));
103
- }))] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: colors.primary, children: redact(selectedApp ?? '') }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: secrets.secrets.length === 0 ? (_jsx(Text, { color: colors.muted, children: " No secrets found" })) : (secrets.secrets.map((secret, i) => {
104
- const selected = i === selectedIndex;
105
- const revealed = secrets.revealedValues[secret.key];
106
- return (_jsxs(Text, { 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 })] }, secret.key));
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.1.0",
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",