├── .gitignore
├── .goreleaser.yaml
├── .releaserc
├── CHANGELOG.md
├── Dockerfile
├── LICENSE
├── README.md
├── app
├── .babelrc.json
├── client
│ ├── assets
│ │ ├── css
│ │ │ ├── colors.less
│ │ │ ├── components.less
│ │ │ ├── fonts.less
│ │ │ ├── mixins.less
│ │ │ ├── normalize.less
│ │ │ ├── reset.less
│ │ │ └── style.less
│ │ ├── fonts
│ │ │ ├── hack-bold.woff2
│ │ │ └── hack-regular.woff2
│ │ └── js
│ │ │ ├── isaiah.js
│ │ │ ├── lib.fuse.min.js
│ │ │ ├── lib.highlight.min.js
│ │ │ └── lib.highlight.yaml.min.js
│ ├── favicon.ico
│ ├── index.html
│ └── robots.txt
├── default.env
├── go.mod
├── go.sum
├── main.go
├── package-lock.json
├── package.json
├── sample.custom.css
├── sample.docker_hosts
└── server
│ ├── _internal
│ ├── client
│ │ └── client.go
│ ├── fs
│ │ └── fs.go
│ ├── io
│ │ └── io.go
│ ├── json
│ │ └── json.go
│ ├── os
│ │ └── os.go
│ ├── process
│ │ └── process.go
│ ├── session
│ │ └── session.go
│ ├── slices
│ │ └── slices.go
│ ├── strconv
│ │ └── strconv.go
│ ├── templates
│ │ └── run.tpl
│ └── tty
│ │ └── tty.go
│ ├── resources
│ ├── containers.go
│ ├── images.go
│ ├── networks.go
│ ├── stacks.go
│ └── volumes.go
│ ├── server
│ ├── agents.go
│ ├── authentication.go
│ ├── containers.go
│ ├── hosts.go
│ ├── images.go
│ ├── networks.go
│ ├── server.go
│ ├── stacks.go
│ └── volumes.go
│ └── ui
│ ├── command.go
│ ├── inspector.go
│ ├── menu_action.go
│ ├── notification.go
│ ├── overview.go
│ ├── preference.go
│ ├── row.go
│ ├── size.go
│ ├── tab.go
│ └── table.go
├── assets
├── CAPTURE-1.png
├── CAPTURE-10.png
├── CAPTURE-11.png
├── CAPTURE-12.png
├── CAPTURE-13.png
├── CAPTURE-14.png
├── CAPTURE-15.png
├── CAPTURE-2.png
├── CAPTURE-3.png
├── CAPTURE-4.png
├── CAPTURE-5.png
├── CAPTURE-6.png
├── CAPTURE-7.png
├── CAPTURE-8.png
└── CAPTURE-9.png
├── examples
├── docker-compose.agent.yml
├── docker-compose.host.yml
├── docker-compose.proxy.yml
├── docker-compose.simple.yml
├── docker-compose.ssl.yml
├── docker-compose.traefik.yml
└── docker-compose.volume.yml
├── package-lock.json
├── package.json
├── scripts
├── local-install.sh
├── post-release.sh
├── pre-release.sh
├── release.sh
└── remote-install.sh
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | *.exe
2 | *.exe~
3 | *.dll
4 | *.so
5 | *.dylib
6 | *.test
7 | *.out
8 | *.backup
9 | *.css
10 | *.cache
11 | .*ignore
12 | !.gitignore
13 | go.work
14 | .env
15 | .vimrc
16 | .aliases
17 | tmp
18 | private
19 | dist/
20 | node_modules/
21 | app/package.json
22 | app/package-lock.json
23 | scripts/_*.sh
24 | !app/sample.custom.css
25 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json
2 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj
3 |
4 | version: 1
5 |
6 | before:
7 | hooks:
8 | - ./scripts/pre-release.sh
9 |
10 | builds:
11 | - env:
12 | - CGO_ENABLED=0
13 | goos:
14 | - linux
15 | - windows
16 | - darwin
17 | goarch:
18 | - amd64
19 | - arm
20 | - arm64
21 | - 386
22 | goarm:
23 | - 6
24 | - 7
25 | dir: app
26 | flags:
27 | - -trimpath
28 |
29 | archives:
30 | - format: tar.gz
31 | name_template: >-
32 | {{ .ProjectName }}_{{ .Tag }}_
33 | {{- title .Os }}_
34 | {{- if eq .Arch "amd64" }}x86_64
35 | {{- else if eq .Arch "386" }}i386
36 | {{- else }}{{ .Arch }}{{ end }}
37 | {{- if .Arm }}v{{ .Arm }}{{ end }}
38 | format_overrides:
39 | - goos: windows
40 | format: zip
41 |
42 | checksum:
43 | name_template: "checksums.txt"
44 |
45 | changelog:
46 | sort: asc
47 | filters:
48 | exclude:
49 | - "^docs:"
50 | - "^test:"
51 |
52 | dockers:
53 | - image_templates:
54 | - 'mosswill/isaiah:{{ .Tag }}-amd64'
55 | use: buildx
56 | build_flag_templates:
57 | - "--pull"
58 | - "--platform=linux/amd64"
59 | goarch: amd64
60 |
61 |
62 | - image_templates:
63 | - 'mosswill/isaiah:{{ .Tag }}-arm64'
64 | use: buildx
65 | build_flag_templates:
66 | - "--pull"
67 | - "--platform=linux/arm64"
68 | goarch: arm64
69 |
70 | - image_templates:
71 | - 'mosswill/isaiah:{{ .Tag }}-armv6'
72 | use: buildx
73 | build_flag_templates:
74 | - "--pull"
75 | - "--platform=linux/arm/v6"
76 | goarch: arm
77 | goarm: 6
78 |
79 | - image_templates:
80 | - 'mosswill/isaiah:{{ .Tag }}-armv7'
81 | use: buildx
82 | build_flag_templates:
83 | - "--pull"
84 | - "--platform=linux/arm/v7"
85 | goarch: arm
86 | goarm: 7
87 |
88 | docker_manifests:
89 | - name_template: "mosswill/isaiah:{{ .Tag }}"
90 | image_templates:
91 | - "mosswill/isaiah:{{ .Tag }}-amd64"
92 | - "mosswill/isaiah:{{ .Tag }}-arm64"
93 | - "mosswill/isaiah:{{ .Tag }}-armv6"
94 | - "mosswill/isaiah:{{ .Tag }}-armv7"
95 |
96 | - name_template: "mosswill/isaiah:latest"
97 | image_templates:
98 | - "mosswill/isaiah:{{ .Tag }}-amd64"
99 | - "mosswill/isaiah:{{ .Tag }}-arm64"
100 | - "mosswill/isaiah:{{ .Tag }}-armv6"
101 | - "mosswill/isaiah:{{ .Tag }}-armv7"
102 |
--------------------------------------------------------------------------------
/.releaserc:
--------------------------------------------------------------------------------
1 | {
2 | "branches": ["master"],
3 | "tagFormat": "${version}",
4 | "plugins": [
5 | "@semantic-release/commit-analyzer",
6 | "@semantic-release/release-notes-generator",
7 | "@semantic-release/changelog",
8 | "@semantic-release/git",
9 |
10 | [
11 | "@semantic-release/exec",
12 | {
13 | "publishCmd": "echo \"${nextRelease.notes}\" > /tmp/release-notes.md && ./scripts/release.sh"
14 | }
15 | ]
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM busybox:stable
2 |
3 | COPY isaiah /
4 |
5 | ENV DOCKER_RUNNING=true
6 |
7 | ENTRYPOINT ["./isaiah"]
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Will Moss
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/.babelrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "targets": {
7 | "browsers": ["defaults", "ie >= 8"]
8 | }
9 | }
10 | ]
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/app/client/assets/css/colors.less:
--------------------------------------------------------------------------------
1 | :root {
2 | --color-terminal-background: #000000;
3 | --color-terminal-base: #ffffff;
4 | --color-terminal-accent: #4af626;
5 | --color-terminal-accent-selected: #73f859;
6 | --color-terminal-hover: rgba(255, 255, 255, 0.15);
7 | --color-terminal-border: #ffffff;
8 | --color-terminal-danger: #ff0000;
9 | --color-terminal-warning: #f67e26;
10 | --color-terminal-accent-alternative: #26e1f6;
11 | --color-terminal-log-row-alternative: #222;
12 | --color-terminal-json-key: darkturquoise;
13 | --color-terminal-json-value: beige;
14 | --color-terminal-cell-failure: #ff9999;
15 | --color-terminal-cell-success: #9bff99;
16 | --color-terminal-cell-paused: beige;
17 | }
18 |
19 | [data-theme='moon'] {
20 | --color-terminal-background: hsl(249deg, 22%, 12%);
21 | --color-terminal-base: hsl(245deg, 50%, 91%);
22 | --color-terminal-accent: hsl(35deg, 88%, 72%);
23 | --color-terminal-accent-selected: hsl(35deg, 88%, 72%);
24 | --color-terminal-hover: hsl(249deg, 15%, 28%);
25 | --color-terminal-border: hsl(245deg, 50%, 91%);
26 | --color-terminal-danger: #eb6f92;
27 | --color-terminal-warning: #f6c177;
28 | --color-terminal-accent-alternative: #9ccfd8;
29 | --color-terminal-log-row-alternative: #302c44;
30 | --color-terminal-json-key: #f6c177;
31 | --color-terminal-json-value: #c4a7e7;
32 | --color-terminal-cell-failure: #eb6f92;
33 | --color-terminal-cell-success: #81f383;
34 | --color-terminal-cell-paused: #fe843d;
35 | }
36 |
37 | [data-theme='dawn'] {
38 | --color-terminal-background: hsl(32deg, 57%, 95%);
39 | --color-terminal-base: hsl(248deg, 19%, 40%);
40 | --color-terminal-accent: hsl(35deg, 81%, 56%);
41 | --color-terminal-accent-selected: hsl(35deg, 81%, 56%);
42 | --color-terminal-hover: hsl(10deg, 9%, 86%);
43 | --color-terminal-border: hsl(248deg, 19%, 40%);
44 | --color-terminal-danger: #f15050;
45 | --color-terminal-warning: #ff8142;
46 | --color-terminal-accent-alternative: #2186f3;
47 | --color-terminal-log-row-alternative: #f0dac2;
48 | --color-terminal-json-key: #2186f3;
49 | --color-terminal-json-value: #129fa1;
50 | --color-terminal-cell-failure: #f15050;
51 | --color-terminal-cell-success: hsl(123, 81%, 35%);
52 | --color-terminal-cell-paused: #ff8142;
53 | }
54 |
--------------------------------------------------------------------------------
/app/client/assets/css/components.less:
--------------------------------------------------------------------------------
1 | @width-mobile: 960px;
2 | @width-small-mobile: 440px;
3 |
4 | .line-break {
5 | display: block;
6 | height: 8px;
7 | }
8 |
9 | button {
10 | border: 0;
11 | appearance: none;
12 | background: none;
13 | color: var(--color-terminal-base);
14 | font-size: 16px;
15 | cursor: pointer;
16 |
17 | &:hover {
18 | color: var(--color-terminal-accent);
19 | }
20 | }
21 |
22 | span,
23 | p,
24 | div {
25 | color: var(--color-terminal-base);
26 | font-size: 16px;
27 | font-weight: 300;
28 | }
29 |
30 | .tab {
31 | outline: 1px solid var(--color-terminal-border);
32 | position: relative;
33 | display: flex;
34 | justify-content: center;
35 | align-items: center;
36 | color: var(--color-terminal-base);
37 | width: 100%;
38 | height: 100%;
39 |
40 | .tab-title {
41 | position: absolute;
42 | top: -10px;
43 | background: var(--color-terminal-background);
44 | left: 16px;
45 | }
46 |
47 | .tab-content {
48 | display: flex;
49 | flex-direction: column;
50 | width: 100%;
51 | padding-top: 14px;
52 | padding-bottom: 10px;
53 | overflow: auto;
54 | scrollbar-width: none;
55 | // overflow: hidden;
56 |
57 | pre {
58 | padding-left: 12px;
59 | padding-right: 12px;
60 | padding-top: 8px;
61 |
62 | code {
63 | padding: 0;
64 | }
65 | }
66 |
67 | .row {
68 | display: flex;
69 | align-items: center;
70 | justify-content: flex-start;
71 | height: 30px;
72 | padding-left: 8px;
73 | padding-right: 8px;
74 | flex-shrink: 0;
75 | cursor: pointer;
76 | width: max-content;
77 | min-width: 100%;
78 |
79 | .cell {
80 | display: flex;
81 | justify-content: flex-start;
82 | flex-shrink: 0;
83 | white-space: pre;
84 |
85 | em {
86 | color: var(--color-terminal-danger);
87 | font-style: normal;
88 | }
89 | }
90 |
91 | p em {
92 | color: var(--color-terminal-danger);
93 | font-style: normal;
94 | }
95 |
96 | .generate-columns(cell; 12);
97 |
98 | &:hover,
99 | &.is-active {
100 | background: var(--color-terminal-hover);
101 | }
102 |
103 | &.is-not-interactive {
104 | pointer-events: none;
105 | }
106 |
107 | &.is-for-code {
108 | &:hover {
109 | background: transparent;
110 | }
111 | }
112 |
113 | &.is-textual {
114 | width: unset;
115 | height: unset;
116 | line-height: 145%;
117 | }
118 | &.is-json {
119 | gap: 8px;
120 | }
121 | &.is-colored {
122 | > .cell:nth-child(1) {
123 | color: var(--color-terminal-json-key);
124 | }
125 | > .cell:nth-child(2),
126 | .cell.is-array-value {
127 | color: var(--color-terminal-json-value);
128 | }
129 | }
130 |
131 | &:has(.sub-row) {
132 | height: unset;
133 | gap: 0;
134 | flex-direction: column;
135 | align-items: flex-start;
136 | justify-content: flex-start;
137 |
138 | > .cell {
139 | height: 30px;
140 | align-items: center;
141 | }
142 | }
143 |
144 | &.sub-row {
145 | gap: 8px;
146 | &:has(.sub-row) {
147 | gap: 0;
148 | }
149 | }
150 | }
151 |
152 | table {
153 | padding-top: 4px;
154 | padding-left: 8px;
155 | th {
156 | text-align: left;
157 | }
158 | td {
159 | white-space: nowrap;
160 | padding-right: 24px;
161 | }
162 | }
163 | }
164 |
165 | .tab-scroller {
166 | position: absolute;
167 | height: 90%;
168 | right: -5.5px;
169 | width: 10px;
170 | background: black;
171 | display: none;
172 | flex-direction: column;
173 | align-items: center;
174 |
175 | .up,
176 | .down {
177 | display: flex;
178 | justify-content: center;
179 | align-items: center;
180 | width: 100%;
181 | // height: 14px;
182 | background: black;
183 | color: var(--color-terminal-accent);
184 | }
185 |
186 | .up {
187 | padding-bottom: 3px;
188 | }
189 | .down {
190 | padding-bottom: 3px;
191 | }
192 |
193 | .track {
194 | height: 100%;
195 | width: 1px;
196 | background: var(--color-terminal-accent);
197 | display: flex;
198 | justify-content: center;
199 | position: relative;
200 |
201 | .thumb {
202 | background: var(--color-terminal-accent);
203 | position: absolute;
204 | top: 0;
205 | width: 10px;
206 | }
207 | }
208 | }
209 |
210 | .tab-title-group {
211 | position: absolute;
212 | top: -10px;
213 | background: var(--color-terminal-background);
214 | left: 16px;
215 | display: flex;
216 | align-items: center;
217 |
218 | .tab-sub-title {
219 | &:nth-child(n + 2) {
220 | &:before {
221 | content: ' — ';
222 | color: var(--color-terminal-base);
223 | white-space: pre;
224 | }
225 | &:hover:before {
226 | color: var(--color-terminal-base);
227 | }
228 | }
229 |
230 | &.is-active {
231 | color: var(--color-terminal-accent);
232 | font-weight: bold;
233 |
234 | &:before {
235 | font-weight: 400;
236 | }
237 | }
238 | }
239 |
240 | &.for-controls {
241 | left: unset;
242 | right: 16px;
243 |
244 | @media screen and (max-width: @width-small-mobile) {
245 | display: none;
246 | }
247 | }
248 |
249 | @media screen and (max-width: @width-small-mobile) {
250 | top: -9px;
251 |
252 | .tab-sub-title {
253 | font-size: 14px;
254 | }
255 | }
256 | }
257 |
258 | .tab-sub-content {
259 | &:not(.is-active) {
260 | display: none;
261 | }
262 | }
263 |
264 | &.is-active {
265 | outline-color: var(--color-terminal-accent);
266 |
267 | .tab-title {
268 | font-weight: bold;
269 | color: var(--color-terminal-accent-selected);
270 |
271 | @media screen and (max-width: @width-small-mobile) {
272 | white-space: nowrap;
273 | text-overflow: ellipsis;
274 | overflow: hidden;
275 | max-width: 90%;
276 | }
277 | }
278 |
279 | &.is-scrollable {
280 | .tab-scroller {
281 | display: flex;
282 | }
283 | }
284 | }
285 | }
286 |
--------------------------------------------------------------------------------
/app/client/assets/css/fonts.less:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'Hack';
3 | src: url('/assets/fonts/hack-regular.woff2') format('woff2');
4 | font-weight: 400;
5 | font-style: normal;
6 | }
7 |
8 | @font-face {
9 | font-family: 'Hack';
10 | src: url('/assets/fonts/hack-bold.woff2') format('woff2');
11 | font-weight: 700;
12 | font-style: normal;
13 | }
14 |
15 | .ft1() {
16 | font-family: 'Hack', Consolas, Menlo, monospace, sans-serif;
17 | }
18 |
--------------------------------------------------------------------------------
/app/client/assets/css/mixins.less:
--------------------------------------------------------------------------------
1 | .generate-columns(@class; @number-cols; @i: 1) when (@i =< @number-cols) {
2 | .@{class}-@{i}\/@{number-cols} {
3 | width: 100% * (@i / @number-cols);
4 | }
5 | .generate-columns(@class; @number-cols; @i + 1);
6 | }
7 |
--------------------------------------------------------------------------------
/app/client/assets/css/normalize.less:
--------------------------------------------------------------------------------
1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
2 |
3 | /* Document
4 | ========================================================================== */
5 |
6 | /**
7 | * 1. Correct the line height in all browsers.
8 | * 2. Prevent adjustments of font size after orientation changes in iOS.
9 | */
10 |
11 | html {
12 | line-height: 1.15; /* 1 */
13 | -webkit-text-size-adjust: 100%; /* 2 */
14 | }
15 |
16 | /* Sections
17 | ========================================================================== */
18 |
19 | /**
20 | * Remove the margin in all browsers.
21 | */
22 |
23 | body {
24 | margin: 0;
25 | }
26 |
27 | /**
28 | * Render the `main` element consistently in IE.
29 | */
30 |
31 | main {
32 | display: block;
33 | }
34 |
35 | /**
36 | * Correct the font size and margin on `h1` elements within `section` and
37 | * `article` contexts in Chrome, Firefox, and Safari.
38 | */
39 |
40 | h1 {
41 | font-size: 2em;
42 | margin: 0.67em 0;
43 | }
44 |
45 | /* Grouping content
46 | ========================================================================== */
47 |
48 | /**
49 | * 1. Add the correct box sizing in Firefox.
50 | * 2. Show the overflow in Edge and IE.
51 | */
52 |
53 | hr {
54 | box-sizing: content-box; /* 1 */
55 | height: 0; /* 1 */
56 | overflow: visible; /* 2 */
57 | }
58 |
59 | /**
60 | * 1. Correct the inheritance and scaling of font size in all browsers.
61 | * 2. Correct the odd `em` font sizing in all browsers.
62 | */
63 |
64 | pre {
65 | font-family: monospace, monospace; /* 1 */
66 | font-size: 1em; /* 2 */
67 | }
68 |
69 | /* Text-level semantics
70 | ========================================================================== */
71 |
72 | /**
73 | * Remove the gray background on active links in IE 10.
74 | */
75 |
76 | a {
77 | background-color: transparent;
78 | }
79 |
80 | /**
81 | * 1. Remove the bottom border in Chrome 57-
82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
83 | */
84 |
85 | abbr[title] {
86 | border-bottom: none; /* 1 */
87 | text-decoration: underline; /* 2 */
88 | text-decoration: underline dotted; /* 2 */
89 | }
90 |
91 | /**
92 | * Add the correct font weight in Chrome, Edge, and Safari.
93 | */
94 |
95 | b,
96 | strong {
97 | font-weight: bolder;
98 | }
99 |
100 | /**
101 | * 1. Correct the inheritance and scaling of font size in all browsers.
102 | * 2. Correct the odd `em` font sizing in all browsers.
103 | */
104 |
105 | code,
106 | kbd,
107 | samp {
108 | font-family: monospace, monospace; /* 1 */
109 | font-size: 1em; /* 2 */
110 | }
111 |
112 | /**
113 | * Add the correct font size in all browsers.
114 | */
115 |
116 | small {
117 | font-size: 80%;
118 | }
119 |
120 | /**
121 | * Prevent `sub` and `sup` elements from affecting the line height in
122 | * all browsers.
123 | */
124 |
125 | sub,
126 | sup {
127 | font-size: 75%;
128 | line-height: 0;
129 | position: relative;
130 | vertical-align: baseline;
131 | }
132 |
133 | sub {
134 | bottom: -0.25em;
135 | }
136 |
137 | sup {
138 | top: -0.5em;
139 | }
140 |
141 | /* Embedded content
142 | ========================================================================== */
143 |
144 | /**
145 | * Remove the border on images inside links in IE 10.
146 | */
147 |
148 | img {
149 | border-style: none;
150 | }
151 |
152 | /* Forms
153 | ========================================================================== */
154 |
155 | /**
156 | * 1. Change the font styles in all browsers.
157 | * 2. Remove the margin in Firefox and Safari.
158 | */
159 |
160 | button,
161 | input,
162 | optgroup,
163 | select,
164 | textarea {
165 | font-family: inherit; /* 1 */
166 | font-size: 100%; /* 1 */
167 | line-height: 1.15; /* 1 */
168 | margin: 0; /* 2 */
169 | }
170 |
171 | /**
172 | * Show the overflow in IE.
173 | * 1. Show the overflow in Edge.
174 | */
175 |
176 | button,
177 | input {
178 | /* 1 */
179 | overflow: visible;
180 | }
181 |
182 | /**
183 | * Remove the inheritance of text transform in Edge, Firefox, and IE.
184 | * 1. Remove the inheritance of text transform in Firefox.
185 | */
186 |
187 | button,
188 | select {
189 | /* 1 */
190 | text-transform: none;
191 | }
192 |
193 | /**
194 | * Correct the inability to style clickable types in iOS and Safari.
195 | */
196 |
197 | button,
198 | [type='button'],
199 | [type='reset'],
200 | [type='submit'] {
201 | -webkit-appearance: button;
202 | }
203 |
204 | /**
205 | * Remove the inner border and padding in Firefox.
206 | */
207 |
208 | button::-moz-focus-inner,
209 | [type='button']::-moz-focus-inner,
210 | [type='reset']::-moz-focus-inner,
211 | [type='submit']::-moz-focus-inner {
212 | border-style: none;
213 | padding: 0;
214 | }
215 |
216 | /**
217 | * Restore the focus styles unset by the previous rule.
218 | */
219 |
220 | button:-moz-focusring,
221 | [type='button']:-moz-focusring,
222 | [type='reset']:-moz-focusring,
223 | [type='submit']:-moz-focusring {
224 | outline: 1px dotted ButtonText;
225 | }
226 |
227 | /**
228 | * Correct the padding in Firefox.
229 | */
230 |
231 | fieldset {
232 | padding: 0.35em 0.75em 0.625em;
233 | }
234 |
235 | /**
236 | * 1. Correct the text wrapping in Edge and IE.
237 | * 2. Correct the color inheritance from `fieldset` elements in IE.
238 | * 3. Remove the padding so developers are not caught out when they zero out
239 | * `fieldset` elements in all browsers.
240 | */
241 |
242 | legend {
243 | box-sizing: border-box; /* 1 */
244 | color: inherit; /* 2 */
245 | display: table; /* 1 */
246 | max-width: 100%; /* 1 */
247 | padding: 0; /* 3 */
248 | white-space: normal; /* 1 */
249 | }
250 |
251 | /**
252 | * Add the correct vertical alignment in Chrome, Firefox, and Opera.
253 | */
254 |
255 | progress {
256 | vertical-align: baseline;
257 | }
258 |
259 | /**
260 | * Remove the default vertical scrollbar in IE 10+.
261 | */
262 |
263 | textarea {
264 | overflow: auto;
265 | }
266 |
267 | /**
268 | * 1. Add the correct box sizing in IE 10.
269 | * 2. Remove the padding in IE 10.
270 | */
271 |
272 | [type='checkbox'],
273 | [type='radio'] {
274 | box-sizing: border-box; /* 1 */
275 | padding: 0; /* 2 */
276 | }
277 |
278 | /**
279 | * Correct the cursor style of increment and decrement buttons in Chrome.
280 | */
281 |
282 | [type='number']::-webkit-inner-spin-button,
283 | [type='number']::-webkit-outer-spin-button {
284 | height: auto;
285 | }
286 |
287 | /**
288 | * 1. Correct the odd appearance in Chrome and Safari.
289 | * 2. Correct the outline style in Safari.
290 | */
291 |
292 | [type='search'] {
293 | -webkit-appearance: textfield; /* 1 */
294 | outline-offset: -2px; /* 2 */
295 | }
296 |
297 | /**
298 | * Remove the inner padding in Chrome and Safari on macOS.
299 | */
300 |
301 | [type='search']::-webkit-search-decoration {
302 | -webkit-appearance: none;
303 | }
304 |
305 | /**
306 | * 1. Correct the inability to style clickable types in iOS and Safari.
307 | * 2. Change font properties to `inherit` in Safari.
308 | */
309 |
310 | ::-webkit-file-upload-button {
311 | -webkit-appearance: button; /* 1 */
312 | font: inherit; /* 2 */
313 | }
314 |
315 | /* Interactive
316 | ========================================================================== */
317 |
318 | /*
319 | * Add the correct display in Edge, IE 10+, and Firefox.
320 | */
321 |
322 | details {
323 | display: block;
324 | }
325 |
326 | /*
327 | * Add the correct display in all browsers.
328 | */
329 |
330 | summary {
331 | display: list-item;
332 | }
333 |
334 | /* Misc
335 | ========================================================================== */
336 |
337 | /**
338 | * Add the correct display in IE 10+.
339 | */
340 |
341 | template {
342 | display: none;
343 | }
344 |
345 | /**
346 | * Add the correct display in IE 10.
347 | */
348 |
349 | [hidden] {
350 | display: none;
351 | }
352 |
--------------------------------------------------------------------------------
/app/client/assets/css/reset.less:
--------------------------------------------------------------------------------
1 | *,
2 | *:before,
3 | *:after {
4 | box-sizing: border-box;
5 | margin: 0;
6 | padding: 0;
7 | border: 0;
8 | }
9 |
10 | html {
11 | scroll-behavior: smooth;
12 | }
13 |
14 | html,
15 | body {
16 | overscroll-behavior-y: none;
17 | overflow-x: hidden;
18 | }
19 |
20 | img,
21 | video,
22 | iframe {
23 | max-width: 100%;
24 | }
25 |
26 | ::-webkit-scrollbar {
27 | display: none;
28 | }
29 |
--------------------------------------------------------------------------------
/app/client/assets/css/style.less:
--------------------------------------------------------------------------------
1 | @import './fonts.less';
2 | @import './normalize.less';
3 | @import './reset.less';
4 | @import './mixins.less';
5 | @import './colors.less';
6 | @import './components.less';
7 |
8 | @width-mobile: 960px;
9 | @width-medium-mobile: 620px;
10 | @width-small-mobile: 440px;
11 |
12 | * {
13 | // outline: 1px dashed blue;
14 | }
15 |
16 | // Globals
17 | html {
18 | .ft1();
19 | }
20 |
21 | // Screen - Animations
22 | @keyframes fade-in {
23 | from {
24 | opacity: 0;
25 | }
26 | to {
27 | opacity: 1;
28 | }
29 | }
30 | @keyframes fade-out {
31 | to {
32 | opacity: 0;
33 | }
34 | }
35 | @keyframes spin {
36 | to {
37 | transform: rotate(180deg);
38 | }
39 | }
40 |
41 | .app-wrapper {
42 | width: 100vw;
43 | height: 100vh;
44 | overflow: hidden;
45 | position: relative;
46 | background: var(--color-terminal-background);
47 | display: flex;
48 | justify-content: center;
49 | align-items: center;
50 | }
51 |
52 | .screen {
53 | width: 100%;
54 | height: 100%;
55 | position: absolute;
56 | top: 0;
57 | left: 0;
58 |
59 | // Screen - Active
60 | &.is-active {
61 | pointer-events: all;
62 | z-index: 2;
63 | animation: fade-in 0.25s ease-in-out forwards;
64 | }
65 |
66 | // Screen - Inactive
67 | &:not(.is-active) {
68 | pointer-events: none;
69 | z-index: -1;
70 | animation: fade-out 0.25s ease-in-out forwards;
71 | }
72 |
73 | // Screen - Loading
74 | &.for-loading {
75 | display: flex;
76 | flex-direction: column;
77 | align-items: center;
78 | justify-content: center;
79 | padding-top: 60px;
80 |
81 | @keyframes blink {
82 | 0% {
83 | opacity: 0;
84 | }
85 | 100% {
86 | opacity: 1;
87 | }
88 | }
89 | .loader {
90 | color: #ffffff;
91 | animation: blink 1s infinite alternate;
92 | width: 48px;
93 | height: 48px;
94 | display: flex;
95 | justify-content: center;
96 | align-items: center;
97 |
98 | svg {
99 | width: 100%;
100 | }
101 | }
102 |
103 | p {
104 | margin-top: 30px;
105 | text-align: center;
106 | }
107 | }
108 |
109 | // Screen - Dashboard
110 | &.for-dashboard {
111 | display: flex;
112 | flex-direction: column;
113 |
114 | .main,
115 | .footer {
116 | width: 100%;
117 | }
118 |
119 | .main {
120 | height: 100%;
121 | display: flex;
122 |
123 | @media screen and (max-width: @width-mobile) {
124 | flex-direction: column;
125 | }
126 | }
127 |
128 | .footer {
129 | display: flex;
130 | align-items: center;
131 | justify-content: space-between;
132 | flex-shrink: 0;
133 | padding: 8px 4px;
134 | height: 40px;
135 |
136 | .left,
137 | .right {
138 | height: 100%;
139 | display: flex;
140 | align-items: center;
141 | }
142 |
143 | .left {
144 | .help {
145 | &:not(.is-active) {
146 | display: none;
147 | }
148 |
149 | @media screen and (max-width: @width-mobile) {
150 | display: none;
151 | }
152 | }
153 |
154 | .search-control {
155 | display: flex;
156 | align-items: center;
157 |
158 | &:not(.is-active) {
159 | display: none;
160 | }
161 |
162 | span {
163 | color: var(--color-terminal-accent);
164 | }
165 |
166 | input {
167 | height: 100%;
168 | width: 440px;
169 | margin-left: 4px;
170 | border: 0;
171 | background: transparent;
172 | color: var(--color-terminal-base);
173 | caret-color: var(--color-terminal-base);
174 | outline: 0;
175 | }
176 |
177 | @media screen and (max-width: @width-mobile) {
178 | display: none;
179 | }
180 | }
181 |
182 | .mobile-controls {
183 | align-items: center;
184 | width: 100%;
185 | height: 100%;
186 | gap: 26px;
187 | display: none;
188 |
189 | @media screen and (max-width: @width-mobile) {
190 | display: flex;
191 | }
192 |
193 | @media screen and (max-width: @width-medium-mobile) {
194 | max-width: 420px;
195 | overflow-x: auto;
196 | }
197 |
198 | @media screen and (max-width: @width-small-mobile) {
199 | max-width: 240px;
200 | }
201 |
202 | button {
203 | width: 36px;
204 | height: 100%;
205 | flex-shrink: 0;
206 |
207 | &.has-icon {
208 | display: flex;
209 | justify-content: center;
210 | align-items: center;
211 |
212 | svg {
213 | width: 100%;
214 | height: 100%;
215 | pointer-events: none;
216 | }
217 | }
218 |
219 | &:not(.is-active) {
220 | display: none;
221 | }
222 | }
223 | }
224 | }
225 |
226 | .right {
227 | justify-content: flex-end;
228 | position: relative;
229 |
230 | .indicator {
231 | color: var(--color-terminal-base);
232 | justify-content: center;
233 | align-items: center;
234 | height: 100%;
235 | transition: opacity 0.3s;
236 | display: none;
237 |
238 | svg {
239 | height: 20px;
240 | }
241 |
242 | &.for-loading {
243 | animation: spin 1s infinite linear;
244 | }
245 | &.for-disconnected {
246 | color: var(--color-terminal-warning);
247 | }
248 | &.for-connected,
249 | &.for-communication-target {
250 | color: var(--color-terminal-accent-alternative);
251 | button {
252 | color: var(--color-terminal-accent-alternative);
253 | &:hover {
254 | color: var(--color-terminal-accent);
255 | }
256 | }
257 | }
258 | &.for-communication-target {
259 | margin-right: 8px;
260 |
261 | &.is-active {
262 | @media screen and (max-width: @width-mobile) {
263 | display: none;
264 | }
265 | }
266 | }
267 | &.is-active {
268 | display: flex;
269 | }
270 | }
271 | }
272 | }
273 |
274 | // Layouts
275 | // &[data-layout='default'] {
276 | .main {
277 | column-gap: 16px;
278 | padding-left: 4px; // Account for the tabs borders
279 | padding-right: 16px + 4px; // Account for the tabs borders
280 | padding-top: 18px; // Account for the first tabs' title + borders
281 |
282 | @width-left: 34%;
283 | @width-right: 66%;
284 |
285 | @media screen and (max-width: @width-mobile) {
286 | padding-right: 6px;
287 | row-gap: 24px;
288 | }
289 |
290 | .left,
291 | .right {
292 | // width: 50%;
293 | flex-shrink: 0;
294 | display: flex;
295 | flex-direction: column;
296 |
297 | .tab {
298 | width: 100%;
299 | height: 100%;
300 | }
301 | }
302 |
303 | .left {
304 | width: @width-left;
305 | row-gap: 28px;
306 |
307 | @media screen and (max-width: @width-mobile) {
308 | width: 100%;
309 | height: ~'calc(50% - 12px)';
310 | }
311 |
312 | .tab {
313 | .tab-content {
314 | height: 0; // Trick to make overflow:auto work without setting a defined height
315 | min-height: 100%;
316 |
317 | .row {
318 | gap: 24px;
319 | }
320 | }
321 |
322 | &.for-containers,
323 | &.for-stacks {
324 | .cell {
325 | &[data-value='exited'] {
326 | color: var(--color-terminal-cell-failure);
327 |
328 | + .cell {
329 | color: var(--color-terminal-cell-failure);
330 | }
331 | }
332 | &[data-value='running'] {
333 | color: var(--color-terminal-cell-success);
334 | }
335 | &[data-value='paused'] {
336 | color: var(--color-terminal-cell-paused);
337 | }
338 | }
339 | }
340 |
341 | &.for-images {
342 | .cell {
343 | &[data-value='unknown'] {
344 | color: var(--color-terminal-cell-paused);
345 | }
346 | &[data-value='unused'] {
347 | color: var(--color-terminal-cell-failure);
348 | }
349 | &[data-value='used'] {
350 | color: var(--color-terminal-cell-success);
351 | }
352 | }
353 | }
354 | }
355 | }
356 | // Inspector part
357 | .right {
358 | width: @width-right;
359 |
360 | @media screen and (max-width: @width-mobile) {
361 | width: 100%;
362 | height: ~'calc(50% - 12px)';
363 | }
364 |
365 | .tab {
366 | .tab-content {
367 | height: 0; // Trick to make overflow:auto work without setting a defined height
368 | min-height: 100%;
369 | overflow: auto;
370 |
371 | .row:not(:has(.sub-row)) {
372 | gap: 24px;
373 |
374 | &.sub-row {
375 | gap: 8px;
376 | &:has(.sub-row) {
377 | gap: 0;
378 | }
379 | }
380 | }
381 | .row:not(:has(.sub-row)).is-json {
382 | gap: 8px;
383 | }
384 | }
385 | }
386 |
387 | [data-tab='Logs'] {
388 | .tab-content {
389 | display: grid;
390 | grid-auto-rows: 30px;
391 | }
392 |
393 | .row.is-textual {
394 | white-space: nowrap;
395 | line-height: 185%;
396 | min-width: unset;
397 | }
398 |
399 | &.no-wrap {
400 | .tab-content {
401 | display: flex;
402 | }
403 | .row.is-textual {
404 | white-space: wrap;
405 | }
406 | }
407 |
408 | &.stripped-background {
409 | .row.is-textual {
410 | &:nth-child(2n + 1) {
411 | background: var(--color-terminal-log-row-alternative);
412 | }
413 | }
414 | }
415 | }
416 |
417 | [data-tab='Services'] .tab-content .cell {
418 | &[data-value='exited'] {
419 | color: var(--color-terminal-cell-failure);
420 |
421 | + .cell {
422 | color: var(--color-terminal-cell-failure);
423 | }
424 | }
425 | &[data-value='running'] {
426 | color: var(--color-terminal-cell-success);
427 | }
428 | &[data-value='paused'] {
429 | color: var(--color-terminal-cell-paused);
430 | }
431 | }
432 | }
433 | }
434 |
435 | &[data-layout='half'] {
436 | .main {
437 | .left,
438 | .right {
439 | width: 50%;
440 | }
441 | }
442 | }
443 |
444 | &[data-layout='focus'] {
445 | .main {
446 | .left,
447 | .right {
448 | width: 50%;
449 | }
450 |
451 | .left .tab:not(.is-current) {
452 | display: none;
453 | }
454 | }
455 | }
456 |
457 | @media screen and (max-width: @width-mobile) {
458 | // Copied from data-layout='focus'
459 | .left .tab:not(.is-current) {
460 | display: none;
461 | }
462 | }
463 |
464 | // States
465 | &.is-loading {
466 | .footer {
467 | .right {
468 | .indicator.for-loading {
469 | opacity: 1;
470 | }
471 | }
472 | }
473 | }
474 | }
475 | }
476 |
477 | // Popup
478 | .popup-layer {
479 | position: fixed;
480 | width: 100%;
481 | height: 100%;
482 | z-index: 9;
483 | display: none;
484 |
485 | &.is-active {
486 | display: flex;
487 | justify-content: center;
488 | align-items: center;
489 | }
490 |
491 | .popup {
492 | width: 55vw;
493 | background: var(--color-terminal-background);
494 |
495 | &[data-type='error'] .tab-content .row.is-textual p {
496 | color: var(--color-terminal-danger);
497 | }
498 |
499 | &.for-menu .tab-content {
500 | overflow: auto;
501 | }
502 |
503 | &.for-tty {
504 | width: 90%;
505 | height: 80%;
506 |
507 | .tab-content {
508 | justify-content: flex-start;
509 | height: 100%;
510 | overflow: auto;
511 |
512 | input {
513 | border: 0;
514 | background: transparent;
515 | color: var(--color-terminal-base);
516 | caret-color: var(--color-terminal-base);
517 | outline: 0;
518 | margin-left: 8px;
519 | width: 90%;
520 | }
521 | }
522 | }
523 |
524 | &.for-prompt {
525 | &.for-login {
526 | width: 435px;
527 | }
528 |
529 | .tab-content {
530 | justify-content: flex-start;
531 | overflow: auto;
532 |
533 | input {
534 | border: 0;
535 | background: transparent;
536 | color: var(--color-terminal-base);
537 | caret-color: var(--color-terminal-base);
538 | outline: 0;
539 | margin-left: 8px;
540 | width: 90%;
541 | }
542 |
543 | textarea {
544 | border: 0;
545 | background: rgba(0, 0, 0, 0);
546 | color: rgba(0, 0, 0, 0);
547 | caret-color: var(--color-terminal-base);
548 | outline: 0;
549 | width: 98%;
550 | resize: none;
551 | height: 600px;
552 | z-index: 2;
553 | line-height: 135%;
554 | font-family: monospace;
555 | font-size: 1em;
556 | overflow: auto;
557 | white-space: pre;
558 | }
559 |
560 | pre {
561 | position: absolute;
562 | left: 4px;
563 | top: 14px;
564 | z-index: 1;
565 | pointer-events: none;
566 | width: 98%;
567 | overflow: auto;
568 | height: 610px;
569 | white-space: pre;
570 |
571 | code {
572 | color: var(--color-terminal-base);
573 | }
574 | }
575 |
576 | &:has(textarea) {
577 | position: relative;
578 |
579 | .row {
580 | padding-top: 8px;
581 | justify-content: center;
582 | }
583 | .cell {
584 | position: absolute;
585 | opacity: 0.25;
586 | pointer-events: none;
587 | }
588 | }
589 | }
590 | }
591 |
592 | &.for-jump {
593 | width: 630px;
594 |
595 | @media screen and (max-width: @width-mobile) {
596 | width: 95%;
597 | }
598 |
599 | .tab-content {
600 | .jump-input-wrapper {
601 | padding-left: 8px;
602 |
603 | input {
604 | border: 0;
605 | background: transparent;
606 | color: var(--color-terminal-base);
607 | caret-color: var(--color-terminal-base);
608 | outline: 0;
609 | width: 90%;
610 | }
611 | }
612 |
613 | .jump-results {
614 | padding-left: 8px;
615 | margin-top: 8px;
616 | max-height: 185px;
617 | overflow: auto;
618 |
619 | .no-result-message {
620 | color: var(--color-terminal-warning);
621 | }
622 |
623 | .jump-result {
624 | padding-left: 0;
625 |
626 | // Host
627 | span.for-host {
628 | color: var(--color-terminal-accent-alternative);
629 | margin-right: 4px;
630 | }
631 |
632 | // Tab
633 | span.for-tab {
634 | color: var(--color-terminal-accent);
635 | margin-right: 4px;
636 | }
637 |
638 | // Resource name
639 | span.for-resource {
640 | margin-left: 4px;
641 | }
642 |
643 | span {
644 | pointer-events: none;
645 | }
646 | }
647 | }
648 | }
649 | }
650 |
651 | &.for-message[data-category='authentication'] {
652 | width: 435px;
653 | }
654 |
655 | &.for-help {
656 | .tab-content {
657 | max-height: 630px;
658 | }
659 | }
660 |
661 | &.for-overview {
662 | width: 860px;
663 |
664 | @media screen and (max-width: @width-mobile) {
665 | width: 95%;
666 | }
667 |
668 | .row {
669 | height: 96px;
670 | display: flex;
671 | align-items: center;
672 | padding-left: 16px;
673 |
674 | > * {
675 | pointer-events: none;
676 | }
677 |
678 | .row-logo {
679 | width: 48px;
680 | height: 100%;
681 | display: flex;
682 | justify-content: center;
683 | align-items: center;
684 | flex-shrink: 0;
685 |
686 | svg {
687 | width: 100%;
688 | }
689 | }
690 |
691 | .row-content {
692 | display: flex;
693 | flex-direction: column;
694 | align-items: flex-start;
695 | padding-left: 16px;
696 | padding-right: 24px;
697 | flex-grow: 1;
698 | height: 100%;
699 | justify-content: center;
700 | gap: 12px;
701 |
702 | .row-summary {
703 | display: flex;
704 | align-items: center;
705 | width: 100%;
706 |
707 | p {
708 | display: flex;
709 | width: 100%;
710 |
711 | i {
712 | color: var(--color-terminal-accent-alternative);
713 | font-style: normal;
714 | margin-right: 8px;
715 | }
716 |
717 | em {
718 | color: var(--color-terminal-accent-alternative);
719 | margin-left: auto;
720 | }
721 |
722 | @media screen and (max-width: @width-medium-mobile) {
723 | flex-direction: column;
724 | gap: 12px;
725 |
726 | i {
727 | display: contents;
728 | }
729 | em {
730 | margin: unset;
731 | }
732 | }
733 | }
734 | }
735 |
736 | .row-information {
737 | display: flex;
738 | align-items: center;
739 | width: 100%;
740 | gap: 16px;
741 |
742 | @media screen and (max-width: @width-medium-mobile) {
743 | display: none;
744 | }
745 |
746 | .row-information-box {
747 | display: flex;
748 | align-items: center;
749 | gap: 4px;
750 |
751 | @media screen and (max-width: @width-mobile) {
752 | &.for-networks,
753 | &.for-volumes {
754 | display: none;
755 | }
756 | }
757 | }
758 |
759 | .row-information-box svg {
760 | width: 18px;
761 | }
762 |
763 | .row-information-box span {
764 | font-size: 10.5pt;
765 | }
766 | }
767 |
768 | .row-information-specs {
769 | margin-left: auto;
770 | display: flex;
771 | gap: 18px;
772 |
773 | @media screen and (max-width: @width-medium-mobile) {
774 | display: none;
775 | }
776 | }
777 |
778 | .row-filler {
779 | margin-left: auto;
780 | }
781 | }
782 | }
783 | }
784 |
785 | @media screen and (max-width: @width-mobile) {
786 | width: 90%;
787 | &.for-message[data-category='authentication'],
788 | &.for-tty,
789 | &.for-prompt.for-login {
790 | width: 90%;
791 | }
792 | }
793 | }
794 |
795 | @media screen and (max-width: @width-mobile) {
796 | height: ~'calc(100% - 78px)';
797 | }
798 | }
799 |
800 | *.has-accent {
801 | color: var(--color-terminal-accent) !important;
802 | }
803 |
804 | pre code.hljs {
805 | background: var(--color-terminal-background);
806 | line-height: 135%;
807 |
808 | .hljs-attr {
809 | color: var(--color-terminal-accent-alternative);
810 | }
811 | .hljs-bullet {
812 | color: var(--color-terminal-accent);
813 | }
814 | .hljs-string,
815 | .hljs-number {
816 | color: var(--color-terminal-base);
817 | }
818 | }
819 |
--------------------------------------------------------------------------------
/app/client/assets/fonts/hack-bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/app/client/assets/fonts/hack-bold.woff2
--------------------------------------------------------------------------------
/app/client/assets/fonts/hack-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/app/client/assets/fonts/hack-regular.woff2
--------------------------------------------------------------------------------
/app/client/assets/js/lib.highlight.yaml.min.js:
--------------------------------------------------------------------------------
1 | /*! `yaml` grammar compiled for Highlight.js 11.9.0 */
2 | (()=>{var e=(()=>{"use strict";return e=>{
3 | const n="true false yes no null",a="[\\w#;/?:@&=+$,.~*'()[\\]]+",s={
4 | className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/
5 | },{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable",
6 | variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(s,{
7 | variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),l={
8 | end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},t={begin:/\{/,
9 | end:/\}/,contains:[l],illegal:"\\n",relevance:0},g={begin:"\\[",end:"\\]",
10 | contains:[l],illegal:"\\n",relevance:0},b=[{className:"attr",variants:[{
11 | begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{
12 | begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$",
13 | relevance:10},{className:"string",
14 | begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{
15 | begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,
16 | relevance:0},{className:"type",begin:"!\\w+!"+a},{className:"type",
17 | begin:"!<"+a+">"},{className:"type",begin:"!"+a},{className:"type",begin:"!!"+a
18 | },{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta",
19 | begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)",
20 | relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{
21 | className:"number",
22 | begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"
23 | },{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},t,g,s],r=[...b]
24 | ;return r.pop(),r.push(i),l.contains=r,{name:"YAML",case_insensitive:!0,
25 | aliases:["yml"],contains:b}}})();hljs.registerLanguage("yaml",e)})();
--------------------------------------------------------------------------------
/app/client/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/app/client/favicon.ico
--------------------------------------------------------------------------------
/app/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Manage your Docker fleet with ease - Isaiah
11 |
12 |
13 |
14 |
15 |
16 |
21 |
Establishing connection with the remote server
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
--------------------------------------------------------------------------------
/app/client/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /
3 |
--------------------------------------------------------------------------------
/app/default.env:
--------------------------------------------------------------------------------
1 | DEV_ENABLED="FALSE"
2 | SSL_ENABLED="FALSE"
3 |
4 | SERVER_PORT="3000"
5 | SERVER_MAX_READ_SIZE="100000"
6 | SERVER_CHUNKED_COMMUNICATION_ENABLED="FALSE"
7 | SERVER_CHUNKED_COMMUNICATION_SIZE="50"
8 |
9 | SERVER_ROLE="Master"
10 | AGENT_REGISTRATION_RETRY_DELAY="30"
11 |
12 | AUTHENTICATION_ENABLED="TRUE"
13 | AUTHENTICATION_SECRET="one-very-long-and-mysterious-secret"
14 |
15 | FORWARD_PROXY_AUTHENTICATION_ENABLED="FALSE"
16 | FORWARD_PROXY_AUTHENTICATION_HEADER_KEY="Remote-User"
17 | FORWARD_PROXY_AUTHENTICATION_HEADER_VALUE="*"
18 |
19 | TABS_ENABLED="Containers,Images,Volumes,Networks,Stacks"
20 |
21 | COLUMNS_CONTAINERS="State,ExitCode,Name,Image"
22 | COLUMNS_IMAGES="UsageState,Name,Version,Size"
23 | COLUMNS_VOLUMES="Driver,Name"
24 | COLUMNS_NETWORKS="Driver,Name"
25 | COLUMNS_STACKS="Status,Name"
26 |
27 | SORTBY_CONTAINERS=""
28 | SORTBY_IMAGES=""
29 | SORTBY_VOLUMES=""
30 | SORTBY_NETWORKS=""
31 | SORTBY_STACKS=""
32 |
33 | CONTAINER_HEALTH_STYLE="long"
34 | CONTAINER_LOGS_TAIL="50"
35 | CONTAINER_LOGS_SINCE="60m"
36 |
37 | STACKS_DIRECTORY="."
38 |
39 | DISPLAY_CONFIRMATIONS="TRUE"
40 |
41 | TTY_SERVER_COMMAND="/bin/sh -i"
42 | TTY_CONTAINER_COMMAND="/bin/sh -c eval $(grep ^$(id -un): /etc/passwd | cut -d : -f 7-)"
43 |
44 | SKIP_VERIFICATIONS="FALSE"
45 |
--------------------------------------------------------------------------------
/app/go.mod:
--------------------------------------------------------------------------------
1 | module will-moss/isaiah
2 |
3 | go 1.21
4 |
5 | require (
6 | github.com/docker/docker v24.0.7+incompatible
7 | github.com/fatih/structs v1.1.0
8 | github.com/google/uuid v1.5.0
9 | github.com/gorilla/websocket v1.5.1
10 | github.com/joho/godotenv v1.5.1
11 | github.com/mitchellh/mapstructure v1.5.0
12 | github.com/olahol/melody v1.1.4
13 | github.com/shirou/gopsutil v3.21.11+incompatible
14 | )
15 |
16 | require (
17 | github.com/Microsoft/go-winio v0.6.1 // indirect
18 | github.com/distribution/reference v0.5.0 // indirect
19 | github.com/docker/distribution v2.8.3+incompatible // indirect
20 | github.com/docker/go-connections v0.4.0 // indirect
21 | github.com/docker/go-units v0.5.0 // indirect
22 | github.com/go-ole/go-ole v1.2.6 // indirect
23 | github.com/gogo/protobuf v1.3.2 // indirect
24 | github.com/moby/term v0.5.0 // indirect
25 | github.com/morikuni/aec v1.0.0 // indirect
26 | github.com/opencontainers/go-digest v1.0.0 // indirect
27 | github.com/opencontainers/image-spec v1.0.2 // indirect
28 | github.com/pkg/errors v0.9.1 // indirect
29 | github.com/stretchr/testify v1.8.4 // indirect
30 | github.com/yusufpapurcu/wmi v1.2.4 // indirect
31 | golang.org/x/mod v0.8.0 // indirect
32 | golang.org/x/net v0.17.0 // indirect
33 | golang.org/x/sys v0.13.0 // indirect
34 | golang.org/x/time v0.5.0 // indirect
35 | golang.org/x/tools v0.6.0 // indirect
36 | gotest.tools/v3 v3.5.1 // indirect
37 | )
38 |
--------------------------------------------------------------------------------
/app/go.sum:
--------------------------------------------------------------------------------
1 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
2 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
3 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
4 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7 | github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
8 | github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
9 | github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
10 | github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
11 | github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM=
12 | github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
13 | github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
14 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
15 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
16 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
17 | github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
18 | github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
19 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
20 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
21 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
22 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
23 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
24 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
25 | github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
26 | github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
27 | github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
28 | github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
29 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
30 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
31 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
32 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
33 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
34 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
35 | github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
36 | github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
37 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
38 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
39 | github.com/olahol/melody v1.1.4 h1:RQHfKZkQmDxI0+SLZRNBCn4LiXdqxLKRGSkT8Dyoe/E=
40 | github.com/olahol/melody v1.1.4/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
41 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
42 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
43 | github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
44 | github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
45 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
46 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
47 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
48 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
49 | github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
50 | github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
51 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
52 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
53 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
54 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
55 | github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
56 | github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
57 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
58 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
59 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
60 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
61 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
62 | golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
63 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
64 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
65 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
66 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
67 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
68 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
69 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
70 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
71 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
72 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
73 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
74 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
75 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
76 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
77 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
78 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
79 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
80 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
81 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
82 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
83 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
84 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
85 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
86 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
87 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
88 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
89 | golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
90 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
91 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
92 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
93 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
94 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
95 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
96 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
97 | gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
98 | gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
99 |
--------------------------------------------------------------------------------
/app/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "embed"
6 | "errors"
7 | "fmt"
8 | "io"
9 | "log"
10 | "net"
11 | "net/http"
12 | "net/url"
13 | "os"
14 | "os/exec"
15 | "strings"
16 | "time"
17 |
18 | "github.com/docker/docker/client"
19 | "github.com/google/uuid"
20 | "github.com/gorilla/websocket"
21 | "github.com/joho/godotenv"
22 | "github.com/olahol/melody"
23 |
24 | _client "will-moss/isaiah/server/_internal/client"
25 | _fs "will-moss/isaiah/server/_internal/fs"
26 | _json "will-moss/isaiah/server/_internal/json"
27 | _os "will-moss/isaiah/server/_internal/os"
28 | _session "will-moss/isaiah/server/_internal/session"
29 | _strconv "will-moss/isaiah/server/_internal/strconv"
30 | "will-moss/isaiah/server/_internal/tty"
31 | "will-moss/isaiah/server/resources"
32 | "will-moss/isaiah/server/server"
33 | "will-moss/isaiah/server/ui"
34 | )
35 |
36 | //go:embed client/*
37 | var clientAssets embed.FS
38 |
39 | //go:embed default.env
40 | var defaultEnv string
41 |
42 | //go:embed server/_internal/templates/run.tpl
43 | var getRunCommandTemplate string
44 |
45 | // Perform checks to ensure the server is ready to start
46 | // Returns an error if any condition isn't met
47 | func performVerifications() error {
48 |
49 | // 1. Ensure Docker CLI is available
50 | if _os.GetEnv("DOCKER_RUNNING") != "TRUE" {
51 | cmd := exec.Command("docker", "version")
52 | _, err := cmd.Output()
53 | if err != nil {
54 | return fmt.Errorf("Failed Verification : Access to Docker CLI -> %s", err)
55 | }
56 | }
57 |
58 | // 2. Ensure Docker socket is reachable
59 | if _os.GetEnv("MULTI_HOST_ENABLED") != "TRUE" {
60 | c, err := client.NewClientWithOpts(client.FromEnv)
61 | if err != nil {
62 | return fmt.Errorf("Failed Verification : Access to Docker socket -> %s", err)
63 | }
64 | defer c.Close()
65 | }
66 |
67 | // 3. Ensure server port is available
68 | if _os.GetEnv("SERVER_ROLE") == "Master" {
69 | l, err := net.Listen("tcp", fmt.Sprintf(":%s", _os.GetEnv("SERVER_PORT")))
70 | if err != nil {
71 | return fmt.Errorf("Failed Verification : Port binding -> %s", err)
72 | }
73 | defer l.Close()
74 | }
75 |
76 | // 4. Ensure certificate and private key are provided
77 | if _os.GetEnv("SSL_ENABLED") == "TRUE" {
78 | if _, err := os.Stat("./certificate.pem"); errors.Is(err, os.ErrNotExist) {
79 | return fmt.Errorf("Failed Verification : Certificate file missing -> Please put your certificate.pem file next to the executable")
80 | }
81 | if _, err := os.Stat("./key.pem"); errors.Is(err, os.ErrNotExist) {
82 | return fmt.Errorf("Failed Verification : Private key file missing -> Please put your key.pem file next to the executable")
83 | }
84 | }
85 |
86 | // 5. Ensure master node is available if current node is an agent
87 | if _os.GetEnv("SERVER_ROLE") == "Agent" {
88 | h, err := net.DialTimeout("tcp", _os.GetEnv("MASTER_HOST"), 5*time.Second)
89 | if err != nil {
90 | return fmt.Errorf("Failed Verification : Master node is unreachable -> %s", err)
91 | }
92 | defer h.Close()
93 | }
94 |
95 | // 6. Ensure an agent name is provided if current node is an agent
96 | if _os.GetEnv("SERVER_ROLE") == "Agent" {
97 | if _os.GetEnv("AGENT_NAME") == "" {
98 | return fmt.Errorf("Failed Verification : You must provide a name for your Agent node")
99 | }
100 | }
101 |
102 | // 7. Ensure docker_hosts file is available when multi-host is enabled
103 | if _os.GetEnv("MULTI_HOST_ENABLED") == "TRUE" {
104 | if _, err := os.Stat("docker_hosts"); errors.Is(err, os.ErrNotExist) {
105 | return fmt.Errorf("Failed Verification : docker_hosts file is missing. Please put it next to the executable")
106 | }
107 | }
108 |
109 | // 8. Ensure every host is reachable if multi-host is enabled, and docker_hosts is well-formatted
110 | if _os.GetEnv("MULTI_HOST_ENABLED") == "TRUE" {
111 | raw, err := os.ReadFile("docker_hosts")
112 | if err != nil {
113 | return fmt.Errorf("Failed Verification : docker_hosts file can't be read -> %s", err)
114 | }
115 | if len(raw) == 0 {
116 | return fmt.Errorf("Failed Verification : docker_hosts file is empty.")
117 | }
118 |
119 | lines := strings.Split(string(raw), "\n")
120 | for _, line := range lines {
121 | if len(line) == 0 {
122 | continue
123 | }
124 |
125 | parts := strings.Split(line, " ")
126 | if len(parts) != 2 {
127 | return fmt.Errorf("Failed Verification : docker_hosts file isn't properly formatted. Line : -> %s", line)
128 | }
129 |
130 | c, _err := client.NewClientWithOpts(client.WithHost(parts[1]))
131 | if _err != nil {
132 | return fmt.Errorf("Failed Verification : Access to Docker host -> %s", _err)
133 | }
134 |
135 | _, _err = c.ServerVersion(context.Background())
136 | if _err != nil {
137 | return fmt.Errorf("Failed Verification : Retrieving version from Docker host -> %s", _err)
138 | }
139 |
140 | c.Close()
141 | }
142 | }
143 |
144 | return nil
145 | }
146 |
147 | // Entrypoint
148 | func main() {
149 | // Handle commandline arguments if any
150 | args := os.Args[1:]
151 | if len(args) > 0 {
152 | // Handle -v / --version switch
153 | if args[0] == "-v" || args[0] == "--version" {
154 | fmt.Printf("Version: -VERSION-")
155 | return
156 | }
157 | }
158 |
159 | // Load default settings via default.env file (workaround since the file is embed)
160 | defaultSettings, _ := godotenv.Unmarshal(defaultEnv)
161 | for k, v := range defaultSettings {
162 | if _os.GetEnv(k) == "" {
163 | os.Setenv(k, v)
164 | }
165 | }
166 |
167 | // Pass embed assets down the tree
168 | resources.GetRunCommandTemplate = getRunCommandTemplate
169 |
170 | // Load custom settings via .env file
171 | err := godotenv.Overload(".env")
172 | if err != nil {
173 | log.Print("No .env file provided, will continue with system env")
174 | }
175 |
176 | if _os.GetEnv("MULTI_HOST_ENABLED") != "TRUE" {
177 | // Automatically discover the Docker host on the machine
178 | discoveredHost, err := _client.DiscoverDockerHost()
179 | if err != nil {
180 | log.Print(err.Error())
181 | return
182 | }
183 | os.Setenv("DOCKER_HOST", discoveredHost)
184 | }
185 |
186 | // Perform initial verifications
187 | if _os.GetEnv("SKIP_VERIFICATIONS") != "TRUE" {
188 | // Ensure everything is ready for our app
189 | log.Print("Performing verifications before starting")
190 | err = performVerifications()
191 | if err != nil {
192 | log.Print("Error performing initial verifications, abort\n")
193 | log.Print(err)
194 | return
195 | }
196 | }
197 |
198 | // Set up everything (Melody instance, Docker client, Server settings)
199 | var _server server.Server
200 | if _os.GetEnv("MULTI_HOST_ENABLED") != "TRUE" {
201 | _server = server.Server{
202 | Melody: melody.New(),
203 | Docker: _client.NewClientWithOpts(client.FromEnv),
204 | }
205 | } else {
206 | _server = server.Server{
207 | Melody: melody.New(),
208 | }
209 |
210 | // Populate server's known hosts when multi-host is enabled
211 | _server.Hosts = make(server.HostsArray, 0)
212 | var firstHost string
213 |
214 | raw, _ := os.ReadFile("docker_hosts")
215 | lines := strings.Split(string(raw), "\n")
216 | for _, line := range lines {
217 | if len(line) == 0 {
218 | continue
219 | }
220 | parts := strings.Split(line, " ")
221 |
222 | _server.Hosts = append(_server.Hosts, []string{parts[0], parts[1]})
223 |
224 | if len(firstHost) == 0 {
225 | firstHost = parts[0]
226 | }
227 | }
228 |
229 | // Set default Docker client on the first known host
230 | _server.SetHost(firstHost)
231 | }
232 | _server.Melody.Config.MaxMessageSize = _strconv.ParseInt(_os.GetEnv("SERVER_MAX_READ_SIZE"), 10, 64)
233 |
234 | // Disable client when current node is an agent
235 | if _os.GetEnv("SERVER_ROLE") != "Agent" {
236 |
237 | // Load embed assets as a filesystem
238 | serverRoot := _fs.Sub(clientAssets, "client")
239 |
240 | // HTTP - Set up static file serving for the CSS theming
241 | http.HandleFunc("/assets/css/custom.css", func(w http.ResponseWriter, r *http.Request) {
242 | if _, err := os.Stat("custom.css"); errors.Is(err, os.ErrNotExist) {
243 | w.WriteHeader(200)
244 | return
245 | }
246 |
247 | http.ServeFile(w, r, "custom.css")
248 | })
249 |
250 | // Use on-disk assets rather than embedded ones when in development
251 | if _os.GetEnv("DEV_ENABLED") != "TRUE" {
252 | // HTTP - Set up static file serving for all the front-end files
253 | http.Handle("/", http.StripPrefix("/", http.FileServer(http.FS(serverRoot))))
254 | } else {
255 | http.Handle("/", http.FileServer(http.Dir("./client")))
256 | }
257 | }
258 |
259 | // Set up an endpoint to handle Websocket connections with Melody
260 | http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
261 | _server.Melody.HandleRequest(w, r)
262 | })
263 |
264 | // WS - Handle first user connecion
265 | _server.Melody.HandleConnect(func(session *melody.Session) {
266 | session.Set("id", uuid.NewString())
267 |
268 | // Handle Forward Proxy Header Authentication if enabled
269 | if _os.GetEnv("FORWARD_PROXY_AUTHENTICATION_ENABLED") == "TRUE" {
270 | requiredHeaderKey := _os.GetEnv("FORWARD_PROXY_AUTHENTICATION_HEADER_KEY")
271 | requiredHeaderValue := _os.GetEnv("FORWARD_PROXY_AUTHENTICATION_HEADER_VALUE")
272 |
273 | suppliedHeaderValue := session.Request.Header.Get(requiredHeaderKey)
274 |
275 | if suppliedHeaderValue != "" {
276 | if requiredHeaderValue == "*" || suppliedHeaderValue == requiredHeaderValue {
277 | session.Set("authenticated", true)
278 | _server.SendNotification(session, ui.NotificationAuth(ui.NP{
279 | Type: ui.TypeSuccess,
280 | Content: ui.JSON{
281 | "Authentication": ui.JSON{
282 | "Spontaneous": true,
283 | "Message": "You are now authenticated",
284 | },
285 | },
286 | }))
287 | }
288 | }
289 | }
290 |
291 | _server.Handle(session)
292 | })
293 |
294 | // WS - Handle user commands
295 | _server.Melody.HandleMessage(func(session *melody.Session, message []byte) {
296 | go _server.Handle(session, message)
297 | // _server.Handle(session, message)
298 | })
299 |
300 | // WS - Handle user disconnection
301 | _server.Melody.HandleDisconnect(func(s *melody.Session) {
302 | // When current node is master
303 | if _os.GetEnv("SERVER_ROLE") == "Master" {
304 | // Clear user tty if there's any open
305 | if terminal, exists := s.Get("tty"); exists {
306 | (terminal.(*tty.TTY)).ClearAndQuit()
307 | s.UnSet("tty")
308 | }
309 |
310 | // Clear user read stream if there's any open
311 | if stream, exists := s.Get("stream"); exists {
312 | (*stream.(*io.ReadCloser)).Close()
313 | s.UnSet("stream")
314 | }
315 |
316 | // Unregister the agent node if applicable
317 | if agent, exists := s.Get("agent"); exists {
318 | newAgents := make(server.AgentsArray, 0)
319 | for _, _agent := range _server.Agents {
320 | if (agent.(server.Agent)).Name != _agent.Name {
321 | newAgents = append(newAgents, _agent)
322 | }
323 | }
324 | _server.Agents = newAgents
325 |
326 | s.UnSet("agent")
327 |
328 | // Notify all the clients about the agent's disconnection
329 | notification := ui.NotificationData(ui.NotificationParams{Content: ui.JSON{"Agents": _server.Agents.ToStrings()}})
330 | _server.Melody.Broadcast(notification.ToBytes())
331 | }
332 | }
333 |
334 | })
335 |
336 | // When current node is an agent, perform agent registration procedure with the master node
337 | if _os.GetEnv("SERVER_ROLE") == "Agent" {
338 | hasRegisteredSuccessfullyAtLeastOnce := false
339 | lastRegistrationAttemptAt := time.Now().Unix()
340 | retryDelay := _strconv.ParseInt(_os.GetEnv("AGENT_REGISTRATION_RETRY_DELAY"), 10, 64)
341 |
342 | agentRegistration:
343 | log.Print("Initiating registration with master node")
344 |
345 | var response ui.Notification
346 |
347 | // 1. Establish connection with Master node
348 | masterAddress := url.URL{Scheme: "ws", Host: _os.GetEnv("MASTER_HOST"), Path: "/ws"}
349 | connection, _, err := websocket.DefaultDialer.Dial(masterAddress.String(), nil)
350 | if err != nil {
351 | log.Print("Error establishing connection to the master node")
352 | log.Print(err)
353 |
354 | if hasRegisteredSuccessfullyAtLeastOnce {
355 | currentAttemptAt := time.Now().Unix()
356 | nextAttemptDelay := retryDelay - (currentAttemptAt - lastRegistrationAttemptAt)
357 |
358 | if nextAttemptDelay > 0 {
359 | log.Printf("New attempt in %d seconds", nextAttemptDelay)
360 | time.Sleep(time.Duration(nextAttemptDelay) * time.Second)
361 | }
362 |
363 | lastRegistrationAttemptAt = time.Now().Unix()
364 | goto agentRegistration
365 | } else {
366 | return
367 | }
368 | }
369 |
370 | if _os.GetEnv("MASTER_SECRET") != "" {
371 | log.Print("Performing authentication")
372 |
373 | // 2. Send authentication command
374 | authCommand := ui.Command{Action: "auth.login", Args: ui.JSON{"Password": _os.GetEnv("MASTER_SECRET")}}
375 | err = connection.WriteMessage(websocket.TextMessage, _json.Marshal(authCommand))
376 | if err != nil {
377 | log.Print("Error sending authentication command to the master node")
378 | log.Print(err)
379 | return
380 | }
381 |
382 | // 3. Verify that authentication was succesful
383 | err = connection.ReadJSON(&response)
384 | if err != nil {
385 | log.Print("Error decoding authentication response from the master node")
386 | log.Print(err)
387 | return
388 | }
389 |
390 | if response.Type != ui.TypeSuccess {
391 | log.Print("Authentication with master node unsuccessful")
392 | log.Print("Please check your MASTER_SECRET setting and restart")
393 | return
394 | }
395 |
396 | // Quirk : When authentication is disabled, the server has already initially sent an auth success
397 | // Trying to empty / vaccuum the message queue proves unfeasible with Gorilla Websocket
398 | // Hence we must undergo the following code to skip authentication in that case
399 | spontaneous, ok := response.Content["Authentication"].(map[string]interface{})["Spontaneous"]
400 | if ok && spontaneous.(bool) {
401 | connection.ReadMessage()
402 | }
403 | } else {
404 | log.Print("No authentication secret was provided, skipping authentication")
405 | // Quirk : Same as above
406 | connection.ReadMessage()
407 | }
408 |
409 | // 4. Send registration command
410 | registrationCommand := ui.Command{
411 | Action: "agent.register",
412 | Args: ui.JSON{
413 | "Resource": server.Agent{
414 | Name: _os.GetEnv("AGENT_NAME"),
415 | },
416 | },
417 | }
418 | err = connection.WriteMessage(websocket.TextMessage, _json.Marshal(registrationCommand))
419 | if err != nil {
420 | log.Print("Error sending registration command to the master node")
421 | log.Print(err)
422 | return
423 | }
424 |
425 | // Quirk : Skip loading indicator
426 | connection.ReadMessage()
427 |
428 | // 5. Verify that registration was succesful
429 | response = ui.Notification{}
430 | err = connection.ReadJSON(&response)
431 | if err != nil {
432 | log.Print("Error decoding registration response from the master node")
433 | log.Print(err)
434 | return
435 | }
436 | if response.Type != ui.TypeSuccess {
437 | log.Print("Registration with master node unsuccessful")
438 | log.Print("Please check your settings and connectivity")
439 | log.Printf("Error : %s", response.Content["Message"])
440 | return
441 | }
442 |
443 | log.Print("Connection with master node is established")
444 | hasRegisteredSuccessfullyAtLeastOnce = true
445 |
446 | // Workaround : Create a tweaked reimplementation of melody.Session to reuse existing code
447 | session := _session.Create(connection)
448 |
449 | // 6. Process the commands as they are received
450 | masterConnectionLost := false
451 | for {
452 | _, message, err := connection.ReadMessage()
453 | if err != nil {
454 | log.Print(err)
455 | log.Print("Connection with master node was lost, will reconnect")
456 | masterConnectionLost = true
457 | break
458 | }
459 |
460 | _server.Handle(session, message)
461 | }
462 |
463 | // 7. Clear all opened TTY / Stream instances when applicable
464 | session.UnSet("initiator")
465 |
466 | // Clear all users' tty if there's any open
467 | for k := range session.Keys {
468 | if strings.HasSuffix(k, "tty") {
469 | (session.Keys[k].(*tty.TTY)).ClearAndQuit()
470 | session.UnSet(k)
471 | }
472 | }
473 |
474 | // Clear all users' read stream if there's any open
475 | for k := range session.Keys {
476 | if strings.HasSuffix(k, "stream") {
477 | (*session.Keys[k].(*io.ReadCloser)).Close()
478 | session.UnSet(k)
479 | }
480 | }
481 |
482 | if masterConnectionLost {
483 | goto agentRegistration
484 | } else {
485 | return
486 | }
487 | }
488 |
489 | // When current node is master, start the HTTP server
490 | if _os.GetEnv("SERVER_ROLE") == "Master" {
491 | log.Printf("Server starting on port %s", _os.GetEnv("SERVER_PORT"))
492 | if _os.GetEnv("SSL_ENABLED") == "TRUE" {
493 | http.ListenAndServeTLS(fmt.Sprintf(":%s", _os.GetEnv("SERVER_PORT")), "certificate.pem", "key.pem", nil)
494 | } else {
495 | http.ListenAndServe(fmt.Sprintf(":%s", _os.GetEnv("SERVER_PORT")), nil)
496 | }
497 | }
498 | }
499 |
--------------------------------------------------------------------------------
/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@babel/cli": "^7.25.9",
4 | "@babel/core": "^7.26.0",
5 | "@babel/preset-env": "^7.26.0",
6 | "less": "^4.2.1",
7 | "lightningcss-cli": "^1.28.2"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/app/sample.custom.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --color-terminal-background: #000000;
3 | --color-terminal-base: #ffffff;
4 | --color-terminal-accent: #4af626;
5 | --color-terminal-accent-selected: #73f859;
6 | --color-terminal-hover: rgba(255, 255, 255, 0.15);
7 | --color-terminal-border: #ffffff;
8 | --color-terminal-danger: #ff0000;
9 | --color-terminal-warning: #f67e26;
10 | --color-terminal-accent-alternative: #26e1f6;
11 | --color-terminal-json-key: darkturquoise;
12 | --color-terminal-json-value: beige;
13 | --color-terminal-cell-failure: #ff9999;
14 | --color-terminal-cell-success: #9bff99;
15 | --color-terminal-cell-paused: beige;
16 | }
17 |
--------------------------------------------------------------------------------
/app/sample.docker_hosts:
--------------------------------------------------------------------------------
1 | Local unix:///var/run/docker.sock
2 | Host-1 tcp://your-domain.tld:your-port
3 | Host-2 tcp://your-ip:your-port
4 |
--------------------------------------------------------------------------------
/app/server/_internal/client/client.go:
--------------------------------------------------------------------------------
1 | package client
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "os/exec"
7 | "strings"
8 |
9 | "github.com/docker/docker/client"
10 |
11 | _os "will-moss/isaiah/server/_internal/os"
12 | )
13 |
14 | // Alias for client.NewClientWithOpts, without returning any error
15 | func NewClientWithOpts(ops client.Opt) *client.Client {
16 | _client, _ := client.NewClientWithOpts(ops)
17 | return _client
18 | }
19 |
20 | // Try to find the current Docker host on the system, using :
21 | // 1. Env variable : CUSTOMER_DOCKER_HOST
22 | // 2. Env variable : DOCKER_HOST
23 | // 3. Env variable : DOCKER_CONTEXT
24 | // 4. Output of command : docker context show + docker context inspect
25 | // 5. OS-based default location
26 | func DiscoverDockerHost() (string, error) {
27 | // 1. Custom Docker host provided
28 | if _os.GetEnv("CUSTOM_DOCKER_HOST") != "" {
29 | return _os.GetEnv("CUSTOM_DOCKER_HOST"), nil
30 | }
31 |
32 | // 2. Default Docker host already set
33 | if _os.GetEnv("DOCKER_HOST") != "" {
34 | return _os.GetEnv("DOCKER_HOST"), nil
35 | }
36 |
37 | if _os.GetEnv("DOCKER_RUNNING") != "TRUE" {
38 | // 3. Default Docker context already set
39 | if _os.GetEnv("DOCKER_CONTEXT") != "" {
40 | cmd := exec.Command("docker", "context", "inspect", _os.GetEnv("DOCKER_CONTEXT"))
41 | output, err := cmd.Output()
42 |
43 | if err != nil {
44 | return "", fmt.Errorf("An error occurred while trying to inspect the Docker context provided : %s", err)
45 | }
46 |
47 | lines := strings.Split(string(output), "\n")
48 | for _, line := range lines {
49 | if strings.Contains(line, "Host") {
50 | parts := strings.Split(line, "\"Host\": ")
51 | replacer := strings.NewReplacer("\"", "", ",", "")
52 | host := replacer.Replace(parts[1])
53 |
54 | return host, nil
55 | }
56 | }
57 | }
58 |
59 | // 4. Attempt to retrieve the current Docker context if all the other cases proved unsuccesful
60 | {
61 | cmd := exec.Command("docker", "context", "show")
62 | output, err := cmd.Output()
63 |
64 | if err != nil {
65 | return "", fmt.Errorf("An error occurred while trying to retrieve the default Docker context : %s", err)
66 | }
67 |
68 | currentContext := strings.TrimSpace(string(output))
69 | if currentContext != "" {
70 | cmd := exec.Command("docker", "context", "inspect", currentContext)
71 |
72 | output, err := cmd.Output()
73 | if err != nil {
74 | return "", fmt.Errorf("An error occurred while trying to inspect the default Docker context : %s", err)
75 | }
76 |
77 | lines := strings.Split(string(output), "\n")
78 | for _, line := range lines {
79 | if strings.Contains(line, "Host") {
80 | parts := strings.Split(line, "\"Host\": ")
81 | replacer := strings.NewReplacer("\"", "", ",", "")
82 | host := replacer.Replace(parts[1])
83 | return host, nil
84 | }
85 | }
86 | }
87 | }
88 | }
89 |
90 | // 5. Every previous attempt failed, try to use the default location
91 | // 5.1. Unix-like systems
92 | if _, err := os.Stat("/var/run/docker.sock"); err == nil {
93 | return "unix:///var/run/docker.sock", nil
94 | }
95 | // 5.2. Windows system
96 | if _, err := os.Stat("\\\\.\\pipe\\docker_engine"); err == nil {
97 | return "\\\\.\\pipe\\docker_engine", nil
98 | }
99 |
100 | var finalError error
101 | if _os.GetEnv("DOCKER_RUNNING") != "TRUE" {
102 | finalError = fmt.Errorf("Automatic Docker host discovery failed on your system. Please try setting DOCKER_HOST manually")
103 | } else {
104 | finalError = fmt.Errorf("Automatic Docker host discovery failed on your system. Please make sure your Docker socket is mounted on your container")
105 | }
106 | return "", finalError
107 | }
108 |
--------------------------------------------------------------------------------
/app/server/_internal/fs/fs.go:
--------------------------------------------------------------------------------
1 | package fs
2 |
3 | import "io/fs"
4 |
5 | // Alias of fs.Sub, without returning any error
6 | func Sub(fsys fs.FS, dir string) fs.FS {
7 | v, _ := fs.Sub(fsys, dir)
8 | return v
9 | }
10 |
--------------------------------------------------------------------------------
/app/server/_internal/io/io.go:
--------------------------------------------------------------------------------
1 | package _io
2 |
3 | // Represent a default io.Writer but using a custom WriteFunction
4 | // provided by the developer. It enables us to create "any" type of
5 | // io.Writer, without having to create new interfaces for every new
6 | // implementation we need.
7 | type CustomWriter struct {
8 | WriteFunction func(p []byte)
9 | }
10 |
11 | func (cw CustomWriter) Write(p []byte) (int, error) {
12 | cw.WriteFunction(p)
13 | return len(p), nil
14 | }
15 |
--------------------------------------------------------------------------------
/app/server/_internal/json/json.go:
--------------------------------------------------------------------------------
1 | package json
2 |
3 | import (
4 | "encoding/json"
5 | )
6 |
7 | // Alias for json.Marshal, without returning any error
8 | func Marshal(v any) []byte {
9 | r, _ := json.Marshal(v)
10 | return r
11 | }
12 |
--------------------------------------------------------------------------------
/app/server/_internal/os/os.go:
--------------------------------------------------------------------------------
1 | package os
2 |
3 | import (
4 | "os"
5 | "os/exec"
6 | "strings"
7 | "will-moss/isaiah/server/_internal/tty"
8 |
9 | "github.com/shirou/gopsutil/mem"
10 | )
11 |
12 | // Alias for os.GetEnv, with support for fallback value, and boolean normalization
13 | func GetEnv(key string, fallback ...string) string {
14 | value, exists := os.LookupEnv(key)
15 | if !exists {
16 | if len(fallback) > 0 {
17 | value = fallback[0]
18 | } else {
19 | value = ""
20 | }
21 | } else {
22 | // Quotes removal
23 | value = strings.Trim(value, "\"")
24 |
25 | // Boolean normalization
26 | mapping := map[string]string{
27 | "0": "FALSE",
28 | "off": "FALSE",
29 | "false": "FALSE",
30 | "1": "TRUE",
31 | "on": "TRUE",
32 | "true": "TRUE",
33 | "rue": "TRUE",
34 | }
35 | normalized, isBool := mapping[strings.ToLower(value)]
36 | if isBool {
37 | value = normalized
38 | }
39 | }
40 |
41 | return value
42 | }
43 |
44 | // Retrieve all the environment variables as a map
45 | func GetFullEnv() map[string]string {
46 | var structured = make(map[string]string)
47 |
48 | raw := os.Environ()
49 | for i := 0; i < len(raw); i++ {
50 | pair := strings.Split(raw[i], "=")
51 | key := pair[0]
52 | value := GetEnv(key)
53 |
54 | structured[key] = value
55 | }
56 | return structured
57 | }
58 |
59 | // Open a shell on the system, and update the provided channels with
60 | // status / errors as they happen
61 | func OpenShell(tty *tty.TTY, channelErrors chan error, channelUpdates chan string) {
62 | cmd := GetEnv("TTY_SERVER_COMMAND")
63 | cmdParts := strings.Split(cmd, " ")
64 |
65 | process := exec.Command(cmdParts[0], cmdParts[1:]...)
66 | process.Stdin = tty.Stdin
67 | process.Stderr = tty.Stdout
68 | process.Stdout = tty.Stdout
69 | err := process.Start()
70 |
71 | if err != nil {
72 | channelErrors <- err
73 | } else {
74 | channelUpdates <- "started"
75 | process.Wait()
76 | channelUpdates <- "exited"
77 | }
78 | }
79 |
80 | // Alias for mem.VirtualMemory, swallowing the potential error
81 | func VirtualMemory() *mem.VirtualMemoryStat {
82 | v, err := mem.VirtualMemory()
83 |
84 | if err != nil {
85 | v := mem.VirtualMemoryStat{Total: 0, Used: 0, Available: 0}
86 | return &v
87 | }
88 |
89 | return v
90 | }
91 |
--------------------------------------------------------------------------------
/app/server/_internal/process/process.go:
--------------------------------------------------------------------------------
1 | package process
2 |
3 | import "github.com/docker/docker/client"
4 |
5 | // Represent a tri-channel holder for a long task to communicate
6 | type LongTaskMonitor struct {
7 | Results chan string
8 | Errors chan error
9 | Done chan bool
10 | }
11 |
12 | // Represent a long-running function on a Docker resource
13 | type LongTask struct {
14 | Function func(*client.Client, LongTaskMonitor, map[string]interface{})
15 | Args map[string]interface{}
16 | OnStep func(string)
17 | OnError func(error)
18 | OnDone func()
19 | }
20 |
21 | // Run task.Function in a goroutine, and update the Function monitor provided
22 | // as the Function is executed
23 | func (task LongTask) RunSync(docker *client.Client) {
24 | finished, results, errors, done := false, make(chan string), make(chan error), make(chan bool)
25 | go task.Function(docker, LongTaskMonitor{Results: results, Errors: errors, Done: done}, task.Args)
26 |
27 | for {
28 | if finished {
29 | break
30 | }
31 |
32 | select {
33 | case r := <-results:
34 | task.OnStep(r)
35 | case e := <-errors:
36 | task.OnError(e)
37 | case <-done:
38 | finished = true
39 | }
40 | }
41 |
42 | task.OnDone()
43 | }
44 |
--------------------------------------------------------------------------------
/app/server/_internal/session/session.go:
--------------------------------------------------------------------------------
1 | package session
2 |
3 | import (
4 | "sync"
5 |
6 | "github.com/gorilla/websocket"
7 | )
8 |
9 | // Represent a Generic Session entity
10 | // The only reason this type exists is to provide inheritance
11 | // In some parts of the code, a melody.Session will be a GenericSession
12 | // In other parts of the code, a Session will be a GenericSession
13 | // GenericSession hence enables us to use both types transparently
14 | type GenericSession interface {
15 | Set(string, interface{})
16 | Get(string) (interface{}, bool)
17 | UnSet(key string)
18 | Write([]byte) error
19 | }
20 |
21 | // Stripped down copy of melody.Session
22 | // This version is used in place of melody.Session when current node is an agent
23 | // We can't use melody.Session as an agent because this requires a server, yet the agent
24 | // isn't a server. It is a client connecting to the master node
25 | type Session struct {
26 | Connection *websocket.Conn
27 | Keys map[string]interface{}
28 | rwmutex sync.RWMutex
29 | mutex sync.Mutex
30 | }
31 |
32 | func Create(connection *websocket.Conn) *Session {
33 | return &Session{Connection: connection}
34 | }
35 |
36 | func (s *Session) Write(msg []byte) error {
37 | s.mutex.Lock()
38 | defer s.mutex.Unlock()
39 | return s.Connection.WriteMessage(websocket.TextMessage, msg)
40 | }
41 |
42 | // Custom reimplementation of melody.Session.Set that first checks if an "initiator"
43 | // field is set, and sets the value associated with _ field if it is
44 | // Otherwise, simply sets the value associated with
45 | func (s *Session) Set(key string, value interface{}) {
46 | s.rwmutex.Lock()
47 | defer s.rwmutex.Unlock()
48 |
49 | if s.Keys == nil {
50 | s.Keys = make(map[string]interface{})
51 | }
52 |
53 | if key == "initiator" {
54 | s.Keys[key] = value
55 | return
56 | }
57 |
58 | if initiator, ok := s.Keys["initiator"]; ok {
59 | s.Keys[initiator.(string)+"_"+key] = value
60 | return
61 | }
62 |
63 | s.Keys[key] = value
64 | }
65 |
66 | // Same custom mechanism as Set (retrieve value associated with _ when applicable)
67 | func (s *Session) Get(key string) (value interface{}, exists bool) {
68 | s.rwmutex.RLock()
69 | defer s.rwmutex.RUnlock()
70 |
71 | if s.Keys != nil {
72 | if key == "initiator" {
73 | value, exists := s.Keys[key]
74 | return value, exists
75 | }
76 |
77 | if initiator, ok := s.Keys["initiator"]; ok {
78 | value, exists := s.Keys[initiator.(string)+"_"+key]
79 | return value, exists
80 | }
81 |
82 | value, exists := s.Keys[key]
83 | return value, exists
84 | }
85 |
86 | return nil, false
87 | }
88 |
89 | func (s *Session) UnSet(key string) {
90 | s.rwmutex.Lock()
91 | defer s.rwmutex.Unlock()
92 |
93 | if s.Keys != nil {
94 | if key == "initiator" {
95 | delete(s.Keys, key)
96 | return
97 | }
98 |
99 | if initiator, ok := s.Keys["initiator"]; ok {
100 | delete(s.Keys, initiator.(string)+"_"+key)
101 | return
102 | }
103 |
104 | delete(s.Keys, key)
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/app/server/_internal/slices/slices.go:
--------------------------------------------------------------------------------
1 | package slices
2 |
3 | func Chunk[T any](items []T, chunkSize int) (chunks [][]T) {
4 | for chunkSize < len(items) {
5 | items, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize])
6 | }
7 | return append(chunks, items)
8 | }
9 |
--------------------------------------------------------------------------------
/app/server/_internal/strconv/strconv.go:
--------------------------------------------------------------------------------
1 | package strconv
2 |
3 | import "strconv"
4 |
5 | // Alias of strconv.ParseInt, without returning any error
6 | func ParseInt(s string, base int, bitSize int) int64 {
7 | i, _ := strconv.ParseInt(s, base, bitSize)
8 | return i
9 | }
10 |
--------------------------------------------------------------------------------
/app/server/_internal/templates/run.tpl:
--------------------------------------------------------------------------------
1 | docker run \
2 | --name {{printf "%q" .Name}} \
3 | {{- with .HostConfig}}
4 | {{- if .Privileged}}
5 | --privileged \
6 | {{- end}}
7 | {{- if .AutoRemove}}
8 | --rm \
9 | {{- end}}
10 | {{- if .Runtime}}
11 | --runtime {{printf "%q" .Runtime}} \
12 | {{- end}}
13 | {{- range $b := .Binds}}
14 | --volume {{printf "%q" $b}} \
15 | {{- end}}
16 | {{- range $v := .VolumesFrom}}
17 | --volumes-from {{printf "%q" $v}} \
18 | {{- end}}
19 | {{- range $l := .Links}}
20 | --link {{printf "%q" $l}} \
21 | {{- end}}
22 | {{- if index . "Mounts"}}
23 | {{- range $m := .Mounts}}
24 | --mount type={{.Type}}
25 | {{- if $s := index $m "Source"}},source={{$s}}{{- end}}
26 | {{- if $t := index $m "Target"}},destination={{$t}}{{- end}}
27 | {{- if index $m "ReadOnly"}},readonly{{- end}}
28 | {{- if $vo := index $m "VolumeOptions"}}
29 | {{- range $i, $v := $vo.Labels}}
30 | {{- printf ",volume-label=%s=%s" $i $v}}
31 | {{- end}}
32 | {{- if $dc := index $vo "DriverConfig" }}
33 | {{- if $n := index $dc "Name" }}
34 | {{- printf ",volume-driver=%s" $n}}
35 | {{- end}}
36 | {{- range $i, $v := $dc.Options}}
37 | {{- printf ",volume-opt=%s=%s" $i $v}}
38 | {{- end}}
39 | {{- end}}
40 | {{- end}}
41 | {{- if $bo := index $m "BindOptions"}}
42 | {{- if $p := index $bo "Propagation" }}
43 | {{- printf ",bind-propagation=%s" $p}}
44 | {{- end}}
45 | {{- end}} \
46 | {{- end}}
47 | {{- end}}
48 | {{- if .PublishAllPorts}}
49 | --publish-all \
50 | {{- end}}
51 | {{- if .UTSMode}}
52 | --uts {{printf "%q" .UTSMode}} \
53 | {{- end}}
54 | {{- with .LogConfig}}
55 | --log-driver {{printf "%q" .Type}} \
56 | {{- range $o, $v := .Config}}
57 | --log-opt {{$o}}={{printf "%q" $v}} \
58 | {{- end}}
59 | {{- end}}
60 | {{- with .RestartPolicy}}
61 | --restart "{{.Name -}}
62 | {{- if eq .Name "on-failure"}}:{{.MaximumRetryCount}}
63 | {{- end}}" \
64 | {{- end}}
65 | {{- range $e := .ExtraHosts}}
66 | --add-host {{printf "%q" $e}} \
67 | {{- end}}
68 | {{- range $v := .CapAdd}}
69 | --cap-add {{printf "%q" $v}} \
70 | {{- end}}
71 | {{- range $v := .CapDrop}}
72 | --cap-drop {{printf "%q" $v}} \
73 | {{- end}}
74 | {{- range $d := .Devices}}
75 | --device {{printf "%q" (index $d).PathOnHost}}:{{printf "%q" (index $d).PathInContainer}}:{{(index $d).CgroupPermissions}} \
76 | {{- end}}
77 | {{- end}}
78 | {{- with .NetworkSettings -}}
79 | {{- range $p, $conf := .Ports}}
80 | {{- with $conf}}
81 | --publish "
82 | {{- if $h := (index $conf 0).HostIp}}{{$h}}:
83 | {{- end}}
84 | {{- (index $conf 0).HostPort}}:{{$p}}" \
85 | {{- end}}
86 | {{- end}}
87 | {{- range $n, $conf := .Networks}}
88 | {{- with $conf}}
89 | --network {{printf "%q" $n}} \
90 | {{- range $a := $conf.Aliases}}
91 | --network-alias {{printf "%q" $a}} \
92 | {{- end}}
93 | {{- end}}
94 | {{- end}}
95 | {{- end}}
96 | {{- with .Config}}
97 | {{- if .Hostname}}
98 | --hostname {{printf "%q" .Hostname}} \
99 | {{- end}}
100 | {{- if .Domainname}}
101 | --domainname {{printf "%q" .Domainname}} \
102 | {{- end}}
103 | {{- if index . "ExposedPorts"}}
104 | {{- range $p, $conf := .ExposedPorts}}
105 | --expose {{printf "%q" $p}} \
106 | {{- end}}
107 | {{- end}}
108 | {{- if .User}}
109 | --user {{printf "%q" .User}} \
110 | {{- end}}
111 | {{- range $e := .Env}}
112 | --env {{printf "%q" $e}} \
113 | {{- end}}
114 | {{- range $l, $v := .Labels}}
115 | --label {{printf "%q" $l}}={{printf "%q" $v}} \
116 | {{- end}}
117 | --detach \
118 | {{- if .Tty}}
119 | --tty \
120 | {{- end}}
121 | {{- if .OpenStdin}}
122 | --interactive \
123 | {{- end}}
124 | {{- if .Entrypoint}}
125 | {{- if eq (len .Entrypoint) 1 }}
126 | --entrypoint "
127 | {{- range $i, $v := .Entrypoint}}
128 | {{- if $i}} {{end}}
129 | {{- $v}}
130 | {{- end}}" \
131 | {{- end}}
132 | {{- end}}
133 | {{printf "%q" .Image}} \
134 | {{range .Cmd}}{{printf "%q " .}}{{- end}}
135 | {{- end}}
136 |
--------------------------------------------------------------------------------
/app/server/_internal/tty/tty.go:
--------------------------------------------------------------------------------
1 | package tty
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | )
7 |
8 | // Represent a TTY (pseudo-terminal)
9 | type TTY struct {
10 | Stdin TTYReader // Standard Input
11 | Stdout io.Writer // Standard Output
12 | Stderr io.Writer // Standard Error (may be used as stdin mirror)
13 | Input TTYWriter // Writer piped to Stdin to send commands
14 | }
15 |
16 | func New(stdout io.Writer) TTY {
17 | commandReader, commandWriter := io.Pipe()
18 | return TTY{
19 | Stdin: TTYReader{Reader: commandReader},
20 | Input: TTYWriter{Writer: commandWriter},
21 | Stdout: stdout,
22 | }
23 | }
24 |
25 | // Send an "exit" command to the pseudo-terminal, and close Stdin
26 | func (tty *TTY) ClearAndQuit() {
27 | if tty == nil {
28 | return
29 | }
30 |
31 | if tty.Stdin == (TTYReader{}) {
32 | return
33 | }
34 |
35 | if tty.Input == (TTYWriter{}) {
36 | return
37 | }
38 |
39 | io.WriteString(tty.Input.Writer, "exit\n")
40 | tty.Stdin.Reader.Close()
41 | tty.Input.Writer.Close()
42 | }
43 |
44 | // Send the given command to Stdin, with specific treatment to later
45 | // distinguish our commands from Stdout results
46 | func (tty *TTY) RunCommand(command string) error {
47 | bashCommand := fmt.Sprintf("%s #ISAIAH", command)
48 |
49 | _, err := io.WriteString(
50 | tty.Input,
51 | bashCommand+"\n",
52 | )
53 |
54 | return err
55 | }
56 |
57 | // Wrapper around io.PipeReader to be able to pass it as an io.Reader
58 | type TTYReader struct {
59 | Reader *io.PipeReader
60 | }
61 |
62 | func (r TTYReader) Read(p []byte) (int, error) {
63 | return r.Reader.Read(p)
64 | }
65 | func (r TTYReader) Close() error {
66 | return r.Reader.Close()
67 | }
68 |
69 | // Wrapper around io.PipeWriter to be able to pass it as an io.Writer
70 | type TTYWriter struct {
71 | Writer *io.PipeWriter
72 | }
73 |
74 | func (w TTYWriter) Write(p []byte) (int, error) {
75 | return w.Writer.Write(p)
76 | }
77 | func (w TTYWriter) Close() error {
78 | return w.Writer.Close()
79 | }
80 |
--------------------------------------------------------------------------------
/app/server/resources/images.go:
--------------------------------------------------------------------------------
1 | package resources
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "fmt"
7 | "log"
8 | "sort"
9 | "strconv"
10 | "strings"
11 | "sync"
12 | "will-moss/isaiah/server/_internal/process"
13 | "will-moss/isaiah/server/ui"
14 |
15 | "github.com/docker/docker/api/types"
16 | "github.com/docker/docker/api/types/container"
17 | "github.com/docker/docker/api/types/filters"
18 | "github.com/docker/docker/client"
19 | "github.com/fatih/structs"
20 | )
21 |
22 | // Represent a Docker image
23 | type Image struct {
24 | ID string
25 | Name string
26 | Version string
27 | Size int64
28 | UsageState string
29 | UsedBy []string
30 | }
31 |
32 | // Represent an array of Docker images
33 | type Images []Image
34 |
35 | // Retrieve all inspector tabs for Docker images
36 | func ImagesInspectorTabs() []string {
37 | return []string{"Config"}
38 | }
39 |
40 | // Usage translations using symbol icons
41 | var iconUsageTranslations = map[string]rune{
42 | "unknown": '—',
43 | "used": '▶',
44 | "unused": '⨯',
45 | }
46 |
47 | // Retrieve all the single actions associated with Docker images
48 | func ImageSingleActions() []ui.MenuAction {
49 | var actions []ui.MenuAction
50 | actions = append(
51 | actions,
52 | ui.MenuAction{
53 | Label: "remove image",
54 | Command: "image.menu.remove",
55 | Key: "d",
56 | RequiresResource: true,
57 | },
58 | ui.MenuAction{
59 | Label: "run image",
60 | Command: "run_restart",
61 | Key: "r",
62 | RequiresResource: false,
63 | RunLocally: true,
64 | },
65 | ui.MenuAction{
66 | Label: "open on Docker Hub",
67 | Command: "hub",
68 | Key: "h",
69 | RequiresResource: false,
70 | RunLocally: true,
71 | },
72 | ui.MenuAction{
73 | Label: "pull a new image",
74 | Command: "pull",
75 | Key: "P",
76 | RequiresResource: false,
77 | RunLocally: true,
78 | },
79 | )
80 | return actions
81 | }
82 |
83 | // Retrieve all the remove actions associated with Docker images
84 | func ImageRemoveActions(v Volume) []ui.MenuAction {
85 | var actions []ui.MenuAction
86 | actions = append(
87 | actions,
88 | ui.MenuAction{
89 | Key: "remove",
90 | Label: fmt.Sprintf("docker image rm %s ?", v.Name),
91 | Command: "image.remove.default",
92 | RequiresResource: true,
93 | },
94 | )
95 | actions = append(
96 | actions,
97 | ui.MenuAction{
98 | Key: "remove without deleting untagged parents",
99 | Label: fmt.Sprintf("docker image rm --no-prune %s ?", v.Name),
100 | Command: "image.remove.default.unprune",
101 | RequiresResource: true,
102 | },
103 | )
104 | actions = append(
105 | actions,
106 | ui.MenuAction{
107 | Key: "force remove",
108 | Label: fmt.Sprintf("docker image rm --force %s ?", v.Name),
109 | Command: "image.remove.force",
110 | RequiresResource: true,
111 | },
112 | )
113 | actions = append(
114 | actions,
115 | ui.MenuAction{
116 | Key: "force remove without deleting untagged parents ",
117 | Label: fmt.Sprintf("docker image rm --no-prune --force %s ?", v.Name),
118 | Command: "image.remove.force.unprune",
119 | RequiresResource: true,
120 | },
121 | )
122 | return actions
123 | }
124 |
125 | // Retrieve all the bulk actions associated with Docker images
126 | func ImagesBulkActions() []ui.MenuAction {
127 | var actions []ui.MenuAction
128 | actions = append(
129 | actions,
130 | ui.MenuAction{
131 | Label: "prune unused images",
132 | Prompt: "Are you sure you want to prune all unused images?",
133 | Command: "images.prune",
134 | },
135 | ui.MenuAction{
136 | Label: "pull latest images",
137 | Command: "images.pull",
138 | },
139 | )
140 | return actions
141 | }
142 |
143 | // Retrieve all Docker images
144 | func ImagesList(client *client.Client) Images {
145 | imgReader, err := client.ImageList(context.Background(), types.ImageListOptions{All: true})
146 |
147 | if err != nil {
148 | return []Image{}
149 | }
150 |
151 | // Fetch used image ids from containers as well to determine if an image is currently in use
152 | var usedImageIds = make(map[string][]string, 0)
153 | cntReader, cntErr := client.ContainerList(context.Background(), types.ContainerListOptions{All: true})
154 | if cntErr == nil {
155 | for i := 0; i < len(cntReader); i++ {
156 | var imageID = cntReader[i].ImageID
157 | var containerName = cntReader[i].Names[0][1:]
158 |
159 | if _, exists := usedImageIds[imageID]; exists {
160 | usedImageIds[imageID] = append(usedImageIds[imageID], containerName)
161 | } else {
162 | usedImageIds[imageID] = []string{containerName}
163 | }
164 | }
165 | }
166 |
167 | var images []Image
168 | for i := 0; i < len(imgReader); i++ {
169 | var summary = imgReader[i]
170 |
171 | var image Image
172 | image.ID = summary.ID
173 |
174 | if len(summary.RepoTags) > 0 {
175 | if strings.Contains(summary.RepoTags[0], ":") {
176 | parts := strings.Split(summary.RepoTags[0], ":")
177 | image.Name = parts[0]
178 | image.Version = parts[1]
179 | }
180 | } else {
181 | if len(summary.RepoDigests) > 0 {
182 | if strings.Contains(summary.RepoDigests[0], "@") {
183 | parts := strings.Split(summary.RepoDigests[0], "@")
184 | image.Name = parts[0]
185 | image.Version = ""
186 | }
187 | } else {
188 | image.Name = ""
189 | image.Version = ""
190 | }
191 | }
192 |
193 | image.Size = summary.Size
194 |
195 | if cntErr != nil {
196 | image.UsageState = "unknown"
197 | } else {
198 | if _, exists := usedImageIds[image.ID]; exists {
199 | image.UsageState = "used"
200 | image.UsedBy = usedImageIds[image.ID]
201 | } else {
202 | image.UsageState = "unused"
203 | }
204 | }
205 |
206 | images = append(images, image)
207 | }
208 |
209 | return images
210 | }
211 |
212 | // Count the number of Docker images
213 | func ImagesCount(client *client.Client) int {
214 | reader, err := client.ImageList(context.Background(), types.ImageListOptions{All: true})
215 |
216 | if err != nil {
217 | return 0
218 | }
219 |
220 | return len(reader)
221 | }
222 |
223 | // Prune unused Docker images
224 | func ImagesPrune(client *client.Client) error {
225 | args := filters.NewArgs(filters.KeyValuePair{Key: "dangling", Value: "false"})
226 | _, err := client.ImagesPrune(context.Background(), args)
227 |
228 | return err
229 | }
230 |
231 | // Turn the list of Docker images into a list of rows representing them
232 | func (images Images) ToRows(columns []string) ui.Rows {
233 | var rows = make(ui.Rows, 0)
234 |
235 | sort.Slice(images, func(i, j int) bool {
236 | if images[i].UsageState == "used" && images[j].UsageState != "used" {
237 | return true
238 | }
239 | if images[j].UsageState == "used" && images[i].UsageState != "used" {
240 | return false
241 | }
242 |
243 | if images[i].Name == "" {
244 | return false
245 | }
246 | if images[j].Name == "" {
247 | return true
248 | }
249 |
250 | return images[i].Name < images[j].Name
251 | })
252 |
253 | for i := 0; i < len(images); i++ {
254 | image := images[i]
255 |
256 | row := structs.Map(image)
257 | var flat = make([]map[string]string, 0)
258 |
259 | for j := 0; j < len(columns); j++ {
260 | _entry := make(map[string]string)
261 | _entry["field"] = columns[j]
262 |
263 | switch columns[j] {
264 | case "ID":
265 | _entry["value"] = image.ID
266 | case "Name":
267 | _entry["value"] = image.Name
268 | case "Version":
269 | _entry["value"] = image.Version
270 | case "Size":
271 | _entry["value"] = strconv.FormatInt(image.Size, 10)
272 | _entry["representation"] = ui.ByteCount(image.Size)
273 | case "UsageState":
274 | _entry["value"] = image.UsageState
275 | _entry["representation"] = string(iconUsageTranslations[image.UsageState])
276 | }
277 |
278 | flat = append(flat, _entry)
279 | }
280 | row["_representation"] = flat
281 | rows = append(rows, row)
282 | }
283 |
284 | return rows
285 | }
286 |
287 | // Remove the Docker image
288 | func (i Image) Remove(client *client.Client, force bool, prune bool) error {
289 | _, err := client.ImageRemove(context.Background(), i.ID, types.ImageRemoveOptions{Force: force, PruneChildren: prune})
290 | return err
291 | }
292 |
293 | // Pull a new Docker image
294 | func ImagePull(c *client.Client, m process.LongTaskMonitor, args map[string]interface{}) {
295 | name := args["Image"].(string)
296 | rc, err := c.ImagePull(context.Background(), name, types.ImagePullOptions{})
297 |
298 | if err != nil {
299 | m.Errors <- err
300 | return
301 | }
302 |
303 | wg := sync.WaitGroup{}
304 | wg.Add(1)
305 |
306 | go func() {
307 | scanner := bufio.NewScanner(rc)
308 | for scanner.Scan() {
309 | m.Results <- scanner.Text()
310 | }
311 | wg.Done()
312 | }()
313 |
314 | wg.Wait()
315 | m.Done <- true
316 | }
317 |
318 | // Inspector - Retrieve the full configuration associated with a Docker image
319 | func (i Image) GetConfig(client *client.Client) (ui.InspectorContent, error) {
320 | information, _, err := client.ImageInspectWithRaw(context.Background(), i.ID)
321 |
322 | if err != nil {
323 | return nil, err
324 | }
325 |
326 | // Build the first part of the config (main information)
327 | firstPart := ui.InspectorContentPart{Type: "rows"}
328 | rows := make(ui.Rows, 0)
329 | fields := []string{"Name", "ID", "Tags", "Size", "Created", "Used"}
330 | for _, field := range fields {
331 | row := make(ui.Row)
332 | switch field {
333 | case "Name":
334 | row["Name"] = i.Name
335 | row["_representation"] = []string{"Name:", i.Name}
336 | case "ID":
337 | row["ID"] = i.ID
338 | row["_representation"] = []string{"ID:", i.ID}
339 | case "Tags":
340 | row["Tags"] = information.RepoTags
341 | row["_representation"] = []string{"Tags:", strings.Join(information.RepoTags, ", ")}
342 | case "Size":
343 | row["Size"] = information.Size
344 | row["_representation"] = []string{"Size:", ui.ByteCount(information.Size)}
345 | case "Created":
346 | row["Created"] = information.Created
347 | row["_representation"] = []string{"Created:", information.Created}
348 | case "Used":
349 | row["Used"] = i.UsedBy
350 | if i.UsageState == "used" {
351 | row["_representation"] = []string{"Used by:", strings.Join(i.UsedBy, ", ")}
352 | } else {
353 | row["_representation"] = []string{"Used by:", "-"}
354 | }
355 | }
356 |
357 | rows = append(rows, row)
358 | }
359 | firstPart.Content = rows
360 |
361 | separator := ui.InspectorContentPart{Type: "lines", Content: []string{" ", " "}}
362 |
363 | // Build the image's history
364 | table := ui.Table{}
365 | table.Headers = []string{"ID", "TAG", "SIZE", "COMMAND"}
366 |
367 | history, err := client.ImageHistory(context.Background(), i.ID)
368 | if err == nil {
369 | rows := make([][]string, 0)
370 | for _, entry := range history {
371 | _id := "<none>"
372 | if entry.ID != "" {
373 | if len(entry.ID) > 17 {
374 | _id = entry.ID[7:17]
375 | }
376 | }
377 |
378 | _tag := ""
379 | if len(entry.Tags) > 0 {
380 | _tag = entry.Tags[0]
381 | }
382 |
383 | rows = append(
384 | rows,
385 | []string{
386 | _id,
387 | _tag,
388 | ui.ByteCount(entry.Size),
389 | entry.CreatedBy,
390 | },
391 | )
392 | }
393 | table.Rows = rows
394 | } else {
395 | log.Print(err)
396 | }
397 |
398 | // Build the full config using : First part // Separator // History
399 | allConfig := ui.InspectorContent{
400 | firstPart,
401 | separator,
402 | ui.InspectorContentPart{Type: "table", Content: table},
403 | }
404 |
405 | return allConfig, nil
406 | }
407 |
408 | // Create and start a new Docker container based on the Docker image
409 | func (i Image) Run(client *client.Client, name string) error {
410 | response, err := client.ContainerCreate(
411 | context.Background(),
412 | &container.Config{Image: i.Name},
413 | nil,
414 | nil,
415 | nil,
416 | name,
417 | )
418 |
419 | if err != nil {
420 | return err
421 | }
422 |
423 | // Start the container
424 | err = client.ContainerStart(
425 | context.Background(),
426 | response.ID,
427 | types.ContainerStartOptions{},
428 | )
429 |
430 | return err
431 | }
432 |
--------------------------------------------------------------------------------
/app/server/resources/networks.go:
--------------------------------------------------------------------------------
1 | package resources
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "sort"
7 | "strconv"
8 | "will-moss/isaiah/server/ui"
9 |
10 | "github.com/docker/docker/api/types"
11 | "github.com/docker/docker/api/types/filters"
12 | "github.com/docker/docker/client"
13 | "github.com/fatih/structs"
14 | )
15 |
16 | // Represent a Docker netowrk
17 | type Network struct {
18 | ID string
19 | Name string
20 | Driver string
21 | }
22 |
23 | // Represent a array of Docker networks
24 | type Networks []Network
25 |
26 | // Retrieve all inspector tabs for networks
27 | func NetworksInspectorTabs() []string {
28 | return []string{"Config"}
29 | }
30 |
31 | // Retrieve all the single actions associated with Docker networks
32 | func NetworkSingleActions(n Network) []ui.MenuAction {
33 | var actions []ui.MenuAction
34 | actions = append(
35 | actions,
36 | ui.MenuAction{
37 | Key: "d",
38 | Label: "remove network",
39 | Command: "network.menu.remove",
40 | RequiresResource: true,
41 | },
42 | )
43 | return actions
44 | }
45 |
46 | // Retrieve all the remove actions associated with Docker networks
47 | func NetworkRemoveActions(n Network) []ui.MenuAction {
48 | var actions []ui.MenuAction
49 | actions = append(
50 | actions,
51 | ui.MenuAction{
52 | Key: "remove",
53 | Label: fmt.Sprintf("docker network rm %s ?", n.Name),
54 | Command: "network.remove.default",
55 | RequiresResource: true,
56 | },
57 | )
58 | return actions
59 | }
60 |
61 | // Retrieve all the bulk actions associated with Docker networks
62 | func NetworksBulkActions() []ui.MenuAction {
63 | var actions []ui.MenuAction
64 | actions = append(
65 | actions,
66 | ui.MenuAction{
67 | Label: "prune unused networks",
68 | Prompt: "Are you sure you want to prune all unused networks?",
69 | Command: "networks.prune",
70 | },
71 | )
72 | return actions
73 | }
74 |
75 | // Retrieve all Docker networks
76 | func NetworksList(client *client.Client) Networks {
77 | reader, err := client.NetworkList(context.Background(), types.NetworkListOptions{})
78 |
79 | if err != nil {
80 | return []Network{}
81 | }
82 |
83 | var networks []Network
84 | for i := 0; i < len(reader); i++ {
85 | var information = reader[i]
86 |
87 | var network Network
88 | network.ID = information.ID
89 | network.Name = information.Name
90 | network.Driver = information.Driver
91 |
92 | networks = append(networks, network)
93 | }
94 |
95 | return networks
96 | }
97 |
98 | // Count the number of Docker networks
99 | func NetworksCount(client *client.Client) int {
100 | images, err := client.NetworkList(context.Background(), types.NetworkListOptions{})
101 |
102 | if err != nil {
103 | return 0
104 | }
105 |
106 | return len(images)
107 | }
108 |
109 | // Prune unused Docker networks
110 | func NetworksPrune(client *client.Client) error {
111 | _, err := client.NetworksPrune(context.Background(), filters.Args{})
112 | return err
113 | }
114 |
115 | // Remove the Docker network
116 | func (n Network) Remove(client *client.Client) error {
117 | err := client.NetworkRemove(context.Background(), n.ID)
118 | return err
119 | }
120 |
121 | // Turn the list of Docker networks into a list of string rows representing them
122 | func (networks Networks) ToRows(columns []string) ui.Rows {
123 | var rows = make(ui.Rows, 0)
124 |
125 | sort.Slice(networks, func(i, j int) bool {
126 | return networks[i].Name < networks[j].Name
127 | })
128 |
129 | for i := 0; i < len(networks); i++ {
130 | network := networks[i]
131 |
132 | row := structs.Map(network)
133 | var flat = make([]map[string]string, 0)
134 |
135 | for j := 0; j < len(columns); j++ {
136 | _entry := make(map[string]string)
137 | _entry["field"] = columns[j]
138 |
139 | switch columns[j] {
140 | case "ID":
141 | _entry["value"] = network.ID
142 | case "Name":
143 | _entry["value"] = network.Name
144 | case "Driver":
145 | _entry["value"] = network.Driver
146 | }
147 |
148 | flat = append(flat, _entry)
149 | }
150 | row["_representation"] = flat
151 | rows = append(rows, row)
152 | }
153 |
154 | return rows
155 | }
156 |
157 | // Inspector - Retrieve the full configuration associated with a Docker network
158 | func (n Network) GetConfig(client *client.Client) (ui.InspectorContent, error) {
159 | information, err := client.NetworkInspect(context.Background(), n.ID, types.NetworkInspectOptions{})
160 |
161 | if err != nil {
162 | return nil, err
163 | }
164 |
165 | // Build the first part of the config (main information)
166 | firstPart := ui.InspectorContentPart{Type: "rows"}
167 | rows := make(ui.Rows, 0)
168 | fields := []string{"ID", "Name", "Driver", "Scope", "EnabledIPV6", "Internal", "Attachable", "Ingress"}
169 | for _, field := range fields {
170 | row := make(ui.Row)
171 | switch field {
172 | case "ID":
173 | row["ID"] = n.Name
174 | row["_representation"] = []string{"ID:", n.ID}
175 | case "Name":
176 | row["Name"] = n.Name
177 | row["_representation"] = []string{"Name:", n.Name}
178 | case "Driver":
179 | row["Driver"] = n.Driver
180 | row["_representation"] = []string{"Driver:", n.Driver}
181 | case "Scope":
182 | row["Scope"] = information.Scope
183 | row["_representation"] = []string{"Scope:", information.Scope}
184 | case "EnabledIPV6":
185 | row["EnabledIPV6"] = information.EnableIPv6
186 | row["_representation"] = []string{"EnabledIPV6:", strconv.FormatBool(information.EnableIPv6)}
187 | case "Internal":
188 | row["Internal"] = information.Internal
189 | row["_representation"] = []string{"Internal:", strconv.FormatBool(information.Internal)}
190 | case "Attachable":
191 | row["Attachable"] = information.Attachable
192 | row["_representation"] = []string{"Attachable:", strconv.FormatBool(information.Attachable)}
193 | case "Ingress":
194 | row["Ingress"] = information.Ingress
195 | row["_representation"] = []string{"Ingress:", strconv.FormatBool(information.Ingress)}
196 | }
197 |
198 | rows = append(rows, row)
199 | }
200 | firstPart.Content = rows
201 |
202 | // Build the full config using : First part // Containers // Labels // Options
203 | allConfig := ui.InspectorContent{
204 | firstPart,
205 | ui.InspectorContentPart{Type: "json", Content: ui.JSON{"Containers": information.Containers}},
206 | ui.InspectorContentPart{Type: "json", Content: ui.JSON{"Labels": information.Labels}},
207 | ui.InspectorContentPart{Type: "json", Content: ui.JSON{"Options": information.Options}},
208 | }
209 |
210 | return allConfig, nil
211 | }
212 |
--------------------------------------------------------------------------------
/app/server/resources/stacks.go:
--------------------------------------------------------------------------------
1 | package resources
2 |
3 | import (
4 | "bufio"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "io"
9 | "os"
10 | "os/exec"
11 | "path"
12 | "sort"
13 | "strings"
14 | "sync"
15 | _os "will-moss/isaiah/server/_internal/os"
16 | "will-moss/isaiah/server/_internal/process"
17 | "will-moss/isaiah/server/ui"
18 |
19 | "github.com/docker/docker/api/types/filters"
20 | "github.com/docker/docker/client"
21 | "github.com/google/uuid"
22 |
23 | "github.com/fatih/structs"
24 | )
25 |
26 | // Represent a Docker stack
27 | type Stack struct {
28 | Name string
29 | Status string
30 | ConfigFiles string
31 | }
32 |
33 | // Represent an array of Docker stacks
34 | type Stacks []Stack
35 |
36 | // Retrieve all inspector tabs for Docker stacks
37 | func StacksInspectorTabs() []string {
38 | return []string{"Logs", "Services", "Config"}
39 | }
40 |
41 | // Retrieve all the single actions associated with Docker stacks
42 | func StackSingleActions() []ui.MenuAction {
43 | var actions []ui.MenuAction
44 | actions = append(
45 | actions,
46 | ui.MenuAction{
47 | Label: "up the stack",
48 | Command: "stack.up",
49 | Key: "u",
50 | RequiresResource: true,
51 | },
52 | )
53 |
54 | actions = append(
55 | actions,
56 | ui.MenuAction{
57 | Label: "pause/unpause the stack",
58 | Command: "stack.pause",
59 | Key: "p",
60 | RequiresResource: true,
61 | },
62 | )
63 |
64 | actions = append(
65 | actions,
66 | ui.MenuAction{
67 | Label: "stop the stack",
68 | Command: "stack.stop",
69 | Key: "s",
70 | RequiresResource: true,
71 | },
72 | )
73 |
74 | actions = append(
75 | actions,
76 | ui.MenuAction{
77 | Label: "down the stack",
78 | Command: "stack.down",
79 | Key: "d",
80 | RequiresResource: true,
81 | },
82 | )
83 |
84 | actions = append(
85 | actions,
86 | ui.MenuAction{
87 | Label: "restart the stack",
88 | Command: "stack.restart",
89 | Key: "r",
90 | RequiresResource: true,
91 | },
92 | )
93 |
94 | actions = append(
95 | actions,
96 | ui.MenuAction{
97 | Label: "update the stack (down, pull, up)",
98 | Command: "stack.update",
99 | Key: "U",
100 | RequiresResource: true,
101 | },
102 | )
103 |
104 | actions = append(
105 | actions,
106 | ui.MenuAction{
107 | Label: "edit the stack configuration",
108 | Command: "stack.update",
109 | Key: "e",
110 | RequiresResource: true,
111 | },
112 | )
113 |
114 | actions = append(
115 | actions,
116 | ui.MenuAction{
117 | Label: "create a new stack",
118 | Command: "createStack",
119 | Key: "C",
120 | RequiresResource: false,
121 | RunLocally: true,
122 | },
123 | )
124 |
125 | return actions
126 | }
127 |
128 | // Retrieve all the bulk actions associated with Docker stacks
129 | func StacksBulkActions() []ui.MenuAction {
130 | var actions []ui.MenuAction
131 | actions = append(
132 | actions,
133 | ui.MenuAction{
134 | Label: "update all stacks",
135 | Prompt: "Are you sure you want to update all stacks?",
136 | Command: "stacks.update",
137 | },
138 | )
139 |
140 | actions = append(
141 | actions,
142 | ui.MenuAction{
143 | Label: "restart all stacks",
144 | Prompt: "Are you sure you want to restart all stacks?",
145 | Command: "stacks.restart",
146 | },
147 | )
148 |
149 | actions = append(
150 | actions,
151 | ui.MenuAction{
152 | Label: "pause all stacks",
153 | Prompt: "Are you sure you want to pause all stacks?",
154 | Command: "stacks.pause",
155 | },
156 | )
157 |
158 | actions = append(
159 | actions,
160 | ui.MenuAction{
161 | Label: "unpause all stacks",
162 | Prompt: "Are you sure you want to unpause all stacks?",
163 | Command: "stacks.unpause",
164 | },
165 | )
166 |
167 | actions = append(
168 | actions,
169 | ui.MenuAction{
170 | Label: "down all stacks",
171 | Prompt: "Are you sure you want to down all stacks?",
172 | Command: "stacks.down",
173 | },
174 | )
175 |
176 | return actions
177 | }
178 |
179 | // Retrieve all Docker stacks
180 | func StacksList(client *client.Client) Stacks {
181 | if _os.GetEnv("DOCKER_RUNNING") == "TRUE" {
182 | return []Stack{}
183 | }
184 |
185 | output, err := exec.Command("docker", "-H", client.DaemonHost(), "compose", "ls", "--format", "json").Output()
186 |
187 | if err != nil {
188 | return []Stack{}
189 | }
190 |
191 | var stacks []Stack
192 | err = json.Unmarshal(output, &stacks)
193 |
194 | if err != nil {
195 | return []Stack{}
196 | }
197 |
198 | return stacks
199 | }
200 |
201 | // Count the number of Docker stacks
202 | func StacksCount(client *client.Client) int {
203 | var list = StacksList(client)
204 | return len(list)
205 | }
206 |
207 | // Turn the list of Docker stacks into a list of rows representing them
208 | func (stacks Stacks) ToRows(columns []string) ui.Rows {
209 | var rows = make(ui.Rows, 0)
210 |
211 | sort.Slice(stacks, func(i, j int) bool {
212 | return stacks[i].Name < stacks[j].Name
213 | })
214 |
215 | for i := 0; i < len(stacks); i++ {
216 | stack := stacks[i]
217 |
218 | row := structs.Map(stack)
219 | var flat = make([]map[string]string, 0)
220 |
221 | for j := 0; j < len(columns); j++ {
222 | _entry := make(map[string]string)
223 | _entry["field"] = columns[j]
224 |
225 | switch columns[j] {
226 | case "Name":
227 | _entry["value"] = stack.Name
228 | case "Status":
229 | _entry["value"] = strings.Split(stack.Status, "(")[0]
230 | case "ConfigFiles":
231 | _entry["value"] = stack.ConfigFiles
232 | }
233 |
234 | flat = append(flat, _entry)
235 | }
236 | row["_representation"] = flat
237 | rows = append(rows, row)
238 | }
239 |
240 | return rows
241 | }
242 |
243 | // Single - Start the stack (docker compose up -d)
244 | func (s Stack) Up(client *client.Client) error {
245 | output, err := exec.Command("docker", "-H", client.DaemonHost(), "compose", "-f", s.ConfigFiles, "up", "-d").CombinedOutput()
246 |
247 | if err != nil {
248 | return errors.New(string(output))
249 | }
250 |
251 | return nil
252 | }
253 |
254 | // Single - Pause the stack (docker compose pause)
255 | func (s Stack) Pause(client *client.Client) error {
256 | output, err := exec.Command("docker", "-H", client.DaemonHost(), "compose", "-p", s.Name, "pause").CombinedOutput()
257 |
258 | if err != nil {
259 | return errors.New(string(output))
260 | }
261 |
262 | return nil
263 | }
264 |
265 | // Single - Unpause the stack (docker compose unpause)
266 | func (s Stack) Unpause(client *client.Client) error {
267 | output, err := exec.Command("docker", "-H", client.DaemonHost(), "compose", "-p", s.Name, "unpause").CombinedOutput()
268 |
269 | if err != nil {
270 | return errors.New(string(output))
271 | }
272 |
273 | return nil
274 | }
275 |
276 | // Single - Stop the stack (docker compose stop)
277 | func (s Stack) Stop(client *client.Client) error {
278 | output, err := exec.Command("docker", "-H", client.DaemonHost(), "compose", "-p", s.Name, "stop").CombinedOutput()
279 |
280 | if err != nil {
281 | return errors.New(string(output))
282 | }
283 |
284 | return nil
285 | }
286 |
287 | // Single - Down the stack (docker compose down)
288 | func (s Stack) Down(client *client.Client) error {
289 | output, err := exec.Command("docker", "-H", client.DaemonHost(), "compose", "-p", s.Name, "down").CombinedOutput()
290 |
291 | if err != nil {
292 | return errors.New(string(output))
293 | }
294 |
295 | return nil
296 | }
297 |
298 | // Single - Update the stack (docker compose down, docker compose pull, docker compose up)
299 | func (s Stack) Update(client *client.Client) error {
300 | output, err := exec.Command("docker", "-H", client.DaemonHost(), "compose", "-p", s.Name, "down").CombinedOutput()
301 |
302 | if err != nil {
303 | return errors.New(string(output))
304 | }
305 |
306 | output, err = exec.Command("docker", "-H", client.DaemonHost(), "compose", "-f", s.ConfigFiles, "pull").CombinedOutput()
307 |
308 | if err != nil {
309 | return errors.New(string(output))
310 | }
311 |
312 | output, err = exec.Command("docker", "-H", client.DaemonHost(), "compose", "-f", s.ConfigFiles, "up", "-d").CombinedOutput()
313 |
314 | if err != nil {
315 | return errors.New(string(output))
316 | }
317 |
318 | return nil
319 | }
320 |
321 | // Single - Restart the stack (docker compose restart)
322 | func (s Stack) Restart(client *client.Client) error {
323 | output, err := exec.Command("docker", "-H", client.DaemonHost(), "compose", "-p", s.Name, "restart").CombinedOutput()
324 |
325 | if err != nil {
326 | return errors.New(string(output))
327 | }
328 |
329 | return nil
330 | }
331 |
332 | // Inspector - Retrieve the list of services (containers) inside a Docker stack
333 | func (s Stack) GetServices(client *client.Client) (ui.InspectorContent, error) {
334 | output, err := exec.Command("docker", "-H", client.DaemonHost(), "compose", "-p", s.Name, "ps", "-aq").CombinedOutput()
335 |
336 | if err != nil {
337 | return nil, errors.New(string(output))
338 | }
339 |
340 | ids := strings.Split(string(output), "\n")
341 | filterArgs := filters.NewArgs()
342 | for _, id := range ids {
343 | filterArgs.Add("id", id)
344 | }
345 |
346 | containers := ContainersList(client, filterArgs)
347 |
348 | allConfig := ui.InspectorContent{
349 | ui.InspectorContentPart{Type: "rows", Content: containers.ToRows(strings.Split(_os.GetEnv("COLUMNS_CONTAINERS"), ","))},
350 | }
351 |
352 | return allConfig, nil
353 | }
354 |
355 | // Inspector - Retrieve the full configuration associated with a Docker stack
356 | func (s Stack) GetConfig(client *client.Client) (ui.InspectorContent, error) {
357 | firstPartRows := make(ui.Rows, 0)
358 | firstPartRows = append(firstPartRows, ui.Row{"_representation": []string{"Location:", s.ConfigFiles}})
359 | firstPart := ui.InspectorContentPart{Type: "rows", Content: firstPartRows}
360 |
361 | separator := ui.InspectorContentPart{Type: "lines", Content: []string{}}
362 |
363 | code := make([]string, 0)
364 | if _os.GetEnv("MULTI_HOST_ENABLED") != "TRUE" || strings.HasPrefix(client.DaemonHost(), "unix://") {
365 |
366 | config, err := os.ReadFile(s.ConfigFiles)
367 |
368 | if err != nil {
369 | return nil, err
370 | }
371 |
372 | code = strings.Split(string(config), "\n")
373 | } else {
374 | code = append(code, "The content of this docker-compose.yml file is unavailable because it is located on the remote host.")
375 | code = append(code, "Please consider deploying a multi-node setup for full access to Stacks features.")
376 | }
377 |
378 | allConfig := ui.InspectorContent{
379 | firstPart,
380 | separator,
381 | ui.InspectorContentPart{Type: "code", Content: code},
382 | }
383 |
384 | return allConfig, nil
385 | }
386 |
387 | // Inspector - Retrieve the full configuration associated with a Docker stack - The raw file lines only
388 | func (s Stack) GetRawConfig(client *client.Client) (string, error) {
389 | config, err := os.ReadFile(s.ConfigFiles)
390 |
391 | if err != nil {
392 | return "", err
393 | }
394 |
395 | return string(config), nil
396 | }
397 |
398 | // Inspector - Retrieve the logs written by the Docker stack
399 | func (s Stack) GetLogs(client *client.Client, writer io.Writer, showTimestamps bool) (*io.ReadCloser, error) {
400 | opts := make([]string, 0)
401 |
402 | opts = append(opts, "-H")
403 | opts = append(opts, client.DaemonHost())
404 | opts = append(opts, "compose")
405 | opts = append(opts, "-p")
406 | opts = append(opts, s.Name)
407 | opts = append(opts, "logs")
408 |
409 | opts = append(opts, "--follow")
410 | opts = append(opts, "--no-color")
411 |
412 | opts = append(opts, "--since")
413 | opts = append(opts, _os.GetEnv("CONTAINER_LOGS_SINCE"))
414 |
415 | opts = append(opts, "--tail")
416 | opts = append(opts, _os.GetEnv("CONTAINER_LOGS_TAIL"))
417 |
418 | if showTimestamps {
419 | opts = append(opts, "--timestamps")
420 | }
421 |
422 | process := exec.Command("docker", opts...)
423 |
424 | reader, err := process.StdoutPipe()
425 | if err != nil {
426 | return nil, err
427 | }
428 |
429 | err = process.Start()
430 | if err != nil {
431 | return nil, err
432 | }
433 |
434 | go io.Copy(writer, reader)
435 |
436 | return &reader, nil
437 | }
438 |
439 | // Create a new Docker stack from a docker-compose.yml content
440 | func StackCreate(c *client.Client, m process.LongTaskMonitor, args map[string]interface{}) {
441 | content := args["Content"].(string)
442 | filename := fmt.Sprintf("docker-compose.%s.yml", uuid.NewString())
443 | filepath := path.Join(_os.GetEnv("STACKS_DIRECTORY"), filename)
444 |
445 | err := os.WriteFile(filepath, []byte(content), 0644)
446 |
447 | if err != nil {
448 | m.Errors <- err
449 | return
450 | }
451 |
452 | output, err := exec.Command("docker", "-H", c.DaemonHost(), "compose", "-f", filepath, "config").CombinedOutput()
453 |
454 | if err != nil {
455 | m.Errors <- errors.New(string(output))
456 | return
457 | }
458 |
459 | process := exec.Command("docker", "-H", c.DaemonHost(), "compose", "-f", filepath, "up", "-d")
460 | reader, err := process.StdoutPipe()
461 |
462 | if err != nil {
463 | m.Errors <- err
464 | return
465 | }
466 |
467 | err = process.Start()
468 | if err != nil {
469 | m.Errors <- err
470 | return
471 | }
472 |
473 | wg := sync.WaitGroup{}
474 | wg.Add(1)
475 |
476 | go func() {
477 | scanner := bufio.NewScanner(reader)
478 | for scanner.Scan() {
479 | m.Results <- scanner.Text()
480 | }
481 | wg.Done()
482 | }()
483 |
484 | wg.Wait()
485 | m.Done <- true
486 | }
487 |
488 | // Edit an existing Docker stack by overwriting a docker-compose.yml (down, overwrite, up)
489 | func (s Stack) Edit(c *client.Client, m process.LongTaskMonitor, args map[string]interface{}) {
490 | content := args["Content"].(string)
491 | err := s.Down(c)
492 |
493 | if err != nil {
494 | m.Errors <- err
495 | return
496 | }
497 |
498 | originalContent, err := os.ReadFile(s.ConfigFiles)
499 |
500 | if err != nil {
501 | m.Errors <- err
502 | return
503 | }
504 |
505 | err = os.WriteFile(s.ConfigFiles, []byte(content), 0644)
506 |
507 | if err != nil {
508 | m.Errors <- err
509 | return
510 | }
511 |
512 | output, err := exec.Command("docker", "-H", c.DaemonHost(), "compose", "-f", s.ConfigFiles, "config").CombinedOutput()
513 |
514 | if err != nil {
515 | m.Errors <- errors.New(string(output))
516 | os.WriteFile(s.ConfigFiles, originalContent, 0644)
517 | s.Up(c)
518 | return
519 | }
520 |
521 | process := exec.Command("docker", "-H", c.DaemonHost(), "compose", "-f", s.ConfigFiles, "up", "-d")
522 | reader, err := process.StdoutPipe()
523 |
524 | if err != nil {
525 | m.Errors <- err
526 | return
527 | }
528 |
529 | err = process.Start()
530 | if err != nil {
531 | m.Errors <- err
532 | return
533 | }
534 |
535 | wg := sync.WaitGroup{}
536 | wg.Add(1)
537 |
538 | go func() {
539 | scanner := bufio.NewScanner(reader)
540 | for scanner.Scan() {
541 | m.Results <- scanner.Text()
542 | }
543 | wg.Done()
544 | }()
545 |
546 | wg.Wait()
547 | m.Done <- true
548 | }
549 |
--------------------------------------------------------------------------------
/app/server/resources/volumes.go:
--------------------------------------------------------------------------------
1 | package resources
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "sort"
7 | "will-moss/isaiah/server/ui"
8 |
9 | "github.com/docker/docker/api/types/filters"
10 | "github.com/docker/docker/api/types/volume"
11 | "github.com/docker/docker/client"
12 |
13 | "github.com/fatih/structs"
14 | )
15 |
16 | // Represent a Docker volume
17 | type Volume struct {
18 | Name string
19 | Driver string
20 | MountPoint string
21 | }
22 |
23 | // Represent an array of Docker volumes
24 | type Volumes []Volume
25 |
26 | // Retrieve all inspector tabs for Docker volumes
27 | func VolumesInspectorTabs() []string {
28 | return []string{"Config"}
29 | }
30 |
31 | // Retrieve all the single actions associated with Docker volumes
32 | func VolumeSingleActions() []ui.MenuAction {
33 | var actions []ui.MenuAction
34 | actions = append(
35 | actions,
36 | ui.MenuAction{
37 | Label: "remove volume",
38 | Command: "volume.menu.remove",
39 | Key: "d",
40 | RequiresResource: true,
41 | },
42 | )
43 |
44 | actions = append(
45 | actions,
46 | ui.MenuAction{
47 | Label: "browse volume in shell",
48 | Command: "volume.browse",
49 | Key: "B",
50 | RequiresResource: true,
51 | },
52 | )
53 | return actions
54 | }
55 |
56 | // Retrieve all the remove actions associated with Docker volumes
57 | func VolumeRemoveActions(v Volume) []ui.MenuAction {
58 | var actions []ui.MenuAction
59 | actions = append(
60 | actions,
61 | ui.MenuAction{
62 | Key: "remove",
63 | Label: fmt.Sprintf("docker volume rm %s ?", v.Name),
64 | Command: "volume.remove.default",
65 | RequiresResource: true,
66 | },
67 | )
68 | actions = append(
69 | actions,
70 | ui.MenuAction{
71 | Key: "force remove",
72 | Label: fmt.Sprintf("docker volume rm --force %s ?", v.Name),
73 | Command: "volume.remove.force",
74 | RequiresResource: true,
75 | },
76 | )
77 | return actions
78 | }
79 |
80 | // Retrieve all the bulk actions associated with Docker volumes
81 | func VolumesBulkActions() []ui.MenuAction {
82 | var actions []ui.MenuAction
83 | actions = append(
84 | actions,
85 | ui.MenuAction{
86 | Label: "prune unused volumes",
87 | Prompt: "Are you sure you want to prune all unused volumes?",
88 | Command: "volumes.prune",
89 | },
90 | )
91 | return actions
92 | }
93 |
94 | // Retrieve all Docker volumes
95 | func VolumesList(client *client.Client) Volumes {
96 | reader, err := client.VolumeList(context.Background(), volume.ListOptions{})
97 |
98 | if err != nil {
99 | return []Volume{}
100 | }
101 |
102 | var volumes []Volume
103 | for i := 0; i < len(reader.Volumes); i++ {
104 | var information = reader.Volumes[i]
105 |
106 | var volume Volume
107 | volume.Name = information.Name
108 | volume.Driver = information.Driver
109 | volume.MountPoint = information.Mountpoint
110 |
111 | volumes = append(volumes, volume)
112 | }
113 |
114 | return volumes
115 | }
116 |
117 | // Count the number of Docker volumes
118 | func VolumesCount(client *client.Client) int {
119 | reader, err := client.VolumeList(context.Background(), volume.ListOptions{})
120 |
121 | if err != nil {
122 | return 0
123 | }
124 |
125 | return len(reader.Volumes)
126 | }
127 |
128 | // Prune unused Docker volumes
129 | func VolumesPrune(client *client.Client) error {
130 | _, err := client.VolumesPrune(context.Background(), filters.Args{})
131 | return err
132 | }
133 |
134 | // Turn the list of Docker volumes into a list of rows representing them
135 | func (volumes Volumes) ToRows(columns []string) ui.Rows {
136 | var rows = make(ui.Rows, 0)
137 |
138 | sort.Slice(volumes, func(i, j int) bool {
139 | return volumes[i].Name < volumes[j].Name
140 | })
141 |
142 | for i := 0; i < len(volumes); i++ {
143 | volume := volumes[i]
144 |
145 | row := structs.Map(volume)
146 | var flat = make([]map[string]string, 0)
147 |
148 | for j := 0; j < len(columns); j++ {
149 | _entry := make(map[string]string)
150 | _entry["field"] = columns[j]
151 |
152 | switch columns[j] {
153 | case "Name":
154 | _entry["value"] = volume.Name
155 | case "Driver":
156 | _entry["value"] = volume.Driver
157 | case "MountPoint":
158 | _entry["value"] = volume.MountPoint
159 | }
160 |
161 | flat = append(flat, _entry)
162 | }
163 | row["_representation"] = flat
164 | rows = append(rows, row)
165 | }
166 |
167 | return rows
168 | }
169 |
170 | // Remove the Docker Volume
171 | func (v Volume) Remove(client *client.Client, force bool) error {
172 | err := client.VolumeRemove(context.Background(), v.Name, force)
173 | return err
174 | }
175 |
176 | // Inspector - Retrieve the full configuration associated with a Docker volume
177 | func (v Volume) GetConfig(client *client.Client) (ui.InspectorContent, error) {
178 | information, err := client.VolumeInspect(context.Background(), v.Name)
179 |
180 | if err != nil {
181 | return nil, err
182 | }
183 |
184 | // Build the first part of the config (main information)
185 | firstPart := ui.InspectorContentPart{Type: "rows"}
186 | rows := make(ui.Rows, 0)
187 | fields := []string{"Name", "Driver", "Scope", "Mountpoint"}
188 | for _, field := range fields {
189 | row := make(ui.Row)
190 | switch field {
191 | case "Name":
192 | row["Name"] = v.Name
193 | row["_representation"] = []string{"Name:", v.Name}
194 | case "Driver":
195 | row["Driver"] = v.Driver
196 | row["_representation"] = []string{"Driver:", v.Driver}
197 | case "Scope":
198 | row["Scope"] = information.Scope
199 | row["_representation"] = []string{"Scope:", information.Scope}
200 | case "Mountpoint":
201 | row["Mountpoint"] = information.Mountpoint
202 | row["_representation"] = []string{"Mountpoint:", information.Mountpoint}
203 | }
204 |
205 | rows = append(rows, row)
206 | }
207 | firstPart.Content = rows
208 |
209 | // Build the full config using : First part // Labels // Options // Status
210 | allConfig := ui.InspectorContent{
211 | firstPart,
212 | ui.InspectorContentPart{Type: "json", Content: ui.JSON{"Labels": information.Labels}},
213 | ui.InspectorContentPart{Type: "json", Content: ui.JSON{"Options": information.Options}},
214 | ui.InspectorContentPart{Type: "json", Content: ui.JSON{"Status": information.Status}},
215 | }
216 |
217 | return allConfig, nil
218 | }
219 |
--------------------------------------------------------------------------------
/app/server/server/agents.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | _session "will-moss/isaiah/server/_internal/session"
5 | "will-moss/isaiah/server/ui"
6 |
7 | "github.com/mitchellh/mapstructure"
8 | )
9 |
10 | // Represent an Isaiah agent
11 | type Agent struct {
12 | Name string
13 | }
14 |
15 | // Represent an array of Isaiah agents
16 | type AgentsArray []Agent
17 |
18 | // Placeholder used for internal organization
19 | type Agents struct{}
20 |
21 | func (handler Agents) RunCommand(server *Server, session _session.GenericSession, command ui.Command) {
22 | switch command.Action {
23 |
24 | // Command : Register a new agent
25 | case "agent.register":
26 | var agent Agent
27 | mapstructure.Decode(command.Args["Resource"], &agent)
28 |
29 | for _, name := range server.Agents.ToStrings() {
30 | if name == agent.Name {
31 | server.SendNotification(
32 | session,
33 | ui.NotificationError(ui.NP{Content: ui.JSON{
34 | "Message": "This name is already taken. Please use another unique name for your agent",
35 | }}),
36 | )
37 | return
38 | }
39 | }
40 |
41 | session.Set("agent", agent)
42 | server.Agents = append(server.Agents, agent)
43 |
44 | server.SendNotification(
45 | session,
46 | ui.NotificationSuccess(ui.NP{Content: ui.JSON{"Message": "The agent was succesfully registered"}}),
47 | )
48 |
49 | // Notify all the clients about the new agent's registration
50 | notification := ui.NotificationData(ui.NotificationParams{Content: ui.JSON{"Agents": server.Agents.ToStrings()}})
51 | server.Melody.Broadcast(notification.ToBytes())
52 |
53 | // Command : Agent replies to a specific client
54 | case "agent.reply":
55 | var to string
56 | mapstructure.Decode(command.Args["To"], &to)
57 |
58 | if to == "" {
59 | return
60 | }
61 |
62 | sessions, _ := server.Melody.Sessions()
63 | for index := range sessions {
64 | _session := sessions[index]
65 |
66 | if id, exists := _session.Get("id"); !exists || id != to {
67 | continue
68 | }
69 |
70 | var _notification ui.Notification
71 | mapstructure.Decode(command.Args["Notification"], &_notification)
72 | _session.Write(_notification.ToBytes())
73 | break
74 | }
75 |
76 | // -> Agent's "logout" is performed when the websocket connection is terminated
77 |
78 | }
79 |
80 | }
81 |
82 | func (agents AgentsArray) ToStrings() []string {
83 | arr := make([]string, 0)
84 |
85 | for _, v := range agents {
86 | arr = append(arr, v.Name)
87 | }
88 |
89 | return arr
90 | }
91 |
--------------------------------------------------------------------------------
/app/server/server/authentication.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "crypto/sha256"
5 | "fmt"
6 | _os "will-moss/isaiah/server/_internal/os"
7 | _session "will-moss/isaiah/server/_internal/session"
8 | "will-moss/isaiah/server/ui"
9 | )
10 |
11 | type Authentication struct{}
12 |
13 | func (Authentication) RunCommand(server *Server, session _session.GenericSession, command ui.Command) {
14 | switch command.Action {
15 |
16 | // Command : Authenticate the client by password
17 | case "auth.login":
18 | if _os.GetEnv("AUTHENTICATION_ENABLED") != "TRUE" {
19 | session.Set("authenticated", true)
20 | server.SendNotification(session, ui.NotificationAuth(ui.NP{
21 | Type: ui.TypeSuccess,
22 | Content: ui.JSON{
23 | "Authentication": ui.JSON{
24 | "Message": "You are now authenticated",
25 | },
26 | "Preferences": server.GetPreferences(),
27 | },
28 | }))
29 | break
30 | }
31 |
32 | password := command.Args["Password"]
33 |
34 | // Authentication against raw password
35 | if _os.GetEnv("AUTHENTICATION_HASH") == "" {
36 | if password != _os.GetEnv("AUTHENTICATION_SECRET") {
37 | session.Set("authenticated", false)
38 | server.SendNotification(
39 | session,
40 | ui.NotificationAuth(ui.NP{
41 | Type: ui.TypeError,
42 | Content: ui.JSON{
43 | "Authentication": ui.JSON{
44 | "Message": "Invalid password",
45 | },
46 | },
47 | }),
48 | )
49 | break
50 | }
51 | }
52 |
53 | // Authentication against hashed password
54 | if _os.GetEnv("AUTHENTICATION_HASH") != "" {
55 | hasher := sha256.New()
56 | hasher.Write([]byte(password.(string)))
57 | hashed := fmt.Sprintf("%x", hasher.Sum(nil))
58 |
59 | if hashed != _os.GetEnv("AUTHENTICATION_HASH") {
60 | session.Set("authenticated", false)
61 | server.SendNotification(
62 | session,
63 | ui.NotificationAuth(ui.NP{
64 | Type: ui.TypeError,
65 | Content: ui.JSON{
66 | "Authentication": ui.JSON{
67 | "Message": "Invalid password",
68 | },
69 | },
70 | }),
71 | )
72 | break
73 | }
74 | }
75 |
76 | session.Set("authenticated", true)
77 |
78 | showNothingOnFront, ok := command.Args["AutoLogin"]
79 | if !ok {
80 | showNothingOnFront = false
81 | }
82 |
83 | server.SendNotification(
84 | session,
85 | ui.NotificationAuth(ui.NP{
86 | Type: ui.TypeSuccess,
87 | Content: ui.JSON{
88 | "Authentication": ui.JSON{
89 | "Message": "You are now authenticated",
90 | "Seamless": showNothingOnFront,
91 | },
92 | "Preferences": server.GetPreferences(),
93 | },
94 | }),
95 | )
96 |
97 | // Command : Log out the client
98 | case "auth.logout":
99 | session.Set("authenticated", false)
100 |
101 | // Command not found
102 | default:
103 | server.SendNotification(
104 | session,
105 | ui.NotificationAuth(ui.NP{
106 | Type: ui.TypeError,
107 | Content: ui.JSON{
108 | "Authentication": ui.JSON{
109 | "Message": "You are not authenticated yet",
110 | },
111 | },
112 | }),
113 | )
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/app/server/server/hosts.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | // Represent an array of Isaiah hosts ([name, hostname])
4 | type HostsArray [][]string
5 |
6 | func (hosts HostsArray) ToStrings() []string {
7 | arr := make([]string, 0)
8 |
9 | for _, v := range hosts {
10 | arr = append(arr, v[0])
11 | }
12 |
13 | return arr
14 | }
15 |
--------------------------------------------------------------------------------
/app/server/server/images.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "strings"
7 | _os "will-moss/isaiah/server/_internal/os"
8 | "will-moss/isaiah/server/_internal/process"
9 | _session "will-moss/isaiah/server/_internal/session"
10 | _slices "will-moss/isaiah/server/_internal/slices"
11 | _strconv "will-moss/isaiah/server/_internal/strconv"
12 | "will-moss/isaiah/server/resources"
13 | "will-moss/isaiah/server/ui"
14 |
15 | "github.com/mitchellh/mapstructure"
16 | )
17 |
18 | // Placeholder used for internal organization
19 | type Images struct{}
20 |
21 | func (Images) RunCommand(server *Server, session _session.GenericSession, command ui.Command) {
22 | switch command.Action {
23 |
24 | // Single - Default menu
25 | case "image.menu":
26 | actions := resources.ImageSingleActions()
27 | server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
28 |
29 | // Single - Remove menu
30 | case "image.menu.remove":
31 | var volume resources.Volume
32 | mapstructure.Decode(command.Args["Resource"], &volume)
33 |
34 | actions := resources.ImageRemoveActions(volume)
35 | server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
36 |
37 | // Bulk - Bulk menu
38 | case "images.bulk":
39 | actions := resources.ImagesBulkActions()
40 | server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
41 |
42 | // Bulk - List
43 | case "images.list":
44 | columns := strings.Split(_os.GetEnv("COLUMNS_IMAGES"), ",")
45 | images := resources.ImagesList(server.Docker)
46 |
47 | rows := images.ToRows(columns)
48 |
49 | // Default communication method - Send all at once
50 | if _os.GetEnv("SERVER_CHUNKED_COMMUNICATION_ENABLED") != "TRUE" {
51 | server.SendNotification(
52 | session,
53 | ui.NotificationData(ui.NP{
54 | Content: ui.JSON{"Tab": ui.Tab{Key: "images", Title: "Images", Rows: rows, SortBy: _os.GetEnv("SORTBY_IMAGES")}}}),
55 | )
56 | } else {
57 | // Chunked communication method, send resources chunk by chunk
58 | chunkSize := int(_strconv.ParseInt(_os.GetEnv("SERVER_CHUNKED_COMMUNICATION_SIZE"), 10, 64))
59 | chunkIndex := 1
60 | chunks := _slices.Chunk(rows, chunkSize)
61 | for _, c := range chunks {
62 | server.SendNotification(
63 | session,
64 | ui.NotificationDataChunk(ui.NP{
65 | Content: ui.JSON{
66 | "Tab": ui.Tab{Key: "images", Title: "Images", Rows: c, SortBy: _os.GetEnv("SORTBY_IMAGES")},
67 | "ChunkIndex": chunkIndex,
68 | }}),
69 | )
70 | chunkIndex += 1
71 | }
72 | }
73 |
74 | // Bulk - Prune
75 | case "images.prune":
76 | err := resources.ImagesPrune(server.Docker)
77 | if err != nil {
78 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
79 | break
80 | }
81 | server.SendNotification(
82 | session,
83 | ui.NotificationSuccess(ui.NP{
84 | Content: ui.JSON{"Message": "All the unused images were pruned"}, Follow: "images.list",
85 | }),
86 | )
87 |
88 | // Bulk - Pull
89 | case "images.pull":
90 | images := resources.ImagesList(server.Docker)
91 |
92 | for _, image := range images {
93 | if image.Version != "latest" {
94 | continue
95 | }
96 |
97 | task := process.LongTask{
98 | Function: resources.ImagePull,
99 | Args: map[string]interface{}{"Image": image.Name},
100 | OnStep: func(update string) {
101 | metadata := make(map[string]string)
102 | json.Unmarshal([]byte(update), &metadata)
103 |
104 | message := fmt.Sprintf("Pulling : %s", image.Name)
105 | message += fmt.Sprintf("
Status : %s", metadata["status"])
106 | if _, ok := metadata["progress"]; ok {
107 | message += fmt.Sprintf("
Progress : %s", metadata["progress"])
108 | }
109 |
110 | server.SendNotification(
111 | session,
112 | ui.NotificationInfo(ui.NP{
113 | Content: ui.JSON{
114 | "Message": message,
115 | },
116 | }),
117 | )
118 | },
119 | OnError: func(err error) {
120 | server.SendNotification(
121 | session,
122 | ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}),
123 | )
124 | },
125 | OnDone: func() {
126 | server.SendNotification(
127 | session,
128 | ui.NotificationSuccess(ui.NP{
129 | Content: ui.JSON{"Message": fmt.Sprintf("The image %s was succesfully pulled", image.Name)}, Follow: "images.list",
130 | }),
131 | )
132 | },
133 | }
134 | task.RunSync(server.Docker)
135 | }
136 | server.SendNotification(
137 | session,
138 | ui.NotificationSuccess(ui.NP{
139 | Content: ui.JSON{"Message": "All your latest image were succesfully pulled"}, Follow: "images.list",
140 | }),
141 | )
142 |
143 | // Single - Default remove
144 | case "image.remove.default":
145 | var image resources.Image
146 | mapstructure.Decode(command.Args["Resource"], &image)
147 |
148 | err := image.Remove(server.Docker, false, true)
149 | if err != nil {
150 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
151 | break
152 | }
153 |
154 | server.SendNotification(
155 | session,
156 | ui.NotificationSuccess(ui.NP{
157 | Content: ui.JSON{"Message": "The image was succesfully removed"}, Follow: "images.list",
158 | }),
159 | )
160 |
161 | // Single - Default remove without deleting untagged parents
162 | case "image.remove.default.unprune":
163 | var image resources.Image
164 | mapstructure.Decode(command.Args["Resource"], &image)
165 |
166 | err := image.Remove(server.Docker, false, false)
167 | if err != nil {
168 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
169 | break
170 | }
171 |
172 | server.SendNotification(
173 | session,
174 | ui.NotificationSuccess(ui.NP{
175 | Content: ui.JSON{"Message": "The image was succesfully removed"}, Follow: "images.list",
176 | }),
177 | )
178 |
179 | // Single - Force remove
180 | case "image.remove.force":
181 | var image resources.Image
182 | mapstructure.Decode(command.Args["Resource"], &image)
183 |
184 | err := image.Remove(server.Docker, true, true)
185 | if err != nil {
186 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
187 | break
188 | }
189 |
190 | server.SendNotification(
191 | session,
192 | ui.NotificationSuccess(ui.NP{
193 | Content: ui.JSON{"Message": "The image was succesfully removed"}, Follow: "images.list",
194 | }),
195 | )
196 |
197 | // Single - Force remove without deleting untagged parents
198 | case "image.remove.force.unprune":
199 | var image resources.Image
200 | mapstructure.Decode(command.Args["Resource"], &image)
201 |
202 | err := image.Remove(server.Docker, true, false)
203 | if err != nil {
204 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
205 | break
206 | }
207 |
208 | server.SendNotification(
209 | session,
210 | ui.NotificationSuccess(ui.NP{
211 | Content: ui.JSON{"Message": "The image was succesfully removed"}, Follow: "images.list",
212 | }),
213 | )
214 |
215 | // Single - Pull
216 | case "image.pull":
217 | task := process.LongTask{
218 | Function: resources.ImagePull,
219 | Args: command.Args, // Expects : { "Image": }
220 | OnStep: func(update string) {
221 | metadata := make(map[string]string)
222 | json.Unmarshal([]byte(update), &metadata)
223 |
224 | message := fmt.Sprintf("Pulling : %s", command.Args["Image"])
225 | message += fmt.Sprintf("
Status : %s", metadata["status"])
226 | if _, ok := metadata["progress"]; ok {
227 | message += fmt.Sprintf("
Progress : %s", metadata["progress"])
228 | }
229 |
230 | server.SendNotification(
231 | session,
232 | ui.NotificationInfo(ui.NP{
233 | Content: ui.JSON{
234 | "Message": message,
235 | },
236 | }),
237 | )
238 | },
239 | OnError: func(err error) {
240 | server.SendNotification(
241 | session,
242 | ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}),
243 | )
244 | },
245 | OnDone: func() {
246 | server.SendNotification(
247 | session,
248 | ui.NotificationSuccess(ui.NP{
249 | Content: ui.JSON{"Message": "The image was succesfully pulled"}, Follow: "images.list",
250 | }),
251 | )
252 | },
253 | }
254 | task.RunSync(server.Docker)
255 |
256 | // Single - Get inspector tabs
257 | case "image.inspect.tabs":
258 | tabs := resources.ImagesInspectorTabs()
259 | server.SendNotification(
260 | session,
261 | ui.NotificationData(ui.NP{
262 | Content: ui.JSON{"Inspector": ui.JSON{"Tabs": tabs}},
263 | }),
264 | )
265 |
266 | // Single - Inspect full configuration
267 | case "image.inspect.config":
268 | var image resources.Image
269 | mapstructure.Decode(command.Args["Resource"], &image)
270 | config, err := image.GetConfig(server.Docker)
271 |
272 | if err != nil {
273 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
274 | break
275 | }
276 |
277 | server.SendNotification(
278 | session,
279 | ui.NotificationData(ui.NP{
280 | Content: ui.JSON{
281 | "Inspector": ui.JSON{
282 | "Content": config,
283 | },
284 | },
285 | }),
286 | )
287 |
288 | // Single - Run
289 | case "image.run":
290 | var image resources.Image
291 | mapstructure.Decode(command.Args["Resource"], &image)
292 |
293 | var name string
294 | name = command.Args["Name"].(string)
295 |
296 | err := image.Run(server.Docker, name)
297 | if err != nil {
298 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
299 | break
300 | }
301 |
302 | server.SendNotification(
303 | session,
304 | ui.NotificationSuccess(ui.NP{
305 | Content: ui.JSON{"Message": "The image was succesfully used to run a new container"}, Follow: "containers.list",
306 | }),
307 | )
308 |
309 | // Command not found
310 | default:
311 | server.SendNotification(
312 | session,
313 | ui.NotificationError(ui.NP{
314 | Content: ui.JSON{
315 | "Message": fmt.Sprintf("This command is unknown, unsupported, or not implemented yet : %s", command.Action),
316 | },
317 | }),
318 | )
319 | }
320 | }
321 |
--------------------------------------------------------------------------------
/app/server/server/networks.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | _os "will-moss/isaiah/server/_internal/os"
7 | _session "will-moss/isaiah/server/_internal/session"
8 | _slices "will-moss/isaiah/server/_internal/slices"
9 | _strconv "will-moss/isaiah/server/_internal/strconv"
10 | "will-moss/isaiah/server/resources"
11 | "will-moss/isaiah/server/ui"
12 |
13 | "github.com/mitchellh/mapstructure"
14 | )
15 |
16 | // Placeholder used for internal organization
17 | type Networks struct{}
18 |
19 | func (Networks) RunCommand(server *Server, session _session.GenericSession, command ui.Command) {
20 | switch command.Action {
21 |
22 | // Single - Default menu
23 | case "network.menu":
24 | var network resources.Network
25 | mapstructure.Decode(command.Args["Resource"], &network)
26 |
27 | actions := resources.NetworkSingleActions(network)
28 | server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
29 |
30 | // Single - Remove menu
31 | case "network.menu.remove":
32 | var network resources.Network
33 | mapstructure.Decode(command.Args["Resource"], &network)
34 |
35 | actions := resources.NetworkRemoveActions(network)
36 | server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
37 |
38 | // Bulk - Bulk menu
39 | case "networks.bulk":
40 | actions := resources.NetworksBulkActions()
41 | server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
42 |
43 | // Bulk - List
44 | case "networks.list":
45 | columns := strings.Split(_os.GetEnv("COLUMNS_NETWORKS"), ",")
46 | networks := resources.NetworksList(server.Docker)
47 |
48 | rows := networks.ToRows(columns)
49 |
50 | // Default communication method - Send all at once
51 | if _os.GetEnv("SERVER_CHUNKED_COMMUNICATION_ENABLED") != "TRUE" {
52 | server.SendNotification(
53 | session,
54 | ui.NotificationData(ui.NP{
55 | Content: ui.JSON{"Tab": ui.Tab{Key: "networks", Title: "Networks", Rows: rows, SortBy: _os.GetEnv("SORTBY_NETWORKS")}}}),
56 | )
57 | } else {
58 | // Chunked communication method, send resources chunk by chunk
59 | chunkSize := int(_strconv.ParseInt(_os.GetEnv("SERVER_CHUNKED_COMMUNICATION_SIZE"), 10, 64))
60 | chunkIndex := 1
61 | chunks := _slices.Chunk(rows, chunkSize)
62 | for _, c := range chunks {
63 | server.SendNotification(
64 | session,
65 | ui.NotificationDataChunk(ui.NP{
66 | Content: ui.JSON{
67 | "Tab": ui.Tab{Key: "networks", Title: "Networks", Rows: c, SortBy: _os.GetEnv("SORTBY_NETWORKS")},
68 | "ChunkIndex": chunkIndex,
69 | }}),
70 | )
71 | chunkIndex += 1
72 | }
73 | }
74 |
75 | // Bulk - Prune
76 | case "networks.prune":
77 | err := resources.NetworksPrune(server.Docker)
78 | if err != nil {
79 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
80 | break
81 | }
82 | server.SendNotification(
83 | session,
84 | ui.NotificationSuccess(ui.NP{
85 | Content: ui.JSON{"Message": "All the unused networks were pruned"}, Follow: "networks.list",
86 | }),
87 | )
88 |
89 | // Single - Default remove
90 | case "network.remove.default":
91 | var network resources.Network
92 | mapstructure.Decode(command.Args["Resource"], &network)
93 |
94 | err := network.Remove(server.Docker)
95 | if err != nil {
96 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
97 | break
98 | }
99 | server.SendNotification(
100 | session,
101 | ui.NotificationSuccess(ui.NP{
102 | Content: ui.JSON{"Message": "The network was succesfully removed"}, Follow: "networks.list",
103 | }),
104 | )
105 |
106 | // Single - Get inspector tabs
107 | case "network.inspect.tabs":
108 | tabs := resources.NetworksInspectorTabs()
109 | server.SendNotification(
110 | session,
111 | ui.NotificationData(ui.NP{
112 | Content: ui.JSON{"Inspector": ui.JSON{"Tabs": tabs}},
113 | }),
114 | )
115 |
116 | // Single - Inspect full configuration
117 | case "network.inspect.config":
118 | var network resources.Network
119 | mapstructure.Decode(command.Args["Resource"], &network)
120 | config, err := network.GetConfig(server.Docker)
121 |
122 | if err != nil {
123 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
124 | break
125 | }
126 |
127 | server.SendNotification(
128 | session,
129 | ui.NotificationData(ui.NP{
130 | Content: ui.JSON{
131 | "Inspector": ui.JSON{
132 | "Content": config,
133 | },
134 | },
135 | }),
136 | )
137 |
138 | // Command not found
139 | default:
140 | server.SendNotification(
141 | session,
142 | ui.NotificationError(ui.NP{
143 | Content: ui.JSON{
144 | "Message": fmt.Sprintf("This command is unknown, unsupported, or not implemented yet : %s", command.Action),
145 | },
146 | }),
147 | )
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/app/server/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "io"
8 | "runtime"
9 | "slices"
10 | "strings"
11 | _client "will-moss/isaiah/server/_internal/client"
12 | _io "will-moss/isaiah/server/_internal/io"
13 | _os "will-moss/isaiah/server/_internal/os"
14 | _session "will-moss/isaiah/server/_internal/session"
15 | _slices "will-moss/isaiah/server/_internal/slices"
16 | _strconv "will-moss/isaiah/server/_internal/strconv"
17 | "will-moss/isaiah/server/_internal/tty"
18 | "will-moss/isaiah/server/resources"
19 | "will-moss/isaiah/server/ui"
20 |
21 | "github.com/docker/docker/api/types/filters"
22 | "github.com/docker/docker/client"
23 | "github.com/olahol/melody"
24 | )
25 |
26 | // Represent the current server
27 | type Server struct {
28 | Melody *melody.Melody
29 | Docker *client.Client
30 | Agents AgentsArray
31 | Hosts HostsArray
32 | CurrentHostName string
33 | }
34 |
35 | // Represent a command handler, used only _internally
36 | // to organize functions in files on a per-resource-type basis
37 | type handler interface {
38 | RunCommand(*Server, _session.GenericSession, ui.Command)
39 | }
40 |
41 | // Primary method for sending messages via websocket
42 | func (server *Server) send(session _session.GenericSession, message []byte) {
43 | session.Write(message)
44 | }
45 |
46 | // Send a notification
47 | func (server *Server) SendNotification(session _session.GenericSession, notification ui.Notification) {
48 | // If configured, don't show confirmations
49 | if slices.Contains([]string{ui.TypeInfo, ui.TypeSuccess}, notification.Type) {
50 | notification.Display = _os.GetEnv("DISPLAY_CONFIRMATIONS") == "TRUE"
51 | }
52 |
53 | // By default, show errors and warnings
54 | if slices.Contains([]string{ui.TypeError, ui.TypeWarning}, notification.Type) {
55 | notification.Display = true
56 | }
57 |
58 | // When current node is an agent, wrap the notification in a "agent.reply" command
59 | // and send that to the master node
60 | if _os.GetEnv("SERVER_ROLE") == "Agent" {
61 | initiator, _ := session.Get("initiator")
62 |
63 | command := ui.Command{
64 | Action: "agent.reply",
65 | Args: ui.JSON{
66 | "To": initiator.(string),
67 | "Notification": notification,
68 | },
69 | }
70 |
71 | server.send(session, command.ToBytes())
72 | } else {
73 | // Default, when current node is master, simply send the notification
74 | server.send(session, notification.ToBytes())
75 | }
76 |
77 | }
78 |
79 | // Same as handler.RunCommand
80 | func (server *Server) runCommand(session _session.GenericSession, command ui.Command) {
81 | switch command.Action {
82 | case "init", "enumerate":
83 | var tabs []ui.Tab
84 |
85 | tabs_enabled := strings.Split(strings.ToLower(_os.GetEnv("TABS_ENABLED")), ",")
86 |
87 | containers := resources.ContainersList(server.Docker, filters.Args{})
88 | images := resources.ImagesList(server.Docker)
89 | volumes := resources.VolumesList(server.Docker)
90 | networks := resources.NetworksList(server.Docker)
91 | stacks := resources.StacksList(server.Docker)
92 | agents := server.Agents.ToStrings()
93 | hosts := server.Hosts.ToStrings()
94 |
95 | if len(stacks) > 0 {
96 | columns := strings.Split(_os.GetEnv("COLUMNS_STACKS"), ",")
97 | rows := stacks.ToRows(columns)
98 |
99 | if slices.Contains(tabs_enabled, "stacks") {
100 | tabs = append(tabs, ui.Tab{Key: "stacks", Title: "Stacks", Rows: rows, SortBy: _os.GetEnv("SORTBY_STACKS")})
101 | }
102 | }
103 |
104 | if len(containers) > 0 {
105 | columns := strings.Split(_os.GetEnv("COLUMNS_CONTAINERS"), ",")
106 | rows := containers.ToRows(columns)
107 |
108 | if slices.Contains(tabs_enabled, "containers") {
109 | tabs = append(tabs, ui.Tab{Key: "containers", Title: "Containers", Rows: rows, SortBy: _os.GetEnv("SORTBY_CONTAINERS")})
110 | }
111 | }
112 |
113 | if len(images) > 0 {
114 | columns := strings.Split(_os.GetEnv("COLUMNS_IMAGES"), ",")
115 | rows := images.ToRows(columns)
116 |
117 | if slices.Contains(tabs_enabled, "images") {
118 | tabs = append(tabs, ui.Tab{Key: "images", Title: "Images", Rows: rows, SortBy: _os.GetEnv("SORTBY_IMAGES")})
119 | }
120 | }
121 |
122 | if len(volumes) > 0 {
123 | columns := strings.Split(_os.GetEnv("COLUMNS_VOLUMES"), ",")
124 | rows := volumes.ToRows(columns)
125 |
126 | if slices.Contains(tabs_enabled, "volumes") {
127 | tabs = append(tabs, ui.Tab{Key: "volumes", Title: "Volumes", Rows: rows, SortBy: _os.GetEnv("SORTBY_VOLUMES")})
128 | }
129 | }
130 |
131 | if len(networks) > 0 {
132 | columns := strings.Split(_os.GetEnv("COLUMNS_NETWORKS"), ",")
133 | rows := networks.ToRows(columns)
134 |
135 | if slices.Contains(tabs_enabled, "networks") {
136 | tabs = append(tabs, ui.Tab{Key: "networks", Title: "Networks", Rows: rows, SortBy: _os.GetEnv("SORTBY_NETWORKS")})
137 | }
138 | }
139 |
140 | // Default communication method - Send all at once
141 | if _os.GetEnv("SERVER_CHUNKED_COMMUNICATION_ENABLED") != "TRUE" {
142 | if command.Action == "init" {
143 | server.SendNotification(
144 | session,
145 | ui.NotificationInit(ui.NotificationParams{
146 | Content: ui.JSON{
147 | "Tabs": tabs,
148 | "Agents": agents,
149 | "Hosts": hosts,
150 | },
151 | }))
152 | } else if command.Action == "enumerate" {
153 | // `enumerate` is used only in the context of the `Jump` command
154 | server.SendNotification(
155 | session,
156 | ui.NotificationData(ui.NotificationParams{
157 | Content: ui.JSON{"Enumeration": tabs, "Host": command.Host},
158 | }))
159 | }
160 | } else {
161 | // Chunked communication method, send resources chunk by chunk
162 | chunkSize := int(_strconv.ParseInt(_os.GetEnv("SERVER_CHUNKED_COMMUNICATION_SIZE"), 10, 64))
163 | chunkIndex := 1
164 | if command.Action == "init" {
165 | // First, send the Agents and Hosts
166 | server.SendNotification(
167 | session,
168 | ui.NotificationInit(ui.NotificationParams{
169 | Content: ui.JSON{
170 | "Agents": agents,
171 | "Hosts": hosts,
172 | "ChunkIndex": -1,
173 | },
174 | }))
175 |
176 | // Then, send the resources by chunks
177 | for _, t := range tabs {
178 | chunks := _slices.Chunk(t.Rows, chunkSize)
179 | for _, c := range chunks {
180 | server.SendNotification(
181 | session,
182 | ui.NotificationInitChunk(ui.NotificationParams{
183 | Content: ui.JSON{
184 | "Tab": ui.Tab{Key: t.Key, Title: t.Title, Rows: c, SortBy: t.SortBy},
185 | "ChunkIndex": chunkIndex,
186 | },
187 | }),
188 | )
189 | chunkIndex += 1
190 | }
191 | }
192 | } else if command.Action == "enumerate" {
193 | for _, t := range tabs {
194 | chunks := _slices.Chunk(t.Rows, chunkSize)
195 | for _, c := range chunks {
196 | server.SendNotification(
197 | session,
198 | ui.NotificationDataChunk(ui.NotificationParams{
199 | Content: ui.JSON{
200 | "Host": command.Host,
201 | "Enumeration": ui.Tab{Key: t.Key, Title: t.Title, Rows: c, SortBy: t.SortBy},
202 | "ChunkIndex": chunkIndex,
203 | },
204 | }),
205 | )
206 | chunkIndex += 1
207 | }
208 | }
209 | }
210 | }
211 |
212 | // Command : Agent-only - Clear TTY / Stream
213 | case "clear":
214 | if _os.GetEnv("SERVER_ROLE") != "Agent" {
215 | break
216 | }
217 |
218 | // Clear user tty if there's any open
219 | if terminal, exists := session.Get("tty"); exists {
220 | (terminal.(*tty.TTY)).ClearAndQuit()
221 | session.UnSet("tty")
222 | }
223 |
224 | // Clear user read stream if there's any open
225 | if stream, exists := session.Get("stream"); exists {
226 | (*stream.(*io.ReadCloser)).Close()
227 | session.UnSet("stream")
228 | }
229 |
230 | // Command : Open shell on the server
231 | case "shell":
232 | if _os.GetEnv("DOCKER_RUNNING") == "TRUE" {
233 | server.SendNotification(
234 | session,
235 | ui.NotificationError(ui.NP{
236 | Content: ui.JSON{
237 | "Message": "It seems that you're running Isaiah inside a Docker container." +
238 | " In this case, opening a system shell isn't available because" +
239 | " Isaiah is bound to its container and it can't access the shell on your hosting system.",
240 | },
241 | }),
242 | )
243 | break
244 | }
245 |
246 | terminal := tty.New(_io.CustomWriter{WriteFunction: func(p []byte) {
247 | server.SendNotification(
248 | session,
249 | ui.NotificationTty(ui.NotificationParams{Content: ui.JSON{"Output": string(p)}}),
250 | )
251 |
252 | }})
253 | session.Set("tty", &terminal)
254 |
255 | go func() {
256 | errs, updates, finished := make(chan error), make(chan string), false
257 | go _os.OpenShell(&terminal, errs, updates)
258 |
259 | for {
260 | if finished {
261 | break
262 | }
263 |
264 | select {
265 | case e := <-errs:
266 | server.SendNotification(session, ui.NotificationError(ui.NotificationParams{Content: ui.JSON{"Message": e.Error()}}))
267 | case u := <-updates:
268 | server.SendNotification(session, ui.NotificationTty(ui.NotificationParams{Content: ui.JSON{"Status": u, "Type": "system"}}))
269 | finished = u == "exited"
270 | }
271 | }
272 | }()
273 |
274 | // Command : Run a command inside the currently-opened shell (can be a container shell, or a system shell)
275 | case "shell.command":
276 | command := command.Args["Command"].(string)
277 | shouldQuit := command == "exit"
278 | terminal, exists := session.Get("tty")
279 |
280 | if exists != true {
281 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": "No tty opened"}}))
282 | break
283 | }
284 |
285 | var err error
286 | if shouldQuit {
287 | (terminal.(*tty.TTY)).ClearAndQuit()
288 | session.UnSet("tty")
289 | } else {
290 | err = (terminal.(*tty.TTY)).RunCommand(command)
291 | }
292 |
293 | if err != nil {
294 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
295 | break
296 | }
297 |
298 | // Command : Get a global overview of the server and all other hosts / nodes
299 | case "overview":
300 | overview := ui.Overview{Instances: make(ui.OverviewInstanceArray, 0)}
301 |
302 | serverName := "Master"
303 | if _os.GetEnv("SERVER_ROLE") == "Agent" {
304 | serverName = _os.GetEnv("AGENT_NAME")
305 | }
306 |
307 | // Case when : Standalone
308 | if _os.GetEnv("MULTI_HOST_ENABLED") != "TRUE" && len(server.Agents) == 0 {
309 | dockerVersion, _ := server.Docker.ServerVersion(context.Background())
310 | instance := ui.OverviewInstance{
311 | Server: ui.OverviewServer{
312 | CountCPU: runtime.NumCPU(),
313 | AmountRAM: _os.VirtualMemory().Total,
314 | Name: serverName,
315 | Role: _os.GetEnv("SERVER_ROLE"),
316 | },
317 | Docker: ui.OverviewDocker{
318 | Version: dockerVersion.Version,
319 | Host: server.Docker.DaemonHost(),
320 | },
321 | Resources: ui.OverviewResources{
322 | Containers: ui.JSON{"Count": resources.ContainersCount(server.Docker)},
323 | Images: ui.JSON{"Count": resources.ImagesCount(server.Docker)},
324 | Volumes: ui.JSON{"Count": resources.VolumesCount(server.Docker)},
325 | Networks: ui.JSON{"Count": resources.NetworksCount(server.Docker)},
326 | },
327 | }
328 | overview.Instances = append(overview.Instances, instance)
329 | } else if _os.GetEnv("MULTI_HOST_ENABLED") != "TRUE" && len(server.Agents) > 0 {
330 | // Case when : Multi-agent
331 |
332 | // First : Append current server
333 | dockerVersion, _ := server.Docker.ServerVersion(context.Background())
334 | instance := ui.OverviewInstance{
335 | Server: ui.OverviewServer{
336 | CountCPU: runtime.NumCPU(),
337 | AmountRAM: _os.VirtualMemory().Total,
338 | Name: serverName,
339 | Role: _os.GetEnv("SERVER_ROLE"),
340 | Agents: server.Agents.ToStrings(),
341 | },
342 | Docker: ui.OverviewDocker{
343 | Version: dockerVersion.Version,
344 | Host: server.Docker.DaemonHost(),
345 | },
346 | Resources: ui.OverviewResources{
347 | Containers: ui.JSON{"Count": resources.ContainersCount(server.Docker)},
348 | Images: ui.JSON{"Count": resources.ImagesCount(server.Docker)},
349 | Volumes: ui.JSON{"Count": resources.VolumesCount(server.Docker)},
350 | Networks: ui.JSON{"Count": resources.NetworksCount(server.Docker)},
351 | },
352 | }
353 | overview.Instances = append(overview.Instances, instance)
354 |
355 | // After : Do nothing more, the client will request an overview from each agent
356 |
357 | } else if _os.GetEnv("MULTI_HOST_ENABLED") == "TRUE" {
358 | // Case when : Multi-host
359 | originalHost := server.CurrentHostName
360 | for _, h := range server.Hosts {
361 | server.SetHost(h[0])
362 |
363 | dockerVersion, _ := server.Docker.ServerVersion(context.Background())
364 | instance := ui.OverviewInstance{
365 | Server: ui.OverviewServer{
366 | Name: h[0],
367 | Host: h[1],
368 | Role: "Master",
369 | },
370 | Docker: ui.OverviewDocker{
371 | Version: dockerVersion.Version,
372 | Host: server.Docker.DaemonHost(),
373 | },
374 | Resources: ui.OverviewResources{
375 | Containers: ui.JSON{"Count": resources.ContainersCount(server.Docker)},
376 | Images: ui.JSON{"Count": resources.ImagesCount(server.Docker)},
377 | Volumes: ui.JSON{"Count": resources.VolumesCount(server.Docker)},
378 | Networks: ui.JSON{"Count": resources.NetworksCount(server.Docker)},
379 | },
380 | }
381 |
382 | if strings.HasPrefix(h[1], "unix://") {
383 | instance.Server.CountCPU = runtime.NumCPU()
384 | instance.Server.AmountRAM = _os.VirtualMemory().Total
385 | }
386 |
387 | overview.Instances = append(overview.Instances, instance)
388 | }
389 | server.SetHost(originalHost)
390 | }
391 |
392 | server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Overview": overview}}))
393 |
394 | // Command : Not found
395 | default:
396 | server.SendNotification(
397 | session,
398 | ui.NotificationError(ui.NP{
399 | Content: ui.JSON{
400 | "Message": fmt.Sprintf("This command is unknown, unsupported, or not implemented yet : %s", command.Action),
401 | },
402 | }),
403 | )
404 | }
405 | }
406 |
407 | // Main function (dispatch a message to the appropriate handler, and run it)
408 | func (server *Server) Handle(session _session.GenericSession, message ...[]byte) {
409 | // Dev-only : Set authenticated by default if authentication is disabled
410 | if _os.GetEnv("AUTHENTICATION_ENABLED") != "TRUE" {
411 | session.Set("authenticated", true)
412 | }
413 |
414 | // On first connection
415 | if len(message) == 0 {
416 | // Dev-only : If authentication is disabled
417 | // - send spontaneous auth confirmation to the client
418 | if _os.GetEnv("AUTHENTICATION_ENABLED") != "TRUE" {
419 | server.SendNotification(session, ui.NotificationAuth(ui.NP{
420 | Type: ui.TypeSuccess,
421 | Content: ui.JSON{
422 | "Authentication": ui.JSON{
423 | "Spontaneous": true,
424 | "Message": "Your are now authenticated",
425 | },
426 | "Preferences": server.GetPreferences(),
427 | },
428 | }))
429 | }
430 |
431 | // Normal case : Do nothing
432 | return
433 | }
434 |
435 | // Decode the received command
436 | var command ui.Command
437 | err := json.Unmarshal(message[0], &command)
438 |
439 | if err != nil {
440 | server.SendNotification(session, ui.NotificationError(ui.NotificationParams{Content: ui.JSON{"Message": err.Error()}}))
441 | return
442 | }
443 |
444 | if command.Action == "" {
445 | return
446 | }
447 |
448 | // If the command is meant to be forwarded to the final client, locally store the "initiator" field
449 | if _os.GetEnv("SERVER_ROLE") == "Agent" && command.Initiator != "" {
450 | session.Set("initiator", command.Initiator)
451 |
452 | // Set "authenticated" to true when authentication is disabled
453 | // Why once again? Because now, we have an "initiator" field, so "authenticated" is per-client
454 | if _os.GetEnv("AUTHENTICATION_ENABLED") != "TRUE" {
455 | session.Set("authenticated", true)
456 | }
457 | }
458 |
459 | // By default, prior to running any command, close the current stream if any's still open
460 | if stream, exists := session.Get("stream"); exists {
461 | (*stream.(*io.ReadCloser)).Close()
462 | session.UnSet("stream")
463 | }
464 |
465 | // If the command is meant to be run by an agent, forward it, no further action
466 | if _os.GetEnv("SERVER_ROLE") == "Master" && command.Agent != "" {
467 | allSessions, _ := server.Melody.Sessions()
468 | for index := range allSessions {
469 | s := allSessions[index]
470 |
471 | agent, ok := s.Get("agent")
472 |
473 | if !ok {
474 | continue
475 | }
476 |
477 | if agent.(Agent).Name != command.Agent {
478 | continue
479 | }
480 |
481 | clientId, _ := session.Get("id")
482 |
483 | // Remove Agent from the Command to prevent infinite forwarding
484 | command.Agent = ""
485 |
486 | // Append initial client's id to enable reverse response routing (from agent to initial client)
487 | command.Initiator = clientId.(string)
488 |
489 | // Send the command to the agent
490 | s.Write(command.ToBytes())
491 |
492 | break
493 | }
494 |
495 | // Let the client know the agent is processing their input
496 | if !strings.HasPrefix(command.Action, "auth") {
497 | server.SendNotification(session, ui.NotificationLoading())
498 | }
499 | return
500 | }
501 |
502 | // When multi-host is enabled, set the appropriate host before interacting with Docker
503 | if _os.GetEnv("MULTI_HOST_ENABLED") == "TRUE" {
504 | if command.Host != "" {
505 | server.SetHost(command.Host)
506 | }
507 | }
508 |
509 | // # - Dispatch the command to the appropriate handler
510 | var h handler
511 |
512 | if authenticated, _ := session.Get("authenticated"); authenticated != true ||
513 | strings.HasPrefix(command.Action, "auth") {
514 | h = Authentication{}
515 | } else {
516 | // Let the client know the server is processing their input
517 | // + Disable sending "loading" notifications for agent nodes, as Master does it already
518 | if _os.GetEnv("SERVER_ROLE") == "Master" {
519 | server.SendNotification(session, ui.NotificationLoading())
520 | }
521 |
522 | switch true {
523 | case strings.HasPrefix(command.Action, "image"):
524 | h = Images{}
525 | case strings.HasPrefix(command.Action, "container"):
526 | h = Containers{}
527 | case strings.HasPrefix(command.Action, "volume"):
528 | h = Volumes{}
529 | case strings.HasPrefix(command.Action, "network"):
530 | h = Networks{}
531 | case strings.HasPrefix(command.Action, "stack"):
532 | h = Stacks{}
533 | case strings.HasPrefix(command.Action, "agent"):
534 | h = Agents{}
535 | default:
536 | h = nil
537 | }
538 | }
539 |
540 | if h != nil {
541 | h.RunCommand(server, session, command)
542 | } else {
543 | server.runCommand(session, command)
544 | }
545 |
546 | }
547 |
548 | func (s *Server) SetHost(name string) {
549 | var correspondingHost []string
550 | for _, v := range s.Hosts {
551 | if v[0] == name {
552 | correspondingHost = v
553 | break
554 | }
555 | }
556 |
557 | s.Docker = _client.NewClientWithOpts(client.WithHost(correspondingHost[1]))
558 | s.CurrentHostName = name
559 | }
560 |
561 | func (s *Server) GetPreferences() ui.Preferences {
562 | var preferences = make(ui.Preferences, 0)
563 | for k, v := range _os.GetFullEnv() {
564 | if strings.HasPrefix(k, "CLIENT_PREFERENCE_") {
565 | preferences[k] = v
566 | }
567 | }
568 |
569 | return preferences
570 | }
571 |
--------------------------------------------------------------------------------
/app/server/server/volumes.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "runtime"
7 | "strings"
8 | _io "will-moss/isaiah/server/_internal/io"
9 | _os "will-moss/isaiah/server/_internal/os"
10 | _session "will-moss/isaiah/server/_internal/session"
11 | _slices "will-moss/isaiah/server/_internal/slices"
12 | _strconv "will-moss/isaiah/server/_internal/strconv"
13 | "will-moss/isaiah/server/_internal/tty"
14 | "will-moss/isaiah/server/resources"
15 | "will-moss/isaiah/server/ui"
16 |
17 | "github.com/mitchellh/mapstructure"
18 | )
19 |
20 | // Placeholder used for internal organization
21 | type Volumes struct{}
22 |
23 | func (Volumes) RunCommand(server *Server, session _session.GenericSession, command ui.Command) {
24 | switch command.Action {
25 |
26 | // Single - Default menu
27 | case "volume.menu":
28 | actions := resources.VolumeSingleActions()
29 | server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
30 |
31 | // Single - Remove menu
32 | case "volume.menu.remove":
33 | var volume resources.Volume
34 | mapstructure.Decode(command.Args["Resource"], &volume)
35 |
36 | actions := resources.VolumeRemoveActions(volume)
37 | server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
38 |
39 | // Bulk - Bulk menu
40 | case "volumes.bulk":
41 | actions := resources.VolumesBulkActions()
42 | server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
43 |
44 | // Bulk - List
45 | case "volumes.list":
46 | columns := strings.Split(_os.GetEnv("COLUMNS_VOLUMES"), ",")
47 | volumes := resources.VolumesList(server.Docker)
48 |
49 | rows := volumes.ToRows(columns)
50 |
51 | // Default communication method - Send all at once
52 | if _os.GetEnv("SERVER_CHUNKED_COMMUNICATION_ENABLED") != "TRUE" {
53 | server.SendNotification(
54 | session,
55 | ui.NotificationData(ui.NP{
56 | Content: ui.JSON{"Tab": ui.Tab{Key: "volumes", Title: "Volumes", Rows: rows, SortBy: _os.GetEnv("SORTBY_VOLUMES")}}}),
57 | )
58 | } else {
59 | // Chunked communication method, send resources chunk by chunk
60 | chunkSize := int(_strconv.ParseInt(_os.GetEnv("SERVER_CHUNKED_COMMUNICATION_SIZE"), 10, 64))
61 | chunkIndex := 1
62 | chunks := _slices.Chunk(rows, chunkSize)
63 | for _, c := range chunks {
64 | server.SendNotification(
65 | session,
66 | ui.NotificationDataChunk(ui.NP{
67 | Content: ui.JSON{
68 | "Tab": ui.Tab{Key: "volumes", Title: "Volumes", Rows: c, SortBy: _os.GetEnv("SORTBY_VOLUMES")},
69 | "ChunkIndex": chunkIndex,
70 | }}),
71 | )
72 | chunkIndex += 1
73 | }
74 | }
75 |
76 | // Bulk - Prune
77 | case "volumes.prune":
78 | err := resources.VolumesPrune(server.Docker)
79 | if err != nil {
80 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
81 | break
82 | }
83 | server.SendNotification(
84 | session,
85 | ui.NotificationSuccess(ui.NP{
86 | Content: ui.JSON{"Message": "All the unused volumes were pruned"}, Follow: "volumes.list",
87 | }),
88 | )
89 |
90 | // Single - Default remove
91 | case "volume.remove.default":
92 | var volume resources.Volume
93 | mapstructure.Decode(command.Args["Resource"], &volume)
94 |
95 | err := volume.Remove(server.Docker, false)
96 | if err != nil {
97 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
98 | break
99 | }
100 |
101 | server.SendNotification(
102 | session,
103 | ui.NotificationSuccess(ui.NP{
104 | Content: ui.JSON{"Message": "The volume was succesfully removed"}, Follow: "volumes.list",
105 | }),
106 | )
107 |
108 | // Single - Forced remove
109 | case "volume.remove.force":
110 | var volume resources.Volume
111 | mapstructure.Decode(command.Args["Resource"], &volume)
112 |
113 | err := volume.Remove(server.Docker, true)
114 | if err != nil {
115 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
116 | break
117 | }
118 |
119 | server.SendNotification(
120 | session,
121 | ui.NotificationSuccess(ui.NP{
122 | Content: ui.JSON{"Message": "The volume was succesfully removed"}, Follow: "volumes.list",
123 | }),
124 | )
125 |
126 | // Single - Browse
127 | case "volume.browse":
128 | if runtime.GOOS == "darwin" {
129 | server.SendNotification(
130 | session,
131 | ui.NotificationError(ui.NP{
132 | Content: ui.JSON{
133 | "Message": "It seems that you're running Docker on MacOS. On this operating system" +
134 | " Docker works inside a virtual machine, and therefore volumes can't be accessed" +
135 | " directly."},
136 | }),
137 | )
138 | break
139 | }
140 |
141 | if _os.GetEnv("DOCKER_RUNNING") == "TRUE" {
142 | // Bypass limitation if volumes are mounted on the container
143 | if _, err := os.Stat("/var/lib/docker/volumes/"); err != nil {
144 | server.SendNotification(
145 | session,
146 | ui.NotificationError(ui.NP{
147 | Content: ui.JSON{
148 | "Message": "It seems that you're running Isaiah inside a Docker container." +
149 | " In this case, external volumes can't be accessed directly because" +
150 | " Isaiah is bound to its container and it can't access the volumes on your hosting system." +
151 | " To solve that, please add the following mount to your container configuration :
" +
152 | " - /var/lib/docker/volumes:/var/lib/docker/volumes",
153 | },
154 | }),
155 | )
156 | break
157 | }
158 | }
159 |
160 | var volume resources.Volume
161 | mapstructure.Decode(command.Args["Resource"], &volume)
162 |
163 | terminal := tty.New(&_io.CustomWriter{WriteFunction: func(p []byte) {
164 | server.SendNotification(
165 | session,
166 | ui.NotificationTty(ui.NP{Content: ui.JSON{"Output": string(p)}}),
167 | )
168 | }})
169 | session.Set("tty", &terminal)
170 |
171 | go func() {
172 | errs, updates, finished := make(chan error), make(chan string), false
173 | go _os.OpenShell(&terminal, errs, updates)
174 | go terminal.RunCommand("cd " + volume.MountPoint + "\n")
175 |
176 | for {
177 | if finished {
178 | break
179 | }
180 |
181 | select {
182 | case e := <-errs:
183 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": e.Error()}}))
184 | case u := <-updates:
185 | server.SendNotification(session, ui.NotificationTty(ui.NP{Content: ui.JSON{"Status": u, "Type": "volume"}}))
186 | finished = u == "exited"
187 | }
188 | }
189 | }()
190 |
191 | // Single - Get inspector tabs
192 | case "volume.inspect.tabs":
193 | tabs := resources.VolumesInspectorTabs()
194 | server.SendNotification(
195 | session,
196 | ui.NotificationData(ui.NP{
197 | Content: ui.JSON{"Inspector": ui.JSON{"Tabs": tabs}},
198 | }),
199 | )
200 |
201 | // Single - Inspect full configuration
202 | case "volume.inspect.config":
203 | var volume resources.Volume
204 | mapstructure.Decode(command.Args["Resource"], &volume)
205 | config, err := volume.GetConfig(server.Docker)
206 |
207 | if err != nil {
208 | server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
209 | break
210 | }
211 |
212 | server.SendNotification(
213 | session,
214 | ui.NotificationData(ui.NP{
215 | Content: ui.JSON{
216 | "Inspector": ui.JSON{
217 | "Content": config,
218 | },
219 | },
220 | }),
221 | )
222 |
223 | // Command not found
224 | default:
225 | server.SendNotification(
226 | session,
227 | ui.NotificationError(ui.NP{
228 | Content: ui.JSON{
229 | "Message": fmt.Sprintf("This command is unknown, unsupported, or not implemented yet : %s", command.Action),
230 | },
231 | }),
232 | )
233 | }
234 | }
235 |
--------------------------------------------------------------------------------
/app/server/ui/command.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import _json "will-moss/isaiah/server/_internal/json"
4 |
5 | // Represent a command sent by the web browser
6 | type Command struct {
7 | Action string
8 | Args map[string]interface{}
9 | Agent string
10 | Host string
11 | Initiator string
12 | Sequence int32
13 | }
14 |
15 | func (c Command) ToBytes() []byte {
16 | v := _json.Marshal(c)
17 | return []byte(v)
18 | }
19 |
--------------------------------------------------------------------------------
/app/server/ui/inspector.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | type InspectorContent []InspectorContentPart
4 |
5 | type InspectorContentPart struct {
6 | Type string // One of "rows", "json", "table", "lines", "code"
7 | Content interface{}
8 | }
9 |
--------------------------------------------------------------------------------
/app/server/ui/menu_action.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | // Represent a menu action (row) in the web browser
4 | type MenuAction struct {
5 | Label string
6 | Command string
7 | Prompt string
8 | Key string
9 | RequiresResource bool
10 | RunLocally bool
11 | }
12 |
--------------------------------------------------------------------------------
/app/server/ui/notification.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | _json "will-moss/isaiah/server/_internal/json"
5 | )
6 |
7 | type JSON map[string]interface{}
8 |
9 | // Represent a notification sent to the web browser
10 | type Notification struct {
11 | Category string // The top-most category of notification
12 | Type string // The type of notification (among success, error, warning, and info)
13 | Title string // The title of the notification (as displayed to the end user)
14 | Content map[string]interface{} // The content of the notification (JSON string)
15 | Follow string // The command the client should run when they receive the notification
16 | Display bool // Whether or not the notification should be shown to the end user
17 | }
18 |
19 | type NotificationParams struct {
20 | Content map[string]interface{}
21 | Follow string
22 | Type string
23 | }
24 |
25 | type NP = NotificationParams
26 |
27 | const (
28 | TypeSuccess = "success"
29 | TypeError = "error"
30 | TypeWarning = "warning"
31 | TypeInfo = "info"
32 | )
33 |
34 | const (
35 | CategoryInit = "init" // Notification sent at first connection established
36 | CategoryInitChunk = "init-chunk" // Notification sent at first connection established, in chunked communication
37 | CategoryRefresh = "refresh" // Notification sent when requesting new data for Docker / UI resources
38 | CategoryRefreshChunk = "refresh-chunk" // Notification sent when requesting new data for Docker / UI resources, in chunked communication
39 | CategoryLoading = "loading" // Notification sent to let the user know that the server is loading
40 | CategoryReport = "report" // Notification sent to let the user know something (message, error)
41 | CategoryPrompt = "prompt" // Notification sent to ask confirmation from the user
42 | CategoryTty = "tty" // Notification sent to instruct about TTY status / output
43 | CategoryAuth = "auth" // Notification sent to instruct about authentication
44 | )
45 |
46 | func NotificationInit(p NotificationParams) Notification {
47 | return Notification{Category: CategoryInit, Type: TypeSuccess, Content: p.Content, Follow: p.Follow}
48 | }
49 |
50 | func NotificationInitChunk(p NotificationParams) Notification {
51 | return Notification{Category: CategoryInitChunk, Type: TypeSuccess, Content: p.Content, Follow: p.Follow}
52 | }
53 |
54 | func NotificationError(p NotificationParams) Notification {
55 | return Notification{Category: CategoryReport, Type: TypeError, Title: "Error", Content: p.Content, Follow: p.Follow}
56 | }
57 |
58 | func NotificationData(p NotificationParams) Notification {
59 | return Notification{Category: CategoryRefresh, Type: TypeInfo, Content: p.Content, Follow: p.Follow}
60 | }
61 | func NotificationDataChunk(p NotificationParams) Notification {
62 | return Notification{Category: CategoryRefreshChunk, Type: TypeInfo, Content: p.Content, Follow: p.Follow}
63 | }
64 |
65 | func NotificationInfo(p NotificationParams) Notification {
66 | return Notification{Category: CategoryReport, Type: TypeInfo, Title: "Information", Content: p.Content, Follow: p.Follow}
67 | }
68 |
69 | func NotificationSuccess(p NotificationParams) Notification {
70 | return Notification{Category: CategoryReport, Type: TypeSuccess, Title: "Success", Content: p.Content, Follow: p.Follow}
71 | }
72 |
73 | func NotificationPrompt(p NotificationParams) Notification {
74 | return Notification{Category: CategoryPrompt, Type: TypeInfo, Title: "Confirm", Content: p.Content}
75 | }
76 |
77 | func NotificationAuth(p NotificationParams) Notification {
78 | return Notification{Category: CategoryAuth, Type: p.Type, Title: "Authentication", Content: p.Content}
79 | }
80 |
81 | func NotificationTty(p NotificationParams) Notification {
82 | return Notification{Category: CategoryTty, Type: TypeInfo, Content: p.Content}
83 | }
84 |
85 | func NotificationLoading() Notification {
86 | return Notification{Category: CategoryLoading, Type: TypeInfo}
87 | }
88 |
89 | func (n Notification) ToBytes() []byte {
90 | v := _json.Marshal(n)
91 | return []byte(v)
92 | }
93 |
--------------------------------------------------------------------------------
/app/server/ui/overview.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | type Overview struct {
4 | Instances OverviewInstanceArray
5 | }
6 |
7 | type OverviewInstance struct {
8 | Docker OverviewDocker
9 | Server OverviewServer
10 | Resources OverviewResources
11 | }
12 |
13 | type OverviewInstanceArray []OverviewInstance
14 |
15 | type OverviewServer struct {
16 | Name string
17 | Host string
18 | Role string
19 | Agents []string
20 | CountCPU int
21 | AmountRAM uint64
22 | }
23 |
24 | type OverviewDocker struct {
25 | Version string
26 | Host string
27 | }
28 |
29 | type OverviewResources struct {
30 | Containers JSON
31 | Images JSON
32 | Volumes JSON
33 | Networks JSON
34 | }
35 |
--------------------------------------------------------------------------------
/app/server/ui/preference.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | // Represent a JS preference in the web browser
4 | type Preferences map[string]string
5 |
--------------------------------------------------------------------------------
/app/server/ui/row.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | // Represent a row in the web browser
4 | type Row map[string]interface{}
5 | type Rows []Row
6 |
--------------------------------------------------------------------------------
/app/server/ui/size.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import "fmt"
4 |
5 | func ByteCount(b int64) string {
6 | const unit = 1000
7 | if b < unit {
8 | return fmt.Sprintf("%d B", b)
9 | }
10 | div, exp := int64(unit), 0
11 | for n := b / unit; n >= unit; n /= unit {
12 | div *= unit
13 | exp++
14 | }
15 | return fmt.Sprintf("%.2f%cB",
16 | float64(b)/float64(div), "kMGTPE"[exp])
17 | }
18 | func UByteCount(b uint64) string {
19 | const unit = 1000
20 | if b < unit {
21 | return fmt.Sprintf("%d B", b)
22 | }
23 | div, exp := uint64(unit), 0
24 | for n := b / unit; n >= unit; n /= unit {
25 | div *= unit
26 | exp++
27 | }
28 | return fmt.Sprintf("%.2f%cB",
29 | float64(b)/float64(div), "kMGTPE"[exp])
30 | }
31 |
--------------------------------------------------------------------------------
/app/server/ui/tab.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | // Represent a tab in the web browser
4 | type Tab struct {
5 | Key string
6 | Title string
7 | SortBy string
8 | Rows Rows
9 | }
10 |
--------------------------------------------------------------------------------
/app/server/ui/table.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | type Table struct {
4 | Headers []string
5 | Rows [][]string
6 | }
7 |
--------------------------------------------------------------------------------
/assets/CAPTURE-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/assets/CAPTURE-1.png
--------------------------------------------------------------------------------
/assets/CAPTURE-10.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/assets/CAPTURE-10.png
--------------------------------------------------------------------------------
/assets/CAPTURE-11.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/assets/CAPTURE-11.png
--------------------------------------------------------------------------------
/assets/CAPTURE-12.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/assets/CAPTURE-12.png
--------------------------------------------------------------------------------
/assets/CAPTURE-13.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/assets/CAPTURE-13.png
--------------------------------------------------------------------------------
/assets/CAPTURE-14.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/assets/CAPTURE-14.png
--------------------------------------------------------------------------------
/assets/CAPTURE-15.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/assets/CAPTURE-15.png
--------------------------------------------------------------------------------
/assets/CAPTURE-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/assets/CAPTURE-2.png
--------------------------------------------------------------------------------
/assets/CAPTURE-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/assets/CAPTURE-3.png
--------------------------------------------------------------------------------
/assets/CAPTURE-4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/assets/CAPTURE-4.png
--------------------------------------------------------------------------------
/assets/CAPTURE-5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/assets/CAPTURE-5.png
--------------------------------------------------------------------------------
/assets/CAPTURE-6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/assets/CAPTURE-6.png
--------------------------------------------------------------------------------
/assets/CAPTURE-7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/assets/CAPTURE-7.png
--------------------------------------------------------------------------------
/assets/CAPTURE-8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/assets/CAPTURE-8.png
--------------------------------------------------------------------------------
/assets/CAPTURE-9.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/will-moss/isaiah/582facbf1ffaedfbabc6faeba87c7f13b046aed6/assets/CAPTURE-9.png
--------------------------------------------------------------------------------
/examples/docker-compose.agent.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | isaiah:
4 | image: mosswill/isaiah:latest
5 | restart: unless-stopped
6 | volumes:
7 | - /var/run/docker.sock:/var/run/docker.sock:ro
8 | environment:
9 | SERVER_ROLE: "Agent"
10 | AUTHENTICATION_SECRET: "your-very-long-and-mysterious-secret"
11 |
12 | MASTER_HOST: "your-domain.tld:port"
13 | MASTER_SECRET: "your-very-long-and-mysterious-secret"
14 | AGENT_NAME: "Your custom name"
15 |
--------------------------------------------------------------------------------
/examples/docker-compose.host.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | isaiah:
4 | image: mosswill/isaiah:latest
5 | restart: unless-stopped
6 | volumes:
7 | - /var/run/docker.sock:/var/run/docker.sock:ro
8 | - my_docker_hosts:/docker_hosts
9 | environment:
10 | AUTHENTICATION_SECRET: "your-very-long-and-mysterious-secret"
11 | MULTI_HOST_ENABLED: "TRUE"
12 |
--------------------------------------------------------------------------------
/examples/docker-compose.proxy.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | isaiah:
4 | image: mosswill/isaiah:latest
5 | restart: unless-stopped
6 | networks:
7 | - global
8 | expose:
9 | - 80
10 | volumes:
11 | - /var/run/docker.sock:/var/run/docker.sock:ro
12 | environment:
13 | SERVER_PORT: "80"
14 | AUTHENTICATION_SECRET: "your-very-long-and-mysterious-secret"
15 |
16 | VIRTUAL_HOST: "your-domain.tld"
17 | VIRTUAL_PORT: "80"
18 |
19 | # Depending on your setup, you may also need
20 | # CERT_NAME: "default"
21 | # Or even
22 | # LETSENCRYPT_HOST: "your-domain.tld"
23 |
24 | proxy:
25 | image: jwilder/nginx-proxy
26 | ports:
27 | - "443:443"
28 | volumes:
29 | - /var/run/docker.sock:/tmp/docker.sock:ro
30 | networks:
31 | - global
32 |
33 | networks:
34 | # Assumption made : network "global" is created beforehand
35 | # with : docker network create global
36 | global:
37 | external: true
38 |
--------------------------------------------------------------------------------
/examples/docker-compose.simple.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | isaiah:
4 | image: mosswill/isaiah:latest
5 | restart: unless-stopped
6 | ports:
7 | - "80:80"
8 | volumes:
9 | - /var/run/docker.sock:/var/run/docker.sock:ro
10 | environment:
11 | SERVER_PORT: "80"
12 | AUTHENTICATION_SECRET: "your-very-long-and-mysterious-secret"
13 |
--------------------------------------------------------------------------------
/examples/docker-compose.ssl.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | isaiah:
4 | image: mosswill/isaiah:latest
5 | restart: unless-stopped
6 | ports:
7 | - "443:443"
8 | volumes:
9 | - ./certificate.pem:/certificate.pem
10 | - ./key.pem:/key.pem
11 | - /var/run/docker.sock:/var/run/docker.sock:ro
12 | environment:
13 | SSL_ENABLED: "TRUE"
14 | SERVER_PORT: "443"
15 |
16 | AUTHENTICATION_SECRET: "your-very-long-and-mysterious-secret"
17 |
--------------------------------------------------------------------------------
/examples/docker-compose.traefik.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | isaiah:
4 | image: mosswill/isaiah:latest
5 | restart: unless-stopped
6 | networks:
7 | - global
8 | expose:
9 | - 80
10 | volumes:
11 | - /var/run/docker.sock:/var/run/docker.sock:ro
12 | environment:
13 | AUTHENTICATION_SECRET: "your-very-long-and-mysterious-secret"
14 | labels:
15 | - "traefik.enable=true"
16 | - "traefik.http.routers.isaiah.rule=Host(`your-server.tld`)"
17 | - "traefik.http.routers.isaiah.service=isaiah-server"
18 | - "traefik.http.services.isaiah-server.loadbalancer.server.port=80"
19 | - "traefik.http.services.isaiah-server.loadbalancer.server.scheme=http"
20 |
21 | # Depending on your setup, you may also need
22 | # - "traefik.http.routers.isaiah.entrypoints=websecure"
23 | # - "traefik.http.routers.isaiah.tls=true"
24 | # - "traefik.http.routers.isaiah.tls.certresolver=tlschallenge"
25 |
26 |
27 | # Assumption made : another container running Traefik
28 | # was configured and started beforehand
29 | # and attached to the network "global"
30 |
31 | networks:
32 | # Assumption made : network "global" was created beforehand
33 | # with : docker network create global
34 | global:
35 | external: true
36 |
--------------------------------------------------------------------------------
/examples/docker-compose.volume.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | isaiah:
4 | image: mosswill/isaiah:latest
5 | restart: unless-stopped
6 | ports:
7 | - "80:80"
8 | volumes:
9 | - /var/run/docker.sock:/var/run/docker.sock:ro
10 | - .env:/.env
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "devDependencies": {
3 | "@semantic-release/changelog": "^6.0.3",
4 | "@semantic-release/exec": "^6.0.3",
5 | "@semantic-release/git": "^10.0.1",
6 | "cz-conventional-changelog": "^3.3.0"
7 | },
8 | "config": {
9 | "commitizen": {
10 | "path": "./node_modules/cz-conventional-changelog"
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/scripts/local-install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Navigate to the project's source directory
4 | cd ./app/
5 |
6 | # Install Babel, Less, and LightningCSS for JS and CSS processing
7 | yes | npm install --silent @babel/core @babel/cli @babel/preset-env
8 | yes | npm install --silent less lightningcss-cli
9 |
10 | # Compile LESS files into one unique CSS file
11 | npx --yes lessc ./client/assets/css/style.less > ./client/assets/css/tmp.css
12 |
13 | # Minify and Prefix CSS
14 | npx --yes lightningcss --minify --bundle --targets 'cover 99.5%' ./client/assets/css/tmp.css -o ./client/assets/css/style.css
15 |
16 | # Save the original JS file
17 | cp ./client/assets/js/isaiah.js ./client/assets/js/isaiah.backup.js
18 |
19 | # Make JS cross-browser-compatible
20 | npx --yes babel ./client/assets/js/isaiah.js --out-file ./client/assets/js/isaiah.js --config-file ./.babelrc.json
21 |
22 | # Minify JS
23 | npx --yes terser ./client/assets/js/isaiah.js -o ./client/assets/js/isaiah.js
24 |
25 | # Append a version parameter to the main JS & CSS linked files to prevent caching
26 | VERSION=$(git describe --tags --abbrev=0)
27 | sed -i.bak "s/isaiah.js/isaiah.js?v=$VERSION/" ./client/index.html
28 | sed -i.bak "s/style.css/style.css?v=$VERSION/" ./client/index.html
29 | sed -i.bak "s/-VERSION-/$VERSION/" ./client/assets/js/isaiah.js
30 |
31 | # Replace the version tag with the current version in the main Go file
32 | sed -i.bak "s/-VERSION-/$VERSION/" ./main.go
33 |
34 | # Build the app
35 | go build -o isaiah main.go
36 |
37 | # Reset CSS and JS
38 | rm -f ./client/assets/css/tmp.css
39 | rm -f ./client/assets/css/style.css
40 | mv ./client/assets/js/isaiah.backup.js ./client/assets/js/isaiah.js
41 |
42 | # Remove backup files
43 | rm -f ./client/index.html.bak
44 | rm -f ./client/assets/js/isaiah.js.bak
45 |
46 | DESTINATION="/usr/bin"
47 | if [ -d "/usr/local/bin" ]; then
48 | DESTINATION="/usr/local/bin"
49 | fi
50 |
51 | # Remove any previous installation
52 | rm -f $DESTINATION/isaiah
53 |
54 | # Install the app's binary
55 | mv isaiah $DESTINATION/
56 | chmod 755 $DESTINATION/isaiah
57 |
--------------------------------------------------------------------------------
/scripts/post-release.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Navigate to the project's source directory
4 | cd ./app/
5 |
6 | # Reset CSS and JS
7 | rm -f ./client/assets/css/tmp.css
8 | rm -f ./client/assets/css/style.css
9 | mv ./client/assets/js/isaiah.backup.js ./client/assets/js/isaiah.js
10 |
11 | # Remove the version parameter from the main JS & CSS linked files
12 | sed -i.bak -E 's/\?v=[0-9.]+//' ./client/index.html
13 | rm -f ./client/index.html.bak
14 | rm -f ./client/assets/js/isaiah.js.bak
15 |
16 | # Remove the version parameter from the main Go file
17 | sed -i.bak -E 's/Version\: [0-9.]+/Version\: -VERSION-/' ./main.go
18 | rm -f ./main.go.bak
19 |
20 | # Remove dist folder generated by goreleaser
21 | rm -rf ./dist/
22 |
--------------------------------------------------------------------------------
/scripts/pre-release.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Navigate to the project's source directory
4 | cd ./app/
5 |
6 | # Go dependencies
7 | go mod tidy
8 |
9 | # Compile LESS files into one unique CSS file
10 | npx --yes lessc ./client/assets/css/style.less > ./client/assets/css/tmp.css
11 |
12 | # Minify and Prefix CSS
13 | npx --yes lightningcss --minify --bundle --targets 'cover 99.5%' ./client/assets/css/tmp.css -o ./client/assets/css/style.css
14 |
15 | # Save the original JS file
16 | cp ./client/assets/js/isaiah.js ./client/assets/js/isaiah.backup.js
17 |
18 | # Make JS cross-browser-compatible
19 | npx --yes babel ./client/assets/js/isaiah.js --out-file ./client/assets/js/isaiah.js --config-file ./.babelrc.json
20 |
21 | # Minify JS
22 | npx --yes terser ./client/assets/js/isaiah.js -o ./client/assets/js/isaiah.js
23 |
24 | # Append a version parameter to the main JS & CSS linked files to prevent caching
25 | VERSION=$(git describe --tags --abbrev=0)
26 | sed -i.bak "s/isaiah.js/isaiah.js?v=$VERSION/" ./client/index.html
27 | sed -i.bak "s/style.css/style.css?v=$VERSION/" ./client/index.html
28 | sed -i.bak "s/-VERSION-/$VERSION/" ./client/assets/js/isaiah.js
29 |
30 | # Replace the version tag with the current version in the main Go file
31 | sed -i.bak "s/-VERSION-/$VERSION/" ./main.go
32 |
--------------------------------------------------------------------------------
/scripts/release.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | export $(cat .env | xargs)
4 | docker context use default
5 | goreleaser release --release-notes /tmp/release-notes.md --clean
6 |
7 | ./scripts/post-release.sh
8 |
--------------------------------------------------------------------------------
/scripts/remote-install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | DESTINATION="/usr/bin"
4 | if [ -d "/usr/local/bin" ]; then
5 | DESTINATION="/usr/local/bin"
6 | fi
7 |
8 | # Handle sudo requirement on default install location
9 | if [ $(id -u) -ne 0 ]; then
10 | echo "By default, Isaiah attempts to install its binary in /usr/bin/"
11 | echo "but that requires root permission. You can either restart the"
12 | echo "install script using sudo, or provide a new installation directory."
13 |
14 | # Clear stdin
15 | read -t 1 -n 10000 discard
16 |
17 | read -p "New installation directory: " DESTINATION
18 | if [ ! -d $DESTINATION ]; then
19 | echo "Error: No such directory"
20 | exit
21 | fi
22 |
23 | # Remove trailing slash if any
24 | DESTINATION=${DESTINATION%/}
25 | fi
26 |
27 |
28 | # Retrieve the system's architecture
29 | ARCH=$(uname -m)
30 | case $ARCH in
31 | i386|i686) ARCH=i386 ;;
32 | armv6*) ARCH=armv6 ;;
33 | armv7*) ARCH=armv7 ;;
34 | aarch64*) ARCH=arm64 ;;
35 | esac
36 |
37 | # Prepare the download URL
38 | GITHUB_LATEST_VERSION=$(curl -L -s -H 'Accept: application/json' https://github.com/will-moss/isaiah/releases/latest | sed -e 's/.*"tag_name":"\([^"]*\)".*/\1/')
39 | GITHUB_FILE="isaiah_${GITHUB_LATEST_VERSION//v/}_$(uname -s)_${ARCH}.tar.gz"
40 | GITHUB_URL="https://github.com/will-moss/isaiah/releases/download/${GITHUB_LATEST_VERSION}/${GITHUB_FILE}"
41 |
42 | # Install/Update the local binary
43 | curl -L -o isaiah.tar.gz $GITHUB_URL
44 | tar xzvf isaiah.tar.gz isaiah
45 |
46 |
47 | mv isaiah $DESTINATION
48 | chmod 755 $DESTINATION/isaiah
49 | rm isaiah.tar.gz
50 |
--------------------------------------------------------------------------------