First commit
22
.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,7 @@
|
||||
FROM scratch
|
||||
|
||||
COPY isaiah /
|
||||
|
||||
ENV DOCKER_RUNNING=true
|
||||
|
||||
ENTRYPOINT ["./isaiah"]
|
||||
21
LICENSE
Normal 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
@@ -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
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"targets": {
|
||||
"browsers": ["defaults", "ie >= 8"]
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
16
app/client/assets/css/colors.less
Normal 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;
|
||||
}
|
||||
237
app/client/assets/css/components.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
app/client/assets/css/fonts.less
Normal 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;
|
||||
}
|
||||
6
app/client/assets/css/mixins.less
Normal 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
@@ -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;
|
||||
}
|
||||
24
app/client/assets/css/reset.less
Normal 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%;
|
||||
}
|
||||
395
app/client/assets/css/style.less
Normal 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;
|
||||
}
|
||||
}
|
||||
BIN
app/client/assets/fonts/hack-bold.woff2
Normal file
BIN
app/client/assets/fonts/hack-regular.woff2
Normal file
2709
app/client/assets/js/isaiah.js
Normal file
BIN
app/client/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
129
app/client/index.html
Normal 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
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
23
app/default.env
Normal 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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
107
app/server/_internal/client/client.go
Normal 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
|
||||
}
|
||||
9
app/server/_internal/fs/fs.go
Normal 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
|
||||
}
|
||||
14
app/server/_internal/io/io.go
Normal 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
|
||||
}
|
||||
11
app/server/_internal/json/json.go
Normal 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
|
||||
}
|
||||
76
app/server/_internal/os/os.go
Normal 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"
|
||||
}
|
||||
}
|
||||
43
app/server/_internal/process/process.go
Normal 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()
|
||||
}
|
||||
9
app/server/_internal/strconv/strconv.go
Normal 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
|
||||
}
|
||||
79
app/server/_internal/tty/tty.go
Normal 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()
|
||||
}
|
||||
574
app/server/resources/containers.go
Normal 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:", " "}}
|
||||
|
||||
// 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:", " "}}
|
||||
|
||||
return ui.InspectorContent{
|
||||
mainStats,
|
||||
separator,
|
||||
ui.InspectorContentPart{Type: "json", Content: statsResult},
|
||||
}, nil
|
||||
|
||||
}
|
||||
353
app/server/resources/images.go
Normal 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{" ", " "}}
|
||||
|
||||
// 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 := "<none>"
|
||||
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
|
||||
}
|
||||
200
app/server/resources/networks.go
Normal 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
|
||||
}
|
||||
207
app/server/resources/volumes.go
Normal 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
|
||||
}
|
||||
67
app/server/server/authentication.go
Normal 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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
485
app/server/server/containers.go
Normal 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
@@ -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),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
128
app/server/server/networks.go
Normal 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
@@ -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)
|
||||
}
|
||||
}
|
||||
207
app/server/server/volumes.go
Normal 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
@@ -0,0 +1,7 @@
|
||||
package ui
|
||||
|
||||
// Represent a command sent by the web browser
|
||||
type Command struct {
|
||||
Action string
|
||||
Args map[string]interface{}
|
||||
}
|
||||
8
app/server/ui/inspector.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package ui
|
||||
|
||||
type InspectorContent []InspectorContentPart
|
||||
|
||||
type InspectorContentPart struct {
|
||||
Type string // One of "rows", "json", "table", "lines"
|
||||
Content interface{}
|
||||
}
|
||||
11
app/server/ui/menu_action.go
Normal 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
|
||||
}
|
||||
83
app/server/ui/notification.go
Normal 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
@@ -0,0 +1,5 @@
|
||||
package ui
|
||||
|
||||
// Represent
|
||||
type Row map[string]interface{}
|
||||
type Rows []Row
|
||||
30
app/server/ui/size.go
Normal 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
@@ -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
@@ -0,0 +1,6 @@
|
||||
package ui
|
||||
|
||||
type Table struct {
|
||||
Headers []string
|
||||
Rows [][]string
|
||||
}
|
||||
BIN
assets/CAPTURE-1.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
assets/CAPTURE-10.png
Normal file
|
After Width: | Height: | Size: 483 KiB |
BIN
assets/CAPTURE-11.png
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
assets/CAPTURE-12.png
Normal file
|
After Width: | Height: | Size: 387 KiB |
BIN
assets/CAPTURE-2.png
Normal file
|
After Width: | Height: | Size: 1005 KiB |
BIN
assets/CAPTURE-3.png
Normal file
|
After Width: | Height: | Size: 893 KiB |
BIN
assets/CAPTURE-4.png
Normal file
|
After Width: | Height: | Size: 670 KiB |
BIN
assets/CAPTURE-5.png
Normal file
|
After Width: | Height: | Size: 626 KiB |
BIN
assets/CAPTURE-6.png
Normal file
|
After Width: | Height: | Size: 579 KiB |
BIN
assets/CAPTURE-7.png
Normal file
|
After Width: | Height: | Size: 581 KiB |
BIN
assets/CAPTURE-8.png
Normal file
|
After Width: | Height: | Size: 379 KiB |
BIN
assets/CAPTURE-9.png
Normal file
|
After Width: | Height: | Size: 470 KiB |
36
examples/docker-compose.proxy.yml
Normal 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
|
||||
12
examples/docker-compose.simple.yml
Normal 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"
|
||||
15
examples/docker-compose.ssl.yml
Normal 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"
|
||||
34
examples/docker-compose.traefik.yml
Normal 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
|
||||
10
examples/docker-compose.volume.yml
Normal 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
13
package.json
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||