@runloop/rl-cli 1.1.0 → 1.3.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 +29 -8
- package/dist/commands/blueprint/list.js +97 -28
- package/dist/commands/blueprint/prune.js +258 -0
- package/dist/commands/devbox/create.js +3 -0
- package/dist/commands/devbox/list.js +44 -65
- package/dist/commands/menu.js +2 -1
- package/dist/commands/network-policy/create.js +27 -0
- package/dist/commands/network-policy/delete.js +21 -0
- package/dist/commands/network-policy/get.js +15 -0
- package/dist/commands/network-policy/list.js +494 -0
- package/dist/commands/object/list.js +516 -24
- package/dist/commands/snapshot/list.js +90 -29
- package/dist/components/Banner.js +109 -8
- package/dist/components/ConfirmationPrompt.js +45 -0
- package/dist/components/DevboxActionsMenu.js +42 -6
- package/dist/components/DevboxCard.js +1 -1
- package/dist/components/DevboxCreatePage.js +95 -81
- package/dist/components/DevboxDetailPage.js +218 -272
- package/dist/components/LogsViewer.js +8 -1
- package/dist/components/MainMenu.js +35 -4
- package/dist/components/NavigationTips.js +24 -0
- package/dist/components/NetworkPolicyCreatePage.js +264 -0
- package/dist/components/OperationsMenu.js +9 -1
- package/dist/components/ResourceActionsMenu.js +5 -1
- package/dist/components/ResourceDetailPage.js +204 -0
- package/dist/components/ResourceListView.js +19 -2
- package/dist/components/StatusBadge.js +2 -2
- package/dist/components/Table.js +6 -8
- package/dist/components/form/FormActionButton.js +7 -0
- package/dist/components/form/FormField.js +7 -0
- package/dist/components/form/FormListManager.js +112 -0
- package/dist/components/form/FormSelect.js +34 -0
- package/dist/components/form/FormTextInput.js +8 -0
- package/dist/components/form/index.js +8 -0
- package/dist/hooks/useViewportHeight.js +38 -20
- package/dist/router/Router.js +23 -1
- package/dist/screens/BlueprintDetailScreen.js +337 -0
- package/dist/screens/MenuScreen.js +6 -0
- package/dist/screens/NetworkPolicyCreateScreen.js +7 -0
- package/dist/screens/NetworkPolicyDetailScreen.js +247 -0
- package/dist/screens/NetworkPolicyListScreen.js +7 -0
- package/dist/screens/ObjectDetailScreen.js +377 -0
- package/dist/screens/ObjectListScreen.js +7 -0
- package/dist/screens/SnapshotDetailScreen.js +208 -0
- package/dist/services/blueprintService.js +30 -11
- package/dist/services/networkPolicyService.js +108 -0
- package/dist/services/objectService.js +101 -0
- package/dist/services/snapshotService.js +39 -3
- package/dist/store/blueprintStore.js +4 -10
- package/dist/store/index.js +1 -0
- package/dist/store/networkPolicyStore.js +83 -0
- package/dist/store/objectStore.js +92 -0
- package/dist/store/snapshotStore.js +4 -8
- package/dist/utils/commands.js +58 -0
- package/package.json +2 -2
|
@@ -7,6 +7,7 @@ import React from "react";
|
|
|
7
7
|
import { Box, Text, useInput } from "ink";
|
|
8
8
|
import figures from "figures";
|
|
9
9
|
import { Breadcrumb } from "./Breadcrumb.js";
|
|
10
|
+
import { NavigationTips } from "./NavigationTips.js";
|
|
10
11
|
import { colors } from "../utils/theme.js";
|
|
11
12
|
import { useViewportHeight } from "../hooks/useViewportHeight.js";
|
|
12
13
|
import { parseAnyLogEntry } from "../utils/logFormatter.js";
|
|
@@ -165,5 +166,11 @@ export const LogsViewer = ({ logs, breadcrumbItems = [{ label: "Logs", active: t
|
|
|
165
166
|
: fullMessage;
|
|
166
167
|
return (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: parts.timestamp }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: parts.levelColor === "red", children: parts.level }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, children: ["[", parts.source, "]"] }), _jsx(Text, { children: " " }), parts.shellName && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["(", parts.shellName, ")", " "] })), cmd && _jsx(Text, { color: colors.info, children: cmd }), _jsx(Text, { children: truncatedMessage }), exitCode && (_jsxs(Text, { color: parts.exitCode === 0 ? colors.success : colors.error, children: [" ", exitCode] }))] }, index));
|
|
167
168
|
}
|
|
168
|
-
})) }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", logs.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total logs"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, logs.length), " of", " ", logs.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: logsWrapMode ? colors.success : colors.textDim, bold: logsWrapMode, children: logsWrapMode ? "Wrap: ON" : "Wrap: OFF" }), copyStatus && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: colors.success, bold: true, children: copyStatus })] }))] }), _jsx(
|
|
169
|
+
})) }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", logs.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total logs"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, logs.length), " of", " ", logs.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: logsWrapMode ? colors.success : colors.textDim, bold: logsWrapMode, children: logsWrapMode ? "Wrap: ON" : "Wrap: OFF" }), copyStatus && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: colors.success, bold: true, children: copyStatus })] }))] }), _jsx(NavigationTips, { showArrows: true, tips: [
|
|
170
|
+
{ key: "g", label: "Top" },
|
|
171
|
+
{ key: "G", label: "Bottom" },
|
|
172
|
+
{ key: "w", label: "Toggle Wrap" },
|
|
173
|
+
{ key: "c", label: "Copy" },
|
|
174
|
+
{ key: "Enter/q/esc", label: "Back" },
|
|
175
|
+
] })] }));
|
|
169
176
|
};
|
|
@@ -4,6 +4,7 @@ import { Box, Text, useInput, useApp } from "ink";
|
|
|
4
4
|
import figures from "figures";
|
|
5
5
|
import { Banner } from "./Banner.js";
|
|
6
6
|
import { Breadcrumb } from "./Breadcrumb.js";
|
|
7
|
+
import { NavigationTips } from "./NavigationTips.js";
|
|
7
8
|
import { VERSION } from "../version.js";
|
|
8
9
|
import { colors } from "../utils/theme.js";
|
|
9
10
|
import { execCommand } from "../utils/exec.js";
|
|
@@ -32,6 +33,20 @@ const menuItems = [
|
|
|
32
33
|
icon: "◈",
|
|
33
34
|
color: colors.accent3,
|
|
34
35
|
},
|
|
36
|
+
{
|
|
37
|
+
key: "objects",
|
|
38
|
+
label: "Storage Objects",
|
|
39
|
+
description: "Manage files and data in cloud storage",
|
|
40
|
+
icon: "▤",
|
|
41
|
+
color: colors.secondary,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
key: "network-policies",
|
|
45
|
+
label: "Network Policies",
|
|
46
|
+
description: "Manage egress network access rules",
|
|
47
|
+
icon: "◇",
|
|
48
|
+
color: colors.info,
|
|
49
|
+
},
|
|
35
50
|
];
|
|
36
51
|
export const MainMenu = ({ onSelect }) => {
|
|
37
52
|
const { exit } = useApp();
|
|
@@ -64,6 +79,12 @@ export const MainMenu = ({ onSelect }) => {
|
|
|
64
79
|
else if (input === "s" || input === "3") {
|
|
65
80
|
onSelect("snapshots");
|
|
66
81
|
}
|
|
82
|
+
else if (input === "o" || input === "4") {
|
|
83
|
+
onSelect("objects");
|
|
84
|
+
}
|
|
85
|
+
else if (input === "n" || input === "5") {
|
|
86
|
+
onSelect("network-policies");
|
|
87
|
+
}
|
|
67
88
|
else if (input === "u" && updateAvailable) {
|
|
68
89
|
// Release terminal and exec into update command (never returns)
|
|
69
90
|
execCommand("sh", [
|
|
@@ -75,13 +96,23 @@ export const MainMenu = ({ onSelect }) => {
|
|
|
75
96
|
// Use compact layout if terminal height is less than 20 lines (memoized)
|
|
76
97
|
const useCompactLayout = terminalHeight < 20;
|
|
77
98
|
if (useCompactLayout) {
|
|
78
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { paddingX: 2, marginBottom: 1, children: [_jsx(Text, { color: colors.primary, bold: true, children: "RUNLOOP.ai" }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 Cloud development environments \u2022 v", VERSION] })] }), _jsx(Box, { flexDirection: "column", paddingX: 2, children: menuItems.map((item, index) => {
|
|
99
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsxs(Box, { paddingX: 2, marginBottom: 1, children: [_jsx(Text, { color: colors.primary, bold: true, children: "RUNLOOP.ai" }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 Cloud development environments \u2022 v", VERSION] })] }), _jsx(Box, { flexDirection: "column", paddingX: 2, children: menuItems.map((item, index) => {
|
|
79
100
|
const isSelected = index === selectedIndex;
|
|
80
101
|
return (_jsxs(Box, { marginBottom: 0, children: [_jsx(Text, { color: isSelected ? item.color : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsx(Text, { children: " " }), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "- ", item.description] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", index + 1, "]"] })] }, item.key));
|
|
81
|
-
}) }), _jsx(
|
|
102
|
+
}) }), _jsx(NavigationTips, { showArrows: true, paddingX: 2, tips: [
|
|
103
|
+
{ key: "1-5", label: "Quick select" },
|
|
104
|
+
{ key: "Enter", label: "Select" },
|
|
105
|
+
{ key: "Esc", label: "Quit" },
|
|
106
|
+
{ key: "u", label: "Update", condition: !!updateAvailable },
|
|
107
|
+
] })] }));
|
|
82
108
|
}
|
|
83
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsx(Banner, {}), _jsx(Box, { flexDirection: "column", paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Cloud development environments for your team \u2022 v", VERSION] }) }) }), _jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1,
|
|
109
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsx(Banner, {}), _jsx(Box, { flexDirection: "column", paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Cloud development environments for your team \u2022 v", VERSION] }) }) }), _jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, children: [_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(Text, { color: colors.text, bold: true, children: "Select a resource:" }) }), menuItems.map((item, index) => {
|
|
84
110
|
const isSelected = index === selectedIndex;
|
|
85
111
|
return (_jsxs(Box, { paddingX: 2, paddingY: 0, borderStyle: "single", borderColor: isSelected ? item.color : colors.border, marginTop: index === 0 ? 1 : 0, flexShrink: 0, children: [isSelected && (_jsxs(_Fragment, { children: [_jsx(Text, { color: item.color, bold: true, children: figures.pointer }), _jsx(Text, { children: " " })] })), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsx(Text, { color: colors.textDim, children: " " }), _jsx(Text, { color: colors.textDim, dimColor: true, children: item.description }), _jsx(Text, { children: " " }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["[", index + 1, "]"] })] }, item.key));
|
|
86
|
-
})] }), _jsx(
|
|
112
|
+
})] }), _jsx(NavigationTips, { showArrows: true, tips: [
|
|
113
|
+
{ key: "1-5", label: "Quick select" },
|
|
114
|
+
{ key: "Enter", label: "Select" },
|
|
115
|
+
{ key: "Esc", label: "Quit" },
|
|
116
|
+
{ key: "u", label: "Update", condition: !!updateAvailable },
|
|
117
|
+
] })] }));
|
|
87
118
|
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import figures from "figures";
|
|
4
|
+
import { colors } from "../utils/theme.js";
|
|
5
|
+
/**
|
|
6
|
+
* Renders a responsive navigation tips bar that wraps on small screens
|
|
7
|
+
*/
|
|
8
|
+
export const NavigationTips = ({ tips, showArrows = false, arrowLabel = "Navigate", paddingX = 1, marginTop = 1, }) => {
|
|
9
|
+
// Filter tips by condition (undefined condition means always show)
|
|
10
|
+
const visibleTips = tips.filter((tip) => tip.condition === undefined || tip.condition === true);
|
|
11
|
+
// Build the tips array, prepending arrows if requested
|
|
12
|
+
const allTips = [];
|
|
13
|
+
if (showArrows) {
|
|
14
|
+
allTips.push({
|
|
15
|
+
icon: `${figures.arrowUp}${figures.arrowDown}`,
|
|
16
|
+
label: arrowLabel,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
allTips.push(...visibleTips);
|
|
20
|
+
if (allTips.length === 0) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return (_jsx(Box, { marginTop: marginTop, paddingX: paddingX, flexWrap: "wrap", children: allTips.map((tip, index) => (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [index > 0 && " • ", tip.icon && `${tip.icon} `, tip.key && `[${tip.key}] `, tip.label] }, index))) }));
|
|
24
|
+
};
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* NetworkPolicyCreatePage - Form for creating or editing a network policy
|
|
4
|
+
*/
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { Box, Text, useInput } from "ink";
|
|
7
|
+
import figures from "figures";
|
|
8
|
+
import { getClient } from "../utils/client.js";
|
|
9
|
+
import { SpinnerComponent } from "./Spinner.js";
|
|
10
|
+
import { ErrorMessage } from "./ErrorMessage.js";
|
|
11
|
+
import { SuccessMessage } from "./SuccessMessage.js";
|
|
12
|
+
import { Breadcrumb } from "./Breadcrumb.js";
|
|
13
|
+
import { NavigationTips } from "./NavigationTips.js";
|
|
14
|
+
import { FormTextInput, FormSelect, FormActionButton, FormListManager, useFormSelectNavigation, } from "./form/index.js";
|
|
15
|
+
import { colors } from "../utils/theme.js";
|
|
16
|
+
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
|
|
17
|
+
const BOOLEAN_OPTIONS = ["Yes", "No"];
|
|
18
|
+
export const NetworkPolicyCreatePage = ({ onBack, onCreate, initialPolicy, }) => {
|
|
19
|
+
const isEditMode = !!initialPolicy;
|
|
20
|
+
const [currentField, setCurrentField] = React.useState("submit");
|
|
21
|
+
const [formData, setFormData] = React.useState(() => {
|
|
22
|
+
if (initialPolicy) {
|
|
23
|
+
return {
|
|
24
|
+
name: initialPolicy.name || "",
|
|
25
|
+
description: initialPolicy.description || "",
|
|
26
|
+
allow_all: initialPolicy.egress.allow_all ? "Yes" : "No",
|
|
27
|
+
allow_devbox_to_devbox: initialPolicy.egress.allow_devbox_to_devbox
|
|
28
|
+
? "Yes"
|
|
29
|
+
: "No",
|
|
30
|
+
allowed_hostnames: initialPolicy.egress.allowed_hostnames || [],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
name: "",
|
|
35
|
+
description: "",
|
|
36
|
+
allow_all: "No",
|
|
37
|
+
allow_devbox_to_devbox: "No",
|
|
38
|
+
allowed_hostnames: [],
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
const [hostnamesExpanded, setHostnamesExpanded] = React.useState(false);
|
|
42
|
+
const [submitting, setSubmitting] = React.useState(false);
|
|
43
|
+
const [result, setResult] = React.useState(null);
|
|
44
|
+
const [error, setError] = React.useState(null);
|
|
45
|
+
const [validationError, setValidationError] = React.useState(null);
|
|
46
|
+
const allFields = [
|
|
47
|
+
{
|
|
48
|
+
key: "submit",
|
|
49
|
+
label: isEditMode ? "Update Network Policy" : "Create Network Policy",
|
|
50
|
+
type: "action",
|
|
51
|
+
},
|
|
52
|
+
{ key: "name", label: "Name (required)", type: "text" },
|
|
53
|
+
{ key: "description", label: "Description", type: "text" },
|
|
54
|
+
{ key: "allow_all", label: "Allow All Egress", type: "select" },
|
|
55
|
+
{
|
|
56
|
+
key: "allow_devbox_to_devbox",
|
|
57
|
+
label: "Allow Devbox-to-Devbox",
|
|
58
|
+
type: "select",
|
|
59
|
+
},
|
|
60
|
+
{ key: "allowed_hostnames", label: "Allowed Hostnames", type: "list" },
|
|
61
|
+
];
|
|
62
|
+
// Hide allowed_hostnames when allow_all is enabled
|
|
63
|
+
const fields = formData.allow_all === "Yes"
|
|
64
|
+
? allFields.filter((f) => f.key !== "allowed_hostnames")
|
|
65
|
+
: allFields;
|
|
66
|
+
const currentFieldIndex = fields.findIndex((f) => f.key === currentField);
|
|
67
|
+
// Handle Ctrl+C to exit
|
|
68
|
+
useExitOnCtrlC();
|
|
69
|
+
// Select navigation handlers
|
|
70
|
+
const handleAllowAllNav = useFormSelectNavigation(formData.allow_all, BOOLEAN_OPTIONS, (value) => {
|
|
71
|
+
setFormData({ ...formData, allow_all: value });
|
|
72
|
+
// If enabling allow_all and currently on hostnames field, move to previous field
|
|
73
|
+
if (value === "Yes" && currentField === "allowed_hostnames") {
|
|
74
|
+
setCurrentField("allow_devbox_to_devbox");
|
|
75
|
+
setHostnamesExpanded(false);
|
|
76
|
+
}
|
|
77
|
+
}, currentField === "allow_all");
|
|
78
|
+
const handleDevboxNav = useFormSelectNavigation(formData.allow_devbox_to_devbox, BOOLEAN_OPTIONS, (value) => setFormData({ ...formData, allow_devbox_to_devbox: value }), currentField === "allow_devbox_to_devbox");
|
|
79
|
+
useInput((input, key) => {
|
|
80
|
+
// Handle result screen
|
|
81
|
+
if (result) {
|
|
82
|
+
if (input === "q" || key.escape || key.return) {
|
|
83
|
+
if (onCreate) {
|
|
84
|
+
onCreate(result);
|
|
85
|
+
}
|
|
86
|
+
onBack();
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// Handle error screen
|
|
91
|
+
if (error) {
|
|
92
|
+
if (input === "r" || key.return) {
|
|
93
|
+
// Retry - clear error and return to form
|
|
94
|
+
setError(null);
|
|
95
|
+
}
|
|
96
|
+
else if (input === "q" || key.escape) {
|
|
97
|
+
// Quit - go back to list
|
|
98
|
+
onBack();
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Handle submitting state
|
|
103
|
+
if (submitting) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// Handle hostnames expanded mode - let FormListManager handle input
|
|
107
|
+
if (hostnamesExpanded) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Back to list
|
|
111
|
+
if (input === "q" || key.escape) {
|
|
112
|
+
onBack();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Submit form with Ctrl+S
|
|
116
|
+
if (input === "s" && key.ctrl) {
|
|
117
|
+
handleSubmit();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Handle Enter on submit field
|
|
121
|
+
if (currentField === "submit" && key.return) {
|
|
122
|
+
handleSubmit();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// Handle Enter on hostnames field to expand
|
|
126
|
+
if (currentField === "allowed_hostnames" && key.return) {
|
|
127
|
+
setHostnamesExpanded(true);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Handle select field navigation
|
|
131
|
+
if (handleAllowAllNav(input, key))
|
|
132
|
+
return;
|
|
133
|
+
if (handleDevboxNav(input, key))
|
|
134
|
+
return;
|
|
135
|
+
// Navigation between fields (up/down arrows and tab/shift+tab)
|
|
136
|
+
if ((key.upArrow || (key.tab && key.shift)) && currentFieldIndex > 0) {
|
|
137
|
+
setCurrentField(fields[currentFieldIndex - 1].key);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if ((key.downArrow || (key.tab && !key.shift)) &&
|
|
141
|
+
currentFieldIndex < fields.length - 1) {
|
|
142
|
+
setCurrentField(fields[currentFieldIndex + 1].key);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
const handleSubmit = async () => {
|
|
147
|
+
// Validate required fields
|
|
148
|
+
if (!formData.name.trim()) {
|
|
149
|
+
setValidationError("Name is required");
|
|
150
|
+
setCurrentField("name");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
setSubmitting(true);
|
|
154
|
+
setError(null);
|
|
155
|
+
setValidationError(null);
|
|
156
|
+
try {
|
|
157
|
+
const client = getClient();
|
|
158
|
+
const params = {};
|
|
159
|
+
// For create, name is always required
|
|
160
|
+
// For update, only include if changed
|
|
161
|
+
if (!isEditMode || formData.name.trim() !== initialPolicy?.name) {
|
|
162
|
+
params.name = formData.name.trim();
|
|
163
|
+
}
|
|
164
|
+
// Include description if set (or if clearing in edit mode)
|
|
165
|
+
if (formData.description.trim()) {
|
|
166
|
+
params.description = formData.description.trim();
|
|
167
|
+
}
|
|
168
|
+
else if (isEditMode && initialPolicy?.description) {
|
|
169
|
+
// Clear description if it was set before but now empty
|
|
170
|
+
params.description = "";
|
|
171
|
+
}
|
|
172
|
+
params.allow_all = formData.allow_all === "Yes";
|
|
173
|
+
params.allow_devbox_to_devbox = formData.allow_devbox_to_devbox === "Yes";
|
|
174
|
+
// For allowed_hostnames, always send the current list
|
|
175
|
+
// (empty array means no hostnames allowed)
|
|
176
|
+
params.allowed_hostnames = formData.allowed_hostnames;
|
|
177
|
+
let policy;
|
|
178
|
+
if (isEditMode && initialPolicy) {
|
|
179
|
+
policy = await client.networkPolicies.update(initialPolicy.id, params);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
// For create, name is required
|
|
183
|
+
policy = await client.networkPolicies.create({
|
|
184
|
+
...params,
|
|
185
|
+
name: formData.name.trim(),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
setResult(policy);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
setError(err);
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
setSubmitting(false);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const breadcrumbLabel = isEditMode ? initialPolicy?.name || "Edit" : "Create";
|
|
198
|
+
const actionLabel = isEditMode ? "Edit" : "Create";
|
|
199
|
+
// Result screen
|
|
200
|
+
if (result) {
|
|
201
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
202
|
+
{ label: "Network Policies" },
|
|
203
|
+
{ label: breadcrumbLabel, active: true },
|
|
204
|
+
] }), _jsx(SuccessMessage, { message: `Network policy ${isEditMode ? "updated" : "created"} successfully!` }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["ID:", " "] }), _jsx(Text, { color: colors.idColor, children: result.id })] }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Name: ", result.name] }) }), _jsx(Box, { children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Allow All: ", result.egress.allow_all ? "Yes" : "No"] }) })] }), isEditMode && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsxs(Text, { color: colors.warning, children: [figures.info, " Changes are eventually consistent and may take a few moments to propagate."] }) })), _jsx(NavigationTips, { tips: [
|
|
205
|
+
{
|
|
206
|
+
key: "Enter/q/esc",
|
|
207
|
+
label: isEditMode ? "Return to details" : "Return to list",
|
|
208
|
+
},
|
|
209
|
+
] })] }));
|
|
210
|
+
}
|
|
211
|
+
// Error screen
|
|
212
|
+
if (error) {
|
|
213
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
214
|
+
{ label: "Network Policies" },
|
|
215
|
+
{ label: breadcrumbLabel, active: true },
|
|
216
|
+
] }), _jsx(ErrorMessage, { message: `Failed to ${isEditMode ? "update" : "create"} network policy`, error: error }), _jsx(NavigationTips, { tips: [
|
|
217
|
+
{ key: "Enter/r", label: "Retry" },
|
|
218
|
+
{ key: "q/esc", label: "Cancel" },
|
|
219
|
+
] })] }));
|
|
220
|
+
}
|
|
221
|
+
// Submitting screen
|
|
222
|
+
if (submitting) {
|
|
223
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
224
|
+
{ label: "Network Policies" },
|
|
225
|
+
{ label: breadcrumbLabel, active: true },
|
|
226
|
+
] }), _jsx(SpinnerComponent, { message: `${isEditMode ? "Updating" : "Creating"} network policy...` })] }));
|
|
227
|
+
}
|
|
228
|
+
// Form screen
|
|
229
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
230
|
+
{ label: "Network Policies" },
|
|
231
|
+
{ label: breadcrumbLabel, active: true },
|
|
232
|
+
] }), isEditMode && (_jsx(Box, { borderStyle: "round", borderColor: colors.warning, paddingX: 1, paddingY: 0, marginBottom: 1, children: _jsxs(Text, { color: colors.warning, children: [figures.warning, " ", _jsx(Text, { bold: true, children: "Note:" }), " Network policy updates are", " ", _jsx(Text, { bold: true, children: "eventually consistent" }), ". Changes may take a few moments to propagate to all devboxes using this policy."] }) })), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: fields.map((field) => {
|
|
233
|
+
const isActive = currentField === field.key;
|
|
234
|
+
if (field.type === "action") {
|
|
235
|
+
return (_jsx(FormActionButton, { label: field.label, isActive: isActive, hint: `[Enter to ${isEditMode ? "update" : "create"}]` }, field.key));
|
|
236
|
+
}
|
|
237
|
+
if (field.type === "text") {
|
|
238
|
+
const value = formData[field.key];
|
|
239
|
+
const hasError = field.key === "name" && validationError;
|
|
240
|
+
return (_jsx(FormTextInput, { label: field.label, value: value, onChange: (newValue) => {
|
|
241
|
+
setFormData({ ...formData, [field.key]: newValue });
|
|
242
|
+
// Clear validation error when typing in the field with error
|
|
243
|
+
if (field.key === "name" && validationError) {
|
|
244
|
+
setValidationError(null);
|
|
245
|
+
}
|
|
246
|
+
}, isActive: isActive, placeholder: field.key === "name"
|
|
247
|
+
? "my-network-policy"
|
|
248
|
+
: field.key === "description"
|
|
249
|
+
? "Policy description"
|
|
250
|
+
: "", error: hasError ? validationError : undefined }, field.key));
|
|
251
|
+
}
|
|
252
|
+
if (field.type === "select") {
|
|
253
|
+
const value = formData[field.key];
|
|
254
|
+
return (_jsx(FormSelect, { label: field.label, value: value, options: BOOLEAN_OPTIONS, onChange: (newValue) => setFormData({ ...formData, [field.key]: newValue }), isActive: isActive }, field.key));
|
|
255
|
+
}
|
|
256
|
+
if (field.type === "list") {
|
|
257
|
+
return (_jsx(FormListManager, { title: field.label, items: formData.allowed_hostnames, onItemsChange: (items) => setFormData({ ...formData, allowed_hostnames: items }), isActive: isActive, isExpanded: hostnamesExpanded, onExpandedChange: setHostnamesExpanded, itemPlaceholder: "github.com or *.npmjs.org", addLabel: "+ Add hostname", collapsedLabel: "hostname(s)" }, field.key));
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}) }), !hostnamesExpanded && (_jsx(NavigationTips, { showArrows: true, tips: [
|
|
261
|
+
{ key: "Enter", label: `${actionLabel}/Expand` },
|
|
262
|
+
{ key: "q", label: "Cancel" },
|
|
263
|
+
] }))] }));
|
|
264
|
+
};
|
|
@@ -2,6 +2,7 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import figures from "figures";
|
|
4
4
|
import { colors } from "../utils/theme.js";
|
|
5
|
+
import { NavigationTips } from "./NavigationTips.js";
|
|
5
6
|
/**
|
|
6
7
|
* Reusable operations menu component for detail pages
|
|
7
8
|
* Displays a list of available operations with keyboard navigation
|
|
@@ -10,7 +11,14 @@ export const OperationsMenu = ({ operations, selectedIndex, onNavigate: _onNavig
|
|
|
10
11
|
return (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Operations"] }), _jsx(Box, { flexDirection: "column", children: operations.map((op, index) => {
|
|
11
12
|
const isSelected = index === selectedIndex;
|
|
12
13
|
return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? colors.primary : colors.textDim, children: [isSelected ? figures.pointer : " ", " "] }), _jsxs(Text, { color: isSelected ? op.color : colors.textDim, bold: isSelected, children: [op.icon, " ", op.label] })] }, op.key));
|
|
13
|
-
}) })] }), _jsx(
|
|
14
|
+
}) })] }), _jsx(NavigationTips, { showArrows: true, paddingX: 0, tips: [
|
|
15
|
+
{ key: "Enter", label: "Select" },
|
|
16
|
+
...additionalActions.map((action) => ({
|
|
17
|
+
key: action.key,
|
|
18
|
+
label: action.label,
|
|
19
|
+
})),
|
|
20
|
+
{ key: "q", label: "Back" },
|
|
21
|
+
] })] }));
|
|
14
22
|
};
|
|
15
23
|
/**
|
|
16
24
|
* Helper to filter operations based on conditions
|
|
@@ -4,6 +4,7 @@ import { Box, Text, useInput } from "ink";
|
|
|
4
4
|
import figures from "figures";
|
|
5
5
|
import { colors } from "../utils/theme.js";
|
|
6
6
|
import { Breadcrumb } from "./Breadcrumb.js";
|
|
7
|
+
import { NavigationTips } from "./NavigationTips.js";
|
|
7
8
|
import { ActionsPopup } from "./ActionsPopup.js";
|
|
8
9
|
import { DevboxActionsMenu } from "./DevboxActionsMenu.js";
|
|
9
10
|
export const ResourceActionsMenu = (props) => {
|
|
@@ -104,7 +105,10 @@ export const ResourceActionsMenu = (props) => {
|
|
|
104
105
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: operationError ? colors.error : colors.success, children: operationError ? `${label} failed` : `${label} completed` }), !!operationResult && (_jsx(Text, { color: colors.textDim, dimColor: true, children: operationResult })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.pointerSmall, " Press [Enter] to go back"] })] })] }));
|
|
105
106
|
}
|
|
106
107
|
if (executingOperation && selectedOp?.needsInput) {
|
|
107
|
-
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: colors.textDim, children: [selectedOp.inputPrompt || "Input:", " "] }), _jsxs(Text, { children: [" ", operationInput] }), _jsx(
|
|
108
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: colors.textDim, children: [selectedOp.inputPrompt || "Input:", " "] }), _jsxs(Text, { children: [" ", operationInput] }), _jsx(NavigationTips, { marginTop: 0, paddingX: 0, tips: [
|
|
109
|
+
{ key: "Enter", label: "Execute" },
|
|
110
|
+
{ key: "q/esc", label: "Cancel" },
|
|
111
|
+
] })] })] }));
|
|
108
112
|
}
|
|
109
113
|
// Operations menu
|
|
110
114
|
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(ActionsPopup, { devbox: resource, operations: operations.map((op) => ({
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ResourceDetailPage - Generic detail page component for resources
|
|
4
|
+
* Can be used for devboxes, blueprints, snapshots, etc.
|
|
5
|
+
*/
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { Box, Text, useInput } from "ink";
|
|
8
|
+
import figures from "figures";
|
|
9
|
+
import { Header } from "./Header.js";
|
|
10
|
+
import { StatusBadge } from "./StatusBadge.js";
|
|
11
|
+
import { Breadcrumb } from "./Breadcrumb.js";
|
|
12
|
+
import { NavigationTips } from "./NavigationTips.js";
|
|
13
|
+
import { colors } from "../utils/theme.js";
|
|
14
|
+
import { useViewportHeight } from "../hooks/useViewportHeight.js";
|
|
15
|
+
import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
|
|
16
|
+
// Format time ago in a succinct way
|
|
17
|
+
const formatTimeAgo = (timestamp) => {
|
|
18
|
+
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
19
|
+
if (seconds < 60)
|
|
20
|
+
return `${seconds}s ago`;
|
|
21
|
+
const minutes = Math.floor(seconds / 60);
|
|
22
|
+
if (minutes < 60)
|
|
23
|
+
return `${minutes}m ago`;
|
|
24
|
+
const hours = Math.floor(minutes / 60);
|
|
25
|
+
if (hours < 24)
|
|
26
|
+
return `${hours}h ago`;
|
|
27
|
+
const days = Math.floor(hours / 24);
|
|
28
|
+
if (days < 30)
|
|
29
|
+
return `${days}d ago`;
|
|
30
|
+
const months = Math.floor(days / 30);
|
|
31
|
+
if (months < 12)
|
|
32
|
+
return `${months}mo ago`;
|
|
33
|
+
const years = Math.floor(months / 12);
|
|
34
|
+
return `${years}y ago`;
|
|
35
|
+
};
|
|
36
|
+
// Truncate long strings to prevent layout issues
|
|
37
|
+
const truncateString = (str, maxLength) => {
|
|
38
|
+
if (str.length <= maxLength)
|
|
39
|
+
return str;
|
|
40
|
+
return str.substring(0, maxLength - 3) + "...";
|
|
41
|
+
};
|
|
42
|
+
export function ResourceDetailPage({ resource: initialResource, resourceType, getDisplayName, getId, getStatus, getUrl, breadcrumbPrefix = [], detailSections, operations, onOperation, onBack, buildDetailLines, additionalContent, pollResource, pollInterval = 3000, }) {
|
|
43
|
+
const isMounted = React.useRef(true);
|
|
44
|
+
// Track mounted state
|
|
45
|
+
React.useEffect(() => {
|
|
46
|
+
isMounted.current = true;
|
|
47
|
+
return () => {
|
|
48
|
+
isMounted.current = false;
|
|
49
|
+
};
|
|
50
|
+
}, []);
|
|
51
|
+
// Local state for resource data (updated by polling)
|
|
52
|
+
const [currentResource, setCurrentResource] = React.useState(initialResource);
|
|
53
|
+
const [showDetailedInfo, setShowDetailedInfo] = React.useState(false);
|
|
54
|
+
const [detailScroll, setDetailScroll] = React.useState(0);
|
|
55
|
+
const [selectedOperation, setSelectedOperation] = React.useState(0);
|
|
56
|
+
// Background polling for resource details
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
if (!pollResource || showDetailedInfo)
|
|
59
|
+
return;
|
|
60
|
+
const interval = setInterval(async () => {
|
|
61
|
+
if (isMounted.current) {
|
|
62
|
+
try {
|
|
63
|
+
const updatedResource = await pollResource();
|
|
64
|
+
if (isMounted.current) {
|
|
65
|
+
setCurrentResource(updatedResource);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Silently ignore polling errors
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}, pollInterval);
|
|
73
|
+
return () => clearInterval(interval);
|
|
74
|
+
}, [pollResource, pollInterval, showDetailedInfo]);
|
|
75
|
+
// Calculate viewport for detailed info view
|
|
76
|
+
const detailViewport = useViewportHeight({ overhead: 18, minHeight: 10 });
|
|
77
|
+
const displayName = getDisplayName(currentResource);
|
|
78
|
+
const resourceId = getId(currentResource);
|
|
79
|
+
const status = getStatus(currentResource);
|
|
80
|
+
// Handle Ctrl+C to exit
|
|
81
|
+
useExitOnCtrlC();
|
|
82
|
+
useInput((input, key) => {
|
|
83
|
+
if (!isMounted.current)
|
|
84
|
+
return;
|
|
85
|
+
// Handle detailed info mode
|
|
86
|
+
if (showDetailedInfo) {
|
|
87
|
+
if (input === "q" || key.escape) {
|
|
88
|
+
setShowDetailedInfo(false);
|
|
89
|
+
setDetailScroll(0);
|
|
90
|
+
}
|
|
91
|
+
else if (input === "j" || input === "s" || key.downArrow) {
|
|
92
|
+
setDetailScroll(detailScroll + 1);
|
|
93
|
+
}
|
|
94
|
+
else if (input === "k" || input === "w" || key.upArrow) {
|
|
95
|
+
setDetailScroll(Math.max(0, detailScroll - 1));
|
|
96
|
+
}
|
|
97
|
+
else if (key.pageDown) {
|
|
98
|
+
setDetailScroll(detailScroll + 10);
|
|
99
|
+
}
|
|
100
|
+
else if (key.pageUp) {
|
|
101
|
+
setDetailScroll(Math.max(0, detailScroll - 10));
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// Main view input handling
|
|
106
|
+
if (input === "q" || key.escape) {
|
|
107
|
+
onBack();
|
|
108
|
+
}
|
|
109
|
+
else if (input === "i" && buildDetailLines) {
|
|
110
|
+
setShowDetailedInfo(true);
|
|
111
|
+
setDetailScroll(0);
|
|
112
|
+
}
|
|
113
|
+
else if (key.upArrow && selectedOperation > 0) {
|
|
114
|
+
setSelectedOperation(selectedOperation - 1);
|
|
115
|
+
}
|
|
116
|
+
else if (key.downArrow && selectedOperation < operations.length - 1) {
|
|
117
|
+
setSelectedOperation(selectedOperation + 1);
|
|
118
|
+
}
|
|
119
|
+
else if (key.return) {
|
|
120
|
+
const op = operations[selectedOperation];
|
|
121
|
+
if (op) {
|
|
122
|
+
onOperation(op.key, currentResource);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (input) {
|
|
126
|
+
// Check if input matches any operation shortcut
|
|
127
|
+
const matchedOpIndex = operations.findIndex((op) => op.shortcut === input);
|
|
128
|
+
if (matchedOpIndex !== -1) {
|
|
129
|
+
setSelectedOperation(matchedOpIndex);
|
|
130
|
+
onOperation(operations[matchedOpIndex].key, currentResource);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (input === "o" && getUrl) {
|
|
134
|
+
const url = getUrl(currentResource);
|
|
135
|
+
const openBrowser = async () => {
|
|
136
|
+
const { exec } = await import("child_process");
|
|
137
|
+
const platform = process.platform;
|
|
138
|
+
let openCommand;
|
|
139
|
+
if (platform === "darwin") {
|
|
140
|
+
openCommand = `open "${url}"`;
|
|
141
|
+
}
|
|
142
|
+
else if (platform === "win32") {
|
|
143
|
+
openCommand = `start "${url}"`;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
openCommand = `xdg-open "${url}"`;
|
|
147
|
+
}
|
|
148
|
+
exec(openCommand);
|
|
149
|
+
};
|
|
150
|
+
openBrowser();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
// Detailed info mode - full screen
|
|
154
|
+
if (showDetailedInfo && buildDetailLines) {
|
|
155
|
+
const detailLines = buildDetailLines(currentResource);
|
|
156
|
+
const viewportHeight = detailViewport.viewportHeight;
|
|
157
|
+
const maxScroll = Math.max(0, detailLines.length - viewportHeight);
|
|
158
|
+
const actualScroll = Math.min(detailScroll, maxScroll);
|
|
159
|
+
const visibleLines = detailLines.slice(actualScroll, actualScroll + viewportHeight);
|
|
160
|
+
const hasMore = actualScroll + viewportHeight < detailLines.length;
|
|
161
|
+
const hasLess = actualScroll > 0;
|
|
162
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
163
|
+
...breadcrumbPrefix,
|
|
164
|
+
{ label: resourceType },
|
|
165
|
+
{ label: displayName },
|
|
166
|
+
{ label: "Full Details", active: true },
|
|
167
|
+
] }), _jsx(Header, { title: `${displayName} - Complete Information` }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: _jsxs(Box, { marginBottom: 1, children: [_jsx(StatusBadge, { status: status }), _jsx(Text, { children: " " }), _jsx(Text, { color: colors.idColor, children: resourceId })] }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, borderStyle: "round", borderColor: colors.border, paddingX: 2, paddingY: 1, children: _jsx(Box, { flexDirection: "column", children: visibleLines }) }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Scroll \u2022 Line ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, detailLines.length), " of", " ", detailLines.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [q or esc] Back to Details"] })] })] }));
|
|
168
|
+
}
|
|
169
|
+
// Main detail view
|
|
170
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
171
|
+
...breadcrumbPrefix,
|
|
172
|
+
{ label: resourceType },
|
|
173
|
+
{ label: displayName, active: true },
|
|
174
|
+
] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", flexWrap: "wrap", children: [_jsx(Text, { color: colors.primary, bold: true, children: truncateString(displayName, Math.max(20, detailViewport.terminalWidth - 35)) }), displayName !== resourceId && (_jsxs(Text, { color: colors.idColor, children: [" \u2022 ", resourceId] }))] }), _jsx(Box, { children: _jsx(StatusBadge, { status: status, fullText: true }) })] }), detailSections.map((section, sectionIndex) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { color: section.color || colors.warning, bold: true, children: [section.icon || figures.squareSmallFilled, " ", section.title] }), _jsx(Box, { flexDirection: "column", paddingLeft: 2, children: section.fields
|
|
175
|
+
.filter((field) => field.value !== undefined && field.value !== null)
|
|
176
|
+
.map((field, fieldIndex) => (_jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, children: [field.label, " "] }), typeof field.value === "string" ? (_jsx(Text, { color: field.color, dimColor: !field.color, children: field.value })) : (field.value)] }, fieldIndex))) })] }, sectionIndex))), additionalContent, operations.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Actions"] }), _jsx(Box, { flexDirection: "column", paddingLeft: 2, children: operations.map((op, index) => {
|
|
177
|
+
const isSelected = index === selectedOperation;
|
|
178
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? colors.primary : colors.textDim, children: [isSelected ? figures.pointer : " ", " "] }), _jsxs(Text, { color: isSelected ? op.color : colors.textDim, bold: isSelected, children: [op.icon, " ", op.label] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", op.shortcut, "]"] })] }, op.key));
|
|
179
|
+
}) })] })), _jsx(NavigationTips, { showArrows: true, tips: [
|
|
180
|
+
{ key: "Enter", label: "Execute" },
|
|
181
|
+
{ key: "i", label: "Full Details", condition: !!buildDetailLines },
|
|
182
|
+
{ key: "o", label: "Browser", condition: !!getUrl },
|
|
183
|
+
{ key: "q", label: "Back" },
|
|
184
|
+
] })] }));
|
|
185
|
+
}
|
|
186
|
+
// Helper to format timestamp as "time (ago)"
|
|
187
|
+
export function formatTimestamp(timestamp) {
|
|
188
|
+
if (!timestamp)
|
|
189
|
+
return undefined;
|
|
190
|
+
const formatted = new Date(timestamp).toLocaleString();
|
|
191
|
+
const ago = formatTimeAgo(timestamp);
|
|
192
|
+
return `${formatted} (${ago})`;
|
|
193
|
+
}
|
|
194
|
+
// Helper to format create time with arrow to end time
|
|
195
|
+
export function formatTimeRange(createTime, endTime) {
|
|
196
|
+
if (!createTime)
|
|
197
|
+
return undefined;
|
|
198
|
+
const start = new Date(createTime).toLocaleString();
|
|
199
|
+
if (endTime) {
|
|
200
|
+
const end = new Date(endTime).toLocaleString();
|
|
201
|
+
return `${start} → ${end}`;
|
|
202
|
+
}
|
|
203
|
+
return `${start} (${formatTimeAgo(createTime)})`;
|
|
204
|
+
}
|