First commit

This commit is contained in:
Will Moss
2024-01-04 01:24:18 +01:00
commit 56ff42d15a
76 changed files with 20463 additions and 0 deletions

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
*.backup
*.css
*.cache
.*ignore
!.gitignore
go.work
.env
.vimrc
.aliases
tmp
dist/
node_modules/
app/package.json
app/package-lock.json
scripts/_*.sh

99
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,99 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 1
before:
hooks:
- ./scripts/pre-release.sh
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm
- arm64
- 386
goarm:
- 6
- 7
dir: app
archives:
- format: tar.gz
name_template: >-
{{ .ProjectName }}_{{ .Tag }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
format_overrides:
- goos: windows
format: zip
checksum:
name_template: "checksums.txt"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
dockers:
- image_templates:
- 'mosswill/isaiah:{{ .Tag }}-amd64'
use: buildx
build_flag_templates:
- "--pull"
- "--platform=linux/amd64"
goarch: amd64
- image_templates:
- 'mosswill/isaiah:{{ .Tag }}-arm64'
use: buildx
build_flag_templates:
- "--pull"
- "--platform=linux/arm64"
goarch: arm64
- image_templates:
- 'mosswill/isaiah:{{ .Tag }}-armv6'
use: buildx
build_flag_templates:
- "--pull"
- "--platform=linux/arm/v6"
goarch: arm
goarm: 6
- image_templates:
- 'mosswill/isaiah:{{ .Tag }}-armv7'
use: buildx
build_flag_templates:
- "--pull"
- "--platform=linux/arm/v7"
goarch: arm
goarm: 7
docker_manifests:
- name_template: "mosswill/isaiah:{{ .Tag }}"
image_templates:
- "mosswill/isaiah:{{ .Tag }}-amd64"
- "mosswill/isaiah:{{ .Tag }}-arm64"
- "mosswill/isaiah:{{ .Tag }}-armv6"
- "mosswill/isaiah:{{ .Tag }}-armv7"
- name_template: "mosswill/isaiah:latest"
image_templates:
- "mosswill/isaiah:{{ .Tag }}-amd64"
- "mosswill/isaiah:{{ .Tag }}-arm64"
- "mosswill/isaiah:{{ .Tag }}-armv6"
- "mosswill/isaiah:{{ .Tag }}-armv7"

17
.releaserc Normal file
View File

@@ -0,0 +1,17 @@
{
"branches": ["master"],
"tagFormat": "${version}",
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/git",
[
"@semantic-release/exec",
{
"publishCmd": "echo \"${nextRelease.notes}\" > /tmp/release-notes.md && ./scripts/release.sh"
}
]
]
}

20
CHANGELOG.md Normal file
View File

@@ -0,0 +1,20 @@
# 1.0.0 (2024-01-04)
### Features
* **project:** first release ([ad046b9](https://github.com/will-moss/isaiah/commit/ad046b933353f9e949f01380655b2a9ddd54e249))
# 1.0.0 (2024-01-04)
### Features
* **first release:** x ([a3558cd](https://github.com/will-moss/isaiah/commit/a3558cd25fc6fddee2032a3bc76e4ecaf8f5be27))
# [1.1.0](https://github.com/will-moss/isaiah/compare/v1.0.0...1.1.0) (2024-01-04)
### Features
* **project:** first release ([c25685c](https://github.com/will-moss/isaiah/commit/c25685cce00caba1f35e71441afa204fdf7f937c))

7
Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
FROM scratch
COPY isaiah /
ENV DOCKER_RUNNING=true
ENTRYPOINT ["./isaiah"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Will Moss
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

372
README.md Normal file
View File

@@ -0,0 +1,372 @@
<p align="center">
<h1 align="center">Isaiah</h1>
<p align="center">Self-hostable clone of lazydocker for the web</p>
</p>
| | | |
|:-------------------------:|:-------------------------:|:-------------------------:|
|<img width="1604" src="/assets/CAPTURE-1.png"/> | <img width="1604" src="/assets/CAPTURE-2.png"/> | <img width="1604" src="/assets/CAPTURE-3.png" /> |
|<img width="1604" src="/assets/CAPTURE-4.png"/> | <img width="1604" src="/assets/CAPTURE-5.png"/> | <img width="1604" src="/assets/CAPTURE-6.png" /> |
|<img width="1604" src="/assets/CAPTURE-7.png"/> | <img width="1604" src="/assets/CAPTURE-8.png"/> | <img width="1604" src="/assets/CAPTURE-9.png" /> |
|<img width="1604" src="/assets/CAPTURE-10.png"/> | <img width="1604" src="/assets/CAPTURE-11.png"/> | <img width="1604" src="/assets/CAPTURE-12.png"/> |
## Introduction
Isaiah is a self-hostable service that enables you to manage all your Docker resources on a remote server. It is an attempt at recreating the [lazydocker](https://github.com/jesseduffield/lazydocker) command-line application from scratch, while making it available as a web application without compromising on the features.
## Features
Isaiah has all these features implemented :
- For containers :
- Bulk stop, Bulk remove, Prune
- Remove, Pause, Unpause, Restart, Open in browser
- Open a shell inside the container (from your browser)
- Inspect (live logs, stats, env, full configuration, top)
- For images :
- Prune
- Remove
- Run (create and start a container using the image)
- Open on Docker Hub
- Pull a new image (from Docker Hub)
- Inspect (full configuration, layers)
- For volumes :
- Prune
- Remove
- Browse volume files (from your browser, via shell)
- Inspect (full configuration)
- For networks :
- Prune
- Remove
- Inspect (full configuration)
- Built-in automatic Docker host discovery
- Built-in authentication by master password
- Built-in terminal emulator (with support for opening a shell on the server)
- Support for multiple layouts
- Support for color-theming
- Support for keyboard navigation
- Support for mouse navigation
- Support for custom Docker Host / Context.
- Support for extensive configuration with `.env`
- Support for HTTP and HTTPS
- Support for standalone / proxy deployment
On top of these, one may appreciate the following characteristics :
- Written in Go (for the server) and Vanilla JS (for the client)
- Holds in a ~4 MB single file executable
- Holds in a ~4 MB Docker image
- Works exclusively over Websocket, with very little bandwidth usage
- Uses the official Docker SDK for 100% of the Docker features
For more information, read about [Configuration](#configuration) and [Deployment](#deployment-and-examples).
## Deployment and Examples
### Deploy with Docker
You can run Isaiah with Docker on the command line very quickly.
You can use the following commands :
```sh
# Create a .env file
touch .env
# Edit .env file ...
# Option 1 : Run Isaiah attached to the terminal (useful for debugging)
docker run \
--env-file .env \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-p <YOUR-PORT-MAPPING> \
mosswill/isaiah
# Option 2 : Run Isaiah as a daemon
docker run \
-d \
--env-file .env \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-p <YOUR-PORT-MAPPING> \
mosswill/isaiah
# Option 3 : Quick run with default variables
docker run -v /var/run/docker.sock:/var/run/docker.sock:ro -p 3000:3000 mosswill/isaiah
```
### Deploy with Docker Compose
To help you get started quickly, multiple example `docker-compose` files are located in the ["examples/"](examples) directory.
Here's a description of every example :
- `docker-compose.simple.yml`: Run Isaiah as a front-facing service on port 80., with environment variables supplied in the `docker-compose` file directly.
- `docker-compose.volume.yml`: Run Isaiah as a front-facing service on port 80, with environment variables supplied as a `.env` file mounted as a volume.
- `docker-compose.ssl.yml`: Run Isaiah as a front-facing service on port 443, listening for HTTPS requests, with certificate and private key provided as mounted volumes.
- `docker-compose.proxy.yml`: A full setup with Isaiah running on port 80, behind a proxy listening on port 443.
- `docker-compose.traefik.yml`: A sample setup with Isaiah running on port 80, behind a Traefik proxy listening on port 443.
When your `docker-compose` file is on point, you can use the following commands :
```sh
# Option 1 : Run Isaiah in the current terminal (useful for debugging)
docker-compose up
# Option 2 : Run Isaiah in a detached terminal (most common)
docker-compose up -d
# Show the logs written by Isaiah (useful for debugging)
docker logs <NAME-OF-YOUR-CONTAINER>
```
> Warning : Always make sure that your Docker Unix socket is mounted, else Isaiah won't be able to communicate with the Docker API.
### Deploy as a standalone application
You can deploy Isaiah as a standalone application, either by downloading an existing binary that fits your architecture,
or by building the binary yourself on your machine.
#### Using an existing binary
An install script was created to help you install Isaiah in one line, from your terminal :
> As always, check the content of every file you pipe in bash
```sh
curl https://raw.githubusercontent.com/will-moss/isaiah/master/scripts/remote-install.sh | bash
```
This script will try to automatically download a binary that matches your operating system and architecture, and put it
in your `/usr/bin/` directory to ease running it. Later on, you can run :
```sh
# Create a new .env file
touch .env
# Edit .env file ...
# Run Isaiah
isaiah
```
In case you feel uncomfortable running the install script, you can head to the `Releases`, find the binary that meets your system, and install it yourself.
#### Building the binary manually
In this case, make sure that your system meets the following requirements :
- You have Go 1.21 installed
- You have Node 20+ installed along with npm and npx
When all the prerequisites are met, you can run the following commands in your terminal :
> As always, check the content of everything you run inside your terminal
```sh
# Retrieve the code
git clone https://github.com/will-moss/isaiah
cd isaiah
# Run the local install script
./scripts/local-install.sh
# Move anywhere else, and create a dedicated directory
cd ~
mkdir isaiah-config
cd isaiah-config
# Create a new .env file
touch .env
# Edit .env file ...
# Option 1 : Run Isaiah in the current terminal
isaiah
# Option 2 : Run Isaiah as a background process
isaiah &
# Option 3 : Run Isaiah using screen
screen -S isaiah
isaiah
<CTRL+A> <D>
# Optional : Remove the cloned repository
# cd <back to the cloned repository>
# rm -rf ./isaiah
```
The local install script will try to perform a production build on your machine, and move `isaiah` to your `/usr/bin/` directory
to ease running it. In more details, the following actions are performed :
- Local install of Babel, LightningCSS, Less, and Terser
- Prefixing, Transpilation, and Minification of CSS and JS assets
- Building of the Go source code into a single-file executable (with CSS and JS embed)
- Cleaning of the artifacts generated during the previous steps
- Removal of the previous `isaiah` executable, if any in `/usr/bin/`
- Moving the new `isaiah` executable in `/usr/bin` with `755` permissions.
If you encounter any issue during this process, please feel free to tweak the install script or reach out by opening an issue.
## Configuration
To run Isaiah, you will need to set the following environment variables in a `.env` file located next to your executable :
> **Note :** Regular environment variables provided on the commandline work too
| Parameter | Type | Description | Default |
| :---------------------- | :-------- | :------------------------- | ------- |
| `SSL_ENABLED` | `boolean` | Whether HTTPS should be used in place of HTTP. When configured, Isaiah will look for `certificate.pem` and `key.pem` next to the executable for configuring SSL. Note that if Isaiah is behind a proxy that already handles SSL, this should be set to `false`. | False |
| `SERVER_PORT` | `integer` | The port Isaiah listens on. | 3000 |
| `SERVER_MAX_READ_SIZE` | `integer` | The maximum size (in bytes) per message that Isaiah will accept over Websocket. (Shouldn't be modified, unless your server randomly restarts the Websocket session for no obvious reason) | 1024 |
| `AUTHENTICATION_ENABLED`| `boolean` | Whether a master password is required to access Isaiah. (Recommended) | True |
| `AUTHENTICATION_SECRET` | `string` | The master password used to secure your Isaiah instance against malicious actors. | one-very-long-and-mysterious-secret |
| `DISPLAY_CONFIRMATIONS` | `boolean` | Whether the web interface should display a confirmation message after every succesful operation. | True |
| `COLUMNS_CONTAINERS` | `string` | Comma-separated list of fields to display in the `Containers` panel. (Case-sensitive) (Available: ID, State, ExitCode, Name, Image) | State,ExitCode,Name,Image |
| `COLUMNS_IMAGES` | `string` | Comma-separated list of fields to display in the `Images` panel. (Case-sensitive) (Available: ID, Name, Version, Size) | Name,Version,Size |
| `COLUMNS_VOLUMES` | `string` | Comma-separated list of fields to display in the `Volumes` panel. (Case-sensitive) (Available: Name, Driver, MountPoint) | Driver,Name |
| `COLUMNS_NETWORKS` | `string` | Comma-separated list of fields to display in the `Networks` panel. (Case-sensitive) (Available: ID, Name, Driver) | Driver,Name |
| `CONTAINER_HEALTH_STYLE`| `string` | Style used to display the containers' health state. (Available: long, short, icon)| long |
| `CONTAINER_LOGS_TAIL` | `integer` | Number of lines to retrieve when requesting the last container logs | 50 |
| `CONTAINER_LOGS_SINCE` | `string` | The amount of time from now to use for retrieving the last container logs | 60m |
| `TTY_SERVER_COMMAND` | `string` | The command used to spawn a new shell inside the server where Isaiah is running | `/bin/sh -i` |
| `TTY_CONTAINER_COMMAND` | `string` | The command used to spawn a new shell inside the containers that Isaiah manages | `/bin/sh -c eval $(grep ^$(id -un): /etc/passwd \| cut -d : -f 7-) -i` |
| `CUSTOM_DOCKER_HOST` | `string` | The host to use in place of the one defined by the DOCKER_HOST default variable | Empty |
| `CUSTOM_DOCKER_CONTEXT` | `string` | The Docker context to use in place of the current Docker context set on the system | Empty |
| `SKIP_VERIFICATIONS` | `boolean` | Whether Isaiah should skip startup verification checks before running the HTTP(S) server. (Not recommended) | False |
> **Note :** Boolean values are case-insensitive, and can be represented via "ON" / "OFF" / "TRUE" / "FALSE" / 0 / 1.
## Troubleshoot
Should you encounter any issue running Isaiah, please refer to the following common problems with their solutions.
#### Isaiah is unreachable over HTTP / HTTPS
Please make sure that the following requirements are met :
- If Isaiah runs as a standalone application without proxy :
- Make sure your server / firewall accepts incoming connections on Isaiah's port.
- Make sure your DNS configuration is correct. (Usually, such record should suffice : `A isaiah XXX.XXX.XXX.XXX` for `https://isaiah.your-server-tld`)
- Make sure your `.env` file is well configured according to the [Configuration](#configuration) section.
- If Isaiah runs on Docker :
- Perform the previous (standalone) verifications first.
- Make sure you mounted your server's Docker Unix socket onto the container that runs Isaiah (/var/run/docker.sock)
- Make sure your Docker container is accessible remotely
- If Isaiah runs behind a proxy :
- Perform the previous (standalone) verifications first.
- Make sure that `SERVER_PORT` (Isaiah's port) are well set in `.env`.
- Check your proxy forwarding rules.
In any case, the crucial part is [Configuration](#configuration) and making sure your Docker / Proxy setup is correct as well.
#### The emulated shell behaves unconsistently or displays unexpected characters
Please note that the emulated shell works by performing the following steps :
- Open a headless terminal on the remote server / inside the remote Docker container.
- Capture standard output, standard error, and bind standard input to the web interface.
- Display standard output and standard error on the web interface as they are streamed over Websocket from the terminal.
According to this implementation, the remote terminal never receives key presses. It only receives commands.
Also, the following techniques are used to try to enhance the user experience on the web interface :
- Enable clearing the shell (HTML) screen via "Ctrl+L" (while the real terminal remains untouched)
- Enable quitting the (HTML) shell via "Ctrl+D" (by sending an "exit" command to the real terminal)
- Handle "command mirror" by appending "# ISAIAH" to every command sent by the user (to distinguish it from command output)
- Handle both "\r" and "\n" newline characters
- Use a time-based approach to detect when a command is finished if it doesn't output anything that shows clear ending
- Remove all escape sequences meant for coloring the terminal output
Therefore it appears that, unless we use a VNC-like solution, the emulation can neither be enhanced nor use keyboard-based features (such as tab completion).
Unless a contributor points the project in the right direction, and as far as my skills go, I personally believe that the current implementation has reached its maximum potential.
I leave here a few ideas that I believe could be implemented, but may require more knowledge, time, testing :
- Convert escape sequences to CSS colors
- Wrap every command in a "block" (begin - command - end) to easily distinguish user-sent commands from output
- Sending to the real terminal the key presses captured from the web (a.k.a sending key presses to a running process)
#### An error happens when spawning a new shell on the server / inside a Docker container
The default commands used to spawn a shell, although being more or less standard, may not fit your environment.
In this case, please edit the `TTY_SERVER_COMMAND` and `TTY_CONTAINER_COMMAND` variables to define a command that works better in your setup.
#### Isaiah doesn't work on mobile
As of now, the web interface isn't responsive. For this reason, a piece of code was intentionally added to prevent any user
from using Isaiah on mobile, so as to spare the unpleasant usage. If this was a mistake on our end, please feel free to open an issue.
Making it an option, or disabling it altogether shouldn't take too long. Also note that the piece of code that is responsible for
disabling Isaiah on mobile devices is easy to bypass as it is 100% CSS-based (removing/editing a CSS class is all it takes, so a browser extension could do it)
#### The connection with the remote server randomly stops or restarts
This is a known incident that happens when the Websocket server receives a data message that exceeds its maximum read size.
You should be able to fix that by setting the `SERVER_MAX_READ_SIZE` variable to a higher value (default is 1024 bytes).
This operation shouldn't cause any problem or impact performances.
#### I can neither click nor use the keyboard, nothing happens
In such a case, please check the icon in the lower right corner.
If you see an orange warning symbol, it means that the connection with the server was lost.
When the connection is lost, all inputs are disabled, until the connection is reestablished (a new attempt is performed every second).
#### Something else
Please feel free to open an issue, explaining what happens, and describing your environment.
## Security
Due to the very nature of Isaiah, I can't emphasize enough how important it is to harden your server :
- Always enable the authentication (with `AUTHENTICATION_ENABLED` and `AUTHENTICATION_SECRET` variables) unless you have your own authentication mechanism built into a proxy.
- Always use a long and secure password to prevent any malicious actor from taking over your Isaiah instance.
- You may also consider putting Isaiah on a private network accessible only through a VPN.
Keep in mind that any breach or misconfiguration on your end could allow a malicious actor to fully take over your server.
## Disclaimer
I believe that, although we're both in the open-source sphere and have all the best intentions, it is important to state the following :
- Isaiah isn't a competitor or any attempt at replacing the lazydocker project. Funnily enough, I'm myself more comfortable running lazydocker through SSH rather than in a browser.
- I've browsed almost all the open issues on lazydocker, and tried to implement and improve what I could (hence the `TTY_CONTAINER_COMMAND` variable, as an example, or even the Image pulling feature).
- Isaiah was built from absolute zero (for both the server and the client), and was ultimately completed using knowledge from lazydocker that I'm personally missing (e.g. the container states and icons).
- Before creating Isaiah, I tried to "serve lazydocker over websocket" (trying to send keypresses to the lazydocker process, and retrieving the output via Websocket), but didn't succeed, hence the full rewrite.
- I also tried to start Isaiah from the lazydocker codebase and implement a web interface on top of it, but it seemed impractical or simply beyond my skills, hence the full rewrite.
Ultimately, thanks to the people behind lazydocker both for the amazing project (that I'm using daily) and for paving the way for Isaiah.
PS : Please also note that Isaiah isn't exactly 100% feature-equivalent with lazydocker (e.g. charts are missing)
PS2 : What spurred me to build Isaiah in the first place is a bunch of comments on the Reddit self-hosted community, stating that Portainer and other available solutions were too heavy or hard to use. A Redditor said that having lazydocker over the web would be amazing, so I thought I'd do just that.
## Contribute
This is one of my first ever open-source projects, and I'm not a Docker / Github / Docker Hub / Git guru yet.
If you can help in any way, please do! I'm looking forward to learning from you.
From the top of my head, I'm sure there's already improvement to be made on :
- Terminology (using the proper words to describe technical stuff)
- Coding practices (e.g. writing better comments, avoiding monkey patches)
- Shell emulation (e.g. improving on what's done already)
- Release process (e.g. making explicit commits, pushing Docker images properly to Docker Hub)
- Github settings (e.g. using discussions, wiki, etc.)
- And more!
## Credits
Hey hey ! It's always a good idea to say thank you and mention the people and projects that help us move forward.
Big thanks to the individuals / teams behind these projects :
- [laydocker](https://github.com/jesseduffield/lazydocker) : Isaiah wouldn't exist if Lazydocker hadn't been created prior, and to say that it is an absolutely incredible and very advanced project is an understatement.
- [Heroicons](https://github.com/tailwindlabs/heroicons) : For the great icons.
- [Melody](https://github.com/olahol/melody) : For the awesome Websocket implementation in Go.
- [GoReleaser](https://github.com/goreleaser/goreleaser) : For the amazing release tool.
- The countless others!
And don't forget to mention Isaiah if it makes your life easier!

12
app/.babelrc.json Normal file
View File

@@ -0,0 +1,12 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"browsers": ["defaults", "ie >= 8"]
}
}
]
]
}

View File

@@ -0,0 +1,16 @@
:root {
--color-terminal-background: #000000;
--color-terminal-base: #ffffff;
--color-terminal-accent: #4af626;
--color-terminal-accent-selected: #73f859;
--color-terminal-hover: rgba(255, 255, 255, 0.15);
--color-terminal-border: #ffffff;
--color-terminal-danger: #ff0000;
--color-terminal-warning: #f67e26;
--color-terminal-accent-alternative: #26e1f6;
--color-terminal-json-key: darkturquoise;
--color-terminal-json-value: beige;
--color-terminal-cell-failure: #ff9999;
--color-terminal-cell-success: #9bff99;
--color-terminal-cell-paused: beige;
}

View File

@@ -0,0 +1,237 @@
button {
border: 0;
appearance: none;
background: none;
color: var(--color-terminal-base);
font-size: 16px;
cursor: pointer;
&:hover {
color: var(--color-terminal-accent);
}
}
span,
p,
div {
color: var(--color-terminal-base);
font-size: 16px;
font-weight: 300;
}
.tab {
outline: 1px solid var(--color-terminal-border);
position: relative;
display: flex;
justify-content: center;
align-items: center;
color: var(--color-terminal-base);
width: 100%;
height: 100%;
.tab-title {
position: absolute;
top: -10px;
background: var(--color-terminal-background);
left: 16px;
}
.tab-content {
display: flex;
flex-direction: column;
width: 100%;
padding-top: 14px;
padding-bottom: 10px;
overflow: auto;
// overflow: hidden;
.row {
display: flex;
align-items: center;
justify-content: flex-start;
height: 30px;
padding-left: 8px;
padding-right: 8px;
flex-shrink: 0;
cursor: pointer;
width: max-content;
min-width: 100%;
.cell {
display: flex;
justify-content: flex-start;
flex-shrink: 0;
white-space: pre;
em {
color: var(--color-terminal-danger);
font-style: normal;
}
}
p em {
color: var(--color-terminal-danger);
font-style: normal;
}
.generate-columns(cell; 12);
&:hover,
&.is-active {
background: var(--color-terminal-hover);
}
&.is-not-interactive {
pointer-events: none;
}
&.is-textual {
width: unset;
height: unset;
line-height: 145%;
}
&.is-json {
gap: 8px;
}
&.is-colored {
> .cell:nth-child(1) {
color: var(--color-terminal-json-key);
}
> .cell:nth-child(2),
.cell.is-array-value {
color: var(--color-terminal-json-value);
}
}
&:has(.sub-row) {
height: unset;
gap: 0;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
> .cell {
height: 30px;
align-items: center;
}
}
&.sub-row {
gap: 8px;
&:has(.sub-row) {
gap: 0;
}
}
}
table {
padding-top: 4px;
padding-left: 8px;
th {
text-align: left;
}
td {
white-space: nowrap;
padding-right: 24px;
}
}
}
.tab-scroller {
position: absolute;
height: 90%;
right: -5.5px;
width: 10px;
background: black;
display: none;
flex-direction: column;
align-items: center;
.up,
.down {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
// height: 14px;
background: black;
color: var(--color-terminal-accent);
}
.up {
padding-bottom: 3px;
}
.down {
padding-bottom: 3px;
}
.track {
height: 100%;
width: 1px;
background: var(--color-terminal-accent);
display: flex;
justify-content: center;
position: relative;
.thumb {
background: var(--color-terminal-accent);
position: absolute;
top: 0;
width: 10px;
}
}
}
.tab-title-group {
position: absolute;
top: -10px;
background: var(--color-terminal-background);
left: 16px;
display: flex;
align-items: center;
.tab-sub-title {
&:nth-child(n + 2) {
&:before {
content: ' — ';
color: var(--color-terminal-base);
white-space: pre;
}
&:hover:before {
color: var(--color-terminal-base);
}
}
&.is-active {
color: var(--color-terminal-accent);
font-weight: bold;
&:before {
font-weight: 400;
}
}
}
}
.tab-sub-content {
&:not(.is-active) {
display: none;
}
}
&.is-active {
outline-color: var(--color-terminal-accent);
.tab-title {
font-weight: bold;
color: var(--color-terminal-accent-selected);
}
&.is-scrollable {
.tab-scroller {
display: flex;
}
}
}
}

View File

@@ -0,0 +1,17 @@
@font-face {
font-family: 'Hack';
src: url('/assets/fonts/hack-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Hack';
src: url('/assets/fonts/hack-bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
.ft1() {
font-family: 'Hack', Consolas, Menlo, monospace, sans-serif;
}

View File

@@ -0,0 +1,6 @@
.generate-columns(@class; @number-cols; @i: 1) when (@i =< @number-cols) {
.@{class}-@{i}\/@{number-cols} {
width: 100% * (@i / @number-cols);
}
.generate-columns(@class; @number-cols; @i + 1);
}

351
app/client/assets/css/normalize.less vendored Normal file
View File

@@ -0,0 +1,351 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input {
/* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select {
/* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type='button']::-moz-focus-inner,
[type='reset']::-moz-focus-inner,
[type='submit']::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type='button']:-moz-focusring,
[type='reset']:-moz-focusring,
[type='submit']:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type='checkbox'],
[type='radio'] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type='number']::-webkit-inner-spin-button,
[type='number']::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type='search']::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}

View File

@@ -0,0 +1,24 @@
*,
*:before,
*:after {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0;
}
html {
scroll-behavior: smooth;
}
html,
body {
overscroll-behavior-y: none;
overflow-x: hidden;
}
img,
video,
iframe {
max-width: 100%;
}

View File

@@ -0,0 +1,395 @@
@import './fonts.less';
@import './normalize.less';
@import './reset.less';
@import './mixins.less';
@import './colors.less';
@import './components.less';
* {
// outline: 1px dashed blue;
}
// Globals
html {
.ft1();
}
// Screen - Animations
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
to {
opacity: 0;
}
}
@keyframes spin {
to {
transform: rotate(180deg);
}
}
.app-wrapper {
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
background: var(--color-terminal-background);
display: flex;
justify-content: center;
align-items: center;
}
.screen {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
// Screen - Active
&.is-active {
pointer-events: all;
z-index: 2;
animation: fade-in 0.25s ease-in-out forwards;
}
// Screen - Inactive
&:not(.is-active) {
pointer-events: none;
z-index: -1;
animation: fade-out 0.25s ease-in-out forwards;
}
// Screen - Loading
&.for-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 60px;
@keyframes blink {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.loader {
color: #ffffff;
animation: blink 1s infinite alternate;
width: 48px;
height: 48px;
display: flex;
justify-content: center;
align-items: center;
svg {
width: 100%;
}
}
p {
margin-top: 30px;
text-align: center;
}
}
// Screen - Dashboard
&.for-dashboard {
display: flex;
flex-direction: column;
.main,
.footer {
width: 100%;
}
.main {
height: 100%;
display: flex;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: 8px 4px;
height: 40px;
.left,
.right {
height: 100%;
display: flex;
align-items: center;
}
.left {
.help {
&:not(.is-active) {
display: none;
}
}
}
.right {
justify-content: flex-end;
position: relative;
.indicator {
color: var(--color-terminal-base);
justify-content: center;
align-items: center;
height: 100%;
transition: opacity 0.3s;
display: none;
svg {
height: 20px;
}
&.for-loading {
animation: spin 1s infinite linear;
}
&.for-disconnected {
color: var(--color-terminal-warning);
}
&.for-connected {
color: var(--color-terminal-accent-alternative);
}
&.is-active {
display: flex;
}
}
}
}
// Layouts
// &[data-layout='default'] {
.main {
column-gap: 16px;
padding-left: 4px; // Account for the tabs borders
padding-right: 16px + 4px; // Account for the tabs borders
padding-top: 18px; // Account for the first tabs' title + borders
@width-left: 34%;
@width-right: 66%;
.left,
.right {
// width: 50%;
flex-shrink: 0;
display: flex;
flex-direction: column;
.tab {
width: 100%;
height: 100%;
}
}
.left {
width: @width-left;
row-gap: 28px;
.tab {
.tab-content {
height: 0; // Trick to make overflow:auto work without setting a defined height
min-height: 100%;
.row {
gap: 24px;
}
}
&.for-containers {
.cell {
&[data-value='exited'] {
color: var(--color-terminal-cell-failure);
+ .cell {
color: var(--color-terminal-cell-failure);
}
}
&[data-value='running'] {
color: var(--color-terminal-cell-success);
}
&[data-value='paused'] {
color: var(--color-terminal-cell-paused);
}
}
}
}
}
// Inspector part
.right {
width: @width-right;
.tab {
.tab-content {
height: 0; // Trick to make overflow:auto work without setting a defined height
min-height: 100%;
overflow: auto;
.row:not(:has(.sub-row)) {
gap: 24px;
&.sub-row {
gap: 8px;
&:has(.sub-row) {
gap: 0;
}
}
}
.row:not(:has(.sub-row)).is-json {
gap: 8px;
}
}
}
[data-tab='Logs'] .row.is-textual {
white-space: nowrap;
}
}
}
// }
&[data-layout='half'] {
.main {
.left,
.right {
width: 50%;
}
}
}
&[data-layout='focus'] {
.main {
.left,
.right {
width: 50%;
}
.left .tab:not(.is-current) {
display: none;
}
}
}
// States
&.is-loading {
.footer {
.right {
.indicator.for-loading {
opacity: 1;
}
}
}
}
}
}
// Popup
.popup-layer {
position: fixed;
width: 100%;
height: 100%;
z-index: 9;
display: none;
&.is-active {
display: flex;
justify-content: center;
align-items: center;
}
.popup {
width: 55vw;
background: var(--color-terminal-background);
&[data-type='error'] .tab-content .row.is-textual p {
color: var(--color-terminal-danger);
}
&.for-menu .tab-content {
overflow: auto;
}
&.for-tty {
width: 90%;
height: 80%;
.tab-content {
justify-content: flex-start;
height: 100%;
overflow: auto;
input {
border: 0;
background: transparent;
color: var(--color-terminal-base);
caret-color: var(--color-terminal-base);
outline: 0;
margin-left: 8px;
width: 90%;
}
}
}
&.for-prompt {
&.for-login {
width: 435px;
}
.tab-content {
justify-content: flex-start;
overflow: auto;
input {
border: 0;
background: transparent;
color: var(--color-terminal-base);
caret-color: var(--color-terminal-base);
outline: 0;
margin-left: 8px;
width: 90%;
}
}
}
&.for-message[data-category='authentication'] {
width: 435px;
}
}
}
// Mobile blocker
.mobile-blocker {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 99;
pointer-events: none;
background: var(--color-terminal-background);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
p {
padding: 0 24px;
line-height: 125%;
}
@media screen and (min-width: 920px) {
display: none;
}
}

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

BIN
app/client/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

129
app/client/index.html Normal file
View File

@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
<link rel="stylesheet" type="text/css" href="/assets/css/style.css" />
<!-- <link rel="stylesheet" type="text/css" href="/assets/css/custom.css" /> -->
<script type="text/javascript" src="/assets/js/isaiah.js"></script>
<title>Manage your Docker fleet with ease - Isaiah</title>
</head>
<body>
<div class="app-wrapper">
<!-- SCREEN -- LOADING -->
<div class="screen for-loading is-active">
<div class="loader">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z" />
</svg>
</div>
<p>Establishing connection with the remote server</p>
</div>
<!-- SCREEN -- DASHBOARD -->
<div class="screen for-dashboard">
<div class="main">
<!-- OVERVIEW TABS (CONTAINERS, IMAGES, ETC.) -->
<div class="left"></div>
<!-- MAIN TAB (INSPECTOR) -->
<div class="right"></div>
</div>
<!-- FOOTER -->
<div class="footer">
<!-- HELPER -->
<div class="left">
<p class="help for-default">
← → ↑ ↓: navigate,
<button data-action="menu">x: menu</button>,
<button data-action="bulk">b: view bulk commands</button>,
<button data-action="quit">q: quit</button>,
<button data-action="help">?: help</button>
</p>
<p class="help for-menu">
enter: execute, esc: close, ↑ ↓: navigate
</p>
<p class="help for-prompt">
n/esc: no, y/enter: yes
</p>
<p class="help for-prompt-input">
esc: cancel, enter: submit
</p>
<p class="help for-message">
n/esc/y/enter: ok
</p>
</div>
<!-- LOADING & CONNECTION INDICATOR -->
<div class="right">
<div class="indicator for-loading" title="Loading">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</div>
<div
class="indicator for-connected"
title="Connection with server is enabled"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
/>
</svg>
</div>
<div
class="indicator for-disconnected"
title="Connection with server was lost"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</div>
</div>
</div>
</div>
<!-- POPUP LAYER -->
<div class="popup-layer"></div>
<!-- MOBILE BLOCKER -->
<div class="mobile-blocker">
<p>
As of now, Isaiah is not a responsive web application.<br /><br />
Please use a device with a display that is at least 920px wide.
</p>
</div>
</div>
</body>
</html>

2
app/client/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

23
app/default.env Normal file
View File

@@ -0,0 +1,23 @@
SSL_ENABLED="FALSE"
SERVER_PORT="3000"
SERVER_MAX_READ_SIZE="1024"
AUTHENTICATION_ENABLED="TRUE"
AUTHENTICATION_SECRET="one-very-long-and-mysterious-secret"
COLUMNS_CONTAINERS="State,ExitCode,Name,Image"
COLUMNS_IMAGES="Name,Version,Size"
COLUMNS_VOLUMES="Driver,Name"
COLUMNS_NETWORKS="Driver,Name"
CONTAINER_HEALTH_STYLE="long"
CONTAINER_LOGS_TAIL="50"
CONTAINER_LOGS_SINCE="60m"
DISPLAY_CONFIRMATIONS="TRUE"
TTY_SERVER_COMMAND="/bin/sh -i"
TTY_CONTAINER_COMMAND="/bin/sh -c eval $(grep ^$(id -un): /etc/passwd | cut -d : -f 7-)"
SKIP_VERIFICATIONS="FALSE"

33
app/go.mod Normal file
View File

@@ -0,0 +1,33 @@
module will-moss/isaiah
go 1.21
require (
github.com/docker/docker v24.0.7+incompatible
github.com/fatih/structs v1.1.0
github.com/joho/godotenv v1.5.1
github.com/mitchellh/mapstructure v1.5.0
github.com/olahol/melody v1.1.4
)
require (
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/distribution/reference v0.5.0 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/stretchr/testify v1.8.4 // indirect
golang.org/x/mod v0.8.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.6.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

89
app/go.sum Normal file
View File

@@ -0,0 +1,89 @@
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM=
github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/olahol/melody v1.1.4 h1:RQHfKZkQmDxI0+SLZRNBCn4LiXdqxLKRGSkT8Dyoe/E=
github.com/olahol/melody v1.1.4/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=

157
app/main.go Normal file
View File

@@ -0,0 +1,157 @@
package main
import (
"embed"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"github.com/docker/docker/client"
"github.com/joho/godotenv"
"github.com/olahol/melody"
_client "will-moss/isaiah/server/_internal/client"
_fs "will-moss/isaiah/server/_internal/fs"
_os "will-moss/isaiah/server/_internal/os"
_strconv "will-moss/isaiah/server/_internal/strconv"
"will-moss/isaiah/server/_internal/tty"
"will-moss/isaiah/server/server"
)
//go:embed client/*
var clientAssets embed.FS
//go:embed default.env
var defaultEnv string
// Perform checks to ensure the server is ready to start
// Returns an error if any condition isn't met
func performVerifications() error {
// 1. Ensure Docker CLI is available
if _os.GetEnv("DOCKER_RUNNING") != "TRUE" {
cmd := exec.Command("docker", "version")
_, err := cmd.Output()
if err != nil {
return fmt.Errorf("Failed Verification : Access to Docker CLI -> %s", err)
}
}
// 2. Ensure Docker socket is reachable
c, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return fmt.Errorf("Failed Verification : Access to Docker socket -> %s", err)
}
defer c.Close()
// 3. Ensure server port is available
l, err := net.Listen("tcp", fmt.Sprintf(":%s", _os.GetEnv("SERVER_PORT")))
if err != nil {
return fmt.Errorf("Failed Verification : Port binding -> %s", err)
}
defer l.Close()
// 4. Ensure certificate and private key are provided
if _os.GetEnv("SSL_ENABLED") == "TRUE" {
if _, err := os.Stat("./certificate.pem"); errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("Failed Verification : Certificate file missing -> Please put your certificate.pem file next to the executable")
}
if _, err := os.Stat("./key.pem"); errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("Failed Verification : Private key file missing -> Please put your key.pem file next to the executable")
}
}
return nil
}
// Entrypoint
func main() {
// Automatically discover the Docker host on the machine
discoveredHost, err := _client.DiscoverDockerHost()
if err != nil {
log.Print(err.Error())
return
}
os.Setenv("DOCKER_HOST", discoveredHost)
// Load default settings via default.env file (workaround since the file is embed)
defaultSettings, _ := godotenv.Unmarshal(defaultEnv)
for k, v := range defaultSettings {
if _os.GetEnv(k) == "" {
os.Setenv(k, v)
}
}
// Load custom settings via .env file
err = godotenv.Overload(".env")
if err != nil {
log.Print("No .env file provided, will continue with system env")
}
// Perform initial verifications
if _os.GetEnv("SKIP_VERIFICATIONS") != "TRUE" {
// Ensure everything is ready for our app
log.Print("Performing verifications before starting")
err = performVerifications()
if err != nil {
log.Print("Error performing initial verifications, abort\n")
log.Print(err)
return
}
}
// Set up everything (Melody instance, Docker client, Server settings)
server := server.Server{
Melody: melody.New(),
Docker: _client.NewClientWithOpts(client.FromEnv),
}
server.Melody.Config.MaxMessageSize = _strconv.ParseInt(_os.GetEnv("SERVER_MAX_READ_SIZE"), 10, 64)
// Set up static file serving for the front-end
serverRoot := _fs.Sub(clientAssets, "client")
http.Handle("/", http.StripPrefix("/", http.FileServer(http.FS(serverRoot))))
// Set up an endpoint to handle Websocket connections with Melody
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
server.Melody.HandleRequest(w, r)
})
// WS - Handle first user connecion
server.Melody.HandleConnect(func(session *melody.Session) {
server.Handle(session)
})
// WS - Handle user commands
server.Melody.HandleMessage(func(session *melody.Session, message []byte) {
go server.Handle(session, message)
})
// WS - Handle user disconnection
server.Melody.HandleDisconnect(func(s *melody.Session) {
// Clear user tty if there's any open
if terminal, exists := s.Get("tty"); exists {
(terminal.(*tty.TTY)).ClearAndQuit()
s.UnSet("tty")
}
// Clear user read stream if there's any open
if stream, exists := s.Get("stream"); exists {
(*stream.(*io.ReadCloser)).Close()
s.UnSet("stream")
}
})
log.Printf("Server starting on port %s", _os.GetEnv("SERVER_PORT"))
// Start the server
if _os.GetEnv("SSL_ENABLED") == "TRUE" {
http.ListenAndServeTLS(fmt.Sprintf(":%s", _os.GetEnv("SERVER_PORT")), "certificate.pem", "key.pem", nil)
} else {
http.ListenAndServe(fmt.Sprintf(":%s", _os.GetEnv("SERVER_PORT")), nil)
}
}

View File

@@ -0,0 +1,107 @@
package client
import (
"fmt"
"os"
"os/exec"
"strings"
"github.com/docker/docker/client"
_os "will-moss/isaiah/server/_internal/os"
)
// Alias for client.NewClientWithOpts, without returning any error
func NewClientWithOpts(ops client.Opt) *client.Client {
_client, _ := client.NewClientWithOpts(ops)
return _client
}
// Try to find the current Docker host on the system, using :
// 1. Env variable : CUSTOMER_DOCKER_HOST
// 2. Env variable : DOCKER_HOST
// 3. Env variable : DOCKER_CONTEXT
// 4. Output of command : docker context show + docker context inspect
// 5. OS-based default location
func DiscoverDockerHost() (string, error) {
// 1. Custom Docker host provided
if _os.GetEnv("CUSTOM_DOCKER_HOST") != "" {
return _os.GetEnv("CUSTOM_DOCKER_HOST"), nil
}
// 2. Default Docker host already set
if _os.GetEnv("DOCKER_HOST") != "" {
return _os.GetEnv("DOCKER_HOST"), nil
}
if _os.GetEnv("DOCKER_RUNNING") != "TRUE" {
// 3. Default Docker context already set
if _os.GetEnv("DOCKER_CONTEXT") != "" {
cmd := exec.Command("docker", "context", "inspect", _os.GetEnv("DOCKER_CONTEXT"))
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("An error occurred while trying to inspect the Docker context provided : %s", err)
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "Host") {
parts := strings.Split(line, "\"Host\": ")
replacer := strings.NewReplacer("\"", "", ",", "")
host := replacer.Replace(parts[1])
return host, nil
}
}
}
// 4. Attempt to retrieve the current Docker context if all the other cases proved unsuccesful
{
cmd := exec.Command("docker", "context", "show")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("An error occurred while trying to retrieve the default Docker context : %s", err)
}
currentContext := strings.TrimSpace(string(output))
if currentContext != "" {
cmd := exec.Command("docker", "context", "inspect", currentContext)
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("An error occurred while trying to inspect the default Docker context : %s", err)
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, "Host") {
parts := strings.Split(line, "\"Host\": ")
replacer := strings.NewReplacer("\"", "", ",", "")
host := replacer.Replace(parts[1])
return host, nil
}
}
}
}
}
// 5. Every previous attempt failed, try to use the default location
// 5.1. Unix-like systems
if _, err := os.Stat("/var/run/docker.sock"); err == nil {
return "unix:///var/run/docker.sock", nil
}
// 5.2. Windows system
if _, err := os.Stat("\\\\.\\pipe\\docker_engine"); err == nil {
return "\\\\.\\pipe\\docker_engine", nil
}
var finalError error
if _os.GetEnv("DOCKER_RUNNING") != "TRUE" {
finalError = fmt.Errorf("Automatic Docker host discovery failed on your system. Please try setting DOCKER_HOST manually")
} else {
finalError = fmt.Errorf("Automatic Docker host discovery failed on your system. Please make sure your Docker socket is mounted on your container")
}
return "", finalError
}

View File

@@ -0,0 +1,9 @@
package fs
import "io/fs"
// Alias of fs.Sub, without returning any error
func Sub(fsys fs.FS, dir string) fs.FS {
v, _ := fs.Sub(fsys, dir)
return v
}

View File

@@ -0,0 +1,14 @@
package _io
// Represent a default io.Writer but using a custom WriteFunction
// provided by the developer. It enables us to create "any" type of
// io.Writer, without having to create new interfaces for every new
// implementation we need.
type CustomWriter struct {
WriteFunction func(p []byte)
}
func (cw CustomWriter) Write(p []byte) (int, error) {
cw.WriteFunction(p)
return len(p), nil
}

View File

@@ -0,0 +1,11 @@
package json
import (
"encoding/json"
)
// Alias for json.Marshal, without returning any error
func Marshal(v any) []byte {
r, _ := json.Marshal(v)
return r
}

View File

@@ -0,0 +1,76 @@
package os
import (
"os"
"os/exec"
"strings"
"will-moss/isaiah/server/_internal/tty"
)
// Alias for os.GetEnv, with support for fallback value, and boolean normalization
func GetEnv(key string, fallback ...string) string {
value, exists := os.LookupEnv(key)
if !exists {
if len(fallback) > 0 {
value = fallback[0]
} else {
value = ""
}
} else {
// Quotes removal
value = strings.Trim(value, "\"")
// Boolean normalization
mapping := map[string]string{
"0": "FALSE",
"off": "FALSE",
"false": "FALSE",
"1": "TRUE",
"on": "TRUE",
"true": "TRUE",
"rue": "TRUE",
}
normalized, isBool := mapping[strings.ToLower(value)]
if isBool {
value = normalized
}
}
return value
}
// Retrieve all the environment variables as a map
func GetFullEnv() map[string]string {
var structured = make(map[string]string)
raw := os.Environ()
for i := 0; i < len(raw); i++ {
pair := strings.Split(raw[i], "=")
key := pair[0]
value := GetEnv(key)
structured[key] = value
}
return structured
}
// Open a shell on the system, and update the provided channels with
// status / errors as they happen
func OpenShell(tty *tty.TTY, channelErrors chan error, channelUpdates chan string) {
cmd := GetEnv("TTY_SERVER_COMMAND")
cmdParts := strings.Split(cmd, " ")
process := exec.Command(cmdParts[0], cmdParts[1:]...)
process.Stdin = tty.Stdin
process.Stderr = tty.Stdout
process.Stdout = tty.Stdout
err := process.Start()
if err != nil {
channelErrors <- err
} else {
channelUpdates <- "started"
process.Wait()
channelUpdates <- "exited"
}
}

View File

@@ -0,0 +1,43 @@
package process
import "github.com/docker/docker/client"
// Represent a tri-channel holder for a long task to communicate
type LongTaskMonitor struct {
Results chan string
Errors chan error
Done chan bool
}
// Represent a long-running function on a Docker resource
type LongTask struct {
Function func(*client.Client, LongTaskMonitor, map[string]interface{})
Args map[string]interface{}
OnStep func(string)
OnError func(error)
OnDone func()
}
// Run task.Function in a goroutine, and update the Function monitor provided
// as the Function is executed
func (task LongTask) RunSync(docker *client.Client) {
finished, results, errors, done := false, make(chan string), make(chan error), make(chan bool)
go task.Function(docker, LongTaskMonitor{Results: results, Errors: errors, Done: done}, task.Args)
for {
if finished {
break
}
select {
case r := <-results:
task.OnStep(r)
case e := <-errors:
task.OnError(e)
case <-done:
finished = true
}
}
task.OnDone()
}

View File

@@ -0,0 +1,9 @@
package strconv
import "strconv"
// Alias of strconv.ParseInt, without returning any error
func ParseInt(s string, base int, bitSize int) int64 {
i, _ := strconv.ParseInt(s, base, bitSize)
return i
}

View File

@@ -0,0 +1,79 @@
package tty
import (
"fmt"
"io"
)
// Represent a TTY (pseudo-terminal)
type TTY struct {
Stdin TTYReader // Standard Input
Stdout io.Writer // Standard Output
Stderr io.Writer // Standard Error (may be used as stdin mirror)
Input TTYWriter // Writer piped to Stdin to send commands
}
func New(stdout io.Writer) TTY {
commandReader, commandWriter := io.Pipe()
return TTY{
Stdin: TTYReader{Reader: commandReader},
Input: TTYWriter{Writer: commandWriter},
Stdout: stdout,
}
}
// Send an "exit" command to the pseudo-terminal, and close Stdin
func (tty *TTY) ClearAndQuit() {
if tty == nil {
return
}
if tty.Stdin == (TTYReader{}) {
return
}
if tty.Input == (TTYWriter{}) {
return
}
io.WriteString(tty.Input.Writer, "exit\n")
tty.Stdin.Reader.Close()
tty.Input.Writer.Close()
}
// Send the given command to Stdin, with specific treatment to later
// distinguish our commands from Stdout results
func (tty *TTY) RunCommand(command string) error {
bashCommand := fmt.Sprintf("%s #ISAIAH", command)
_, err := io.WriteString(
tty.Input,
bashCommand+"\n",
)
return err
}
// Wrapper around io.PipeReader to be able to pass it as an io.Reader
type TTYReader struct {
Reader *io.PipeReader
}
func (r TTYReader) Read(p []byte) (int, error) {
return r.Reader.Read(p)
}
func (r TTYReader) Close() error {
return r.Reader.Close()
}
// Wrapper around io.PipeWriter to be able to pass it as an io.Writer
type TTYWriter struct {
Writer *io.PipeWriter
}
func (w TTYWriter) Write(p []byte) (int, error) {
return w.Writer.Write(p)
}
func (w TTYWriter) Close() error {
return w.Writer.Close()
}

View File

@@ -0,0 +1,574 @@
package resources
import (
"context"
"encoding/json"
"fmt"
"io"
"sort"
"strconv"
"strings"
"sync"
_os "will-moss/isaiah/server/_internal/os"
"will-moss/isaiah/server/_internal/process"
"will-moss/isaiah/server/_internal/tty"
"will-moss/isaiah/server/ui"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/fatih/structs"
)
// Represent a Docker container
type Container struct {
ID string
State string
ExitCode int
Name string
Image string
Ports []types.Port
}
// Represent an array of Docker containers
type Containers []Container
// Status translations using one/two-letter words
var shortStateTranslations = map[string]string{
"paused": "P",
"exited": "X",
"created": "C",
"removing": "RM",
"restarting": "RS",
"running": "R",
"dead": "D",
}
// Status translations using symbol icons
var iconStateTranslations = map[string]rune{
"paused": '◫',
"exited": '',
"created": '+',
"removing": '',
"restarting": '⟳',
"running": '▶',
"dead": '!',
}
// Retrieve all inspector tabs for Docker containers
func ContainersInspectorTabs() []string {
return []string{"Logs", "Stats", "Env", "Config", "Top"}
}
// Retrieve all the single actions associated with Docker containers
func ContainerSingleActions() []ui.MenuAction {
var actions []ui.MenuAction
actions = append(
actions,
ui.MenuAction{
Key: "d",
Label: "remove container",
Command: "container.menu.remove",
RequiresResource: true,
},
ui.MenuAction{
Key: "p",
Label: "pause/unpause container",
Command: "container.pause",
RequiresResource: true,
},
ui.MenuAction{
Key: "s",
Label: "stop container",
Command: "container.stop",
Prompt: "Are you sure you want to stop this container?",
RequiresResource: true,
},
ui.MenuAction{
Key: "r",
Label: "restart container",
Command: "container.restart",
RequiresResource: true,
},
ui.MenuAction{
Key: "E",
Label: "exec shell inside container",
Command: "container.shell",
RequiresResource: true,
},
ui.MenuAction{
Key: "w",
Label: "open in browser",
Command: "container.browser",
RequiresResource: true,
},
)
return actions
}
// Retrieve all the remove actions associated with Docker containers
func ContainerRemoveActions(c Container) []ui.MenuAction {
var actions []ui.MenuAction
actions = append(
actions,
ui.MenuAction{
Key: "remove",
Label: fmt.Sprintf("<em>docker rm %s</em> ?", c.Name),
Command: "container.remove.default",
RequiresResource: true,
},
)
actions = append(
actions,
ui.MenuAction{
Key: "remove with volumes",
Label: fmt.Sprintf("<em>docker rm --volumes %s</em> ?", c.Name),
Command: "container.remove.default.volumes",
RequiresResource: true,
},
)
return actions
}
// Retrieve all the bulk actions associated with Docker containers
func ContainersBulkActions() []ui.MenuAction {
var actions []ui.MenuAction
actions = append(
actions,
ui.MenuAction{
Label: "stop all containers",
Prompt: "Are you sure you want to stop all containers?",
Command: "containers.stop",
},
)
actions = append(
actions,
ui.MenuAction{
Label: "remove all containers (forced)",
Prompt: "Are you sure you want to remove all containers?",
Command: "containers.remove",
},
)
actions = append(
actions,
ui.MenuAction{
Label: "prune unused containers",
Prompt: "Are you sure you want to prune all unused containers?",
Command: "containers.prune",
},
)
return actions
}
// Retrieve all Docker containers
func ContainersList(client *client.Client) Containers {
reader, err := client.ContainerList(context.Background(), types.ContainerListOptions{All: true})
if err != nil {
return []Container{}
}
var containers []Container
for i := 0; i < len(reader); i++ {
var information = reader[i]
var container Container
container.ID = information.ID
container.Name = information.Names[0][1:]
container.State = information.State
container.Image = information.Image
container.Ports = information.Ports
inspection, err := client.ContainerInspect(context.Background(), information.ID)
if err == nil {
container.ExitCode = inspection.State.ExitCode
}
containers = append(containers, container)
}
return containers
}
// Stop all Docker containers
func ContainersStop(client *client.Client, monitor process.LongTaskMonitor, args map[string]interface{}) {
containers := ContainersList(client)
wg := sync.WaitGroup{}
wg.Add(len(containers))
for i := 0; i < len(containers); i++ {
go func(_container Container) {
defer wg.Done()
err := client.ContainerStop(context.Background(), _container.ID, container.StopOptions{})
if err != nil {
monitor.Errors <- err
return
}
monitor.Results <- _container.ID
}(containers[i])
}
wg.Wait()
monitor.Done <- true
}
// Force remove Docker containers
func ContainersRemove(client *client.Client) error {
containers := ContainersList(client)
for i := 0; i < len(containers); i++ {
_container := containers[i]
err := client.ContainerRemove(context.Background(), _container.ID, types.ContainerRemoveOptions{Force: true})
if err != nil {
return err
}
}
return nil
}
// Prune unused Docker containers
func ContainersPrune(client *client.Client) error {
_, err := client.ContainersPrune(context.Background(), filters.Args{})
return err
}
// Turn the list of Docker containers into a list of rows representing them
func (containers Containers) ToRows(columns []string) ui.Rows {
var rows = make(ui.Rows, 0)
sort.Slice(containers, func(i, j int) bool {
if containers[i].State == "running" && containers[j].State != "running" {
return true
}
if containers[j].State == "running" && containers[i].State != "running" {
return false
}
return containers[i].Name < containers[j].Name
})
for i := 0; i < len(containers); i++ {
container := containers[i]
row := structs.Map(container)
var flat = make([]map[string]string, 0)
for j := 0; j < len(columns); j++ {
_entry := make(map[string]string)
_entry["field"] = columns[j]
switch columns[j] {
case "ID":
_entry["value"] = container.ID
case "Name":
_entry["value"] = container.Name
case "State":
_entry["value"] = container.State
if _os.GetEnv("CONTAINER_HEALTH_STYLE") == "short" {
_entry["representation"] = shortStateTranslations[container.State]
} else if _os.GetEnv("CONTAINER_HEALTH_STYLE") == "icon" {
_entry["representation"] = string(iconStateTranslations[container.State])
}
case "ExitCode":
if container.ExitCode != 0 {
_entry["value"] = fmt.Sprintf("(%d)", container.ExitCode)
} else {
_entry["value"] = ""
}
case "Image":
_entry["value"] = container.Image
}
flat = append(flat, _entry)
}
row["_representation"] = flat
rows = append(rows, row)
}
return rows
}
// Remove the Docker container
func (c Container) Remove(client *client.Client, force bool, removeVolumes bool) error {
return client.ContainerRemove(context.Background(), c.ID, types.ContainerRemoveOptions{Force: force, RemoveVolumes: removeVolumes})
}
// Pause the Docker container
func (c Container) Pause(client *client.Client) error {
return client.ContainerPause(context.Background(), c.ID)
}
// Unpause the Docker container
func (c Container) Unpause(client *client.Client) error {
return client.ContainerUnpause(context.Background(), c.ID)
}
// Stop the Docker container
func (c Container) Stop(client *client.Client) error {
return client.ContainerStop(context.Background(), c.ID, container.StopOptions{})
}
// Restart the Docker container
func (c Container) Restart(client *client.Client) error {
return client.ContainerRestart(context.Background(), c.ID, container.StopOptions{})
}
// Inspect the Docker container
func (c Container) Inspect(client *client.Client) (types.ContainerJSON, error) {
return client.ContainerInspect(context.Background(), c.ID)
}
// Open a shell inside the Docker container
func (c Container) Shell(client *client.Client, tty *tty.TTY, channelErrors chan error, channelUpdates chan string) {
cmd := _os.GetEnv("TTY_SERVER_COMMAND")
execConfig := types.ExecConfig{
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
Cmd: strings.Split(cmd, " "),
}
exec, err := client.ContainerExecCreate(context.Background(), c.ID, execConfig)
if err != nil {
channelErrors <- err
return
}
process, err := client.ContainerExecAttach(context.Background(), exec.ID, types.ExecStartCheck{Tty: true})
if err != nil {
channelErrors <- err
}
defer process.Close()
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_, _ = io.Copy(tty.Stdout, process.Reader)
}()
go func() {
defer wg.Done()
_, _ = io.Copy(process.Conn, tty.Stdin)
}()
channelUpdates <- "started"
wg.Wait()
channelUpdates <- "exited"
}
// Retrieve the public URL to access the Docker container
func (c Container) GetBrowserUrl(client *client.Client) (string, error) {
if len(c.Ports) == 0 {
return "", fmt.Errorf("No port is exposed on this container")
}
host := c.Ports[0].IP
if host == "0.0.0.0" {
host = "localhost"
}
address := ""
for _, p := range c.Ports {
if p.PublicPort == 443 {
address = fmt.Sprintf("https://%s", host)
return address, nil
}
if p.PublicPort == 80 {
address = fmt.Sprintf("http://%s", host)
return address, nil
}
}
if len(address) == 0 {
address = fmt.Sprintf("http://%s:%d", host, c.Ports[0].PublicPort)
}
return address, nil
}
// Inspector - Retrieve the logs written by the Docker container
func (c Container) GetLogs(client *client.Client, writer io.Writer) (*io.ReadCloser, error) {
opts := types.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Timestamps: false,
Tail: _os.GetEnv("CONTAINER_LOGS_TAIL"),
Since: _os.GetEnv("CONTAINER_LOGS_SINCE"),
Follow: true,
}
reader, err := client.ContainerLogs(context.Background(), c.ID, opts)
if err != nil {
return nil, err
}
go stdcopy.StdCopy(writer, writer, reader)
return &reader, nil
}
// Inspector - Retrieve the full configuration of the Docker Container
func (c Container) GetConfig(client *client.Client) (ui.InspectorContent, error) {
information, err := client.ContainerInspect(context.Background(), c.ID)
if err != nil {
return nil, err
}
// Build the first part of the config (main information)
firstPart := ui.InspectorContentPart{Type: "rows"}
rows := make(ui.Rows, 0)
fields := []string{"ID", "Name", "Image", "Command"}
for _, field := range fields {
row := make(ui.Row)
switch field {
case "ID":
row["ID"] = c.ID
row["_representation"] = []string{"ID:", c.ID}
case "Name":
row["Name"] = c.Name
row["_representation"] = []string{"Name:", c.Name}
case "Image":
row["Image"] = c.Name
row["_representation"] = []string{"Image:", c.Image}
case "Command":
row["Command"] = strings.Join(information.Config.Entrypoint, " ") + " " + strings.Join(information.Config.Cmd, " ")
row["_representation"] = []string{"Command:", row["Command"].(string)}
}
rows = append(rows, row)
}
firstPart.Content = rows
separator := ui.InspectorContentPart{Type: "lines", Content: []string{"Full details:", "&nbsp;"}}
// Build the full config using : First part // Separator // JSONBase // Mounts // Config // NetworkSettings
allConfig := ui.InspectorContent{
firstPart,
separator,
ui.InspectorContentPart{Type: "json", Content: information.ContainerJSONBase},
ui.InspectorContentPart{Type: "json", Content: ui.JSON{"Mounts": information.Mounts}},
ui.InspectorContentPart{Type: "json", Content: ui.JSON{"Config": information.Config}},
ui.InspectorContentPart{Type: "json", Content: ui.JSON{"NetworkSettings": information.NetworkSettings}},
}
return allConfig, nil
}
// Inspector - Retrieve the environment variables used to run the Docker container
func (c Container) GetEnv(client *client.Client) (ui.Rows, error) {
information, err := client.ContainerInspect(context.Background(), c.ID)
if err != nil {
return nil, err
}
var env = make(ui.Rows, 0)
raw := information.Config.Env
for i := 0; i < len(raw); i++ {
structured := make(ui.Row)
pair := strings.Split(raw[i], "=")
key := pair[0]
value := pair[1]
structured[key] = value
structured["_representation"] = []string{key + ":", value}
env = append(env, structured)
}
return env, nil
}
// Inspector - Retrieve the list of running processes inside the Docker container
func (c Container) GetTop(client *client.Client) (ui.Table, error) {
if c.State == "exited" {
return ui.Table{Headers: []string{"Notice"}, Rows: [][]string{[]string{"The container isn't running"}}}, nil
}
information, err := client.ContainerTop(context.Background(), c.ID, []string{})
if err != nil {
return ui.Table{}, err
}
table := ui.Table{}
table.Headers = information.Titles
table.Rows = information.Processes
return table, nil
}
// Inspector - Retrieve the stats of the Docker container
func (c Container) GetStats(client *client.Client) (ui.InspectorContent, error) {
if c.State == "exited" {
return ui.InspectorContent{
ui.InspectorContentPart{
Type: "table",
Content: ui.Table{Headers: []string{"Notice"}, Rows: [][]string{[]string{"The container isn't running"}}},
},
}, nil
}
information, err := client.ContainerStatsOneShot(context.Background(), c.ID)
if err != nil {
return nil, err
}
defer information.Body.Close()
var statsResult types.StatsJSON
if err := json.NewDecoder(information.Body).Decode(&statsResult); err != nil {
return nil, err
}
mainStats := ui.InspectorContentPart{Type: "rows"}
rows := make(ui.Rows, 0)
fields := []string{"CPU", "Memory", "Network", "PIDs"}
for _, field := range fields {
row := make(ui.Row)
switch field {
case "CPU":
cpuUsageDelta := statsResult.CPUStats.CPUUsage.TotalUsage - statsResult.PreCPUStats.CPUUsage.TotalUsage
cpuTotalUsageDelta := statsResult.CPUStats.SystemUsage - statsResult.PreCPUStats.SystemUsage
value := float64(cpuUsageDelta*100) / float64(cpuTotalUsageDelta)
row["CPU"] = value
row["_representation"] = []string{"CPU:", fmt.Sprintf("%.2f%%", row["CPU"])}
case "Memory":
row["Memory"] = float64(statsResult.MemoryStats.Usage*100) / float64(statsResult.MemoryStats.Limit)
row["_representation"] = []string{"Memory:", fmt.Sprintf("%.2f%%", row["Memory"])}
case "Network":
row["Network"] = fmt.Sprintf("%s / %s (RX/TX)", ui.UByteCount(statsResult.Networks["eth0"].RxBytes), ui.UByteCount(statsResult.Networks["eth0"].TxBytes))
row["_representation"] = []string{"Network:", row["Network"].(string)}
case "PIDs":
row["PIDs"] = statsResult.PidsStats.Current
row["_representation"] = []string{"PIDs:", strconv.FormatUint(statsResult.PidsStats.Current, 10)}
}
rows = append(rows, row)
}
mainStats.Content = rows
separator := ui.InspectorContentPart{Type: "lines", Content: []string{"Full stats:", "&nbsp;"}}
return ui.InspectorContent{
mainStats,
separator,
ui.InspectorContentPart{Type: "json", Content: statsResult},
}, nil
}

View File

@@ -0,0 +1,353 @@
package resources
import (
"bufio"
"context"
"fmt"
"log"
"sort"
"strconv"
"strings"
"sync"
"will-moss/isaiah/server/_internal/process"
"will-moss/isaiah/server/ui"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/fatih/structs"
)
// Represent a Docker image
type Image struct {
ID string
Name string
Version string
Size int64
}
// Represent an array of Docker images
type Images []Image
// Retrieve all inspector tabs for Docker images
func ImagesInspectorTabs() []string {
return []string{"Config"}
}
// Retrieve all the single actions associated with Docker images
func ImageSingleActions() []ui.MenuAction {
var actions []ui.MenuAction
actions = append(
actions,
ui.MenuAction{
Label: "remove image",
Command: "image.menu.remove",
Key: "d",
RequiresResource: true,
},
ui.MenuAction{
Label: "run image",
Command: "run_restart",
Key: "r",
RequiresResource: false,
RunLocally: true,
},
ui.MenuAction{
Label: "open on Docker Hub",
Command: "hub",
Key: "h",
RequiresResource: false,
RunLocally: true,
},
ui.MenuAction{
Label: "pull a new image",
Command: "pull",
Key: "P",
RequiresResource: false,
RunLocally: true,
},
)
return actions
}
// Retrieve all the remove actions associated with Docker images
func ImageRemoveActions(v Volume) []ui.MenuAction {
var actions []ui.MenuAction
actions = append(
actions,
ui.MenuAction{
Key: "remove",
Label: fmt.Sprintf("<em>docker image rm %s</em> ?", v.Name),
Command: "image.remove.default",
RequiresResource: true,
},
)
actions = append(
actions,
ui.MenuAction{
Key: "remove without deleting untagged parents",
Label: fmt.Sprintf("<em>docker image rm --no-prune %s</em> ?", v.Name),
Command: "image.remove.default.unprune",
RequiresResource: true,
},
)
actions = append(
actions,
ui.MenuAction{
Key: "force remove",
Label: fmt.Sprintf("<em>docker image rm --force %s</em> ?", v.Name),
Command: "image.remove.force",
RequiresResource: true,
},
)
actions = append(
actions,
ui.MenuAction{
Key: "force remove without deleting untagged parents ",
Label: fmt.Sprintf("<em>docker image rm --no-prune --force %s</em> ?", v.Name),
Command: "image.remove.force.unprune",
RequiresResource: true,
},
)
return actions
}
// Retrieve all the bulk actions associated with Docker images
func ImagesBulkActions() []ui.MenuAction {
var actions []ui.MenuAction
actions = append(
actions,
ui.MenuAction{
Label: "prune unused images",
Prompt: "Are you sure you want to prune all unused images?",
Command: "images.prune",
},
)
return actions
}
// Retrieve all Docker images
func ImagesList(client *client.Client) Images {
reader, err := client.ImageList(context.Background(), types.ImageListOptions{All: true})
if err != nil {
return []Image{}
}
var images []Image
for i := 0; i < len(reader); i++ {
var summary = reader[i]
var image Image
image.ID = summary.ID
if len(summary.RepoTags) > 0 {
if strings.Contains(summary.RepoTags[0], ":") {
parts := strings.Split(summary.RepoTags[0], ":")
image.Name = parts[0]
image.Version = parts[1]
}
} else {
image.Name = "<none>"
image.Version = "<none>"
}
image.Size = summary.Size
images = append(images, image)
}
return images
}
// Prune unused Docker images
func ImagesPrune(client *client.Client) error {
args := filters.NewArgs(filters.KeyValuePair{Key: "dangling", Value: "false"})
_, err := client.ImagesPrune(context.Background(), args)
return err
}
// Turn the list of Docker images into a list of rows representing them
func (images Images) ToRows(columns []string) ui.Rows {
var rows = make(ui.Rows, 0)
sort.Slice(images, func(i, j int) bool {
if images[i].Name == "<none>" {
return false
}
if images[j].Name == "<none>" {
return true
}
return images[i].Name < images[j].Name
})
for i := 0; i < len(images); i++ {
image := images[i]
row := structs.Map(image)
var flat = make([]map[string]string, 0)
for j := 0; j < len(columns); j++ {
_entry := make(map[string]string)
_entry["field"] = columns[j]
switch columns[j] {
case "ID":
_entry["value"] = image.ID
case "Name":
_entry["value"] = image.Name
case "Version":
_entry["value"] = image.Version
case "Size":
_entry["value"] = strconv.FormatInt(image.Size, 10)
_entry["representation"] = ui.ByteCount(image.Size)
}
flat = append(flat, _entry)
}
row["_representation"] = flat
rows = append(rows, row)
}
return rows
}
// Remove the Docker image
func (i Image) Remove(client *client.Client, force bool, prune bool) error {
_, err := client.ImageRemove(context.Background(), i.ID, types.ImageRemoveOptions{Force: force, PruneChildren: prune})
return err
}
// Pull a new Docker image
func ImagePull(c *client.Client, m process.LongTaskMonitor, args map[string]interface{}) {
name := args["Image"].(string)
rc, err := c.ImagePull(context.Background(), name, types.ImagePullOptions{})
if err != nil {
m.Errors <- err
return
}
wg := sync.WaitGroup{}
wg.Add(1)
go func() {
scanner := bufio.NewScanner(rc)
for scanner.Scan() {
m.Results <- scanner.Text()
}
wg.Done()
}()
wg.Wait()
m.Done <- true
}
// Inspector - Retrieve the full configuration associated with a Docker image
func (i Image) GetConfig(client *client.Client) (ui.InspectorContent, error) {
information, _, err := client.ImageInspectWithRaw(context.Background(), i.ID)
if err != nil {
return nil, err
}
// Build the first part of the config (main information)
firstPart := ui.InspectorContentPart{Type: "rows"}
rows := make(ui.Rows, 0)
fields := []string{"Name", "ID", "Tags", "Size", "Created"}
for _, field := range fields {
row := make(ui.Row)
switch field {
case "Name":
row["Name"] = i.Name
row["_representation"] = []string{"Name:", i.Name}
case "ID":
row["ID"] = i.ID
row["_representation"] = []string{"ID:", i.ID}
case "Tags":
row["Tags"] = information.RepoTags
row["_representation"] = []string{"Tags:", strings.Join(information.RepoTags, ", ")}
case "Size":
row["Size"] = information.Size
row["_representation"] = []string{"Size:", ui.ByteCount(information.Size)}
case "Created":
row["Created"] = information.Created
row["_representation"] = []string{"Created:", information.Created}
}
rows = append(rows, row)
}
firstPart.Content = rows
separator := ui.InspectorContentPart{Type: "lines", Content: []string{"&nbsp;", "&nbsp;"}}
// Build the image's history
table := ui.Table{}
table.Headers = []string{"ID", "TAG", "SIZE", "COMMAND"}
history, err := client.ImageHistory(context.Background(), i.ID)
if err == nil {
rows := make([][]string, 0)
for _, entry := range history {
_id := "&lt;none&gt;"
if entry.ID != "" {
if len(entry.ID) > 17 {
_id = entry.ID[7:17]
}
}
_tag := ""
if len(entry.Tags) > 0 {
_tag = entry.Tags[0]
}
rows = append(
rows,
[]string{
_id,
_tag,
ui.ByteCount(entry.Size),
entry.CreatedBy,
},
)
}
table.Rows = rows
} else {
log.Print(err)
}
// Build the full config using : First part // Separator // History
allConfig := ui.InspectorContent{
firstPart,
separator,
ui.InspectorContentPart{Type: "table", Content: table},
}
return allConfig, nil
}
// Create and start a new Docker container based on the Docker image
func (i Image) Run(client *client.Client, name string) error {
response, err := client.ContainerCreate(
context.Background(),
&container.Config{Image: i.Name},
nil,
nil,
nil,
name,
)
if err != nil {
return err
}
// Start the container
err = client.ContainerStart(
context.Background(),
response.ID,
types.ContainerStartOptions{},
)
return err
}

View File

@@ -0,0 +1,200 @@
package resources
import (
"context"
"fmt"
"sort"
"strconv"
"will-moss/isaiah/server/ui"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/fatih/structs"
)
// Represent a Docker netowrk
type Network struct {
ID string
Name string
Driver string
}
// Represent a array of Docker networks
type Networks []Network
// Retrieve all inspector tabs for networks
func NetworksInspectorTabs() []string {
return []string{"Config"}
}
// Retrieve all the single actions associated with Docker networks
func NetworkSingleActions(n Network) []ui.MenuAction {
var actions []ui.MenuAction
actions = append(
actions,
ui.MenuAction{
Key: "d",
Label: "remove network",
Command: "network.menu.remove",
RequiresResource: true,
},
)
return actions
}
// Retrieve all the remove actions associated with Docker networks
func NetworkRemoveActions(n Network) []ui.MenuAction {
var actions []ui.MenuAction
actions = append(
actions,
ui.MenuAction{
Key: "remove",
Label: fmt.Sprintf("<em>docker network rm %s</em> ?", n.Name),
Command: "network.remove.default",
RequiresResource: true,
},
)
return actions
}
// Retrieve all the bulk actions associated with Docker networks
func NetworksBulkActions() []ui.MenuAction {
var actions []ui.MenuAction
actions = append(
actions,
ui.MenuAction{
Label: "prune unused networks",
Prompt: "Are you sure you want to prune all unused networks?",
Command: "networks.prune",
},
)
return actions
}
// Retrieve all Docker networks
func NetworksList(client *client.Client) Networks {
reader, err := client.NetworkList(context.Background(), types.NetworkListOptions{})
if err != nil {
return []Network{}
}
var networks []Network
for i := 0; i < len(reader); i++ {
var information = reader[i]
var network Network
network.ID = information.ID
network.Name = information.Name
network.Driver = information.Driver
networks = append(networks, network)
}
return networks
}
// Prune unused Docker networks
func NetworksPrune(client *client.Client) error {
_, err := client.NetworksPrune(context.Background(), filters.Args{})
return err
}
// Remove the Docker network
func (n Network) Remove(client *client.Client) error {
err := client.NetworkRemove(context.Background(), n.ID)
return err
}
// Turn the list of Docker networks into a list of string rows representing them
func (networks Networks) ToRows(columns []string) ui.Rows {
var rows = make(ui.Rows, 0)
sort.Slice(networks, func(i, j int) bool {
return networks[i].Name < networks[j].Name
})
for i := 0; i < len(networks); i++ {
network := networks[i]
row := structs.Map(network)
var flat = make([]map[string]string, 0)
for j := 0; j < len(columns); j++ {
_entry := make(map[string]string)
_entry["field"] = columns[j]
switch columns[j] {
case "ID":
_entry["value"] = network.ID
case "Name":
_entry["value"] = network.Name
case "Driver":
_entry["value"] = network.Driver
}
flat = append(flat, _entry)
}
row["_representation"] = flat
rows = append(rows, row)
}
return rows
}
// Inspector - Retrieve the full configuration associated with a Docker network
func (n Network) GetConfig(client *client.Client) (ui.InspectorContent, error) {
information, err := client.NetworkInspect(context.Background(), n.ID, types.NetworkInspectOptions{})
if err != nil {
return nil, err
}
// Build the first part of the config (main information)
firstPart := ui.InspectorContentPart{Type: "rows"}
rows := make(ui.Rows, 0)
fields := []string{"ID", "Name", "Driver", "Scope", "EnabledIPV6", "Internal", "Attachable", "Ingress"}
for _, field := range fields {
row := make(ui.Row)
switch field {
case "ID":
row["ID"] = n.Name
row["_representation"] = []string{"ID:", n.ID}
case "Name":
row["Name"] = n.Name
row["_representation"] = []string{"Name:", n.Name}
case "Driver":
row["Driver"] = n.Driver
row["_representation"] = []string{"Driver:", n.Driver}
case "Scope":
row["Scope"] = information.Scope
row["_representation"] = []string{"Scope:", information.Scope}
case "EnabledIPV6":
row["EnabledIPV6"] = information.EnableIPv6
row["_representation"] = []string{"EnabledIPV6:", strconv.FormatBool(information.EnableIPv6)}
case "Internal":
row["Internal"] = information.Internal
row["_representation"] = []string{"Internal:", strconv.FormatBool(information.Internal)}
case "Attachable":
row["Attachable"] = information.Attachable
row["_representation"] = []string{"Attachable:", strconv.FormatBool(information.Attachable)}
case "Ingress":
row["Ingress"] = information.Ingress
row["_representation"] = []string{"Ingress:", strconv.FormatBool(information.Ingress)}
}
rows = append(rows, row)
}
firstPart.Content = rows
// Build the full config using : First part // Containers // Labels // Options
allConfig := ui.InspectorContent{
firstPart,
ui.InspectorContentPart{Type: "json", Content: ui.JSON{"Containers": information.Containers}},
ui.InspectorContentPart{Type: "json", Content: ui.JSON{"Labels": information.Labels}},
ui.InspectorContentPart{Type: "json", Content: ui.JSON{"Options": information.Options}},
}
return allConfig, nil
}

View File

@@ -0,0 +1,207 @@
package resources
import (
"context"
"fmt"
"sort"
"will-moss/isaiah/server/ui"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
"github.com/fatih/structs"
)
// Represent a Docker volume
type Volume struct {
Name string
Driver string
MountPoint string
}
// Represent an array of Docker volumes
type Volumes []Volume
// Retrieve all inspector tabs for Docker volumes
func VolumesInspectorTabs() []string {
return []string{"Config"}
}
// Retrieve all the single actions associated with Docker volumes
func VolumeSingleActions() []ui.MenuAction {
var actions []ui.MenuAction
actions = append(
actions,
ui.MenuAction{
Label: "remove volume",
Command: "volume.menu.remove",
Key: "d",
RequiresResource: true,
},
)
actions = append(
actions,
ui.MenuAction{
Label: "browse volume in shell",
Command: "volume.browse",
Key: "B",
RequiresResource: true,
},
)
return actions
}
// Retrieve all the remove actions associated with Docker volumes
func VolumeRemoveActions(v Volume) []ui.MenuAction {
var actions []ui.MenuAction
actions = append(
actions,
ui.MenuAction{
Key: "remove",
Label: fmt.Sprintf("<em>docker volume rm %s</em> ?", v.Name),
Command: "volume.remove.default",
RequiresResource: true,
},
)
actions = append(
actions,
ui.MenuAction{
Key: "force remove",
Label: fmt.Sprintf("<em>docker volume rm --force %s</em> ?", v.Name),
Command: "volume.remove.force",
RequiresResource: true,
},
)
return actions
}
// Retrieve all the bulk actions associated with Docker volumes
func VolumesBulkActions() []ui.MenuAction {
var actions []ui.MenuAction
actions = append(
actions,
ui.MenuAction{
Label: "prune unused volumes",
Prompt: "Are you sure you want to prune all unused volumes?",
Command: "volumes.prune",
},
)
return actions
}
// Retrieve all Docker volumes
func VolumesList(client *client.Client) Volumes {
reader, err := client.VolumeList(context.Background(), volume.ListOptions{})
if err != nil {
return []Volume{}
}
var volumes []Volume
for i := 0; i < len(reader.Volumes); i++ {
var information = reader.Volumes[i]
var volume Volume
volume.Name = information.Name
volume.Driver = information.Driver
volume.MountPoint = information.Mountpoint
volumes = append(volumes, volume)
}
return volumes
}
// Prune unused Docker volumes
func VolumesPrune(client *client.Client) error {
_, err := client.VolumesPrune(context.Background(), filters.Args{})
return err
}
// Turn the list of Docker volumes into a list of rows representing them
func (volumes Volumes) ToRows(columns []string) ui.Rows {
var rows = make(ui.Rows, 0)
sort.Slice(volumes, func(i, j int) bool {
return volumes[i].Name < volumes[j].Name
})
for i := 0; i < len(volumes); i++ {
volume := volumes[i]
row := structs.Map(volume)
var flat = make([]map[string]string, 0)
for j := 0; j < len(columns); j++ {
_entry := make(map[string]string)
_entry["field"] = columns[j]
switch columns[j] {
case "Name":
_entry["value"] = volume.Name
case "Driver":
_entry["value"] = volume.Driver
case "MountPoint":
_entry["value"] = volume.MountPoint
}
flat = append(flat, _entry)
}
row["_representation"] = flat
rows = append(rows, row)
}
return rows
}
// Remove the Docker Volume
func (v Volume) Remove(client *client.Client, force bool) error {
err := client.VolumeRemove(context.Background(), v.Name, force)
return err
}
// Inspector - Retrieve the full configuration associated with a Docker volume
func (v Volume) GetConfig(client *client.Client) (ui.InspectorContent, error) {
information, err := client.VolumeInspect(context.Background(), v.Name)
if err != nil {
return nil, err
}
// Build the first part of the config (main information)
firstPart := ui.InspectorContentPart{Type: "rows"}
rows := make(ui.Rows, 0)
fields := []string{"Name", "Driver", "Scope", "Mountpoint"}
for _, field := range fields {
row := make(ui.Row)
switch field {
case "Name":
row["Name"] = v.Name
row["_representation"] = []string{"Name:", v.Name}
case "Driver":
row["Driver"] = v.Driver
row["_representation"] = []string{"Driver:", v.Driver}
case "Scope":
row["Scope"] = information.Scope
row["_representation"] = []string{"Scope:", information.Scope}
case "Mountpoint":
row["Mountpoint"] = information.Mountpoint
row["_representation"] = []string{"Mountpoint:", information.Mountpoint}
}
rows = append(rows, row)
}
firstPart.Content = rows
// Build the full config using : First part // Labels // Options // Status
allConfig := ui.InspectorContent{
firstPart,
ui.InspectorContentPart{Type: "json", Content: ui.JSON{"Labels": information.Labels}},
ui.InspectorContentPart{Type: "json", Content: ui.JSON{"Options": information.Options}},
ui.InspectorContentPart{Type: "json", Content: ui.JSON{"Status": information.Status}},
}
return allConfig, nil
}

View File

@@ -0,0 +1,67 @@
package server
import (
_os "will-moss/isaiah/server/_internal/os"
"will-moss/isaiah/server/ui"
"github.com/olahol/melody"
)
type Authentication struct{}
func (Authentication) RunCommand(server *Server, session *melody.Session, command ui.Command) {
switch command.Action {
// Command : Authenticate the client by password
case "auth.login":
password := command.Args["Password"]
if password != _os.GetEnv("AUTHENTICATION_SECRET") {
session.Set("authenticated", false)
server.SendNotification(
session,
ui.NotificationAuth(ui.NP{
Type: ui.TypeError,
Content: ui.JSON{
"Authentication": ui.JSON{
"Message": "Invalid password",
},
},
}),
)
break
}
session.Set("authenticated", true)
server.SendNotification(
session,
ui.NotificationAuth(ui.NP{
Type: ui.TypeSuccess,
Content: ui.JSON{
"Authentication": ui.JSON{
"Message": "Your are now authenticated",
},
},
}),
)
// Command : Log out the client
case "auth.logout":
session.Set("authenticated", false)
// Command not found
default:
server.SendNotification(
session,
ui.NotificationAuth(ui.NP{
Type: ui.TypeError,
Content: ui.JSON{
"Authentication": ui.JSON{
"Message": "You are not authenticated yet",
},
},
}),
)
}
}

View File

@@ -0,0 +1,485 @@
package server
import (
"fmt"
"io"
"strings"
_io "will-moss/isaiah/server/_internal/io"
_os "will-moss/isaiah/server/_internal/os"
"will-moss/isaiah/server/_internal/process"
"will-moss/isaiah/server/_internal/tty"
"will-moss/isaiah/server/resources"
"will-moss/isaiah/server/ui"
"github.com/mitchellh/mapstructure"
"github.com/olahol/melody"
)
// Placeholder used for internal organization
type Containers struct{}
func (Containers) RunCommand(server *Server, session *melody.Session, command ui.Command) {
switch command.Action {
// Single - Default menu
case "container.menu":
actions := resources.ContainerSingleActions()
server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
// Single - Remove menu
case "container.menu.remove":
var container resources.Container
mapstructure.Decode(command.Args["Resource"], &container)
actions := resources.ContainerRemoveActions(container)
server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
// Bulk - Bulk menu
case "containers.bulk":
actions := resources.ContainersBulkActions()
server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
// Bulk - List
case "containers.list":
columns := strings.Split(_os.GetEnv("COLUMNS_CONTAINERS"), ",")
containers := resources.ContainersList(server.Docker)
rows := containers.ToRows(columns)
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{"Tab": ui.Tab{Key: "containers", Title: "Containers", Rows: rows}}}),
)
// Bulk - Prune
case "containers.prune":
err := resources.ContainersPrune(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "All the unused containers were pruned"}, Follow: "containers.list",
}),
)
// Bulk - Stop
case "containers.stop":
task := process.LongTask{
Function: resources.ContainersStop,
OnStep: func(id string) {
server.SendNotification(
session,
ui.NotificationInfo(ui.NP{Content: ui.JSON{"Message": fmt.Sprintf("Container %s was stopped", id)}}),
)
server.SendNotification(
session,
ui.NotificationLoading(),
)
},
OnError: func(err error) {
server.SendNotification(
session,
ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}),
)
},
OnDone: func() {
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "All the containers were stopped"}, Follow: "containers.list",
}),
)
},
}
task.RunSync(server.Docker)
// Bulk - Remove
case "containers.remove":
err := resources.ContainersRemove(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "All the containers were removed"}, Follow: "containers.list",
}),
)
// Single - Pause/Unpause
case "container.pause":
var container resources.Container
mapstructure.Decode(command.Args["Resource"], &container)
information, err := container.Inspect(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
var newState string
if information.State.Paused {
err = container.Unpause(server.Docker)
newState = "unpaused"
} else {
err = container.Pause(server.Docker)
newState = "paused"
}
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": fmt.Sprintf("The container was succesfully %s", newState)}, Follow: "containers.list",
}),
)
// Single - Stop
case "container.stop":
var container resources.Container
mapstructure.Decode(command.Args["Resource"], &container)
err := container.Stop(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "The container was succesfully stopped"}, Follow: "containers.list",
}),
)
// Single - Restart
case "container.restart":
var container resources.Container
mapstructure.Decode(command.Args["Resource"], &container)
err := container.Restart(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "The container was succesfully restarted"}, Follow: "containers.list",
}),
)
// Single - Default remove
case "container.remove.default":
var container resources.Container
mapstructure.Decode(command.Args["Resource"], &container)
information, err := container.Inspect(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
if information.State.Running {
server.SendNotification(
session,
ui.NotificationPrompt(ui.NP{
Content: ui.JSON{
"Message": "You cannot remove a container unless you force it. Do you want to force it?",
"Command": "container.remove.force",
},
}),
)
break
}
err = container.Remove(server.Docker, false, false)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "The container was succesfully removed"}, Follow: "containers.list",
}),
)
// Single - Force remove
case "container.remove.force":
var container resources.Container
mapstructure.Decode(command.Args["Resource"], &container)
err := container.Remove(server.Docker, true, false)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "The container was succesfully removed"}, Follow: "containers.list",
}),
)
// Single - Default remove with volumes
case "container.remove.default.volumes":
var container resources.Container
mapstructure.Decode(command.Args["Resource"], &container)
information, err := container.Inspect(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
if information.State.Running {
server.SendNotification(
session,
ui.NotificationPrompt(ui.NP{
Content: ui.JSON{
"Message": "You cannot remove a container unless you force it. Do you want to force it?",
"Command": "container.remove.force.volumes",
},
}),
)
break
}
err = container.Remove(server.Docker, false, true)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "The container was succesfully removed"}, Follow: "containers.list",
}),
)
// Single - Force remove with volumes
case "container.remove.force.volumes":
var container resources.Container
mapstructure.Decode(command.Args["Resource"], &container)
err := container.Remove(server.Docker, true, true)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "The container was succesfully removed"}, Follow: "containers.list",
}),
)
// Single - Open shell inside container
case "container.shell":
var container resources.Container
mapstructure.Decode(command.Args["Resource"], &container)
terminal := tty.New(&_io.CustomWriter{WriteFunction: func(p []byte) {
server.SendNotification(
session,
ui.NotificationTty(ui.NP{Content: ui.JSON{"Output": string(p)}}),
)
}})
session.Set("tty", &terminal)
errs, updates, finished := make(chan error), make(chan string), false
go container.Shell(server.Docker, &terminal, errs, updates)
for {
if finished {
break
}
select {
case e := <-errs:
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": e.Error()}}))
case u := <-updates:
server.SendNotification(session, ui.NotificationTty(ui.NP{Content: ui.JSON{"Status": u, "Type": "container"}}))
finished = u == "exited"
}
}
// Single - Open in browser
case "container.browser":
var container resources.Container
mapstructure.Decode(command.Args["Resource"], &container)
address, err := container.GetBrowserUrl(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Address": address}}))
// Single - Get inspector tabs
case "container.inspect.tabs":
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{"Inspector": ui.JSON{"Tabs": resources.ContainersInspectorTabs()}},
}),
)
// Single - Inspect logs
case "container.inspect.logs":
var container resources.Container
mapstructure.Decode(command.Args["Resource"], &container)
stream, err := container.GetLogs(
server.Docker,
_io.CustomWriter{WriteFunction: func(p []byte) {
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{
"Inspector": ui.JSON{
"Content": ui.InspectorContent{
ui.InspectorContentPart{Type: "lines", Content: []string{string(p)}},
},
},
},
}),
)
}},
)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
if _stream, exists := session.Get("stream"); exists {
(*_stream.(*io.ReadCloser)).Close()
session.UnSet("stream")
}
break
}
session.Set("stream", stream)
server.SendNotification(
session,
ui.NotificationData(ui.NP{Content: ui.JSON{}}),
)
// Single - Inspect full configuration
case "container.inspect.config":
var container resources.Container
mapstructure.Decode(command.Args["Resource"], &container)
config, err := container.GetConfig(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{
"Inspector": ui.JSON{
"Content": config,
},
},
}),
)
// Single - Inspect top (running processes)
case "container.inspect.top":
var container resources.Container
mapstructure.Decode(command.Args["Resource"], &container)
processes, err := container.GetTop(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{
"Inspector": ui.JSON{
"Content": ui.InspectorContent{
ui.InspectorContentPart{Type: "table", Content: processes},
},
},
},
}),
)
// Single - Inspect environment variables
case "container.inspect.env":
var container resources.Container
mapstructure.Decode(command.Args["Resource"], &container)
env, err := container.GetEnv(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{
"Inspector": ui.JSON{
"Content": ui.InspectorContent{
ui.InspectorContentPart{Type: "rows", Content: env},
},
},
},
}),
)
// Single - Inspect stats
case "container.inspect.stats":
var container resources.Container
mapstructure.Decode(command.Args["Resource"], &container)
stats, err := container.GetStats(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{
"Inspector": ui.JSON{
"Content": stats,
},
},
}),
)
// Command not found
default:
server.SendNotification(
session,
ui.NotificationError(ui.NP{
Content: ui.JSON{
"Message": fmt.Sprintf("This command is unknown, unsupported, or not implemented yet : %s", command.Action),
},
}),
)
}
}

244
app/server/server/images.go Normal file
View File

@@ -0,0 +1,244 @@
package server
import (
"encoding/json"
"fmt"
"strings"
_os "will-moss/isaiah/server/_internal/os"
"will-moss/isaiah/server/_internal/process"
"will-moss/isaiah/server/resources"
"will-moss/isaiah/server/ui"
"github.com/mitchellh/mapstructure"
"github.com/olahol/melody"
)
// Placeholder used for internal organization
type Images struct{}
func (Images) RunCommand(server *Server, session *melody.Session, command ui.Command) {
switch command.Action {
// Single - Default menu
case "image.menu":
actions := resources.ImageSingleActions()
server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
// Single - Remove menu
case "image.menu.remove":
var volume resources.Volume
mapstructure.Decode(command.Args["Resource"], &volume)
actions := resources.ImageRemoveActions(volume)
server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
// Bulk - Bulk menu
case "images.bulk":
actions := resources.ImagesBulkActions()
server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
// Bulk - List
case "images.list":
columns := strings.Split(_os.GetEnv("COLUMNS_IMAGES"), ",")
images := resources.ImagesList(server.Docker)
rows := images.ToRows(columns)
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{"Tab": ui.Tab{Key: "images", Title: "Images", Rows: rows}},
}),
)
// Bulk - Prune
case "images.prune":
err := resources.ImagesPrune(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "All the unused images were pruned"}, Follow: "images.list",
}),
)
// Single - Default remove
case "image.remove.default":
var image resources.Image
mapstructure.Decode(command.Args["Resource"], &image)
err := image.Remove(server.Docker, false, true)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "The image was succesfully removed"}, Follow: "images.list",
}),
)
// Single - Default remove without deleting untagged parents
case "image.remove.default.unprune":
var image resources.Image
mapstructure.Decode(command.Args["Resource"], &image)
err := image.Remove(server.Docker, false, false)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "The image was succesfully removed"}, Follow: "images.list",
}),
)
// Single - Force remove
case "image.remove.force":
var image resources.Image
mapstructure.Decode(command.Args["Resource"], &image)
err := image.Remove(server.Docker, true, true)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "The image was succesfully removed"}, Follow: "images.list",
}),
)
// Single - Force remove without deleting untagged parents
case "image.remove.force.unprune":
var image resources.Image
mapstructure.Decode(command.Args["Resource"], &image)
err := image.Remove(server.Docker, true, false)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "The image was succesfully removed"}, Follow: "images.list",
}),
)
// Single - Pull
case "image.pull":
task := process.LongTask{
Function: resources.ImagePull,
Args: command.Args, // Expects : { "Image": <string> }
OnStep: func(update string) {
metadata := make(map[string]string)
json.Unmarshal([]byte(update), &metadata)
message := fmt.Sprintf("Pulling : %s", command.Args["Image"])
message += fmt.Sprintf("<br />Status : %s", metadata["status"])
if _, ok := metadata["progress"]; ok {
message += fmt.Sprintf("<br />Progress : %s", metadata["progress"])
}
server.SendNotification(
session,
ui.NotificationInfo(ui.NP{
Content: ui.JSON{
"Message": message,
},
}),
)
},
OnError: func(err error) {
server.SendNotification(
session,
ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}),
)
},
OnDone: func() {
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "The image was succesfully pulled"}, Follow: "images.list",
}),
)
},
}
task.RunSync(server.Docker)
// Single - Get inspector tabs
case "image.inspect.tabs":
tabs := resources.ImagesInspectorTabs()
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{"Inspector": ui.JSON{"Tabs": tabs}},
}),
)
// Single - Inspect full configuration
case "image.inspect.config":
var image resources.Image
mapstructure.Decode(command.Args["Resource"], &image)
config, err := image.GetConfig(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{
"Inspector": ui.JSON{
"Content": config,
},
},
}),
)
// Single - Run
case "image.run":
var image resources.Image
mapstructure.Decode(command.Args["Resource"], &image)
var name string
name = command.Args["Name"].(string)
err := image.Run(server.Docker, name)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "The image was succesfully used to run a new container"}, Follow: "containers.list",
}),
)
// Command not found
default:
server.SendNotification(
session,
ui.NotificationError(ui.NP{
Content: ui.JSON{
"Message": fmt.Sprintf("This command is unknown, unsupported, or not implemented yet : %s", command.Action),
},
}),
)
}
}

View File

@@ -0,0 +1,128 @@
package server
import (
"fmt"
"strings"
_os "will-moss/isaiah/server/_internal/os"
"will-moss/isaiah/server/resources"
"will-moss/isaiah/server/ui"
"github.com/mitchellh/mapstructure"
"github.com/olahol/melody"
)
// Placeholder used for internal organization
type Networks struct{}
func (Networks) RunCommand(server *Server, session *melody.Session, command ui.Command) {
switch command.Action {
// Single - Default menu
case "network.menu":
var network resources.Network
mapstructure.Decode(command.Args["Resource"], &network)
actions := resources.NetworkSingleActions(network)
server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
// Single - Remove menu
case "network.menu.remove":
var network resources.Network
mapstructure.Decode(command.Args["Resource"], &network)
actions := resources.NetworkRemoveActions(network)
server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
// Bulk - Bulk menu
case "networks.bulk":
actions := resources.NetworksBulkActions()
server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
// Bulk - List
case "networks.list":
columns := strings.Split(_os.GetEnv("COLUMNS_NETWORKS"), ",")
networks := resources.NetworksList(server.Docker)
rows := networks.ToRows(columns)
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{"Tab": ui.Tab{Key: "networks", Title: "Networks", Rows: rows}},
}),
)
// Bulk - Prune
case "networks.prune":
err := resources.NetworksPrune(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "All the unused networks were pruned"}, Follow: "networks.list",
}),
)
// Single - Default remove
case "network.remove.default":
var network resources.Network
mapstructure.Decode(command.Args["Resource"], &network)
err := network.Remove(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "The network was succesfully removed"}, Follow: "networks.list",
}),
)
// Single - Get inspector tabs
case "network.inspect.tabs":
tabs := resources.NetworksInspectorTabs()
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{"Inspector": ui.JSON{"Tabs": tabs}},
}),
)
// Single - Inspect full configuration
case "network.inspect.config":
var network resources.Network
mapstructure.Decode(command.Args["Resource"], &network)
config, err := network.GetConfig(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{
"Inspector": ui.JSON{
"Content": config,
},
},
}),
)
// Command not found
default:
server.SendNotification(
session,
ui.NotificationError(ui.NP{
Content: ui.JSON{
"Message": fmt.Sprintf("This command is unknown, unsupported, or not implemented yet : %s", command.Action),
},
}),
)
}
}

221
app/server/server/server.go Normal file
View File

@@ -0,0 +1,221 @@
package server
import (
"encoding/json"
"fmt"
"io"
"slices"
"strings"
_io "will-moss/isaiah/server/_internal/io"
_os "will-moss/isaiah/server/_internal/os"
"will-moss/isaiah/server/_internal/tty"
"will-moss/isaiah/server/resources"
"will-moss/isaiah/server/ui"
"github.com/docker/docker/client"
"github.com/olahol/melody"
)
// Represent the current server
type Server struct {
Melody *melody.Melody
Docker *client.Client
}
// Represent a command handler, used only _internally
// to organize functions in files on a per-resource-type basis
type handler interface {
RunCommand(*Server, *melody.Session, ui.Command)
}
// Primary method for sending messages via websocket
func (server *Server) send(session *melody.Session, message []byte) {
session.Write(message)
}
// Send a notification
func (server *Server) SendNotification(session *melody.Session, notification ui.Notification) {
// If configured, don't show confirmations
if slices.Contains([]string{ui.TypeInfo, ui.TypeSuccess}, notification.Type) {
notification.Display = _os.GetEnv("DISPLAY_CONFIRMATIONS") == "TRUE"
}
// By default, show errors and warnings
if slices.Contains([]string{ui.TypeError, ui.TypeWarning}, notification.Type) {
notification.Display = true
}
server.send(session, notification.ToBytes())
}
// Same as handler.RunCommand
func (server *Server) runCommand(session *melody.Session, command ui.Command) {
switch command.Action {
// Command : Initialization
case "init":
var tabs []ui.Tab
containers := resources.ContainersList(server.Docker)
images := resources.ImagesList(server.Docker)
volumes := resources.VolumesList(server.Docker)
networks := resources.NetworksList(server.Docker)
if len(containers) > 0 {
columns := strings.Split(_os.GetEnv("COLUMNS_CONTAINERS"), ",")
rows := containers.ToRows(columns)
tabs = append(tabs, ui.Tab{Key: "containers", Title: "Containers", Rows: rows})
}
if len(images) > 0 {
columns := strings.Split(_os.GetEnv("COLUMNS_IMAGES"), ",")
rows := images.ToRows(columns)
tabs = append(tabs, ui.Tab{Key: "images", Title: "Images", Rows: rows})
}
if len(volumes) > 0 {
columns := strings.Split(_os.GetEnv("COLUMNS_VOLUMES"), ",")
rows := volumes.ToRows(columns)
tabs = append(tabs, ui.Tab{Key: "volumes", Title: "Volumes", Rows: rows})
}
if len(networks) > 0 {
columns := strings.Split(_os.GetEnv("COLUMNS_NETWORKS"), ",")
rows := networks.ToRows(columns)
tabs = append(tabs, ui.Tab{Key: "networks", Title: "Networks", Rows: rows})
}
server.SendNotification(session, ui.NotificationInit(ui.NotificationParams{Content: ui.JSON{"Tabs": tabs}}))
// Command : Open shell on the server
case "shell":
terminal := tty.New(_io.CustomWriter{WriteFunction: func(p []byte) {
server.SendNotification(
session,
ui.NotificationTty(ui.NotificationParams{Content: ui.JSON{"Output": string(p)}}),
)
}})
session.Set("tty", &terminal)
errs, updates, finished := make(chan error), make(chan string), false
go _os.OpenShell(&terminal, errs, updates)
for {
if finished {
break
}
select {
case e := <-errs:
server.SendNotification(session, ui.NotificationError(ui.NotificationParams{Content: ui.JSON{"Message": e.Error()}}))
case u := <-updates:
server.SendNotification(session, ui.NotificationTty(ui.NotificationParams{Content: ui.JSON{"Status": u, "Type": "system"}}))
finished = u == "exited"
}
}
// Command : Run a command inside the currently-opened shell (can be a container shell, or a system shell)
case "shell.command":
command := command.Args["Command"].(string)
shouldQuit := command == "exit"
terminal, exists := session.Get("tty")
if exists != true {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": "No tty opened"}}))
break
}
var err error
if shouldQuit {
(terminal.(*tty.TTY)).ClearAndQuit()
session.UnSet("tty")
} else {
err = (terminal.(*tty.TTY)).RunCommand(command)
}
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
// Command : Not found
default:
server.SendNotification(
session,
ui.NotificationError(ui.NP{
Content: ui.JSON{
"Message": fmt.Sprintf("This command is unknown, unsupported, or not implemented yet : %s", command.Action),
},
}),
)
}
}
// Main function (dispatch a message to the appropriate handler, and run it)
func (server *Server) Handle(session *melody.Session, message ...[]byte) {
// On first connection
if len(message) == 0 {
// Dev-only : If authentication is disabled
// - set client's session authenticated by default
// - send confirmation to the client
if _os.GetEnv("AUTHENTICATION_ENABLED") != "TRUE" {
session.Set("authenticated", true)
server.SendNotification(session, ui.NotificationAuth(ui.NP{
Type: ui.TypeSuccess,
Content: ui.JSON{
"Authentication": ui.JSON{
"Spontaneous": true,
"Message": "Your are now authenticated",
},
},
}),
)
}
// Normal case : Do nothing
return
}
// Decode the received command
var command ui.Command
err := json.Unmarshal(message[0], &command)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NotificationParams{Content: ui.JSON{"Message": err.Error()}}))
return
}
// By default, prior to running the command, close the current stream if any's still open
if stream, exists := session.Get("stream"); exists {
(*stream.(*io.ReadCloser)).Close()
session.UnSet("stream")
}
// Dispatch the command to the appropriate handler
var h handler
if authenticated, _ := session.Get("authenticated"); authenticated != true ||
strings.HasPrefix(command.Action, "auth") {
h = Authentication{}
} else {
// Let the client know the server is processing their input
server.SendNotification(session, ui.NotificationLoading())
switch true {
case strings.HasPrefix(command.Action, "image"):
h = Images{}
case strings.HasPrefix(command.Action, "container"):
h = Containers{}
case strings.HasPrefix(command.Action, "volume"):
h = Volumes{}
case strings.HasPrefix(command.Action, "network"):
h = Networks{}
default:
h = nil
}
}
if h != nil {
h.RunCommand(server, session, command)
} else {
server.runCommand(session, command)
}
}

View File

@@ -0,0 +1,207 @@
package server
import (
"fmt"
"runtime"
"strings"
_io "will-moss/isaiah/server/_internal/io"
_os "will-moss/isaiah/server/_internal/os"
"will-moss/isaiah/server/_internal/tty"
"will-moss/isaiah/server/resources"
"will-moss/isaiah/server/ui"
"github.com/mitchellh/mapstructure"
"github.com/olahol/melody"
)
// Placeholder used for internal organization
type Volumes struct{}
func (Volumes) RunCommand(server *Server, session *melody.Session, command ui.Command) {
switch command.Action {
// Single - Default menu
case "volume.menu":
actions := resources.VolumeSingleActions()
server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
// Single - Remove menu
case "volume.menu.remove":
var volume resources.Volume
mapstructure.Decode(command.Args["Resource"], &volume)
actions := resources.VolumeRemoveActions(volume)
server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
// Bulk - Bulk menu
case "volumes.bulk":
actions := resources.VolumesBulkActions()
server.SendNotification(session, ui.NotificationData(ui.NP{Content: ui.JSON{"Actions": actions}}))
// Bulk - List
case "volumes.list":
columns := strings.Split(_os.GetEnv("COLUMNS_VOLUMES"), ",")
volumes := resources.VolumesList(server.Docker)
rows := volumes.ToRows(columns)
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{"Tab": ui.Tab{Key: "volumes", Title: "Volumes", Rows: rows}},
}),
)
// Bulk - Prune
case "volumes.prune":
err := resources.VolumesPrune(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "All the unused volumes were pruned"}, Follow: "volumes.list",
}),
)
// Single - Default remove
case "volume.remove.default":
var volume resources.Volume
mapstructure.Decode(command.Args["Resource"], &volume)
err := volume.Remove(server.Docker, false)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "The volume was succesfully removed"}, Follow: "volumes.list",
}),
)
// Single - Forced remove
case "volume.remove.force":
var volume resources.Volume
mapstructure.Decode(command.Args["Resource"], &volume)
err := volume.Remove(server.Docker, true)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationSuccess(ui.NP{
Content: ui.JSON{"Message": "The volume was succesfully removed"}, Follow: "volumes.list",
}),
)
// Single - Browse
case "volume.browse":
if runtime.GOOS == "darwin" {
server.SendNotification(
session,
ui.NotificationError(ui.NP{
Content: ui.JSON{
"Message": "It seems that you're running Docker on MacOS. On this operating system" +
" Docker works inside a virtual machine, and therefore volumes can't be accessed" +
" directly"},
}),
)
break
}
if _os.GetEnv("DOCKER_RUNNING") == "TRUE" {
server.SendNotification(
session,
ui.NotificationError(ui.NP{
Content: ui.JSON{
"Message": "It seems that you're running Isaiah inside a Docker container." +
" In this case, external volumes can't be accessed directly unless" +
" they are mounted on the current Docker container as well." +
" If this is the case, you can open a regular shell using S" +
" and navigate to your volume's (bind) mountpoint",
},
}),
)
break
}
var volume resources.Volume
mapstructure.Decode(command.Args["Resource"], &volume)
terminal := tty.New(&_io.CustomWriter{WriteFunction: func(p []byte) {
server.SendNotification(
session,
ui.NotificationTty(ui.NP{Content: ui.JSON{"Output": string(p)}}),
)
}})
session.Set("tty", &terminal)
errs, updates, finished := make(chan error), make(chan string), false
go _os.OpenShell(&terminal, errs, updates)
go terminal.RunCommand("cd " + volume.MountPoint + "\n")
for {
if finished {
break
}
select {
case e := <-errs:
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": e.Error()}}))
case u := <-updates:
server.SendNotification(session, ui.NotificationTty(ui.NP{Content: ui.JSON{"Status": u, "Type": "volume"}}))
finished = u == "exited"
}
}
// Single - Get inspector tabs
case "volume.inspect.tabs":
tabs := resources.VolumesInspectorTabs()
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{"Inspector": ui.JSON{"Tabs": tabs}},
}),
)
// Single - Inspect full configuration
case "volume.inspect.config":
var volume resources.Volume
mapstructure.Decode(command.Args["Resource"], &volume)
config, err := volume.GetConfig(server.Docker)
if err != nil {
server.SendNotification(session, ui.NotificationError(ui.NP{Content: ui.JSON{"Message": err.Error()}}))
break
}
server.SendNotification(
session,
ui.NotificationData(ui.NP{
Content: ui.JSON{
"Inspector": ui.JSON{
"Content": config,
},
},
}),
)
// Command not found
default:
server.SendNotification(
session,
ui.NotificationError(ui.NP{
Content: ui.JSON{
"Message": fmt.Sprintf("This command is unknown, unsupported, or not implemented yet : %s", command.Action),
},
}),
)
}
}

7
app/server/ui/command.go Normal file
View File

@@ -0,0 +1,7 @@
package ui
// Represent a command sent by the web browser
type Command struct {
Action string
Args map[string]interface{}
}

View File

@@ -0,0 +1,8 @@
package ui
type InspectorContent []InspectorContentPart
type InspectorContentPart struct {
Type string // One of "rows", "json", "table", "lines"
Content interface{}
}

View File

@@ -0,0 +1,11 @@
package ui
// Represent a menu action (row) in the web browser
type MenuAction struct {
Label string
Command string
Prompt string
Key string
RequiresResource bool
RunLocally bool
}

View File

@@ -0,0 +1,83 @@
package ui
import (
_json "will-moss/isaiah/server/_internal/json"
)
type JSON map[string]interface{}
// Represent a notification sent to the web browser
type Notification struct {
Category string // The top-most category of notification
Type string // The type of notification (among success, error, warning, and info)
Title string // The title of the notification (as displayed to the end user)
Content map[string]interface{} // The content of the notification (JSON string)
Follow string // The command the client should run when they receive the notification
Display bool // Whether or not the notification should be shown to the end user
}
type NotificationParams struct {
Content map[string]interface{}
Follow string
Type string
}
type NP = NotificationParams
const (
TypeSuccess = "success"
TypeError = "error"
TypeWarning = "warning"
TypeInfo = "info"
)
const (
CategoryInit = "init" // Notification sent at first connection established
CategoryRefresh = "refresh" // Notification sent when requesting new data for Docker / UI resources
CategoryLoading = "loading" // Notification sent to let the user know that the server is loading
CategoryReport = "report" // Notification sent to let the user know something (message, error)
CategoryPrompt = "prompt" // Notification sent to ask confirmation from the user
CategoryTty = "tty" // Notification sent to instruct about TTY status / output
CategoryAuth = "auth" // Notification sent to instruct about authentication
)
func NotificationInit(p NotificationParams) Notification {
return Notification{Category: CategoryInit, Type: TypeSuccess, Content: p.Content, Follow: p.Follow}
}
func NotificationError(p NotificationParams) Notification {
return Notification{Category: CategoryReport, Type: TypeError, Title: "Error", Content: p.Content, Follow: p.Follow}
}
func NotificationData(p NotificationParams) Notification {
return Notification{Category: CategoryRefresh, Type: TypeInfo, Content: p.Content, Follow: p.Follow}
}
func NotificationInfo(p NotificationParams) Notification {
return Notification{Category: CategoryReport, Type: TypeInfo, Title: "Information", Content: p.Content, Follow: p.Follow}
}
func NotificationSuccess(p NotificationParams) Notification {
return Notification{Category: CategoryReport, Type: TypeSuccess, Title: "Success", Content: p.Content, Follow: p.Follow}
}
func NotificationPrompt(p NotificationParams) Notification {
return Notification{Category: CategoryPrompt, Type: TypeInfo, Title: "Confirm", Content: p.Content}
}
func NotificationAuth(p NotificationParams) Notification {
return Notification{Category: CategoryAuth, Type: p.Type, Title: "Authentication", Content: p.Content}
}
func NotificationTty(p NotificationParams) Notification {
return Notification{Category: CategoryTty, Type: TypeInfo, Content: p.Content}
}
func NotificationLoading() Notification {
return Notification{Category: CategoryLoading, Type: TypeInfo}
}
func (n Notification) ToBytes() []byte {
v := _json.Marshal(n)
return []byte(v)
}

5
app/server/ui/row.go Normal file
View File

@@ -0,0 +1,5 @@
package ui
// Represent
type Row map[string]interface{}
type Rows []Row

30
app/server/ui/size.go Normal file
View File

@@ -0,0 +1,30 @@
package ui
import "fmt"
func ByteCount(b int64) string {
const unit = 1000
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f%cB",
float64(b)/float64(div), "kMGTPE"[exp])
}
func UByteCount(b uint64) string {
const unit = 1000
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.2f%cB",
float64(b)/float64(div), "kMGTPE"[exp])
}

8
app/server/ui/tab.go Normal file
View File

@@ -0,0 +1,8 @@
package ui
// Represent a tab in the web browser
type Tab struct {
Key string
Title string
Rows Rows
}

6
app/server/ui/table.go Normal file
View File

@@ -0,0 +1,6 @@
package ui
type Table struct {
Headers []string
Rows [][]string
}

BIN
assets/CAPTURE-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

BIN
assets/CAPTURE-10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

BIN
assets/CAPTURE-11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

BIN
assets/CAPTURE-12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

BIN
assets/CAPTURE-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

BIN
assets/CAPTURE-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB

BIN
assets/CAPTURE-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 KiB

BIN
assets/CAPTURE-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

BIN
assets/CAPTURE-6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

BIN
assets/CAPTURE-7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

BIN
assets/CAPTURE-8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

BIN
assets/CAPTURE-9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

View File

@@ -0,0 +1,36 @@
version: '3'
services:
isaiah:
image: mosswill/isaiah:latest
networks:
- global
expose:
- 80
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
SERVER_PORT: "80"
AUTHENTICATION_SECRET: "your-very-long-and-mysterious-secret"
VIRTUAL_HOST: "your-domain.tld"
VIRTUAL_PORT: "80"
# Depending on your setup, you may also need
# CERT_NAME: "default"
# Or even
# LETSENCRYPT_HOST: "your-domain.tld"
proxy:
image: jwilder/nginx-proxy
ports:
- "443:443"
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
networks:
- global
networks:
# Assumption made : network "global" is created beforehand
# with : docker network create global
global:
external: true

View File

@@ -0,0 +1,12 @@
version: '3'
services:
isaiah:
image: mosswill/isaiah:latest
restart: unless-stopped
ports:
- "80:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
SERVER_PORT: "80"
AUTHENTICATION_SECRET: "your-very-long-and-mysterious-secret"

View File

@@ -0,0 +1,15 @@
version: '3'
services:
isaiah:
image: mosswill/isaiah:latest
ports:
- "443:443"
volumes:
- ./certificate.pem:/certificate.pem
- ./key.pem:/key.pem
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
SSL_ENABLED: "TRUE"
SERVER_PORT: "443"
AUTHENTICATION_SECRET: "your-very-long-and-mysterious-secret"

View File

@@ -0,0 +1,34 @@
version: '3'
services:
isaiah:
image: mosswill/isaiah:latest
networks:
- global
expose:
- 80
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
AUTHENTICATION_SECRET: "your-very-long-and-mysterious-secret"
labels:
- "traefik.enable=true"
- "traefik.http.routers.isaiah.rule=Host(`your-server.tld`)"
- "traefik.http.routers.isaiah.service=isaiah-server"
- "traefik.http.services.isaiah-server.loadbalancer.server.port=80"
- "traefik.http.services.isaiah-server.loadbalancer.server.scheme=http"
# Depending on your setup, you may also need
# - "traefik.http.routers.isaiah.entrypoints=websecure"
# - "traefik.http.routers.isaiah.tls=true"
# - "traefik.http.routers.isaiah.tls.certresolver=tlschallenge"
# Assumption made : another container running Traefik
# was configured and started beforehand
# and attached to the network "global"
networks:
# Assumption made : network "global" was created beforehand
# with : docker network create global
global:
external: true

View File

@@ -0,0 +1,10 @@
version: '3'
services:
isaiah:
image: mosswill/isaiah:latest
restart: unless-stopped
ports:
- "80:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- .env:/.env

8666
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

13
package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"devDependencies": {
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"cz-conventional-changelog": "^3.3.0"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}

38
scripts/local-install.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
# Navigate to the project's source directory
cd ./app/
# Install Babel, Less, and LightningCSS for JS and CSS processing
yes | npm install --silent @babel/core @babel/cli @babel/preset-env
yes | npm install --silent less lightningcss-cli
# Compile LESS files into one unique CSS file
npx --yes lessc ./client/assets/css/style.less > ./client/assets/css/tmp.css
# Minify and Prefix CSS
npx --yes lightningcss --minify --bundle --targets 'cover 99.5%' ./client/assets/css/tmp.css -o ./client/assets/css/style.css
# Save the original JS file
cp ./client/assets/js/isaiah.js ./client/assets/js/isaiah.backup.js
# Make JS cross-browser-compatible
npx --yes babel ./client/assets/js/isaiah.js --out-file ./client/assets/js/isaiah.js --config-file ./.babelrc.json
# Minify JS
npx --yes terser ./client/assets/js/isaiah.js -o ./client/assets/js/isaiah.js
# Build the app
go build -o isaiah main.go
# Reset CSS and JS
rm -f ./client/assets/css/tmp.css
rm -f ./client/assets/css/style.css
mv ./client/assets/js/isaiah.backup.js ./client/assets/js/isaiah.js
# Remove any previous installation
rm -f /usr/bin/isaiah
# Install the app's binary
mv isaiah /usr/bin/
chmod 755 /usr/bin/isaiah

13
scripts/post-release.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
# Navigate to the project's source directory
cd ./app/
# Reset CSS and JS
rm -f ./client/assets/css/tmp.css
rm -f ./client/assets/css/style.css
mv ./client/assets/js/isaiah.backup.js ./client/assets/js/isaiah.js
# Remove dist folder generated by goreleaser
rm -rf ./dist/

22
scripts/pre-release.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Navigate to the project's source directory
cd ./app/
# Go dependencies
go mod tidy
# Compile LESS files into one unique CSS file
npx --yes lessc ./client/assets/css/style.less > ./client/assets/css/tmp.css
# Minify and Prefix CSS
npx --yes lightningcss --minify --bundle --targets 'cover 99.5%' ./client/assets/css/tmp.css -o ./client/assets/css/style.css
# Save the original JS file
cp ./client/assets/js/isaiah.js ./client/assets/js/isaiah.backup.js
# Make JS cross-browser-compatible
npx --yes babel ./client/assets/js/isaiah.js --out-file ./client/assets/js/isaiah.js --config-file ./.babelrc.json
# Minify JS
npx --yes terser ./client/assets/js/isaiah.js -o ./client/assets/js/isaiah.js

6
scripts/release.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
export $(cat .env | xargs)
goreleaser release --release-notes /tmp/release-notes.md --clean
./scripts/post-release.sh

22
scripts/remote-install.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Retrieve the system's architecture
ARCH=$(uname -m)
case $ARCH in
i386|i686) ARCH=i386 ;;
armv6*) ARCH=armv6 ;;
armv7*) ARCH=armv7 ;;
aarch64*) ARCH=arm64 ;;
esac
# Prepare the download URL
GITHUB_LATEST_VERSION=$(curl -L -s -H 'Accept: application/json' https://github.com/will-moss/isaiah/releases/latest | sed -e 's/.*"tag_name":"\([^"]*\)".*/\1/')
GITHUB_FILE="isaiah_${GITHUB_LATEST_VERSION//v/}_$(uname -s)_${ARCH}.tar.gz"
GITHUB_URL="https://github.com/will-moss/isaiah/releases/download/${GITHUB_LATEST_VERSION}/${GITHUB_FILE}"
# Install/Update the local binary
curl -L -o isaiah.tar.gz $GITHUB_URL
tar xzvf isaiah.tar.gz isaiah
mv isaiah /usr/bin/
chmod 755 /usr/bin/isaiah
rm isaiah.tar.gz

3626
yarn.lock Normal file

File diff suppressed because it is too large Load Diff