├── .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 |
17 | 18 | 19 | 20 |
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 | --------------------------------------------------------------------------------