@matthesketh/fleet 1.0.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/README.md +27 -4
- package/dist/cli.js +8 -0
- package/dist/commands/deps.d.ts +1 -0
- package/dist/commands/deps.js +223 -0
- package/dist/commands/motd.d.ts +1 -0
- package/dist/commands/motd.js +10 -0
- package/dist/core/deps/actors/pr-creator.d.ts +14 -0
- package/dist/core/deps/actors/pr-creator.js +103 -0
- package/dist/core/deps/cache.d.ts +5 -0
- package/dist/core/deps/cache.js +28 -0
- package/dist/core/deps/collectors/composer.d.ts +12 -0
- package/dist/core/deps/collectors/composer.js +70 -0
- package/dist/core/deps/collectors/docker-image.d.ts +18 -0
- package/dist/core/deps/collectors/docker-image.js +132 -0
- package/dist/core/deps/collectors/docker-running.d.ts +17 -0
- package/dist/core/deps/collectors/docker-running.js +55 -0
- package/dist/core/deps/collectors/eol.d.ts +16 -0
- package/dist/core/deps/collectors/eol.js +139 -0
- package/dist/core/deps/collectors/github-pr.d.ts +8 -0
- package/dist/core/deps/collectors/github-pr.js +40 -0
- package/dist/core/deps/collectors/npm.d.ts +12 -0
- package/dist/core/deps/collectors/npm.js +63 -0
- package/dist/core/deps/collectors/pip.d.ts +15 -0
- package/dist/core/deps/collectors/pip.js +94 -0
- package/dist/core/deps/collectors/vulnerability.d.ts +9 -0
- package/dist/core/deps/collectors/vulnerability.js +102 -0
- package/dist/core/deps/config.d.ts +6 -0
- package/dist/core/deps/config.js +55 -0
- package/dist/core/deps/reporters/cli.d.ts +4 -0
- package/dist/core/deps/reporters/cli.js +123 -0
- package/dist/core/deps/reporters/motd.d.ts +3 -0
- package/dist/core/deps/reporters/motd.js +64 -0
- package/dist/core/deps/reporters/telegram.d.ts +6 -0
- package/dist/core/deps/reporters/telegram.js +106 -0
- package/dist/core/deps/scanner.d.ts +4 -0
- package/dist/core/deps/scanner.js +89 -0
- package/dist/core/deps/severity.d.ts +6 -0
- package/dist/core/deps/severity.js +45 -0
- package/dist/core/deps/types.d.ts +64 -0
- package/dist/core/deps/types.js +1 -0
- package/dist/mcp/deps-tools.d.ts +2 -0
- package/dist/mcp/deps-tools.js +81 -0
- package/dist/mcp/server.js +2 -0
- package/dist/templates/motd.d.ts +1 -0
- package/dist/templates/motd.js +7 -0
- 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
|
@@ -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');
|