opendocker 0.1.1 → 0.1.3

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.
@@ -0,0 +1,24 @@
1
+ version: '3.8'
2
+
3
+ services:
4
+ logger1:
5
+ image: alpine:latest
6
+ container_name: logger1
7
+ command: >
8
+ sh -c '
9
+ while true; do
10
+ echo "[$$(date +%Y-%m-%d\ %H:%M:%S)] Container1: $$(openssl rand -hex 8)"
11
+ sleep 1
12
+ done'
13
+ restart: unless-stopped
14
+
15
+ logger2:
16
+ image: alpine:latest
17
+ container_name: logger2
18
+ command: >
19
+ sh -c '
20
+ while true; do
21
+ echo "[$$(date +%Y-%m-%d\ %H:%M:%S)] Container2: $$(openssl rand -hex 8)"
22
+ sleep 1
23
+ done'
24
+ restart: unless-stopped
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "name": "opendocker",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "bin": {
8
8
  "opendocker": "./dist/main.js"
9
9
  },
10
- "files": ["dist/", "package.json", "README.md"],
10
+ "file": ["dist/", "package.json", "README.md"],
11
11
  "scripts": {
12
+ "build": "bun build ./src/main.tsx --outdir ./dist --target bun",
12
13
  "start": "bun run dist/main.js",
13
- "dev": "bun --watch run src/main.tsx",
14
- "build": "bun build ./src/main.tsx --outdir ./dist --target bun --minify"
14
+ "dev": "bun --watch run src/main.tsx"
15
15
  },
16
16
  "devDependencies": {
17
17
  "@types/bun": "latest"
@@ -20,8 +20,8 @@
20
20
  "typescript": "^5"
21
21
  },
22
22
  "dependencies": {
23
- "@opentui/core": "^0.1.34",
24
- "@opentui/react": "^0.1.34",
23
+ "@opentui/core": "^0.1.46",
24
+ "@opentui/react": "^0.1.46",
25
25
  "zustand": "^5.0.8"
26
26
  }
27
27
  }
Binary file
@@ -0,0 +1,19 @@
1
+ import { colors } from "../utils/styling";
2
+
3
+ export const SplitBorder = {
4
+ border: ["left" as const],
5
+ borderColor: colors.border,
6
+ customBorderChars: {
7
+ topLeft: "",
8
+ bottomLeft: "",
9
+ vertical: "┃",
10
+ topRight: "",
11
+ bottomRight: "",
12
+ horizontal: "",
13
+ bottomT: "",
14
+ topT: "",
15
+ cross: "",
16
+ leftT: "",
17
+ rightT: "",
18
+ },
19
+ }
@@ -0,0 +1,146 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import { colors, termColors } from "../utils/styling";
4
+ import Pane from "./Pane";
5
+ import { useContainerStore } from "../stores/containers";
6
+ import { useApplicationStore } from "../stores/application";
7
+ import type { ScrollBoxRenderable } from "@opentui/core";
8
+ import { Docker } from "../lib/docker";
9
+ import { TextAttributes } from "@opentui/core";
10
+
11
+ export default function ContainersPane() {
12
+ const { activePane, setActivePane } = useApplicationStore((state) => state);
13
+ const { containers, setContainers, activeContainer, setActiveContainer } = useContainerStore((state) => state);
14
+ const [selectedIndex, setSelectedIndex] = useState(0);
15
+ const paneActive = activePane === "containers";
16
+ const scrollBoxRef = useRef<ScrollBoxRenderable>(null);
17
+
18
+ useEffect(() => {
19
+ if (!paneActive) return;
20
+
21
+ const docker = new Docker();
22
+
23
+ docker.watch((dockerContainers) => {
24
+ const transformed = dockerContainers.map(container => ({
25
+ name: container.Names[0].replace("/", ""),
26
+ status: container.Status,
27
+ state: container.State,
28
+ health: container.Health,
29
+ }));
30
+
31
+ setContainers(transformed);
32
+
33
+ if (transformed.length === 0) {
34
+ setActiveContainer(undefined);
35
+ }
36
+
37
+ // Get the CURRENT active container from store, not from stale closure
38
+ const currentActive = useContainerStore.getState().activeContainer;
39
+
40
+ if (currentActive) {
41
+ const updatedContainer = transformed.find((c) => c.name === currentActive.name);
42
+ if (updatedContainer) {
43
+ setActiveContainer(updatedContainer);
44
+ return;
45
+ }
46
+ }
47
+
48
+ if (transformed.length > 0) {
49
+ setActiveContainer(transformed[0]);
50
+ }
51
+ });
52
+ }, [paneActive]);
53
+
54
+ useKeyboard((key) => {
55
+ if (!paneActive) {
56
+ return;
57
+ }
58
+
59
+ if (key.name === "left") {
60
+ setActivePane("volumes");
61
+ }
62
+
63
+ if (key.name === "right") {
64
+ setActivePane("images");
65
+ }
66
+
67
+ if (key.name === 'j' || key.name === 'down') {
68
+ const index = Math.min(selectedIndex + 1, containers.length - 1);
69
+ setSelectedIndex(index);
70
+ setActiveContainer(containers[index]);
71
+ }
72
+
73
+ if (key.name === 'k' || key.name === 'up') {
74
+ const index = Math.max(selectedIndex - 1, 0);
75
+ setActiveContainer(containers[index]);
76
+ setSelectedIndex(index);
77
+ }
78
+ });
79
+
80
+ return (
81
+ <Pane
82
+ title="Containers"
83
+ flexDirection="column"
84
+ width="100%"
85
+ active={paneActive}
86
+ >
87
+ <scrollbox
88
+ ref={scrollBoxRef}
89
+ scrollY={true}
90
+ stickyScroll={true}
91
+ stickyStart="bottom"
92
+ viewportOptions={{
93
+ flexGrow: 1
94
+ }}
95
+ >
96
+ {containers.map((item, index) => {
97
+ function getStateColor() {
98
+ if (paneActive && activeContainer?.name === item.name) {
99
+ return colors.backgroundPanel;
100
+ }
101
+
102
+ if (item?.state === "running") {
103
+ if (item.status.includes("starting")) {
104
+ return termColors.orange11;
105
+ }
106
+
107
+ if (item.status.includes("unhealthy")) {
108
+ return termColors.red11;
109
+ }
110
+
111
+ return termColors.green11;
112
+ }
113
+
114
+ if (item?.state === "exited") {
115
+ return termColors.red11;
116
+ }
117
+
118
+ return termColors.blue11;
119
+ }
120
+
121
+ return (
122
+ <box
123
+ key={index}
124
+ backgroundColor={paneActive && activeContainer?.name === item.name ? colors.primary : undefined}
125
+ flexDirection="row"
126
+ justifyContent="space-between"
127
+ paddingLeft={1}
128
+ paddingRight={1}
129
+ >
130
+ <text
131
+ content={item.name}
132
+ fg={paneActive && activeContainer?.name === item.name ? colors.backgroundPanel : colors.textMuted}
133
+ attributes={paneActive && activeContainer?.name === item.name && TextAttributes.BOLD}
134
+ />
135
+ <text
136
+ content={item.state}
137
+ fg={getStateColor()}
138
+ />
139
+ </box>
140
+ )
141
+ })}
142
+ {containers.length < 1 && <text fg={colors.textMuted}>No Containers</text>}
143
+ </scrollbox>
144
+ </Pane>
145
+ )
146
+ }
@@ -0,0 +1,83 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import Pane from "./Pane";
4
+ import { colors } from "../utils/styling";
5
+ import { useImageStore } from "../stores/images";
6
+ import { useApplicationStore } from "../stores/application";
7
+ import type { ScrollBoxRenderable } from "@opentui/core";
8
+
9
+ export default function ImagesPane() {
10
+ const { activePane, setActivePane } = useApplicationStore((state) => state);
11
+ const { images, setImages } = useImageStore();
12
+ const scrollBoxRef = useRef<ScrollBoxRenderable>(null);
13
+
14
+ useEffect(() => {
15
+ const process = Bun.spawn(
16
+ ["docker", "images", "--format", "{{.Repository}}"],
17
+ {stdout: "pipe", stderr: "pipe"},
18
+ );
19
+
20
+ async function read() {
21
+ try {
22
+ const reader = process.stdout.getReader();
23
+
24
+ while (true) {
25
+ const { done, value } = await reader.read();
26
+ if (done) break;
27
+ const decodedValues = new TextDecoder().decode(value);
28
+ const lines = decodedValues.split("\n").filter(Boolean);
29
+ setImages(lines);
30
+ }
31
+ } catch (error) {
32
+ console.error(error);
33
+ }
34
+ }
35
+
36
+ read();
37
+
38
+ return () => process.kill();
39
+ }, []);
40
+
41
+ useKeyboard((key) => {
42
+ if (activePane !== "images") {
43
+ return;
44
+ }
45
+
46
+ if (key.name === "left") {
47
+ setActivePane("containers");
48
+ }
49
+
50
+ if (key.name === "right" || key.name === "tab") {
51
+ setActivePane("volumes");
52
+ }
53
+ });
54
+
55
+ return (
56
+ <Pane
57
+ title="Images"
58
+ width="100%"
59
+ active={activePane === "images"}
60
+ flexDirection="column"
61
+ >
62
+ <scrollbox
63
+ ref={scrollBoxRef}
64
+ scrollY={true}
65
+ stickyScroll={true}
66
+ stickyStart="top"
67
+ >
68
+ {images.map((item: string, index: number) => {
69
+ return (
70
+ <box key={index}>
71
+ <text
72
+ key={index}
73
+ content={item}
74
+ fg={colors.textMuted}
75
+ />
76
+ </box>
77
+ )
78
+ })}
79
+ {images.length < 1 && <text fg={colors.textMuted}>No Images</text>}
80
+ </scrollbox>
81
+ </Pane>
82
+ )
83
+ }
@@ -0,0 +1,132 @@
1
+ import { useState, useEffect, useRef, useMemo } from "react";
2
+ import { colors, termColors } from "../utils/styling";
3
+ import { RGBA, TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
4
+ import Pane from "./Pane";
5
+ import { useContainerStore } from "../stores/containers";
6
+ import { useApplicationStore } from "../stores/application";
7
+ import { Shimmer } from "./ui/Shimmer";
8
+
9
+ type LogEntry = {
10
+ text: string;
11
+ type: 'stdout' | 'stderr';
12
+ };
13
+
14
+ export default function LogsPane() {
15
+ const { activePane } = useApplicationStore((state) => state);
16
+ const { activeContainer } = useContainerStore((state) => state);
17
+ const [logs, setLogs] = useState<LogEntry[]>([]);
18
+ const scrollBoxRef = useRef<ScrollBoxRenderable>(null);
19
+
20
+ useEffect(() => {
21
+ if (!activeContainer || activePane !== "containers") {
22
+ cleanup();
23
+ return;
24
+ };
25
+
26
+ const process = Bun.spawn([
27
+ "docker",
28
+ "logs",
29
+ "--follow",
30
+ "--tail",
31
+ "100",
32
+ activeContainer.name,
33
+ ], {
34
+ stdout: "pipe",
35
+ stderr: "pipe",
36
+ });
37
+
38
+ async function readStream(stream: ReadableStream, type: 'stdout' | 'stderr') {
39
+ const decoder = new TextDecoder();
40
+ const reader = stream.getReader();
41
+
42
+ try {
43
+ while (true) {
44
+ const { done, value } = await reader.read();
45
+ if (done) break;
46
+
47
+ const text = decoder.decode(value, { stream: true });
48
+ // Split by newlines and filter out empty lines
49
+ const lines = text.split('\n').filter(line => line.trim().length > 0);
50
+
51
+ if (lines.length > 0) {
52
+ setLogs(prev => {
53
+ const newEntries = lines.map(line => ({
54
+ text: line,
55
+ type
56
+ }));
57
+ return [...prev, ...newEntries];
58
+ });
59
+ }
60
+ }
61
+ } catch (error) {}
62
+ }
63
+
64
+ readStream(process.stdout, 'stdout');
65
+ readStream(process.stderr, 'stderr');
66
+
67
+ return () => {
68
+ process.kill();
69
+ cleanup();
70
+ }
71
+ }, [activePane, activeContainer?.name, activeContainer?.state])
72
+
73
+ useEffect(() => {
74
+ if (scrollBoxRef.current && logs.length > 0) {
75
+ scrollBoxRef.current.scrollTo({ x: 0, y: scrollBoxRef.current.scrollHeight });
76
+ }
77
+ }, [logs]);
78
+
79
+ function cleanup() {
80
+ setLogs([]);
81
+ }
82
+
83
+ const output = useMemo(() => {
84
+ if (logs.length === 0 && activeContainer?.state === "created") {
85
+ return <Shimmer text="Container starting..." color={RGBA.fromHex(colors.text)} />
86
+ }
87
+
88
+ if (logs.length === 0) {
89
+ return <text fg={colors.textMuted}>No logs available</text>;
90
+ }
91
+
92
+ return logs.map((log, index) => (
93
+ <text
94
+ key={index}
95
+ >
96
+ {log.text}
97
+ </text>
98
+ ));
99
+ }, [logs]);
100
+
101
+ return (
102
+ <Pane
103
+ title="Logs"
104
+ flexDirection="column"
105
+ width="70%"
106
+ >
107
+ <box flexDirection="column" gap={1} height="100%">
108
+ <box flexDirection="row" gap={2} height="auto">
109
+ <box flexDirection="column" gap={1}>
110
+ <text fg={termColors.purple11} attributes={TextAttributes.BOLD}>Container</text>
111
+ <text>{activeContainer?.name || "None"}</text>
112
+ </box>
113
+ <box flexDirection="column">
114
+ <text fg={termColors.purple11} attributes={TextAttributes.BOLD}>Status</text>
115
+ <text>{activeContainer?.status || "None"}</text>
116
+ </box>
117
+ </box>
118
+ <scrollbox
119
+ ref={scrollBoxRef}
120
+ scrollY={true}
121
+ stickyScroll={true}
122
+ stickyStart="bottom"
123
+ height="100%"
124
+ >
125
+ <box flexDirection="column" height="auto">
126
+ {output}
127
+ </box>
128
+ </scrollbox>
129
+ </box>
130
+ </Pane>
131
+ )
132
+ }
@@ -0,0 +1,34 @@
1
+ import { SplitBorder } from "./Border";
2
+ import type { BoxProps } from "@opentui/react";
3
+ import { padding, colors, termColors } from "../utils/styling";
4
+ import { TextAttributes } from "@opentui/core";
5
+
6
+ interface PaneProps extends BoxProps {
7
+ title?: string;
8
+ active?: boolean;
9
+ children: React.ReactNode;
10
+ }
11
+
12
+ export default function Pane({
13
+ title,
14
+ active = false,
15
+ children,
16
+ ...props
17
+ }: PaneProps) {
18
+ return (
19
+ <box
20
+ {...SplitBorder}
21
+ {...props}
22
+ borderColor={active ? termColors.purple11 : colors.backgroundPanel}
23
+ >
24
+ <box
25
+ flexGrow={1}
26
+ backgroundColor={colors.backgroundPanel}
27
+ {...padding}
28
+ >
29
+ {title && <text marginBottom={1} attributes={TextAttributes.BOLD}>{title}</text>}
30
+ {children}
31
+ </box>
32
+ </box>
33
+ )
34
+ }
@@ -0,0 +1,84 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import Pane from "./Pane";
4
+ import { colors } from "../utils/styling";
5
+ import { useVolumeStore } from "../stores/volumes";
6
+ import { useApplicationStore } from "../stores/application";
7
+ import type { ScrollBoxRenderable } from "@opentui/core";
8
+
9
+ export default function ImagesPane() {
10
+ const { activePane, setActivePane } = useApplicationStore((state) => state);
11
+ const { volumes, setVolumes } = useVolumeStore();
12
+ const scrollBoxRef = useRef<ScrollBoxRenderable>(null);
13
+
14
+ useEffect(() => {
15
+ const process = Bun.spawn(
16
+ ["docker", "volume", "ls", "--format", "{{.Name}}"],
17
+ {stdout: "pipe", stderr: "pipe"},
18
+ );
19
+
20
+ async function read() {
21
+ try {
22
+ const reader = process.stdout.getReader();
23
+
24
+ while (true) {
25
+ const { done, value } = await reader.read();
26
+ if (done) break;
27
+ const decodedValues = new TextDecoder().decode(value);
28
+ const lines = decodedValues.split("\n").filter(Boolean);
29
+ setVolumes(lines);
30
+ }
31
+ } catch (error) {
32
+ console.error(error);
33
+ }
34
+ }
35
+
36
+ read();
37
+
38
+ return () => process.kill();
39
+ }, []);
40
+
41
+ useKeyboard((key) => {
42
+ if (activePane !== "volumes") {
43
+ return;
44
+ }
45
+
46
+ if (key.name === "left") {
47
+ setActivePane("images");
48
+ }
49
+
50
+ if (key.name === "right" || key.name === "tab") {
51
+ setActivePane("containers");
52
+ }
53
+ });
54
+
55
+ return (
56
+ <Pane
57
+ title="Volumes"
58
+ active={activePane === "volumes"}
59
+ width="100%"
60
+ flexDirection="column"
61
+ >
62
+ <scrollbox
63
+ ref={scrollBoxRef}
64
+ scrollY={true}
65
+ stickyScroll={true}
66
+ stickyStart="bottom"
67
+ viewportOptions={{
68
+ flexGrow: 1
69
+ }}
70
+ >
71
+ {volumes.map((item, index) => {
72
+ return <box>
73
+ <text
74
+ key={index}
75
+ content={`[*] ${item}`}
76
+ fg={colors.textMuted}
77
+ />
78
+ </box>
79
+ })}
80
+ {volumes.length < 1 && <text fg={colors.textMuted}>No Volumes</text>}
81
+ </scrollbox>
82
+ </Pane>
83
+ )
84
+ }
@@ -0,0 +1,64 @@
1
+ import { RGBA } from "@opentui/core"
2
+ import { useTimeline } from "@opentui/react"
3
+ import { useState, useEffect } from "react"
4
+
5
+ export type ShimmerProps = {
6
+ text: string
7
+ color: RGBA
8
+ }
9
+
10
+ const DURATION = 2_500
11
+
12
+ export function Shimmer(props: ShimmerProps) {
13
+ const timeline = useTimeline({
14
+ duration: DURATION,
15
+ loop: true,
16
+ })
17
+ const characters = props.text.split("")
18
+ const color = props.color
19
+
20
+ const [shimmerValues, setShimmerValues] = useState<number[]>(() =>
21
+ characters.map(() => 0.4)
22
+ )
23
+
24
+ useEffect(() => {
25
+ characters.forEach((_, i) => {
26
+ const target = {
27
+ shimmer: shimmerValues[i],
28
+ setShimmer: (value: number) => {
29
+ setShimmerValues(prev => {
30
+ const newValues = [...prev]
31
+ newValues[i] = value
32
+ return newValues
33
+ })
34
+ },
35
+ }
36
+
37
+ timeline!.add(
38
+ target,
39
+ {
40
+ shimmer: 1,
41
+ duration: DURATION / (props.text.length + 1),
42
+ ease: "linear",
43
+ alternate: true,
44
+ loop: 2,
45
+ onUpdate: () => {
46
+ target.setShimmer(target.shimmer)
47
+ },
48
+ },
49
+ (i * (DURATION / (props.text.length + 1))) / 2,
50
+ )
51
+ })
52
+ }, [timeline, props.text.length])
53
+
54
+ return (
55
+ <text>
56
+ {characters.map((ch, i) => {
57
+ const shimmer = shimmerValues[i]
58
+ const fg = RGBA.fromInts(color.r * 255, color.g * 255, color.b * 255, shimmer * 255)
59
+ return <span key={i} style={{ fg }}>{ch}</span>
60
+ })}
61
+ </text>
62
+ )
63
+ }
64
+
@@ -0,0 +1,60 @@
1
+
2
+ import { useTerminalDimensions } from "@opentui/react";
3
+ import { useState, useEffect } from "react";
4
+ import { colors, padding } from "../utils/styling";
5
+ import { TextAttributes } from "@opentui/core";
6
+ import { useApplicationStore } from "../stores/application";
7
+ import { SplitBorder } from "../components/Border";
8
+
9
+ export default function BaseLayout({ children }: { children: React.ReactNode }) {
10
+ const { activePane, setActivePane } = useApplicationStore((state) => state);
11
+ const dimensions = useTerminalDimensions();
12
+ const [pwd, setPwd] = useState<string>("");
13
+ const version = "v0.1.3";
14
+
15
+ useEffect(() => {
16
+ Bun.$`pwd`.quiet().then(result => setPwd(result.text()));
17
+ setActivePane("containers");
18
+ }, [setActivePane]);
19
+
20
+ return (
21
+ <box
22
+ width={dimensions.width}
23
+ height={dimensions.height}
24
+ backgroundColor={colors.background}
25
+ >
26
+ <box
27
+ flexDirection="row"
28
+ gap={1}
29
+ width="100%"
30
+ {...padding}
31
+ >
32
+ {children}
33
+ </box>
34
+ <box
35
+ height={1}
36
+ backgroundColor={colors.backgroundPanel}
37
+ flexDirection="row"
38
+ justifyContent="space-between"
39
+ flexShrink={0}
40
+ >
41
+ <box flexDirection="row">
42
+ <box flexDirection="row" backgroundColor={colors.backgroundElement} paddingLeft={1} paddingRight={1}>
43
+ <text fg={colors.textMuted}>open</text>
44
+ <text attributes={TextAttributes.BOLD}>docker </text>
45
+ <text fg={colors.textMuted}>{version}</text>
46
+ </box>
47
+ <box paddingLeft={1} paddingRight={1}>
48
+ <text fg={colors.textMuted}>~{pwd}</text>
49
+ </box>
50
+ </box>
51
+ <box flexDirection="row" gap={1}>
52
+ {/* <text fg={colors.textMuted}>tab</text> */}
53
+ <box backgroundColor={colors.accent} paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={colors.backgroundPanel}>
54
+ <text fg={colors.backgroundPanel} attributes={TextAttributes.BOLD}>{activePane}</text>
55
+ </box>
56
+ </box>
57
+ </box>
58
+ </box>
59
+ )
60
+ }