commit 56ff42d15a914483484bda1206d8ab6f0ef243af Author: Will Moss Date: Thu Jan 4 01:24:18 2024 +0100 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29c1b21 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..7a6b1ae --- /dev/null +++ b/.goreleaser.yaml @@ -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" diff --git a/.releaserc b/.releaserc new file mode 100644 index 0000000..4075ee9 --- /dev/null +++ b/.releaserc @@ -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" + } + ] + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c043e84 --- /dev/null +++ b/CHANGELOG.md @@ -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)) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3a03ed8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM scratch + +COPY isaiah / + +ENV DOCKER_RUNNING=true + +ENTRYPOINT ["./isaiah"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f29b20 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b554c2b --- /dev/null +++ b/README.md @@ -0,0 +1,372 @@ +

+

Isaiah

+

Self-hostable clone of lazydocker for the web

+

+ +| | | | +|:-------------------------:|:-------------------------:|:-------------------------:| +| | | | +| | | | +| | | | +| | | | + + +## 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 \ + 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 \ + 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 +``` + +> 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 + + +# Optional : Remove the cloned repository +# cd +# 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! + diff --git a/app/.babelrc.json b/app/.babelrc.json new file mode 100644 index 0000000..70e06fb --- /dev/null +++ b/app/.babelrc.json @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "browsers": ["defaults", "ie >= 8"] + } + } + ] + ] +} diff --git a/app/client/assets/css/colors.less b/app/client/assets/css/colors.less new file mode 100644 index 0000000..f21c0c5 --- /dev/null +++ b/app/client/assets/css/colors.less @@ -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; +} diff --git a/app/client/assets/css/components.less b/app/client/assets/css/components.less new file mode 100644 index 0000000..1a568c5 --- /dev/null +++ b/app/client/assets/css/components.less @@ -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; + } + } + } +} diff --git a/app/client/assets/css/fonts.less b/app/client/assets/css/fonts.less new file mode 100644 index 0000000..6f415da --- /dev/null +++ b/app/client/assets/css/fonts.less @@ -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; +} diff --git a/app/client/assets/css/mixins.less b/app/client/assets/css/mixins.less new file mode 100644 index 0000000..0f8aa72 --- /dev/null +++ b/app/client/assets/css/mixins.less @@ -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); +} diff --git a/app/client/assets/css/normalize.less b/app/client/assets/css/normalize.less new file mode 100644 index 0000000..2768db4 --- /dev/null +++ b/app/client/assets/css/normalize.less @@ -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; +} diff --git a/app/client/assets/css/reset.less b/app/client/assets/css/reset.less new file mode 100644 index 0000000..4b84a8e --- /dev/null +++ b/app/client/assets/css/reset.less @@ -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%; +} diff --git a/app/client/assets/css/style.less b/app/client/assets/css/style.less new file mode 100644 index 0000000..60ddc38 --- /dev/null +++ b/app/client/assets/css/style.less @@ -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; + } +} diff --git a/app/client/assets/fonts/hack-bold.woff2 b/app/client/assets/fonts/hack-bold.woff2 new file mode 100644 index 0000000..93d425e Binary files /dev/null and b/app/client/assets/fonts/hack-bold.woff2 differ diff --git a/app/client/assets/fonts/hack-regular.woff2 b/app/client/assets/fonts/hack-regular.woff2 new file mode 100644 index 0000000..1e3abb9 Binary files /dev/null and b/app/client/assets/fonts/hack-regular.woff2 differ diff --git a/app/client/assets/js/isaiah.js b/app/client/assets/js/isaiah.js new file mode 100644 index 0000000..25e916b --- /dev/null +++ b/app/client/assets/js/isaiah.js @@ -0,0 +1,2709 @@ +/** + * This file holds absolutely all the logic for the app + * on the frontend + * + * It is responsible for handling : + * - keyboard navigation + * - mouse navigation + * - websocket transactions + * - ui rendering + * - remote commands execution + * + * The app logic is operated mostly like in a video game : + * - First loop : + * - 1. key press + * - 2. run command(s) + * - 3. update local state + * - 4. refresh render + * - Second loop : + * - 1. message received from server + * - 2. run command(s) + * - 3. update local state + * - 4. refresh render + * - Third loop : + * - 1. mouse click + * - 2. run command(s) + * - 3. update local state + * - 4. refresh render + * + * A command usually falls into one of two categories : + * - Local : Update local state in prevision for future render + * - Remote : Send a command to the server via websocket + * - Additionally, a command can be private or public, a.k.a + * directly mapped to a key press (public), or used + * internally to facilitate some operations (private). + */ +((window) => { + // === Handy methods and aliases + + /** + * @param {string} s + * @returns {HTMLElement} + */ + const q = (s) => document.querySelector(s); + + /** + * @param {string} s + * @returns {Array} + */ + const qq = (s) => [...document.querySelectorAll(s)]; + + /** + * @param {string} s + * @returns {boolean} + */ + const e = (s) => (document.querySelector(s) ? true : false); + + /** + * Prevent xss + * @param {string} str + * @returns {string} + */ + const s = (str) => { + return str + .replace(/javascript:/gi, '') + .replace(/[^\w-_. ]/gi, function (c) { + return `&#${c.charCodeAt(0)};`; + }); + }; + + /** + * Prevent artifacts from CLI color codes + * @param {string} str + * @returns {string} + */ + const removeEscapeSequences = (str) => + str.replace( + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + '' + ); + + // === Handy HTML-querying methods + + /** + * @returns {HTMLElement} + */ + const hgetApp = () => q(`.app-wrapper`); + + /** + * @returns {HTMLElement} + */ + const hgetPopupContainer = () => q(`.popup-layer`); + + /** + * @param {string} key + * @returns {HTMLElement} + */ + const hgetScreen = (key) => q(`.screen.for-${key}`); + + /** + * @param {string} key + * @returns {HTMLElement} + */ + const hgetTab = (key) => q(`.tab.for-${key}`); + + /** + * @param {string} key + * @returns {HTMLElement} + */ + const hgetPopup = (key) => q(`.popup.for-${key}`); + + /** + * @param {string} key + * @returns {Array} + */ + const hgetTabRows = (key) => qq(`.tab.for-${key} .rows`); + + /** + * @param {string} key + * @returns {Array} + */ + const hgetPopupRows = (key) => qq(`.popup.for-${key} .rows`); + + /** + * @param {string} key + * @param {number} index (1-indexed) + * @returns {HTMLElement} + */ + const hgetTabRow = (key, index) => + q(`.tab.for-${key} .row:nth-of-type(${index})`); + + /** + * @param {string} key + * @param {number} index (1-indexed) + * @returns {HTMLElement} + */ + const hgetPopupRow = (key, index) => + q(`.popup.for-${key} .row:nth-of-type(${index})`); + + /** + * @param {string} key + * @returns {HTMLElement} + */ + const hgetHelper = (key) => q(`.help.for-${key}`); + + /** + * @param {string} key + * @returns {HTMLElement} + */ + const hgetConnectionIndicator = (key) => q(`.indicator.for-${key}`); + + /** + * @returns {Array} + */ + const hgetConnectionIndicators = () => qq(`.indicator`); + + /** + * @returns {HTMLElement} + */ + const hgetTtyInput = () => q('#tty-input'); + + /** + * @returns {HTMLElement} + */ + const hgetPromptInput = () => q('#prompt-input'); + + // === Render-related methods + + /** + * @param {object} action + * @param {string} action.Prompt + * @param {string} action.PromptInput + * @param {string} action.Command + * @param {string} action.Key + * @param {string} action.Label + * @param {boolean} action.RequiresResource + * @param {boolean} action.RunLocally + * @param {number} maxKeyWidth + * @returns {string} + */ + const renderMenuAction = (action, maxKeyWidth) => { + let html = ``; + + return html; + }; + + /** + * @returns {string} + */ + const renderMenuActionCancel = () => { + return ``; + }; + + /** + * @typedef {object} Cell + * @property {string} field + * @property {string} representation + * @property {string} value + */ + + /** + * @param {object} cell + * @param {number} cell.Width + * @param {Cell|string} cell.Content + * @returns {string} + */ + const renderCell = (cell) => { + let html = `
`; + + if (!cell.Content.representation) + html += `${s(cell.Content.value.padEnd(cell.Width, whitespace))}`; + else + html += `${s( + cell.Content.representation.padEnd(cell.Width, whitespace) + )}`; + } + // Raw string + else { + html += `>`; + html += `${s(cell.Content.padEnd(cell.Width, whitespace))}`; + } + + html += `
`; + + return html; + }; + + /** + * @param {object} content + * @param {boolean} isSubObject + * @returns {string} + */ + const renderJSON = (content, isSubObject = false) => { + let html = ''; + for (const entry of Object.entries(content)) { + // prettier-ignore + html += `
`; + html += `
${entry[0]}:
`; + + // Case when Array + if (Array.isArray(entry[1])) { + if (entry[1].length > 0) + for (const cell of entry[1]) { + if (typeof cell === 'object') html += renderJSON(cell, true); + else + html += `
+
-
+
${renderJSONCell( + cell + )}
+
`; + } + else html += `
[]
`; + // Case when Object + } else if (entry[1] !== null && typeof entry[1] === 'object') + html += renderJSON(entry[1], true); + // Case when flat value + else { + html += `
${renderJSONCell(entry[1])}
`; + } + + html += `
`; + } + return html; + }; + + /** + * @param {} cell + * @returns {string} + */ + const renderJSONCell = (cell) => { + if (cell === null) return `null`; + if (Array.isArray(cell)) { + if (cell.length > 0) + for (const v of cell) + html += `
+
-
+
${renderJSONCell( + v + )}
+
`; + else html += `
[]
`; + } + if (typeof cell === 'object') return renderJSON(cell, true); + if (typeof cell === 'string') return cell ? `"${cell}"` : '""'; + return `${cell}`; + }; + + /** + * Render rows with cell padding according to the longest cell of each column + * @param {Array} rows + * @returns {string} + */ + const renderRows = (rows) => { + let html = ''; + + let maxs = []; + for (let i = 0; i < rows[0]._representation.length; i++) maxs[i] = -1; + + // Find the max length of each column + for (const row of rows) { + for (const [index, cell] of row._representation.entries()) { + // Cell object + if (typeof cell === 'object') { + if (!cell.representation) + maxs[index] = Math.max(maxs[index], cell.value.length); + else maxs[index] = Math.max(maxs[index], cell.representation.length); + } + // Raw string + else maxs[index] = Math.max(maxs[index], cell.length); + } + } + + // Rows creation + for (const row of rows) { + html += '
'; + for (const [index, cell] of row._representation.entries()) + html += renderCell({ Width: maxs[index], Content: cell }); + html += '
'; + } + + return html; + }; + + /** + * @param {object} tab + * @param {string} tab.Key + * @param {string} tab.Title + * @param {Array} tab.Rows + * @returns {string} + */ + const renderTab = (tab) => { + let html = `
`; + html += ``; + + html += `
`; + if (tab.Rows.length > 0) { + html += renderRows(tab.Rows); + } + html += `
`; + + /* + html += `
+
+
+
+
+
+
`; + */ + html += `
`; + + return html; + }; + + /** + * @param {Inspector} inspector + * @returns {string} + */ + const renderInspector = (inspector) => { + let html = `
`; + + html += `
`; + for (const tabName of inspector.availableTabs) { + html += ``; + } + html += `
`; + + html += `
`; + if (inspector.content.length > 0) { + // Render Inspector Content + for (const inspectorPart of inspector.content) { + switch (inspectorPart.Type) { + // Render Rows + case 'rows': + html += renderRows(inspectorPart.Content); + break; + + // Render a table + case 'table': + html += ``; + html += ``; + html += ``; + for (const header of inspectorPart.Content.Headers) + html += ``; + html += ``; + html += ``; + html += ''; + for (const row of inspectorPart.Content.Rows) { + html += ``; + for (const cell of row) html += ``; + html += ``; + } + html += ''; + html += '
${header}
${cell}
'; + break; + + // Render a JSON structure + case 'json': + html += renderJSON(inspectorPart.Content); + break; + + // Render raw lines + case 'lines': + if (Array.isArray(inspectorPart.Content)) + for (const line of inspectorPart.Content) + html += `
${line}
`; + else + html += `
${inspectorPart.Content}
`; + break; + } + + // Empty row separator between every content part (except for raw lines) + if (inspectorPart.Type !== 'lines') + html += `
`; + } + } + html += `
`; + + /* + html += `
+
+
+
+
+
+
`; + */ + html += `
`; + + return html; + }; + + /** + * @param {Prompt} prompt + * @returns {string} + */ + const renderPrompt = (prompt) => { + const classname = prompt.isForAuthentication ? 'for-login' : ''; + const title = prompt.input.isEnabled ? 'Input' : 'Confirm'; + const body = prompt.input.isEnabled + ? `
${prompt.input.name}:
` + : `

${prompt.text}

`; + + return ` + + `; + }; + + /** + * @param {Message} message + * @returns {string} + */ + const renderMessage = (message) => { + return ` + + `; + }; + + /** + * @param {"menu"|"bulk"} + * @param {Menu} menu + * @returns {string} + */ + const renderMenu = (key, menu) => { + // Cell padding according to the longest key + let maxKeyWidth = -1; + for (const action of menu.actions) + maxKeyWidth = Math.max(maxKeyWidth, action.Key.length); + + return ` + + `; + }; + + /** + * @param {TTY} tty + * @returns {string} + */ + const renderTty = (tty) => { + const { history, historyCursor } = tty; + const lines = tty.lines.map(removeEscapeSequences); + + let html = ` +