@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
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ObjectDetailScreen - Detail page for storage objects
|
|
4
|
+
* Uses the generic ResourceDetailPage component
|
|
5
|
+
*/
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { Box, Text, useInput } from "ink";
|
|
8
|
+
import TextInput from "ink-text-input";
|
|
9
|
+
import figures from "figures";
|
|
10
|
+
import { writeFile } from "fs/promises";
|
|
11
|
+
import { useNavigation } from "../store/navigationStore.js";
|
|
12
|
+
import { useObjectStore, } from "../store/objectStore.js";
|
|
13
|
+
import { getClient } from "../utils/client.js";
|
|
14
|
+
import { ResourceDetailPage, formatTimestamp, } from "../components/ResourceDetailPage.js";
|
|
15
|
+
import { getObject, deleteObject, formatFileSize, } from "../services/objectService.js";
|
|
16
|
+
import { SpinnerComponent } from "../components/Spinner.js";
|
|
17
|
+
import { ErrorMessage } from "../components/ErrorMessage.js";
|
|
18
|
+
import { SuccessMessage } from "../components/SuccessMessage.js";
|
|
19
|
+
import { Breadcrumb } from "../components/Breadcrumb.js";
|
|
20
|
+
import { Header } from "../components/Header.js";
|
|
21
|
+
import { ConfirmationPrompt } from "../components/ConfirmationPrompt.js";
|
|
22
|
+
import { colors } from "../utils/theme.js";
|
|
23
|
+
export function ObjectDetailScreen({ objectId }) {
|
|
24
|
+
const { goBack } = useNavigation();
|
|
25
|
+
const objects = useObjectStore((state) => state.objects);
|
|
26
|
+
const [loading, setLoading] = React.useState(false);
|
|
27
|
+
const [error, setError] = React.useState(null);
|
|
28
|
+
const [fetchedObject, setFetchedObject] = React.useState(null);
|
|
29
|
+
const [deleting, setDeleting] = React.useState(false);
|
|
30
|
+
const [showDownloadPrompt, setShowDownloadPrompt] = React.useState(false);
|
|
31
|
+
const [downloadPath, setDownloadPath] = React.useState("");
|
|
32
|
+
const [downloading, setDownloading] = React.useState(false);
|
|
33
|
+
const [downloadResult, setDownloadResult] = React.useState(null);
|
|
34
|
+
const [downloadError, setDownloadError] = React.useState(null);
|
|
35
|
+
const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
|
|
36
|
+
// Find object in store first
|
|
37
|
+
const objectFromStore = objects.find((o) => o.id === objectId);
|
|
38
|
+
// Polling function - must be defined before any early returns (Rules of Hooks)
|
|
39
|
+
const pollObject = React.useCallback(async () => {
|
|
40
|
+
if (!objectId)
|
|
41
|
+
return null;
|
|
42
|
+
return getObject(objectId);
|
|
43
|
+
}, [objectId]);
|
|
44
|
+
// Fetch object from API if not in store or missing full details
|
|
45
|
+
React.useEffect(() => {
|
|
46
|
+
if (objectId && !loading && !fetchedObject) {
|
|
47
|
+
// Always fetch full details since store may only have basic info
|
|
48
|
+
setLoading(true);
|
|
49
|
+
setError(null);
|
|
50
|
+
getObject(objectId)
|
|
51
|
+
.then((obj) => {
|
|
52
|
+
setFetchedObject(obj);
|
|
53
|
+
setLoading(false);
|
|
54
|
+
})
|
|
55
|
+
.catch((err) => {
|
|
56
|
+
setError(err);
|
|
57
|
+
setLoading(false);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}, [objectId, loading, fetchedObject]);
|
|
61
|
+
// Use fetched object for full details, fall back to store for basic display
|
|
62
|
+
const storageObject = fetchedObject || objectFromStore;
|
|
63
|
+
// Handle download submission
|
|
64
|
+
const handleDownloadSubmit = React.useCallback(async () => {
|
|
65
|
+
if (!downloadPath.trim() || !storageObject)
|
|
66
|
+
return;
|
|
67
|
+
setShowDownloadPrompt(false);
|
|
68
|
+
setDownloading(true);
|
|
69
|
+
try {
|
|
70
|
+
const client = getClient();
|
|
71
|
+
// Get download URL
|
|
72
|
+
const downloadUrlResponse = await client.objects.download(storageObject.id, {
|
|
73
|
+
duration_seconds: 3600,
|
|
74
|
+
});
|
|
75
|
+
// Download the file
|
|
76
|
+
const response = await fetch(downloadUrlResponse.download_url);
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
throw new Error(`Download failed: HTTP ${response.status}`);
|
|
79
|
+
}
|
|
80
|
+
// Save the file
|
|
81
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
82
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
83
|
+
await writeFile(downloadPath.trim(), buffer);
|
|
84
|
+
setDownloadResult(`Downloaded to ${downloadPath.trim()}`);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
setDownloadError(err);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
setDownloading(false);
|
|
91
|
+
}
|
|
92
|
+
}, [downloadPath, storageObject]);
|
|
93
|
+
// Handle input for download prompt and result screens - must be before early returns (Rules of Hooks)
|
|
94
|
+
useInput((input, key) => {
|
|
95
|
+
if (showDownloadPrompt) {
|
|
96
|
+
if (key.escape) {
|
|
97
|
+
setShowDownloadPrompt(false);
|
|
98
|
+
setDownloadPath("");
|
|
99
|
+
}
|
|
100
|
+
else if (key.return) {
|
|
101
|
+
handleDownloadSubmit();
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (downloadResult || downloadError) {
|
|
106
|
+
if (input === "q" || key.escape || key.return) {
|
|
107
|
+
setDownloadResult(null);
|
|
108
|
+
setDownloadError(null);
|
|
109
|
+
setDownloadPath("");
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}, { isActive: showDownloadPrompt || !!downloadResult || !!downloadError });
|
|
114
|
+
// Show loading state while fetching
|
|
115
|
+
if (loading && !storageObject) {
|
|
116
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
117
|
+
{ label: "Storage Objects" },
|
|
118
|
+
{ label: "Loading...", active: true },
|
|
119
|
+
] }), _jsx(SpinnerComponent, { message: "Loading object details..." })] }));
|
|
120
|
+
}
|
|
121
|
+
// Show error state if fetch failed
|
|
122
|
+
if (error && !storageObject) {
|
|
123
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
124
|
+
{ label: "Storage Objects" },
|
|
125
|
+
{ label: "Error", active: true },
|
|
126
|
+
] }), _jsx(ErrorMessage, { message: "Failed to load object details", error: error })] }));
|
|
127
|
+
}
|
|
128
|
+
// Show error if no object found
|
|
129
|
+
if (!storageObject) {
|
|
130
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
131
|
+
{ label: "Storage Objects" },
|
|
132
|
+
{ label: "Not Found", active: true },
|
|
133
|
+
] }), _jsx(ErrorMessage, { message: `Storage object ${objectId || "unknown"} not found`, error: new Error("Storage object not found") })] }));
|
|
134
|
+
}
|
|
135
|
+
// Build detail sections
|
|
136
|
+
const detailSections = [];
|
|
137
|
+
// Basic details section
|
|
138
|
+
const basicFields = [];
|
|
139
|
+
if (storageObject.content_type) {
|
|
140
|
+
basicFields.push({
|
|
141
|
+
label: "Content Type",
|
|
142
|
+
value: storageObject.content_type,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (storageObject.size_bytes !== undefined) {
|
|
146
|
+
basicFields.push({
|
|
147
|
+
label: "Size",
|
|
148
|
+
value: formatFileSize(storageObject.size_bytes),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (storageObject.state) {
|
|
152
|
+
basicFields.push({
|
|
153
|
+
label: "State",
|
|
154
|
+
value: storageObject.state,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (storageObject.is_public !== undefined) {
|
|
158
|
+
basicFields.push({
|
|
159
|
+
label: "Public",
|
|
160
|
+
value: storageObject.is_public ? "Yes" : "No",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (storageObject.create_time_ms) {
|
|
164
|
+
basicFields.push({
|
|
165
|
+
label: "Created",
|
|
166
|
+
value: formatTimestamp(storageObject.create_time_ms),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
// TTL / Expires - show remaining time before auto-deletion
|
|
170
|
+
if (storageObject.delete_after_time_ms) {
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
const remainingMs = storageObject.delete_after_time_ms - now;
|
|
173
|
+
let ttlValue;
|
|
174
|
+
let ttlColor = colors.text;
|
|
175
|
+
if (remainingMs <= 0) {
|
|
176
|
+
ttlValue = "Expired";
|
|
177
|
+
ttlColor = colors.error;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
const remainingMinutes = Math.floor(remainingMs / 60000);
|
|
181
|
+
if (remainingMinutes < 60) {
|
|
182
|
+
ttlValue = `${remainingMinutes}m remaining`;
|
|
183
|
+
ttlColor = remainingMinutes < 10 ? colors.warning : colors.text;
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const hours = Math.floor(remainingMinutes / 60);
|
|
187
|
+
const mins = remainingMinutes % 60;
|
|
188
|
+
ttlValue = `${hours}h ${mins}m remaining`;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
basicFields.push({
|
|
192
|
+
label: "Expires",
|
|
193
|
+
value: _jsx(Text, { color: ttlColor, children: ttlValue }),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (basicFields.length > 0) {
|
|
197
|
+
detailSections.push({
|
|
198
|
+
title: "Details",
|
|
199
|
+
icon: figures.squareSmallFilled,
|
|
200
|
+
color: colors.warning,
|
|
201
|
+
fields: basicFields,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// Download URL section
|
|
205
|
+
if (storageObject.download_url) {
|
|
206
|
+
detailSections.push({
|
|
207
|
+
title: "Download",
|
|
208
|
+
icon: figures.arrowDown,
|
|
209
|
+
color: colors.success,
|
|
210
|
+
fields: [
|
|
211
|
+
{
|
|
212
|
+
label: "URL",
|
|
213
|
+
value: (_jsxs(Text, { color: colors.info, children: [storageObject.download_url.substring(0, 60), "..."] })),
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// Metadata section
|
|
219
|
+
if (storageObject.metadata &&
|
|
220
|
+
Object.keys(storageObject.metadata).length > 0) {
|
|
221
|
+
const metadataFields = Object.entries(storageObject.metadata).map(([key, value]) => ({
|
|
222
|
+
label: key,
|
|
223
|
+
value: value,
|
|
224
|
+
}));
|
|
225
|
+
detailSections.push({
|
|
226
|
+
title: "Metadata",
|
|
227
|
+
icon: figures.identical,
|
|
228
|
+
color: colors.secondary,
|
|
229
|
+
fields: metadataFields,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
// Operations available for objects
|
|
233
|
+
const operations = [
|
|
234
|
+
{
|
|
235
|
+
key: "download",
|
|
236
|
+
label: "Download",
|
|
237
|
+
color: colors.success,
|
|
238
|
+
icon: figures.arrowDown,
|
|
239
|
+
shortcut: "w",
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
key: "delete",
|
|
243
|
+
label: "Delete",
|
|
244
|
+
color: colors.error,
|
|
245
|
+
icon: figures.cross,
|
|
246
|
+
shortcut: "d",
|
|
247
|
+
},
|
|
248
|
+
];
|
|
249
|
+
// Handle operation selection
|
|
250
|
+
const handleOperation = async (operation, resource) => {
|
|
251
|
+
switch (operation) {
|
|
252
|
+
case "download":
|
|
253
|
+
// Show download prompt
|
|
254
|
+
const defaultName = resource.name || resource.id;
|
|
255
|
+
setDownloadPath(`./${defaultName}`);
|
|
256
|
+
setShowDownloadPrompt(true);
|
|
257
|
+
break;
|
|
258
|
+
case "delete":
|
|
259
|
+
// Show confirmation dialog
|
|
260
|
+
setShowDeleteConfirm(true);
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
// Execute delete after confirmation
|
|
265
|
+
const executeDelete = async () => {
|
|
266
|
+
if (!storageObject)
|
|
267
|
+
return;
|
|
268
|
+
setShowDeleteConfirm(false);
|
|
269
|
+
setDeleting(true);
|
|
270
|
+
try {
|
|
271
|
+
await deleteObject(storageObject.id);
|
|
272
|
+
goBack();
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
setError(err);
|
|
276
|
+
setDeleting(false);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
// Build detailed info lines for full details view
|
|
280
|
+
const buildDetailLines = (obj) => {
|
|
281
|
+
const lines = [];
|
|
282
|
+
// Core Information
|
|
283
|
+
lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Storage Object Details" }, "core-title"));
|
|
284
|
+
lines.push(_jsxs(Text, { color: colors.idColor, children: [" ", "ID: ", obj.id] }, "core-id"));
|
|
285
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Name: ", obj.name || "(none)"] }, "core-name"));
|
|
286
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Content Type: ", obj.content_type || "(unknown)"] }, "core-type"));
|
|
287
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Size: ", formatFileSize(obj.size_bytes)] }, "core-size"));
|
|
288
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", "State: ", obj.state || "(unknown)"] }, "core-state"));
|
|
289
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Public: ", obj.is_public ? "Yes" : "No"] }, "core-public"));
|
|
290
|
+
if (obj.create_time_ms) {
|
|
291
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Created: ", new Date(obj.create_time_ms).toLocaleString()] }, "core-created"));
|
|
292
|
+
}
|
|
293
|
+
if (obj.delete_after_time_ms) {
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
const remainingMs = obj.delete_after_time_ms - now;
|
|
296
|
+
let expiresText;
|
|
297
|
+
if (remainingMs <= 0) {
|
|
298
|
+
expiresText = "Expired";
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
const remainingMinutes = Math.floor(remainingMs / 60000);
|
|
302
|
+
if (remainingMinutes < 60) {
|
|
303
|
+
expiresText = `${remainingMinutes}m remaining`;
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
const hours = Math.floor(remainingMinutes / 60);
|
|
307
|
+
const mins = remainingMinutes % 60;
|
|
308
|
+
expiresText = `${hours}h ${mins}m remaining`;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
lines.push(_jsxs(Text, { color: remainingMs <= 0 ? colors.error : colors.warning, children: [" ", "Expires: ", expiresText] }, "core-expires"));
|
|
312
|
+
}
|
|
313
|
+
lines.push(_jsx(Text, { children: " " }, "core-space"));
|
|
314
|
+
// Download URL
|
|
315
|
+
if (obj.download_url) {
|
|
316
|
+
lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Download URL" }, "url-title"));
|
|
317
|
+
lines.push(_jsxs(Text, { color: colors.info, children: [" ", obj.download_url] }, "url-value"));
|
|
318
|
+
lines.push(_jsx(Text, { children: " " }, "url-space"));
|
|
319
|
+
}
|
|
320
|
+
// Metadata
|
|
321
|
+
if (obj.metadata && Object.keys(obj.metadata).length > 0) {
|
|
322
|
+
lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Metadata" }, "meta-title"));
|
|
323
|
+
Object.entries(obj.metadata).forEach(([key, value], idx) => {
|
|
324
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", key, ": ", value] }, `meta-${idx}`));
|
|
325
|
+
});
|
|
326
|
+
lines.push(_jsx(Text, { children: " " }, "meta-space"));
|
|
327
|
+
}
|
|
328
|
+
// Raw JSON
|
|
329
|
+
lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Raw JSON" }, "json-title"));
|
|
330
|
+
const jsonLines = JSON.stringify(obj, null, 2).split("\n");
|
|
331
|
+
jsonLines.forEach((line, idx) => {
|
|
332
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", line] }, `json-${idx}`));
|
|
333
|
+
});
|
|
334
|
+
return lines;
|
|
335
|
+
};
|
|
336
|
+
// Show download result
|
|
337
|
+
if (downloadResult || downloadError) {
|
|
338
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
339
|
+
{ label: "Storage Objects" },
|
|
340
|
+
{ label: storageObject.name || storageObject.id },
|
|
341
|
+
{ label: "Download", active: true },
|
|
342
|
+
] }), _jsx(Header, { title: "Download Result" }), downloadResult && _jsx(SuccessMessage, { message: downloadResult }), downloadError && (_jsx(ErrorMessage, { message: "Download failed", error: downloadError })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter], [q], or [esc] to continue" }) })] }));
|
|
343
|
+
}
|
|
344
|
+
// Show downloading state
|
|
345
|
+
if (downloading) {
|
|
346
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
347
|
+
{ label: "Storage Objects" },
|
|
348
|
+
{ label: storageObject.name || storageObject.id },
|
|
349
|
+
{ label: "Downloading...", active: true },
|
|
350
|
+
] }), _jsx(SpinnerComponent, { message: `Downloading to ${downloadPath}...` })] }));
|
|
351
|
+
}
|
|
352
|
+
// Show download prompt
|
|
353
|
+
if (showDownloadPrompt) {
|
|
354
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
355
|
+
{ label: "Storage Objects" },
|
|
356
|
+
{ label: storageObject.name || storageObject.id },
|
|
357
|
+
{ label: "Download", active: true },
|
|
358
|
+
] }), _jsx(Header, { title: "Download Storage Object" }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.text, children: [figures.arrowRight, " Downloading:", " ", _jsx(Text, { color: colors.primary, children: storageObject.name || storageObject.id })] }), storageObject.size_bytes && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.info, " Size: ", formatFileSize(storageObject.size_bytes)] }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: colors.text, children: "Save to path:" }), _jsxs(Box, { marginTop: 0, children: [_jsxs(Text, { color: colors.primary, children: [figures.pointer, " "] }), _jsx(TextInput, { value: downloadPath, onChange: setDownloadPath, placeholder: "./filename" })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "[Enter] Download \u2022 [Esc] Cancel" }) })] }));
|
|
359
|
+
}
|
|
360
|
+
// Show delete confirmation
|
|
361
|
+
if (showDeleteConfirm && storageObject) {
|
|
362
|
+
return (_jsx(ConfirmationPrompt, { title: "Delete Storage Object", message: `Are you sure you want to delete "${storageObject.name || storageObject.id}"?`, details: "This action cannot be undone.", breadcrumbItems: [
|
|
363
|
+
{ label: "Storage Objects" },
|
|
364
|
+
{ label: storageObject.name || storageObject.id },
|
|
365
|
+
{ label: "Delete", active: true },
|
|
366
|
+
], onConfirm: executeDelete, onCancel: () => setShowDeleteConfirm(false) }));
|
|
367
|
+
}
|
|
368
|
+
// Show deleting state
|
|
369
|
+
if (deleting) {
|
|
370
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
371
|
+
{ label: "Storage Objects" },
|
|
372
|
+
{ label: storageObject.name || storageObject.id },
|
|
373
|
+
{ label: "Deleting...", active: true },
|
|
374
|
+
] }), _jsx(SpinnerComponent, { message: "Deleting storage object..." })] }));
|
|
375
|
+
}
|
|
376
|
+
return (_jsx(ResourceDetailPage, { resource: storageObject, resourceType: "Storage Objects", getDisplayName: (obj) => obj.name || obj.id, getId: (obj) => obj.id, getStatus: (obj) => obj.state || "unknown", detailSections: detailSections, operations: operations, onOperation: handleOperation, onBack: goBack, buildDetailLines: buildDetailLines, pollResource: pollObject }));
|
|
377
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useNavigation } from "../store/navigationStore.js";
|
|
3
|
+
import { ListObjectsUI } from "../commands/object/list.js";
|
|
4
|
+
export function ObjectListScreen() {
|
|
5
|
+
const { goBack } = useNavigation();
|
|
6
|
+
return _jsx(ListObjectsUI, { onBack: goBack, onExit: goBack });
|
|
7
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* SnapshotDetailScreen - Detail page for snapshots
|
|
4
|
+
* Uses the generic ResourceDetailPage component
|
|
5
|
+
*/
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { Text } from "ink";
|
|
8
|
+
import figures from "figures";
|
|
9
|
+
import { useNavigation } from "../store/navigationStore.js";
|
|
10
|
+
import { useSnapshotStore } from "../store/snapshotStore.js";
|
|
11
|
+
import { ResourceDetailPage, formatTimestamp, } from "../components/ResourceDetailPage.js";
|
|
12
|
+
import { getSnapshot, deleteSnapshot } from "../services/snapshotService.js";
|
|
13
|
+
import { SpinnerComponent } from "../components/Spinner.js";
|
|
14
|
+
import { ErrorMessage } from "../components/ErrorMessage.js";
|
|
15
|
+
import { Breadcrumb } from "../components/Breadcrumb.js";
|
|
16
|
+
import { ConfirmationPrompt } from "../components/ConfirmationPrompt.js";
|
|
17
|
+
import { colors } from "../utils/theme.js";
|
|
18
|
+
export function SnapshotDetailScreen({ snapshotId, }) {
|
|
19
|
+
const { goBack, navigate } = useNavigation();
|
|
20
|
+
const snapshots = useSnapshotStore((state) => state.snapshots);
|
|
21
|
+
const [loading, setLoading] = React.useState(false);
|
|
22
|
+
const [error, setError] = React.useState(null);
|
|
23
|
+
const [fetchedSnapshot, setFetchedSnapshot] = React.useState(null);
|
|
24
|
+
const [deleting, setDeleting] = React.useState(false);
|
|
25
|
+
const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
|
|
26
|
+
// Find snapshot in store first
|
|
27
|
+
const snapshotFromStore = snapshots.find((s) => s.id === snapshotId);
|
|
28
|
+
// Polling function - must be defined before any early returns (Rules of Hooks)
|
|
29
|
+
const pollSnapshot = React.useCallback(async () => {
|
|
30
|
+
if (!snapshotId)
|
|
31
|
+
return null;
|
|
32
|
+
return getSnapshot(snapshotId);
|
|
33
|
+
}, [snapshotId]);
|
|
34
|
+
// Fetch snapshot from API if not in store or missing full details
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
if (snapshotId && !loading && !fetchedSnapshot) {
|
|
37
|
+
// Always fetch full details since store may only have basic info
|
|
38
|
+
setLoading(true);
|
|
39
|
+
setError(null);
|
|
40
|
+
getSnapshot(snapshotId)
|
|
41
|
+
.then((snapshot) => {
|
|
42
|
+
setFetchedSnapshot(snapshot);
|
|
43
|
+
setLoading(false);
|
|
44
|
+
})
|
|
45
|
+
.catch((err) => {
|
|
46
|
+
setError(err);
|
|
47
|
+
setLoading(false);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}, [snapshotId, loading, fetchedSnapshot]);
|
|
51
|
+
// Use fetched snapshot for full details, fall back to store for basic display
|
|
52
|
+
const snapshot = fetchedSnapshot || snapshotFromStore;
|
|
53
|
+
// Show loading state while fetching
|
|
54
|
+
if (loading && !snapshot) {
|
|
55
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
56
|
+
{ label: "Snapshots" },
|
|
57
|
+
{ label: "Loading...", active: true },
|
|
58
|
+
] }), _jsx(SpinnerComponent, { message: "Loading snapshot details..." })] }));
|
|
59
|
+
}
|
|
60
|
+
// Show error state if fetch failed
|
|
61
|
+
if (error && !snapshot) {
|
|
62
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Snapshots" }, { label: "Error", active: true }] }), _jsx(ErrorMessage, { message: "Failed to load snapshot details", error: error })] }));
|
|
63
|
+
}
|
|
64
|
+
// Show error if no snapshot found
|
|
65
|
+
if (!snapshot) {
|
|
66
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [{ label: "Snapshots" }, { label: "Not Found", active: true }] }), _jsx(ErrorMessage, { message: `Snapshot ${snapshotId || "unknown"} not found`, error: new Error("Snapshot not found") })] }));
|
|
67
|
+
}
|
|
68
|
+
// Build detail sections
|
|
69
|
+
const detailSections = [];
|
|
70
|
+
// Basic details section
|
|
71
|
+
const basicFields = [];
|
|
72
|
+
if (snapshot.create_time_ms) {
|
|
73
|
+
basicFields.push({
|
|
74
|
+
label: "Created",
|
|
75
|
+
value: formatTimestamp(snapshot.create_time_ms),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (snapshot.devbox_id) {
|
|
79
|
+
basicFields.push({
|
|
80
|
+
label: "Source Devbox",
|
|
81
|
+
value: _jsx(Text, { color: colors.idColor, children: snapshot.devbox_id }),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (snapshot.disk_size_bytes) {
|
|
85
|
+
const sizeGB = (snapshot.disk_size_bytes / (1024 * 1024 * 1024)).toFixed(2);
|
|
86
|
+
basicFields.push({
|
|
87
|
+
label: "Disk Size",
|
|
88
|
+
value: `${sizeGB} GB`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (basicFields.length > 0) {
|
|
92
|
+
detailSections.push({
|
|
93
|
+
title: "Details",
|
|
94
|
+
icon: figures.squareSmallFilled,
|
|
95
|
+
color: colors.warning,
|
|
96
|
+
fields: basicFields,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// Metadata section
|
|
100
|
+
if (snapshot.metadata && Object.keys(snapshot.metadata).length > 0) {
|
|
101
|
+
const metadataFields = Object.entries(snapshot.metadata).map(([key, value]) => ({
|
|
102
|
+
label: key,
|
|
103
|
+
value: value,
|
|
104
|
+
}));
|
|
105
|
+
detailSections.push({
|
|
106
|
+
title: "Metadata",
|
|
107
|
+
icon: figures.identical,
|
|
108
|
+
color: colors.secondary,
|
|
109
|
+
fields: metadataFields,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// Operations available for snapshots
|
|
113
|
+
const operations = [
|
|
114
|
+
{
|
|
115
|
+
key: "create-devbox",
|
|
116
|
+
label: "Create Devbox from Snapshot",
|
|
117
|
+
color: colors.success,
|
|
118
|
+
icon: figures.play,
|
|
119
|
+
shortcut: "c",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
key: "delete",
|
|
123
|
+
label: "Delete Snapshot",
|
|
124
|
+
color: colors.error,
|
|
125
|
+
icon: figures.cross,
|
|
126
|
+
shortcut: "d",
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
// Handle operation selection
|
|
130
|
+
const handleOperation = async (operation, resource) => {
|
|
131
|
+
switch (operation) {
|
|
132
|
+
case "create-devbox":
|
|
133
|
+
navigate("devbox-create", { snapshotId: resource.id });
|
|
134
|
+
break;
|
|
135
|
+
case "delete":
|
|
136
|
+
// Show confirmation dialog
|
|
137
|
+
setShowDeleteConfirm(true);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
// Execute delete after confirmation
|
|
142
|
+
const executeDelete = async () => {
|
|
143
|
+
if (!snapshot)
|
|
144
|
+
return;
|
|
145
|
+
setShowDeleteConfirm(false);
|
|
146
|
+
setDeleting(true);
|
|
147
|
+
try {
|
|
148
|
+
await deleteSnapshot(snapshot.id);
|
|
149
|
+
goBack();
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
setError(err);
|
|
153
|
+
setDeleting(false);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
// Build detailed info lines for full details view
|
|
157
|
+
const buildDetailLines = (snap) => {
|
|
158
|
+
const lines = [];
|
|
159
|
+
// Core Information
|
|
160
|
+
lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Snapshot Details" }, "core-title"));
|
|
161
|
+
lines.push(_jsxs(Text, { color: colors.idColor, children: [" ", "ID: ", snap.id] }, "core-id"));
|
|
162
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Name: ", snap.name || "(none)"] }, "core-name"));
|
|
163
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Status: ", snap.status] }, "core-status"));
|
|
164
|
+
if (snap.devbox_id) {
|
|
165
|
+
lines.push(_jsxs(Text, { color: colors.idColor, children: [" ", "Source Devbox: ", snap.devbox_id] }, "core-devbox"));
|
|
166
|
+
}
|
|
167
|
+
if (snap.create_time_ms) {
|
|
168
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Created: ", new Date(snap.create_time_ms).toLocaleString()] }, "core-created"));
|
|
169
|
+
}
|
|
170
|
+
if (snap.disk_size_bytes) {
|
|
171
|
+
const sizeGB = (snap.disk_size_bytes / (1024 * 1024 * 1024)).toFixed(2);
|
|
172
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Disk Size: ", sizeGB, " GB"] }, "core-size"));
|
|
173
|
+
}
|
|
174
|
+
lines.push(_jsx(Text, { children: " " }, "core-space"));
|
|
175
|
+
// Metadata
|
|
176
|
+
if (snap.metadata && Object.keys(snap.metadata).length > 0) {
|
|
177
|
+
lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Metadata" }, "meta-title"));
|
|
178
|
+
Object.entries(snap.metadata).forEach(([key, value], idx) => {
|
|
179
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", key, ": ", value] }, `meta-${idx}`));
|
|
180
|
+
});
|
|
181
|
+
lines.push(_jsx(Text, { children: " " }, "meta-space"));
|
|
182
|
+
}
|
|
183
|
+
// Raw JSON
|
|
184
|
+
lines.push(_jsx(Text, { color: colors.warning, bold: true, children: "Raw JSON" }, "json-title"));
|
|
185
|
+
const jsonLines = JSON.stringify(snap, null, 2).split("\n");
|
|
186
|
+
jsonLines.forEach((line, idx) => {
|
|
187
|
+
lines.push(_jsxs(Text, { dimColor: true, children: [" ", line] }, `json-${idx}`));
|
|
188
|
+
});
|
|
189
|
+
return lines;
|
|
190
|
+
};
|
|
191
|
+
// Show delete confirmation
|
|
192
|
+
if (showDeleteConfirm && snapshot) {
|
|
193
|
+
return (_jsx(ConfirmationPrompt, { title: "Delete Snapshot", message: `Are you sure you want to delete "${snapshot.name || snapshot.id}"?`, details: "This action cannot be undone.", breadcrumbItems: [
|
|
194
|
+
{ label: "Snapshots" },
|
|
195
|
+
{ label: snapshot.name || snapshot.id },
|
|
196
|
+
{ label: "Delete", active: true },
|
|
197
|
+
], onConfirm: executeDelete, onCancel: () => setShowDeleteConfirm(false) }));
|
|
198
|
+
}
|
|
199
|
+
// Show deleting state
|
|
200
|
+
if (deleting) {
|
|
201
|
+
return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
|
|
202
|
+
{ label: "Snapshots" },
|
|
203
|
+
{ label: snapshot.name || snapshot.id },
|
|
204
|
+
{ label: "Deleting...", active: true },
|
|
205
|
+
] }), _jsx(SpinnerComponent, { message: "Deleting snapshot..." })] }));
|
|
206
|
+
}
|
|
207
|
+
return (_jsx(ResourceDetailPage, { resource: snapshot, resourceType: "Snapshots", getDisplayName: (snap) => snap.name || snap.id, getId: (snap) => snap.id, getStatus: (snap) => snap.status || "unknown", detailSections: detailSections, operations: operations, onOperation: handleOperation, onBack: goBack, buildDetailLines: buildDetailLines, pollResource: snapshot.status === "pending" ? pollSnapshot : undefined }));
|
|
208
|
+
}
|
|
@@ -33,11 +33,11 @@ export async function listBlueprints(options) {
|
|
|
33
33
|
blueprints.push({
|
|
34
34
|
id: String(b.id || "").substring(0, MAX_ID_LENGTH),
|
|
35
35
|
name: String(b.name || "").substring(0, MAX_NAME_LENGTH),
|
|
36
|
-
status:
|
|
36
|
+
status: b.status,
|
|
37
|
+
state: b.state,
|
|
37
38
|
create_time_ms: b.create_time_ms,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
: undefined,
|
|
39
|
+
parameters: b.parameters,
|
|
40
|
+
// UI-specific convenience fields
|
|
41
41
|
architecture: architecture
|
|
42
42
|
? String(architecture).substring(0, MAX_ARCH_LENGTH)
|
|
43
43
|
: undefined,
|
|
@@ -60,16 +60,35 @@ export async function listBlueprints(options) {
|
|
|
60
60
|
export async function getBlueprint(id) {
|
|
61
61
|
const client = getClient();
|
|
62
62
|
const blueprint = await client.blueprints.retrieve(id);
|
|
63
|
+
// Extract architecture and resources from launch_parameters for convenience
|
|
64
|
+
const launchParams = blueprint.parameters?.launch_parameters;
|
|
63
65
|
return {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
architecture: blueprint.architecture,
|
|
70
|
-
resources: blueprint.resources,
|
|
66
|
+
// Spread all API fields
|
|
67
|
+
...blueprint,
|
|
68
|
+
// UI-specific convenience fields
|
|
69
|
+
architecture: launchParams?.architecture ?? undefined,
|
|
70
|
+
resources: launchParams?.resource_size_request ?? undefined,
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Get a single blueprint by ID or name
|
|
75
|
+
*/
|
|
76
|
+
export async function getBlueprintByIdOrName(idOrName) {
|
|
77
|
+
const client = getClient();
|
|
78
|
+
// Check if it's an ID (starts with bpt_) or a name
|
|
79
|
+
if (idOrName.startsWith("bpt_")) {
|
|
80
|
+
return getBlueprint(idOrName);
|
|
81
|
+
}
|
|
82
|
+
// It's a name, search for it
|
|
83
|
+
const result = await client.blueprints.list({ name: idOrName });
|
|
84
|
+
const blueprints = result.blueprints || [];
|
|
85
|
+
if (blueprints.length === 0) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
// Return the first exact match, or first result if no exact match
|
|
89
|
+
const blueprint = blueprints.find((b) => b.name === idOrName) || blueprints[0];
|
|
90
|
+
return getBlueprint(blueprint.id);
|
|
91
|
+
}
|
|
73
92
|
/**
|
|
74
93
|
* Get blueprint logs
|
|
75
94
|
* Returns the raw logs array from the API response
|