openk8s 1.0.4 → 1.1.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 +1 -1
- package/package.json +11 -10
- package/src/app/__tests__/app-state.test.ts +47 -5
- package/src/app/__tests__/utils.test.ts +5 -14
- package/src/app/app-actions.ts +5 -0
- package/src/app/app-state.ts +11 -2
- package/src/app/app.tsx +57 -46
- package/src/app/components/detail-sections.tsx +2 -2
- package/src/app/components/footer.tsx +23 -30
- package/src/app/components/inspector.tsx +28 -43
- package/src/app/components/kind-rows.tsx +1 -1
- package/src/app/components/notification-card.tsx +145 -0
- package/src/app/components/notification-tray.tsx +19 -38
- package/src/app/components/overlays/delete-confirm-overlay.tsx +15 -17
- package/src/app/components/overlays/helm-rollback-overlay.tsx +12 -66
- package/src/app/components/overlays/help-overlay.tsx +173 -0
- package/src/app/components/overlays/index.ts +5 -2
- package/src/app/components/overlays/logs-dialog.tsx +50 -80
- package/src/app/components/overlays/notification-history-overlay.tsx +79 -0
- package/src/app/components/overlays/port-forward-list-overlay.tsx +85 -0
- package/src/app/components/overlays/port-forward-overlay.tsx +16 -56
- package/src/app/components/overlays/scale-dialog.tsx +12 -67
- package/src/app/components/overlays/select-overlay.tsx +5 -14
- package/src/app/components/overlays/shared.tsx +85 -6
- package/src/app/components/resource-rows.tsx +1 -1
- package/src/app/constants.ts +24 -0
- package/src/app/hooks/keyboard/filter-handlers.ts +2 -1
- package/src/app/hooks/keyboard/global-handlers.ts +32 -11
- package/src/app/hooks/keyboard/helm-handlers.ts +14 -9
- package/src/app/hooks/keyboard/keys.ts +18 -0
- package/src/app/hooks/keyboard/logs-handlers.ts +3 -1
- package/src/app/hooks/keyboard/navigation-handlers.ts +5 -4
- package/src/app/hooks/keyboard/overlay-handlers.ts +11 -7
- package/src/app/hooks/keyboard/port-forward-handlers.ts +19 -14
- package/src/app/hooks/keyboard/shell-edit-handlers.ts +45 -17
- package/src/app/hooks/use-app-keyboard.ts +22 -2
- package/src/app/hooks/use-app-side-effects.ts +10 -7
- package/src/app/hooks/use-clipboard.ts +8 -10
- package/src/app/hooks/use-data-fetching.ts +11 -5
- package/src/app/hooks/use-log-stream.ts +8 -4
- package/src/app/hooks/use-notifications.ts +92 -0
- package/src/app/hooks/use-port-forward.ts +19 -4
- package/src/app/persistence.ts +7 -3
- package/src/app/syntax-theme.ts +31 -0
- package/src/app/theme.ts +2 -3
- package/src/app/use-footer-hints.ts +21 -16
- package/src/app/utils.ts +1 -9
- package/src/assets/tree-sitter/yaml/highlights.scm +79 -0
- package/src/assets/tree-sitter/yaml/tree-sitter-yaml.wasm +0 -0
- package/src/index.tsx +22 -2
- package/src/lib/k8s/__tests__/k8s-format.test.ts +17 -0
- package/src/lib/k8s/__tests__/resource-parser.test.ts +40 -2
- package/src/lib/k8s/detail-builders/event-builder.ts +17 -11
- package/src/lib/k8s/detail-builders/hpa-cronjob-builder.ts +34 -31
- package/src/lib/k8s/detail-builders/node-builder.ts +22 -11
- package/src/lib/k8s/detail-builders/overview-builder.ts +4 -17
- package/src/lib/k8s/detail-builders/pod-builder.ts +52 -24
- package/src/lib/k8s/detail-builders/rbac-builder.ts +20 -29
- package/src/lib/k8s/k8s-format.ts +14 -9
- package/src/lib/k8s/resource-detail-builder.ts +1 -1
- package/src/lib/k8s/resource-parser.ts +17 -2
- package/src/lib/k8s/types.ts +10 -1
- package/src/lib/kubectl/__tests__/metrics-utils.test.ts +9 -0
- package/src/lib/kubectl/kubectl-helpers.ts +16 -11
- package/src/lib/kubectl/kubectl-service.ts +39 -39
- package/src/lib/kubectl/kubectl-types.ts +10 -1
- package/src/lib/kubectl/metrics-utils.ts +21 -7
- package/src/lib/kubectl/spawn-utils.ts +50 -11
- package/src/app/__tests__/components/inspector-tokens.test.ts +0 -101
- package/src/app/components/inspector-tokens.ts +0 -93
- package/src/app/components/port-forwards-tray.tsx +0 -57
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import { spawn, type ChildProcess, type StdioOptions } from "node:child_process";
|
|
2
|
+
import { parseKubectlError } from "./kubectl-helpers";
|
|
2
3
|
|
|
3
4
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
4
5
|
let shutdownController: AbortController | undefined;
|
|
5
6
|
|
|
7
|
+
const persistentProcesses = new Set<ChildProcess>();
|
|
8
|
+
|
|
6
9
|
export function setAbortController(controller: AbortController): void {
|
|
7
10
|
shutdownController = controller;
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
export function abortRunningProcesses(): void {
|
|
11
14
|
shutdownController?.abort();
|
|
15
|
+
for (const child of persistentProcesses) {
|
|
16
|
+
safeKill(child, "SIGTERM");
|
|
17
|
+
}
|
|
18
|
+
persistentProcesses.clear();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function safeKill(child: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): void {
|
|
22
|
+
try {
|
|
23
|
+
if (child.exitCode === null && !child.killed) {
|
|
24
|
+
child.kill(signal);
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// ESRCH: process already exited. Safe to ignore.
|
|
28
|
+
}
|
|
12
29
|
}
|
|
13
30
|
|
|
14
31
|
export interface RunCommandOptions {
|
|
@@ -28,23 +45,30 @@ export function runCommand(options: RunCommandOptions): Promise<CommandResult> {
|
|
|
28
45
|
const child = spawn(options.command, options.args, { env: process.env, stdio: ["pipe", "pipe", "pipe"] });
|
|
29
46
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
30
47
|
|
|
31
|
-
const
|
|
48
|
+
const onShutdown = (): void => {
|
|
32
49
|
if (settled) {
|
|
33
50
|
return;
|
|
34
51
|
}
|
|
35
52
|
settled = true;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
53
|
+
clearTimeout(timeoutId);
|
|
54
|
+
safeKill(child, "SIGTERM");
|
|
55
|
+
reject(new Error("Application shutting down"));
|
|
56
|
+
};
|
|
39
57
|
|
|
40
|
-
const
|
|
58
|
+
const timeoutId = setTimeout(() => {
|
|
41
59
|
if (settled) {
|
|
42
60
|
return;
|
|
43
61
|
}
|
|
44
62
|
settled = true;
|
|
45
|
-
|
|
46
|
-
child
|
|
47
|
-
reject(new Error(
|
|
63
|
+
removeAbortListener();
|
|
64
|
+
safeKill(child, "SIGTERM");
|
|
65
|
+
reject(new Error(`${options.command} ${options.args.slice(0, 4).join(" ")} timed out after ${timeoutMs / 1_000}s`));
|
|
66
|
+
}, timeoutMs);
|
|
67
|
+
|
|
68
|
+
const removeAbortListener = (): void => {
|
|
69
|
+
if (shutdownController) {
|
|
70
|
+
shutdownController.signal.removeEventListener("abort", onShutdown);
|
|
71
|
+
}
|
|
48
72
|
};
|
|
49
73
|
|
|
50
74
|
if (shutdownController) {
|
|
@@ -68,12 +92,15 @@ export function runCommand(options: RunCommandOptions): Promise<CommandResult> {
|
|
|
68
92
|
|
|
69
93
|
child.on("error", (error) => {
|
|
70
94
|
clearTimeout(timeoutId);
|
|
95
|
+
removeAbortListener();
|
|
96
|
+
if (settled) return;
|
|
71
97
|
settled = true;
|
|
72
98
|
reject(error);
|
|
73
99
|
});
|
|
74
100
|
|
|
75
101
|
child.on("close", (code) => {
|
|
76
102
|
clearTimeout(timeoutId);
|
|
103
|
+
removeAbortListener();
|
|
77
104
|
|
|
78
105
|
if (settled) {
|
|
79
106
|
return;
|
|
@@ -86,8 +113,7 @@ export function runCommand(options: RunCommandOptions): Promise<CommandResult> {
|
|
|
86
113
|
}
|
|
87
114
|
|
|
88
115
|
settled = true;
|
|
89
|
-
|
|
90
|
-
reject(new Error(message));
|
|
116
|
+
reject(parseKubectlError(stderr, options.args, options.command));
|
|
91
117
|
});
|
|
92
118
|
});
|
|
93
119
|
}
|
|
@@ -101,8 +127,21 @@ export function runJson<T>(options: RunCommandOptions): Promise<T> {
|
|
|
101
127
|
}
|
|
102
128
|
|
|
103
129
|
export function startPersistentProcess(options: { command: string; args: string[]; stdio?: StdioOptions }): ChildProcess {
|
|
104
|
-
|
|
130
|
+
const child = spawn(options.command, options.args, {
|
|
105
131
|
env: process.env,
|
|
106
132
|
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
|
107
133
|
});
|
|
134
|
+
persistentProcesses.add(child);
|
|
135
|
+
child.on("close", () => {
|
|
136
|
+
persistentProcesses.delete(child);
|
|
137
|
+
});
|
|
138
|
+
child.on("error", () => {
|
|
139
|
+
persistentProcesses.delete(child);
|
|
140
|
+
});
|
|
141
|
+
return child;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function stopPersistentProcess(child: ChildProcess): void {
|
|
145
|
+
persistentProcesses.delete(child);
|
|
146
|
+
safeKill(child, "SIGINT");
|
|
108
147
|
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { tokenizeYamlLine, tokenizeDescribeLine } from "../../components/inspector-tokens";
|
|
3
|
-
import { YAML_COMMENT, YAML_KEY, TEXT_SUBTLE, YAML_VALUE } from "../../theme";
|
|
4
|
-
|
|
5
|
-
describe("tokenizeYamlLine", () => {
|
|
6
|
-
test("empty line", () => {
|
|
7
|
-
const tokens = tokenizeYamlLine("");
|
|
8
|
-
expect(tokens[0]?.text).toBe(" ");
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
test("whitespace line", () => {
|
|
12
|
-
const tokens = tokenizeYamlLine(" ");
|
|
13
|
-
expect(tokens[0]?.text).toBe(" ");
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test("comment line", () => {
|
|
17
|
-
const tokens = tokenizeYamlLine("# this is a comment");
|
|
18
|
-
expect(tokens).toHaveLength(1);
|
|
19
|
-
expect(tokens[0]?.fg).toBe(YAML_COMMENT);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("key: value line", () => {
|
|
23
|
-
const tokens = tokenizeYamlLine("name: nginx");
|
|
24
|
-
expect(tokens).toHaveLength(4);
|
|
25
|
-
expect(tokens[0]?.text).toBe("");
|
|
26
|
-
expect(tokens[1]?.text).toBe("name");
|
|
27
|
-
expect(tokens[1]?.fg).toBe(YAML_KEY);
|
|
28
|
-
expect(tokens[3]?.text).toBe("nginx");
|
|
29
|
-
expect(tokens[3]?.fg).toBe(YAML_VALUE);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("indented key: value", () => {
|
|
33
|
-
const tokens = tokenizeYamlLine(" name: nginx");
|
|
34
|
-
expect(tokens[0]?.text).toBe(" ");
|
|
35
|
-
expect(tokens[1]?.text).toBe("name");
|
|
36
|
-
expect(tokens[1]?.fg).toBe(YAML_KEY);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("list item with value", () => {
|
|
40
|
-
const tokens = tokenizeYamlLine("- name: nginx");
|
|
41
|
-
expect(tokens[0]?.text).toBe("- ");
|
|
42
|
-
expect(tokens[1]?.text).toBe("name");
|
|
43
|
-
expect(tokens[1]?.fg).toBe(YAML_KEY);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test("bare list marker", () => {
|
|
47
|
-
const tokens = tokenizeYamlLine("-");
|
|
48
|
-
expect(tokens).toHaveLength(1);
|
|
49
|
-
expect(tokens[0]?.fg).toBe(TEXT_SUBTLE);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("key only (ends with colon)", () => {
|
|
53
|
-
const tokens = tokenizeYamlLine("metadata:");
|
|
54
|
-
expect(tokens).toHaveLength(3);
|
|
55
|
-
expect(tokens[1]?.text).toBe("metadata");
|
|
56
|
-
expect(tokens[1]?.fg).toBe(YAML_KEY);
|
|
57
|
-
expect(tokens[2]?.text).toBe(":");
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("plain text fallthrough", () => {
|
|
61
|
-
const tokens = tokenizeYamlLine("some random text");
|
|
62
|
-
expect(tokens).toHaveLength(1);
|
|
63
|
-
expect(tokens[0]?.fg).toBe(TEXT_SUBTLE);
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
describe("tokenizeDescribeLine", () => {
|
|
68
|
-
test("empty line", () => {
|
|
69
|
-
const tokens = tokenizeDescribeLine("");
|
|
70
|
-
expect(tokens[0]?.text).toBe(" ");
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("section header", () => {
|
|
74
|
-
const tokens = tokenizeDescribeLine("Name: test-pod");
|
|
75
|
-
expect(tokens).toHaveLength(3);
|
|
76
|
-
expect(tokens[0]?.text).toBe("Name");
|
|
77
|
-
expect(tokens[0]?.fg).toBe(YAML_KEY);
|
|
78
|
-
expect(tokens[2]?.fg).toBe(YAML_VALUE);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("indented key: value (double space after colon)", () => {
|
|
82
|
-
const tokens = tokenizeDescribeLine(" Namespace: default");
|
|
83
|
-
expect(tokens).toHaveLength(2);
|
|
84
|
-
expect(tokens[0]?.fg).toBe(TEXT_SUBTLE);
|
|
85
|
-
expect(tokens[1]?.fg).toBe(YAML_VALUE);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("key: single value", () => {
|
|
89
|
-
const tokens = tokenizeDescribeLine("key:value");
|
|
90
|
-
expect(tokens).toHaveLength(3);
|
|
91
|
-
expect(tokens[0]?.fg).toBe(TEXT_SUBTLE);
|
|
92
|
-
expect(tokens[1]?.text).toBe(":");
|
|
93
|
-
expect(tokens[2]?.text).toBe("value");
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test("plain text", () => {
|
|
97
|
-
const tokens = tokenizeDescribeLine("Some plain description text");
|
|
98
|
-
expect(tokens).toHaveLength(1);
|
|
99
|
-
expect(tokens[0]?.fg).toBe(TEXT_SUBTLE);
|
|
100
|
-
});
|
|
101
|
-
});
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { YAML_COMMENT, YAML_KEY, TEXT_SUBTLE, YAML_VALUE } from "../theme";
|
|
2
|
-
|
|
3
|
-
export interface YamlToken {
|
|
4
|
-
text: string;
|
|
5
|
-
fg: string;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export function tokenizeDescribeLine(line: string): YamlToken[] {
|
|
9
|
-
if (!line.trim()) {
|
|
10
|
-
return [{ text: line || " ", fg: TEXT_SUBTLE }];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
if (/^[A-Z][A-Za-z ]+:/.test(line.trimStart()) && !line.startsWith(" ")) {
|
|
14
|
-
const colonIdx = line.indexOf(":");
|
|
15
|
-
const key = line.slice(0, colonIdx);
|
|
16
|
-
const rest = line.slice(colonIdx + 1);
|
|
17
|
-
return [
|
|
18
|
-
{ text: key, fg: YAML_KEY },
|
|
19
|
-
{ text: ":", fg: TEXT_SUBTLE },
|
|
20
|
-
{ text: rest, fg: YAML_VALUE },
|
|
21
|
-
];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const colonSpaceIdx = line.indexOf(": ");
|
|
25
|
-
if (colonSpaceIdx > 0) {
|
|
26
|
-
const key = line.slice(0, colonSpaceIdx);
|
|
27
|
-
const value = line.slice(colonSpaceIdx + 1);
|
|
28
|
-
return [
|
|
29
|
-
{ text: key, fg: TEXT_SUBTLE },
|
|
30
|
-
{ text: value, fg: YAML_VALUE },
|
|
31
|
-
];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const colonIdx = line.indexOf(":");
|
|
35
|
-
if (colonIdx > 0 && colonIdx < line.length - 1) {
|
|
36
|
-
const key = line.slice(0, colonIdx);
|
|
37
|
-
const value = line.slice(colonIdx + 1);
|
|
38
|
-
return [
|
|
39
|
-
{ text: key, fg: TEXT_SUBTLE },
|
|
40
|
-
{ text: ":", fg: TEXT_SUBTLE },
|
|
41
|
-
{ text: value, fg: YAML_VALUE },
|
|
42
|
-
];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
return [{ text: line, fg: TEXT_SUBTLE }];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function tokenizeYamlLine(line: string): YamlToken[] {
|
|
49
|
-
if (!line.trim()) {
|
|
50
|
-
return [{ text: line || " ", fg: TEXT_SUBTLE }];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (/^\s*#/.test(line)) {
|
|
54
|
-
return [{ text: line, fg: YAML_COMMENT }];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const indentLen = line.length - line.trimStart().length;
|
|
58
|
-
const indent = line.slice(0, indentLen);
|
|
59
|
-
let rest = line.trimStart();
|
|
60
|
-
|
|
61
|
-
let listMarker = "";
|
|
62
|
-
if (rest.startsWith("- ")) {
|
|
63
|
-
listMarker = "- ";
|
|
64
|
-
rest = rest.slice(2);
|
|
65
|
-
} else if (rest === "-") {
|
|
66
|
-
return [{ text: line, fg: TEXT_SUBTLE }];
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const colonSpaceIdx = rest.indexOf(": ");
|
|
70
|
-
const isKeyOnly = rest === rest.trimEnd() && rest.endsWith(":");
|
|
71
|
-
|
|
72
|
-
if (colonSpaceIdx !== -1) {
|
|
73
|
-
const key = rest.slice(0, colonSpaceIdx);
|
|
74
|
-
const value = rest.slice(colonSpaceIdx + 2);
|
|
75
|
-
return [
|
|
76
|
-
{ text: indent + listMarker, fg: TEXT_SUBTLE },
|
|
77
|
-
{ text: key, fg: YAML_KEY },
|
|
78
|
-
{ text: ": ", fg: TEXT_SUBTLE },
|
|
79
|
-
{ text: value, fg: YAML_VALUE },
|
|
80
|
-
];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (isKeyOnly) {
|
|
84
|
-
const key = rest.slice(0, -1);
|
|
85
|
-
return [
|
|
86
|
-
{ text: indent + listMarker, fg: TEXT_SUBTLE },
|
|
87
|
-
{ text: key, fg: YAML_KEY },
|
|
88
|
-
{ text: ":", fg: TEXT_SUBTLE },
|
|
89
|
-
];
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return [{ text: line, fg: TEXT_SUBTLE }];
|
|
93
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { TextAttributes } from "@opentui/core";
|
|
2
|
-
|
|
3
|
-
import { DANGER, GLYPHS, KEY_HINT, OVERLAY_SURFACE, PANEL_BORDER, TEXT_PRIMARY, TEXT_SUBTLE } from "../theme";
|
|
4
|
-
import { activePortForwardLabel } from "../utils";
|
|
5
|
-
import type { ActivePortForward } from "../../lib/k8s/types";
|
|
6
|
-
|
|
7
|
-
export interface PortForwardsTrayProps {
|
|
8
|
-
forwards: ActivePortForward[];
|
|
9
|
-
onStop: (id: string) => void;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function PortForwardsTray({ forwards, onStop }: PortForwardsTrayProps) {
|
|
13
|
-
if (forwards.length === 0) {
|
|
14
|
-
return undefined;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
return (
|
|
18
|
-
<box
|
|
19
|
-
style={{
|
|
20
|
-
border: true,
|
|
21
|
-
borderColor: PANEL_BORDER,
|
|
22
|
-
borderStyle: "rounded",
|
|
23
|
-
backgroundColor: OVERLAY_SURFACE,
|
|
24
|
-
paddingLeft: 1,
|
|
25
|
-
paddingRight: 1,
|
|
26
|
-
flexDirection: "column",
|
|
27
|
-
}}
|
|
28
|
-
>
|
|
29
|
-
{/* Tray heading */}
|
|
30
|
-
<text fg={KEY_HINT}>
|
|
31
|
-
{GLYPHS.forward}
|
|
32
|
-
<span fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>{" Port Forwards"}</span>
|
|
33
|
-
</text>
|
|
34
|
-
<box style={{ flexDirection: "column", width: "100%" }}>
|
|
35
|
-
{forwards.map((forward) => {
|
|
36
|
-
const isReady = forward.status === "ready";
|
|
37
|
-
const statusDot = isReady ? GLYPHS.dot : GLYPHS.dotEmpty;
|
|
38
|
-
const statusFg = isReady ? KEY_HINT : TEXT_SUBTLE;
|
|
39
|
-
|
|
40
|
-
return (
|
|
41
|
-
<box key={forward.id} style={{ flexDirection: "row", justifyContent: "space-between", width: "100%" }}>
|
|
42
|
-
<text fg={isReady ? TEXT_PRIMARY : TEXT_SUBTLE}>
|
|
43
|
-
<span fg={statusFg}>{statusDot}</span>
|
|
44
|
-
{` ${activePortForwardLabel(forward)} `}
|
|
45
|
-
<span fg={TEXT_SUBTLE} attributes={TextAttributes.DIM}>{forward.status}</span>
|
|
46
|
-
</text>
|
|
47
|
-
<text fg={DANGER} onMouseDown={() => onStop(forward.id)}>
|
|
48
|
-
<span fg={DANGER}>{GLYPHS.stop}</span>
|
|
49
|
-
{" stop"}
|
|
50
|
-
</text>
|
|
51
|
-
</box>
|
|
52
|
-
);
|
|
53
|
-
})}
|
|
54
|
-
</box>
|
|
55
|
-
</box>
|
|
56
|
-
);
|
|
57
|
-
}
|