@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.
Files changed (67) hide show
  1. package/README.md +27 -4
  2. package/dist/cli.js +8 -0
  3. package/dist/commands/deps.d.ts +1 -0
  4. package/dist/commands/deps.js +223 -0
  5. package/dist/commands/motd.d.ts +1 -0
  6. package/dist/commands/motd.js +10 -0
  7. package/dist/core/deps/actors/pr-creator.d.ts +14 -0
  8. package/dist/core/deps/actors/pr-creator.js +103 -0
  9. package/dist/core/deps/cache.d.ts +5 -0
  10. package/dist/core/deps/cache.js +28 -0
  11. package/dist/core/deps/collectors/composer.d.ts +12 -0
  12. package/dist/core/deps/collectors/composer.js +70 -0
  13. package/dist/core/deps/collectors/docker-image.d.ts +18 -0
  14. package/dist/core/deps/collectors/docker-image.js +132 -0
  15. package/dist/core/deps/collectors/docker-running.d.ts +17 -0
  16. package/dist/core/deps/collectors/docker-running.js +55 -0
  17. package/dist/core/deps/collectors/eol.d.ts +16 -0
  18. package/dist/core/deps/collectors/eol.js +139 -0
  19. package/dist/core/deps/collectors/github-pr.d.ts +8 -0
  20. package/dist/core/deps/collectors/github-pr.js +40 -0
  21. package/dist/core/deps/collectors/npm.d.ts +12 -0
  22. package/dist/core/deps/collectors/npm.js +63 -0
  23. package/dist/core/deps/collectors/pip.d.ts +15 -0
  24. package/dist/core/deps/collectors/pip.js +94 -0
  25. package/dist/core/deps/collectors/vulnerability.d.ts +9 -0
  26. package/dist/core/deps/collectors/vulnerability.js +102 -0
  27. package/dist/core/deps/config.d.ts +6 -0
  28. package/dist/core/deps/config.js +55 -0
  29. package/dist/core/deps/reporters/cli.d.ts +4 -0
  30. package/dist/core/deps/reporters/cli.js +123 -0
  31. package/dist/core/deps/reporters/motd.d.ts +3 -0
  32. package/dist/core/deps/reporters/motd.js +64 -0
  33. package/dist/core/deps/reporters/telegram.d.ts +6 -0
  34. package/dist/core/deps/reporters/telegram.js +106 -0
  35. package/dist/core/deps/scanner.d.ts +4 -0
  36. package/dist/core/deps/scanner.js +89 -0
  37. package/dist/core/deps/severity.d.ts +6 -0
  38. package/dist/core/deps/severity.js +45 -0
  39. package/dist/core/deps/types.d.ts +64 -0
  40. package/dist/core/deps/types.js +1 -0
  41. package/dist/mcp/deps-tools.d.ts +2 -0
  42. package/dist/mcp/deps-tools.js +81 -0
  43. package/dist/mcp/server.js +2 -0
  44. package/dist/templates/motd.d.ts +1 -0
  45. package/dist/templates/motd.js +7 -0
  46. package/dist/tui/components/AppList.js +1 -1
  47. package/dist/tui/components/Confirm.js +3 -4
  48. package/dist/tui/components/Header.js +37 -8
  49. package/dist/tui/components/KeyHint.js +4 -5
  50. package/dist/tui/hooks/use-terminal-size.d.ts +1 -0
  51. package/dist/tui/hooks/use-terminal-size.js +1 -0
  52. package/dist/tui/router.js +81 -9
  53. package/dist/tui/state.js +15 -0
  54. package/dist/tui/tests/flicker.test.d.ts +1 -0
  55. package/dist/tui/tests/flicker.test.js +105 -0
  56. package/dist/tui/tests/keyboard-integration.test.d.ts +1 -0
  57. package/dist/tui/tests/keyboard-integration.test.js +117 -0
  58. package/dist/tui/tests/test-app.d.ts +4 -0
  59. package/dist/tui/tests/test-app.js +79 -0
  60. package/dist/tui/types.d.ts +13 -0
  61. package/dist/tui/views/AppDetail.js +41 -26
  62. package/dist/tui/views/Dashboard.js +34 -9
  63. package/dist/tui/views/HealthView.js +36 -12
  64. package/dist/tui/views/LogsView.js +14 -9
  65. package/dist/tui/views/SecretEdit.js +8 -4
  66. package/dist/tui/views/SecretsView.js +49 -36
  67. 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,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');