├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── on-pr-submit.yaml │ ├── on-release-tag.yml │ └── python-lint.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── gdash ├── __init__.py ├── __main__.py └── version.py ├── requirements.txt ├── ruff.toml ├── sample_volumes.json ├── screenshots ├── gdash-detail.png └── gdash-home.png ├── setup.cfg ├── setup.py └── ui ├── .gitignore ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── index.html ├── logo-small.png ├── logo.png ├── manifest.json └── robots.txt ├── src ├── App.jsx ├── App.test.js ├── assets │ ├── css │ │ ├── main.css │ │ └── tailwind.css │ └── images │ │ ├── bg1.svg │ │ └── loading.gif ├── components │ ├── breadcrumb.jsx │ ├── content.jsx │ ├── helpers.jsx │ ├── last_updated.jsx │ ├── loading.jsx │ └── sidebar.jsx ├── index.js ├── pages │ ├── bricks.jsx │ ├── dashboard.jsx │ ├── login.jsx │ ├── logout.jsx │ ├── peers.jsx │ ├── volumeDetail.jsx │ └── volumes.jsx ├── serviceWorker.js └── setupTests.js └── tailwind.config.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: kadalu 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "pip" 8 | directory: "/api" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/workflows/on-pr-submit.yaml: -------------------------------------------------------------------------------- 1 | name: Run on every PR 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'docs/**' 9 | - 'extras/**' 10 | - '**.md' 11 | - '**.adoc' 12 | 13 | # Allow to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | jobs: 16 | devel-tag-push: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Use Node.js 18.x 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 18.x 24 | - name: Build 25 | run: | 26 | cd ui && npm install && npm run build 27 | -------------------------------------------------------------------------------- /.github/workflows/on-release-tag.yml: -------------------------------------------------------------------------------- 1 | name: "On Release" 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | gdash_version: $(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') 10 | 11 | jobs: 12 | # Run tests. 13 | # See also https://docs.docker.com/docker-hub/builds/automated-testing/ 14 | push-to-pypi-store: 15 | name: Push to pypi 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: '3.x' 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install setuptools wheel twine 27 | - name: Publish to Pypi 28 | run: | 29 | rm -rf dist; VERSION=${{ env.gdash_version }} make pypi-release; 30 | TWINE_PASSWORD=${{ secrets.TWINE_PASSWORD }} twine upload --username aravindavk dist/* 31 | -------------------------------------------------------------------------------- /.github/workflows/python-lint.yml: -------------------------------------------------------------------------------- 1 | name: Python Lint 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - 'docs/**' 9 | - 'extras/**' 10 | - '**.md' 11 | - '**.adoc' 12 | 13 | jobs: 14 | lint-python-code: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-python@v4 19 | with: 20 | python-version: "3.x" 21 | - name: Run ruff check 22 | uses: chartboost/ruff-action@v1 23 | with: 24 | src: "./gdash" 25 | args: "--verbose" 26 | - name: Run black check 27 | uses: psf/black@stable 28 | with: 29 | options: "--check --diff --verbose -l 120" 30 | src: "./gdash" 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | gdash/dist 2 | *.pyc 3 | .cache* 4 | gdash.egg-info/* 5 | dist/* 6 | build 7 | __pycache__ 8 | node_modules 9 | .DS_Store 10 | gdash/ui 11 | **/.ruff_cache 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright [2014-2020] [Aravinda Vishwanathapura ] 2 | Copyright [2020] [Kadalu.io ] 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include gdash/ui/*.json 2 | include gdash/ui/*.html 3 | include gdash/ui/*.png 4 | include gdash/ui/*.js 5 | include gdash/ui/*.txt 6 | include gdash/ui/static/js/*.js 7 | include gdash/ui/static/js/*.js.map 8 | include gdash/ui/static/js/*.txt 9 | include gdash/ui/static/css/*.css 10 | include gdash/ui/static/css/*.css.map 11 | include gdash/ui/static/media/*.svg 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION ?= "main" 2 | PYTHON ?= python3 3 | PROGNAME = gdash 4 | 5 | help: 6 | @echo "make release - Create Single file Release" 7 | 8 | build-ui: 9 | cd ui && npm install && npm run build; 10 | 11 | gen-version: 12 | @echo "\"\"\"Version\"\"\"" > gdash/version.py 13 | @echo "VERSION = \"${VERSION}\"" >> gdash/version.py 14 | 15 | release: gen-version build-ui 16 | @rm -rf build 17 | @mkdir -p build/src 18 | @cp -r gdash/* build/src/ 19 | @${PYTHON} -m pip install --system -r requirements.txt --target build/src 20 | @cp -r ui/build build/src/ui 21 | @cd build/src && zip -r ../${PROGNAME}.zip * 22 | @echo '#!/usr/bin/env ${PYTHON}' | cat - build/${PROGNAME}.zip > build/${PROGNAME} 23 | @chmod +x build/${PROGNAME} 24 | @rm -rf build/src 25 | @rm -f build/${PROGNAME}.zip 26 | @echo "Single deployment file is ready: build/${PROGNAME}" 27 | 28 | pypi-release: gen-version build-ui 29 | @rm -rf gdash/ui 30 | @mv ui/build gdash/ui 31 | python3 setup.py sdist bdist_wheel 32 | 33 | .PHONY: help release gen-version build-ui pypi-release 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gdash - GlusterFS Dashboard 2 | 3 | ## Install 4 | 5 | ``` 6 | sudo pip3 install gdash 7 | ``` 8 | 9 | ## Usage 10 | 11 | Start `gdash` service in any one of the Gluster server node. 12 | 13 | ``` 14 | sudo gdash 15 | ``` 16 | 17 | Provide the hostname to identify where gdash is running. Use the same hostname or IP which is used with Gluster Peer/Volume commands. Gdash will use this to replace the mention of "localhost" in the peer commands. 18 | 19 | Protect the dashboard access from others by setting the username and password. 20 | 21 | Generate One way hash of Password 22 | 23 | ``` 24 | $ echo -n "MySecret@01" | sha256sum 25 | 1ae946b331052b646ca7d0857dfb205835b2a00a33a35e40b4419a5e150213f3 - 26 | ``` 27 | 28 | Add that to a file(`/etc/glusterfs/gdash.dat`) in the following format, 29 | 30 | ``` 31 | admin=1ae946b331052b646ca7d0857dfb205835b2a00a33a35e40b4419a5e150213f3 32 | aravinda=9e56d42f4be084e5e56c9c23c3917ae10611678743b7f7ca0f6d65c4dd413408 33 | ``` 34 | 35 | Then run gdash using, 36 | 37 | ``` 38 | sudo gdash node1.example.com --auth-file=/etc/glusterfs/gdash.dat 39 | ``` 40 | 41 | Now you can visit http://localhost:8080 (or :8080 if accessing gdash externally) from your browser. 42 | 43 | **Note**: Port can be customized by providing `--port` option(For example, `--port 3000`) 44 | 45 | Other available options are 46 | 47 | ``` 48 | $ gdash --help 49 | usage: gdash [-h] [--version] [--port PORT] [--gluster-binary GLUSTER_BINARY] 50 | [--auth-file AUTH_FILE] [--ssl-cert CERT_FILE] [--ssl-key KEY_FILE] [--ssl-ca CA_CERT_FILE] [--ssl-ciphers LIST_OF_CIPHERS] 51 | host 52 | 53 | gdash - GlusterFS Dashboard 54 | 55 | positional arguments: 56 | host Hostname of Current node as used in Gluster peer 57 | commands. Gdash replaces the "localhost" references 58 | with this name 59 | 60 | optional arguments: 61 | -h, --help show this help message and exit 62 | --version show program's version number and exit 63 | --port PORT Gdash Port(Default is 8080) 64 | --gluster-binary GLUSTER_BINARY Gluster binary path. 65 | --auth-file AUTH_FILE Users Credentials file. One user 66 | entry per row in the 67 | format = 68 | --ssl-cert CERT_FILE Path to SSL Certificate file 69 | --ssl-key KEY_FILE Path to SSL Key file 70 | --ssl-ca CA_FILE Path to SSL CA Certificate file 71 | --ssl-ciphers List of SSL Ciphers to allow 72 | ``` 73 | 74 | ## Blog 75 | 76 | * [Dec 04, 2014] http://aravindavk.in/blog/introducing-gdash (Previous version, UI is different now) 77 | * [Oct 19, 2020] https://kadalu.io/blog/gdash-v1.0 78 | 79 | 80 | ## Issues 81 | 82 | For feature requests, issues, suggestions [here](https://github.com/kadalu/gdash/issues) 83 | -------------------------------------------------------------------------------- /gdash/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kadalu/gdash/3713135ee8be7cbf0bc4e90459098b9f175afe8d/gdash/__init__.py -------------------------------------------------------------------------------- /gdash/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | gdash - GlusterFS Dashboard 3 | """ 4 | import hashlib 5 | import os 6 | from argparse import ArgumentParser 7 | 8 | import cherrypy 9 | from glustercli.cli import peer, set_gluster_path, volume 10 | 11 | from gdash.version import VERSION 12 | 13 | ARGS = None 14 | USERS = None 15 | 16 | conf = { 17 | "/api": { 18 | "tools.response_headers.on": True, 19 | "tools.response_headers.headers": [("Content-Type", "application/json")], 20 | "tools.caching.on": True, 21 | "tools.caching.delay": 5, 22 | }, 23 | "/": { 24 | "tools.staticdir.on": True, 25 | "tools.sessions.on": True, 26 | "tools.sessions.secure": True, 27 | "tools.sessions.httponly": True, 28 | "tools.secureheaders.on": True, 29 | "tools.staticdir.dir": os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui"), 30 | }, 31 | } 32 | 33 | 34 | def secureheaders(): 35 | headers = cherrypy.response.headers 36 | headers["X-Frame-Options"] = "DENY" 37 | headers["X-XSS-Protection"] = "1; mode=block" 38 | headers["Content-Security-Policy"] = "default-src='self'" 39 | 40 | 41 | def is_valid_admin_login(username, password): 42 | if USERS is None: 43 | return True 44 | 45 | pwd_hash = USERS.get(username, None) 46 | if pwd_hash is None: 47 | return False 48 | 49 | pwd_hash_2 = hashlib.sha256() 50 | pwd_hash_2.update(password.encode()) 51 | 52 | return pwd_hash_2.hexdigest() == pwd_hash 53 | 54 | 55 | def is_admin(): 56 | if USERS is None: 57 | return True 58 | 59 | return "admin" == cherrypy.session.get("role", "") 60 | 61 | 62 | def forbidden(): 63 | cherrypy.response.status = 403 64 | return {"error": "Forbidden"} 65 | 66 | 67 | @cherrypy.tools.json_in() 68 | @cherrypy.tools.json_out() 69 | class GdashApis: 70 | @cherrypy.expose 71 | def login(self): 72 | if is_admin(): 73 | return {} 74 | 75 | if cherrypy.request.method == "POST": 76 | data = cherrypy.request.json 77 | if is_valid_admin_login(data["username"], data["password"]): 78 | cherrypy.session["role"] = "admin" 79 | cherrypy.session["username"] = data["username"] 80 | return {} 81 | 82 | return forbidden() 83 | 84 | @cherrypy.expose 85 | def logout(self): 86 | if not is_admin(): 87 | return {} 88 | 89 | if cherrypy.session.get("role", None) is not None: 90 | del cherrypy.session["role"] 91 | 92 | if cherrypy.session.get("username", None) is not None: 93 | del cherrypy.session["username"] 94 | 95 | return {} 96 | 97 | @cherrypy.expose 98 | def volumes(self): 99 | if not is_admin(): 100 | return forbidden() 101 | 102 | return volume.status_detail(group_subvols=True) 103 | 104 | @cherrypy.expose 105 | def peers(self): 106 | if not is_admin(): 107 | return forbidden() 108 | 109 | peers = peer.pool() 110 | for entry in peers: 111 | if entry["hostname"] == "localhost": 112 | entry["hostname"] = ARGS.host 113 | 114 | return peers 115 | 116 | 117 | class GdashWeb: 118 | def __init__(self): 119 | self.api = None 120 | 121 | def default_render(self): 122 | filepath = os.path.dirname(os.path.abspath(__file__)) + "/ui/index.html" 123 | with open(filepath, encoding="utf-8") as index_file: 124 | return index_file.read() 125 | 126 | @cherrypy.expose 127 | def index(self): 128 | return self.default_render() 129 | 130 | @cherrypy.expose 131 | def volumes(self, volume_id=None): 132 | return self.default_render() 133 | 134 | @cherrypy.expose 135 | def peers(self): 136 | return self.default_render() 137 | 138 | @cherrypy.expose 139 | def bricks(self): 140 | return self.default_render() 141 | 142 | @cherrypy.expose 143 | def dashboard(self): 144 | return self.default_render() 145 | 146 | @cherrypy.expose 147 | def login(self): 148 | return self.default_render() 149 | 150 | @cherrypy.expose 151 | def logout(self): 152 | return self.default_render() 153 | 154 | 155 | def get_args(): 156 | parser = ArgumentParser(description=__doc__) 157 | parser.add_argument("--version", action="version", version="%(prog)s " + VERSION) 158 | parser.add_argument("--port", type=int, default=8080, help="Gdash Port") 159 | parser.add_argument( 160 | "host", 161 | help=( 162 | "Hostname of Current node as used in Gluster " 163 | 'peer commands. Gdash replaces the "localhost" ' 164 | "references with this name" 165 | ), 166 | ) 167 | parser.add_argument("--gluster-binary", default="gluster") 168 | parser.add_argument( 169 | "--auth-file", 170 | help=("Users Credentials file. One user entry per row " "in the format ="), 171 | ) 172 | parser.add_argument("--ssl-cert", default=None, help=("Path to SSL Certificate used by Gdash")) 173 | parser.add_argument("--ssl-key", default=None, help=("Path to SSL Key used by Gdash")) 174 | parser.add_argument("--ssl-ca", default=None, help=("Path to SSL CA Certificate used by Gdash")) 175 | parser.add_argument("--ssl-ciphers", default=None, help=("List of SSL Ciphers to allow")) 176 | return parser.parse_args() 177 | 178 | 179 | def main(): 180 | global ARGS, USERS 181 | 182 | ARGS = get_args() 183 | 184 | if ARGS.auth_file is not None: 185 | USERS = {} 186 | with open(ARGS.auth_file, encoding="utf-8") as usersf: 187 | for line in usersf: 188 | line = line.strip() 189 | if line: 190 | username, password_hash = line.split("=") 191 | USERS[username] = password_hash 192 | 193 | set_gluster_path(ARGS.gluster_binary) 194 | 195 | cherrypy_cfg = {"server.socket_host": "0.0.0.0", "server.socket_port": ARGS.port} 196 | 197 | if ARGS.ssl_cert: 198 | cherrypy_cfg["server.ssl_certificate"] = ARGS.ssl_cert 199 | 200 | if ARGS.ssl_key: 201 | cherrypy_cfg["server.ssl_private_key"] = ARGS.ssl_key 202 | 203 | if ARGS.ssl_ca: 204 | cherrypy_cfg["server.ssl_certificate_chain"] = ARGS.ssl_ca 205 | 206 | if ARGS.ssl_ciphers: 207 | cherrypy_cfg["server.ssl_ciphers"] = ARGS.ssl_ciphers 208 | 209 | if ARGS.ssl_cert and ARGS.ssl_key: 210 | cherrypy_cfg["server.ssl_module"] = "builtin" 211 | 212 | cherrypy.config.update(cherrypy_cfg) 213 | cherrypy.tools.secureheaders = cherrypy.Tool("before_finalize", secureheaders, priority=60) 214 | webapp = GdashWeb() 215 | webapp.api = GdashApis() 216 | cherrypy.quickstart(webapp, "/", conf) 217 | 218 | 219 | if __name__ == "__main__": 220 | main() 221 | -------------------------------------------------------------------------------- /gdash/version.py: -------------------------------------------------------------------------------- 1 | """Version""" 2 | VERSION = "main" 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | CherryPy==18.8.0 2 | glustercli==0.8.6 3 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # Enable flake8-bugbear (`B`) rules. 2 | select = ["E", "F", "B"] 3 | 4 | # Never enforce `E501` (line length violations). 5 | ignore = ["E501"] 6 | 7 | # Avoid trying to fix flake8-bugbear (`B`) violations. 8 | unfixable = ["B"] 9 | 10 | # Ignore `E402` (import violations) in all `__init__.py` files, and in `path/to/file.py`. 11 | [per-file-ignores] 12 | "__init__.py" = ["E402"] 13 | "path/to/file.py" = ["E402"] 14 | 15 | -------------------------------------------------------------------------------- /sample_volumes.json: -------------------------------------------------------------------------------- 1 | [{"name": "gvol1", "uuid": "8c3fb80d-8016-4f3a-8c8c-b51eca924f1f", "type": "REPLICATE", "status": "Created", "num_bricks": 3, "distribute": 3, "stripe": 1, "replica": 3, "disperse": 0, "disperse_redundancy": 0, "transport": "TCP", "snapshot_count": 0, "options": [{"name": "performance.client-io-threads", "value": "off"}, {"name": "nfs.disable", "value": "on"}, {"name": "storage.fips-mode-rchecksum", "value": "on"}, {"name": "transport.address-family", "value": "inet"}], "subvols": [{"name": "gvol1-replicate-0", "replica": 3, "disperse": 0, "disperse_redundancy": 0, "type": "REPLICATE", "bricks": [{"name": "sonne:/bricks/gvol1/1", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}, {"name": "sonne:/bricks/gvol1/2", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}, {"name": "sonne:/bricks/gvol1/3", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}]}], "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0}, {"name": "gvol2", "uuid": "3d09aa0f-114c-473c-b0a2-a3f7de2bd0cd", "type": "DISTRIBUTED_REPLICATE", "status": "Started", "num_bricks": 6, "distribute": 3, "stripe": 1, "replica": 3, "disperse": 0, "disperse_redundancy": 0, "transport": "TCP", "snapshot_count": 0, "options": [{"name": "performance.client-io-threads", "value": "off"}, {"name": "nfs.disable", "value": "on"}, {"name": "storage.fips-mode-rchecksum", "value": "on"}, {"name": "transport.address-family", "value": "inet"}], "subvols": [{"name": "gvol2-replicate-0", "replica": 3, "disperse": 0, "disperse_redundancy": 0, "type": "REPLICATE", "bricks": [{"name": "sonne:/bricks/gvol2/1", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "online": true, "pid": "1768", "size_total": 21000437760, "size_free": 6654836736, "inodes_total": 1310720, "inodes_free": 991614, "device": "/dev/sda2", "block_size": "4096", "mnt_options": "rw,relatime,data=ordered", "fs_name": "ext4", "size_used": 14345601024, "inodes_used": 319106, "ports": {"tcp": "49152", "rdma": "N/A"}, "type": "Brick"}, {"name": "sonne:/bricks/gvol2/2", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "online": true, "pid": "1788", "size_total": 21000437760, "size_free": 6654836736, "inodes_total": 1310720, "inodes_free": 991614, "device": "/dev/sda2", "block_size": "4096", "mnt_options": "rw,relatime,data=ordered", "fs_name": "ext4", "size_used": 14345601024, "inodes_used": 319106, "ports": {"tcp": "49153", "rdma": "N/A"}, "type": "Brick"}, {"name": "sonne:/bricks/gvol2/3", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "online": true, "pid": "1791", "size_total": 21000437760, "size_free": 6654836736, "inodes_total": 1310720, "inodes_free": 991614, "device": "/dev/sda2", "block_size": "4096", "mnt_options": "rw,relatime,data=ordered", "fs_name": "ext4", "size_used": 14345601024, "inodes_used": 319106, "ports": {"tcp": "49154", "rdma": "N/A"}, "type": "Brick"}], "health": "up"}, {"name": "gvol2-replicate-1", "replica": 3, "disperse": 0, "disperse_redundancy": 0, "type": "REPLICATE", "bricks": [{"name": "sonne:/bricks/gvol2/4", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "online": true, "pid": "1824", "size_total": 21000437760, "size_free": 6654836736, "inodes_total": 1310720, "inodes_free": 991614, "device": "/dev/sda2", "block_size": "4096", "mnt_options": "rw,relatime,data=ordered", "fs_name": "ext4", "size_used": 14345601024, "inodes_used": 319106, "ports": {"tcp": "49155", "rdma": "N/A"}, "type": "Brick"}, {"name": "sonne:/bricks/gvol2/5", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "online": true, "pid": "1790", "size_total": 21000437760, "size_free": 6654836736, "inodes_total": 1310720, "inodes_free": 991614, "device": "/dev/sda2", "block_size": "4096", "mnt_options": "rw,relatime,data=ordered", "fs_name": "ext4", "size_used": 14345601024, "inodes_used": 319106, "ports": {"tcp": "49156", "rdma": "N/A"}, "type": "Brick"}, {"name": "sonne:/bricks/gvol2/6", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "online": true, "pid": "1789", "size_total": 21000437760, "size_free": 6654836736, "inodes_total": 1310720, "inodes_free": 991614, "device": "/dev/sda2", "block_size": "4096", "mnt_options": "rw,relatime,data=ordered", "fs_name": "ext4", "size_used": 14345601024, "inodes_used": 319106, "ports": {"tcp": "49157", "rdma": "N/A"}, "type": "Brick"}], "health": "up"}], "size_total": 42000875520, "size_free": 13309673472, "size_used": 28691202048, "inodes_total": 2621440, "inodes_free": 1983228, "inodes_used": 638212, "health": "up"}, {"name": "gvol3", "uuid": "3adb5ea4-b3e5-43bc-bf40-d5a8eabb4499", "type": "DISTRIBUTED_DISPERSE", "status": "Created", "num_bricks": 6, "distribute": 3, "stripe": 1, "replica": 1, "disperse": 3, "disperse_redundancy": 1, "transport": "TCP", "snapshot_count": 0, "options": [{"name": "nfs.disable", "value": "on"}, {"name": "storage.fips-mode-rchecksum", "value": "on"}, {"name": "transport.address-family", "value": "inet"}], "subvols": [{"name": "gvol3-disperse-0", "replica": 1, "disperse": 3, "disperse_redundancy": 1, "type": "DISPERSE", "bricks": [{"name": "sonne:/bricks/gvol3/1", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}, {"name": "sonne:/bricks/gvol3/2", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}, {"name": "sonne:/bricks/gvol3/3", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}]}, {"name": "gvol3-disperse-1", "replica": 1, "disperse": 3, "disperse_redundancy": 1, "type": "DISPERSE", "bricks": [{"name": "sonne:/bricks/gvol3/4", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}, {"name": "sonne:/bricks/gvol3/5", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}, {"name": "sonne:/bricks/gvol3/6", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}]}], "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0}, {"name": "gvol4", "uuid": "7203d8a6-e5a4-4fc4-83fe-c611cdbd0e26", "type": "DISPERSE", "status": "Created", "num_bricks": 3, "distribute": 3, "stripe": 1, "replica": 1, "disperse": 3, "disperse_redundancy": 1, "transport": "TCP", "snapshot_count": 0, "options": [{"name": "nfs.disable", "value": "on"}, {"name": "storage.fips-mode-rchecksum", "value": "on"}, {"name": "transport.address-family", "value": "inet"}], "subvols": [{"name": "gvol4-disperse-0", "replica": 1, "disperse": 3, "disperse_redundancy": 1, "type": "DISPERSE", "bricks": [{"name": "sonne:/bricks/gvol4/1", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}, {"name": "sonne:/bricks/gvol4/2", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}, {"name": "sonne:/bricks/gvol4/3", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}]}], "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0}, {"name": "gvol5", "uuid": "22c7912b-40c3-44ca-b8bd-7cf327a96c12", "type": "REPLICATE", "status": "Created", "num_bricks": 3, "distribute": 3, "stripe": 1, "replica": 3, "disperse": 0, "disperse_redundancy": 0, "transport": "TCP", "snapshot_count": 0, "options": [{"name": "performance.client-io-threads", "value": "off"}, {"name": "nfs.disable", "value": "on"}, {"name": "storage.fips-mode-rchecksum", "value": "on"}, {"name": "transport.address-family", "value": "inet"}], "subvols": [{"name": "gvol5-replicate-0", "replica": 3, "disperse": 0, "disperse_redundancy": 0, "type": "REPLICATE", "bricks": [{"name": "sonne:/bricks/gvol5/1", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}, {"name": "sonne:/bricks/gvol5/2", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}, {"name": "sonne:/bricks/gvol5/3", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Arbiter", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}]}], "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0}, {"name": "zfsgvol", "uuid": "e6bf1525-46a3-4abb-9f50-19b136453eac", "type": "REPLICATE", "status": "Stopped", "num_bricks": 3, "distribute": 3, "stripe": 1, "replica": 3, "disperse": 0, "disperse_redundancy": 0, "transport": "TCP", "snapshot_count": 0, "options": [{"name": "transport.address-family", "value": "inet"}, {"name": "storage.fips-mode-rchecksum", "value": "on"}, {"name": "nfs.disable", "value": "on"}, {"name": "performance.client-io-threads", "value": "off"}], "subvols": [{"name": "zfsgvol-replicate-0", "replica": 3, "disperse": 0, "disperse_redundancy": 0, "type": "REPLICATE", "bricks": [{"name": "sonne:/disk1/brick", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}, {"name": "sonne:/disk2/brick", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}, {"name": "sonne:/disk3/brick", "uuid": "769fda2b-8dca-418f-97b1-867bad5b62ea", "type": "Brick", "online": false, "ports": {"tcp": "N/A", "rdma": "N/A"}, "pid": "N/A", "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0, "device": "N/A", "block_size": "N/A", "mnt_options": "N/A", "fs_name": "N/A"}]}], "size_total": 0, "size_free": 0, "size_used": 0, "inodes_total": 0, "inodes_free": 0, "inodes_used": 0}] 2 | -------------------------------------------------------------------------------- /screenshots/gdash-detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kadalu/gdash/3713135ee8be7cbf0bc4e90459098b9f175afe8d/screenshots/gdash-detail.png -------------------------------------------------------------------------------- /screenshots/gdash-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kadalu/gdash/3713135ee8be7cbf0bc4e90459098b9f175afe8d/screenshots/gdash-home.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | def get_version(): 5 | from gdash.version import VERSION 6 | 7 | return VERSION 8 | 9 | 10 | setup( 11 | name="gdash", 12 | version=get_version(), 13 | packages=["gdash"], 14 | include_package_data=True, 15 | install_requires=["cherrypy", "glustercli"], 16 | entry_points={ 17 | "console_scripts": [ 18 | "gdash = gdash.__main__:main", 19 | ] 20 | }, 21 | package_data={ 22 | "gdash": [ 23 | "ui/*.json", 24 | "ui/*.html", 25 | "ui/*.png", 26 | "ui/*.js", 27 | "ui/*.txt", 28 | "ui/static/js/*.js", 29 | "ui/static/js/*.js.map", 30 | "ui/static/js/*.txt", 31 | "ui/static/css/*.css", 32 | "ui/static/css/*.css.map", 33 | "ui/static/media/*.svg", 34 | ] 35 | }, 36 | platforms="linux", 37 | zip_safe=False, 38 | author="Aravinda Vishwanathapura", 39 | author_email="aravinda@kadalu.io", 40 | description="GlusterFS Dashboard", 41 | license="Apache-2.0", 42 | keywords="glusterfs, gui, dashboard", 43 | url="https://github.com/kadalu/gdash", 44 | long_description=""" 45 | This tool is based on remote execution support provided by 46 | GlusterFS cli for `volume info` and `volume status` commands 47 | """, 48 | classifiers=[ 49 | "Development Status :: 5 - Production/Stable", 50 | "Topic :: Utilities", 51 | "Environment :: Console", 52 | "Environment :: Web Environment", 53 | "Framework :: CherryPy", 54 | "License :: OSI Approved :: Apache Software License", 55 | "Operating System :: POSIX :: Linux", 56 | "Programming Language :: JavaScript", 57 | "Programming Language :: Python :: 3", 58 | "Programming Language :: Python :: 3 :: Only", 59 | ], 60 | ) 61 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.5", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^14.4.3", 9 | "axios": "^1.2.1", 10 | "dayjs": "^1.11.7", 11 | "react": "^18.2.0", 12 | "react-dom": "^18.2.0", 13 | "react-router-dom": "^6.5.0" 14 | }, 15 | "scripts": { 16 | "start": "npm run watch:css && react-scripts start", 17 | "build": "npm run build:css && react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject", 20 | "build:css": "npx tailwindcss -i src/assets/css/tailwind.css -o src/assets/css/main.css", 21 | "watch:css": "npx tailwindcss -i src/assets/css/tailwind.css -o src/assets/css/main.css --watch" 22 | }, 23 | "eslintConfig": { 24 | "extends": "react-app" 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | }, 38 | "devDependencies": { 39 | "react-scripts": "^5.0.1", 40 | "tailwindcss": "^3.2.4" 41 | }, 42 | "proxy": "http://localhost:8080" 43 | } 44 | -------------------------------------------------------------------------------- /ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | // postcss.config.js 2 | const purgecss = require('@fullhuman/postcss-purgecss')({ 3 | 4 | // Specify the paths to all of the template files in your project 5 | content: [ 6 | './src/**/*.html', 7 | './src/**/*.jsx', 8 | './src/*.jsx', 9 | ], 10 | 11 | // This is the function used to extract class names from your templates 12 | defaultExtractor: content => { 13 | // Capture as liberally as possible, including things like `h-(screen-1.5)` 14 | const broadMatches = content.match(/[^<>"'`\s]*[^<>"'`\s:]/g) || [] 15 | 16 | // Capture classes within other delimiters like .block(class="w-1/2") in Pug 17 | const innerMatches = content.match(/[^<>"'`\s.()]*[^<>"'`\s.():]/g) || [] 18 | 19 | return broadMatches.concat(innerMatches) 20 | } 21 | }) 22 | 23 | module.exports = { 24 | plugins: [ 25 | require('tailwindcss'), 26 | require('autoprefixer'), 27 | ...process.env.NODE_ENV === 'production' 28 | ? [purgecss] 29 | : [] 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | GlusterFS Dashboard 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /ui/public/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kadalu/gdash/3713135ee8be7cbf0bc4e90459098b9f175afe8d/ui/public/logo-small.png -------------------------------------------------------------------------------- /ui/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kadalu/gdash/3713135ee8be7cbf0bc4e90459098b9f175afe8d/ui/public/logo.png -------------------------------------------------------------------------------- /ui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "gdash", 3 | "name": "GlusterFS Dashboard", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /ui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /ui/src/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | BrowserRouter as Router, 4 | Routes, 5 | Route 6 | } from "react-router-dom"; 7 | 8 | import { Login } from './pages/login.jsx'; 9 | import { Logout } from './pages/logout.jsx'; 10 | import { Dashboard } from './pages/dashboard.jsx'; 11 | import { Volumes } from './pages/volumes.jsx'; 12 | import { Peers } from './pages/peers.jsx'; 13 | import { Bricks } from './pages/bricks.jsx'; 14 | import { VolumeDetail } from './pages/volumeDetail.jsx'; 15 | 16 | function App() { 17 | return ( 18 | 19 | 20 | }/> 22 | }/> 24 | 25 | }/> 27 | }/> 29 | 30 | }/> 32 | }/> 34 | }/> 36 | }/> 38 | 39 | 40 | ); 41 | } 42 | 43 | export default App; 44 | -------------------------------------------------------------------------------- /ui/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /ui/src/assets/css/main.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: #e5e7eb; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | 5. Use the user's configured `sans` font-feature-settings by default. 34 | */ 35 | 36 | html { 37 | line-height: 1.5; 38 | /* 1 */ 39 | -webkit-text-size-adjust: 100%; 40 | /* 2 */ 41 | /* 3 */ 42 | tab-size: 4; 43 | /* 3 */ 44 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 45 | /* 4 */ 46 | -webkit-font-feature-settings: normal; 47 | font-feature-settings: normal; 48 | /* 5 */ 49 | } 50 | 51 | /* 52 | 1. Remove the margin in all browsers. 53 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 54 | */ 55 | 56 | body { 57 | margin: 0; 58 | /* 1 */ 59 | line-height: inherit; 60 | /* 2 */ 61 | } 62 | 63 | /* 64 | 1. Add the correct height in Firefox. 65 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 66 | 3. Ensure horizontal rules are visible by default. 67 | */ 68 | 69 | hr { 70 | height: 0; 71 | /* 1 */ 72 | color: inherit; 73 | /* 2 */ 74 | border-top-width: 1px; 75 | /* 3 */ 76 | } 77 | 78 | /* 79 | Add the correct text decoration in Chrome, Edge, and Safari. 80 | */ 81 | 82 | abbr:where([title]) { 83 | -webkit-text-decoration: underline dotted; 84 | text-decoration: underline dotted; 85 | } 86 | 87 | /* 88 | Remove the default font size and weight for headings. 89 | */ 90 | 91 | h1, 92 | h2, 93 | h3, 94 | h4, 95 | h5, 96 | h6 { 97 | font-size: inherit; 98 | font-weight: inherit; 99 | } 100 | 101 | /* 102 | Reset links to optimize for opt-in styling instead of opt-out. 103 | */ 104 | 105 | a { 106 | color: inherit; 107 | text-decoration: inherit; 108 | } 109 | 110 | /* 111 | Add the correct font weight in Edge and Safari. 112 | */ 113 | 114 | b, 115 | strong { 116 | font-weight: bolder; 117 | } 118 | 119 | /* 120 | 1. Use the user's configured `mono` font family by default. 121 | 2. Correct the odd `em` font sizing in all browsers. 122 | */ 123 | 124 | code, 125 | kbd, 126 | samp, 127 | pre { 128 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 129 | /* 1 */ 130 | font-size: 1em; 131 | /* 2 */ 132 | } 133 | 134 | /* 135 | Add the correct font size in all browsers. 136 | */ 137 | 138 | small { 139 | font-size: 80%; 140 | } 141 | 142 | /* 143 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 144 | */ 145 | 146 | sub, 147 | sup { 148 | font-size: 75%; 149 | line-height: 0; 150 | position: relative; 151 | vertical-align: baseline; 152 | } 153 | 154 | sub { 155 | bottom: -0.25em; 156 | } 157 | 158 | sup { 159 | top: -0.5em; 160 | } 161 | 162 | /* 163 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 164 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 165 | 3. Remove gaps between table borders by default. 166 | */ 167 | 168 | table { 169 | text-indent: 0; 170 | /* 1 */ 171 | border-color: inherit; 172 | /* 2 */ 173 | border-collapse: collapse; 174 | /* 3 */ 175 | } 176 | 177 | /* 178 | 1. Change the font styles in all browsers. 179 | 2. Remove the margin in Firefox and Safari. 180 | 3. Remove default padding in all browsers. 181 | */ 182 | 183 | button, 184 | input, 185 | optgroup, 186 | select, 187 | textarea { 188 | font-family: inherit; 189 | /* 1 */ 190 | font-size: 100%; 191 | /* 1 */ 192 | font-weight: inherit; 193 | /* 1 */ 194 | line-height: inherit; 195 | /* 1 */ 196 | color: inherit; 197 | /* 1 */ 198 | margin: 0; 199 | /* 2 */ 200 | padding: 0; 201 | /* 3 */ 202 | } 203 | 204 | /* 205 | Remove the inheritance of text transform in Edge and Firefox. 206 | */ 207 | 208 | button, 209 | select { 210 | text-transform: none; 211 | } 212 | 213 | /* 214 | 1. Correct the inability to style clickable types in iOS and Safari. 215 | 2. Remove default button styles. 216 | */ 217 | 218 | button, 219 | [type='button'], 220 | [type='reset'], 221 | [type='submit'] { 222 | -webkit-appearance: button; 223 | /* 1 */ 224 | background-color: transparent; 225 | /* 2 */ 226 | background-image: none; 227 | /* 2 */ 228 | } 229 | 230 | /* 231 | Use the modern Firefox focus style for all focusable elements. 232 | */ 233 | 234 | :-moz-focusring { 235 | outline: auto; 236 | } 237 | 238 | /* 239 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 240 | */ 241 | 242 | :-moz-ui-invalid { 243 | box-shadow: none; 244 | } 245 | 246 | /* 247 | Add the correct vertical alignment in Chrome and Firefox. 248 | */ 249 | 250 | progress { 251 | vertical-align: baseline; 252 | } 253 | 254 | /* 255 | Correct the cursor style of increment and decrement buttons in Safari. 256 | */ 257 | 258 | ::-webkit-inner-spin-button, 259 | ::-webkit-outer-spin-button { 260 | height: auto; 261 | } 262 | 263 | /* 264 | 1. Correct the odd appearance in Chrome and Safari. 265 | 2. Correct the outline style in Safari. 266 | */ 267 | 268 | [type='search'] { 269 | -webkit-appearance: textfield; 270 | /* 1 */ 271 | outline-offset: -2px; 272 | /* 2 */ 273 | } 274 | 275 | /* 276 | Remove the inner padding in Chrome and Safari on macOS. 277 | */ 278 | 279 | ::-webkit-search-decoration { 280 | -webkit-appearance: none; 281 | } 282 | 283 | /* 284 | 1. Correct the inability to style clickable types in iOS and Safari. 285 | 2. Change font properties to `inherit` in Safari. 286 | */ 287 | 288 | ::-webkit-file-upload-button { 289 | -webkit-appearance: button; 290 | /* 1 */ 291 | font: inherit; 292 | /* 2 */ 293 | } 294 | 295 | /* 296 | Add the correct display in Chrome and Safari. 297 | */ 298 | 299 | summary { 300 | display: list-item; 301 | } 302 | 303 | /* 304 | Removes the default spacing and border for appropriate elements. 305 | */ 306 | 307 | blockquote, 308 | dl, 309 | dd, 310 | h1, 311 | h2, 312 | h3, 313 | h4, 314 | h5, 315 | h6, 316 | hr, 317 | figure, 318 | p, 319 | pre { 320 | margin: 0; 321 | } 322 | 323 | fieldset { 324 | margin: 0; 325 | padding: 0; 326 | } 327 | 328 | legend { 329 | padding: 0; 330 | } 331 | 332 | ol, 333 | ul, 334 | menu { 335 | list-style: none; 336 | margin: 0; 337 | padding: 0; 338 | } 339 | 340 | /* 341 | Prevent resizing textareas horizontally by default. 342 | */ 343 | 344 | textarea { 345 | resize: vertical; 346 | } 347 | 348 | /* 349 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 350 | 2. Set the default placeholder color to the user's configured gray 400 color. 351 | */ 352 | 353 | input::-webkit-input-placeholder, textarea::-webkit-input-placeholder { 354 | opacity: 1; 355 | /* 1 */ 356 | color: #9ca3af; 357 | /* 2 */ 358 | } 359 | 360 | input::placeholder, 361 | textarea::placeholder { 362 | opacity: 1; 363 | /* 1 */ 364 | color: #9ca3af; 365 | /* 2 */ 366 | } 367 | 368 | /* 369 | Set the default cursor for buttons. 370 | */ 371 | 372 | button, 373 | [role="button"] { 374 | cursor: pointer; 375 | } 376 | 377 | /* 378 | Make sure disabled buttons don't get the pointer cursor. 379 | */ 380 | 381 | :disabled { 382 | cursor: default; 383 | } 384 | 385 | /* 386 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 387 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 388 | This can trigger a poorly considered lint error in some tools but is included by design. 389 | */ 390 | 391 | img, 392 | svg, 393 | video, 394 | canvas, 395 | audio, 396 | iframe, 397 | embed, 398 | object { 399 | display: block; 400 | /* 1 */ 401 | vertical-align: middle; 402 | /* 2 */ 403 | } 404 | 405 | /* 406 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 407 | */ 408 | 409 | img, 410 | video { 411 | max-width: 100%; 412 | height: auto; 413 | } 414 | 415 | /* Make elements with the HTML hidden attribute stay hidden by default */ 416 | 417 | [hidden] { 418 | display: none; 419 | } 420 | 421 | *, ::before, ::after { 422 | --tw-border-spacing-x: 0; 423 | --tw-border-spacing-y: 0; 424 | --tw-translate-x: 0; 425 | --tw-translate-y: 0; 426 | --tw-rotate: 0; 427 | --tw-skew-x: 0; 428 | --tw-skew-y: 0; 429 | --tw-scale-x: 1; 430 | --tw-scale-y: 1; 431 | --tw-pan-x: ; 432 | --tw-pan-y: ; 433 | --tw-pinch-zoom: ; 434 | --tw-scroll-snap-strictness: proximity; 435 | --tw-ordinal: ; 436 | --tw-slashed-zero: ; 437 | --tw-numeric-figure: ; 438 | --tw-numeric-spacing: ; 439 | --tw-numeric-fraction: ; 440 | --tw-ring-inset: ; 441 | --tw-ring-offset-width: 0px; 442 | --tw-ring-offset-color: #fff; 443 | --tw-ring-color: rgb(59 130 246 / 0.5); 444 | --tw-ring-offset-shadow: 0 0 #0000; 445 | --tw-ring-shadow: 0 0 #0000; 446 | --tw-shadow: 0 0 #0000; 447 | --tw-shadow-colored: 0 0 #0000; 448 | --tw-blur: ; 449 | --tw-brightness: ; 450 | --tw-contrast: ; 451 | --tw-grayscale: ; 452 | --tw-hue-rotate: ; 453 | --tw-invert: ; 454 | --tw-saturate: ; 455 | --tw-sepia: ; 456 | --tw-drop-shadow: ; 457 | --tw-backdrop-blur: ; 458 | --tw-backdrop-brightness: ; 459 | --tw-backdrop-contrast: ; 460 | --tw-backdrop-grayscale: ; 461 | --tw-backdrop-hue-rotate: ; 462 | --tw-backdrop-invert: ; 463 | --tw-backdrop-opacity: ; 464 | --tw-backdrop-saturate: ; 465 | --tw-backdrop-sepia: ; 466 | } 467 | 468 | ::-webkit-backdrop { 469 | --tw-border-spacing-x: 0; 470 | --tw-border-spacing-y: 0; 471 | --tw-translate-x: 0; 472 | --tw-translate-y: 0; 473 | --tw-rotate: 0; 474 | --tw-skew-x: 0; 475 | --tw-skew-y: 0; 476 | --tw-scale-x: 1; 477 | --tw-scale-y: 1; 478 | --tw-pan-x: ; 479 | --tw-pan-y: ; 480 | --tw-pinch-zoom: ; 481 | --tw-scroll-snap-strictness: proximity; 482 | --tw-ordinal: ; 483 | --tw-slashed-zero: ; 484 | --tw-numeric-figure: ; 485 | --tw-numeric-spacing: ; 486 | --tw-numeric-fraction: ; 487 | --tw-ring-inset: ; 488 | --tw-ring-offset-width: 0px; 489 | --tw-ring-offset-color: #fff; 490 | --tw-ring-color: rgb(59 130 246 / 0.5); 491 | --tw-ring-offset-shadow: 0 0 #0000; 492 | --tw-ring-shadow: 0 0 #0000; 493 | --tw-shadow: 0 0 #0000; 494 | --tw-shadow-colored: 0 0 #0000; 495 | --tw-blur: ; 496 | --tw-brightness: ; 497 | --tw-contrast: ; 498 | --tw-grayscale: ; 499 | --tw-hue-rotate: ; 500 | --tw-invert: ; 501 | --tw-saturate: ; 502 | --tw-sepia: ; 503 | --tw-drop-shadow: ; 504 | --tw-backdrop-blur: ; 505 | --tw-backdrop-brightness: ; 506 | --tw-backdrop-contrast: ; 507 | --tw-backdrop-grayscale: ; 508 | --tw-backdrop-hue-rotate: ; 509 | --tw-backdrop-invert: ; 510 | --tw-backdrop-opacity: ; 511 | --tw-backdrop-saturate: ; 512 | --tw-backdrop-sepia: ; 513 | } 514 | 515 | ::backdrop { 516 | --tw-border-spacing-x: 0; 517 | --tw-border-spacing-y: 0; 518 | --tw-translate-x: 0; 519 | --tw-translate-y: 0; 520 | --tw-rotate: 0; 521 | --tw-skew-x: 0; 522 | --tw-skew-y: 0; 523 | --tw-scale-x: 1; 524 | --tw-scale-y: 1; 525 | --tw-pan-x: ; 526 | --tw-pan-y: ; 527 | --tw-pinch-zoom: ; 528 | --tw-scroll-snap-strictness: proximity; 529 | --tw-ordinal: ; 530 | --tw-slashed-zero: ; 531 | --tw-numeric-figure: ; 532 | --tw-numeric-spacing: ; 533 | --tw-numeric-fraction: ; 534 | --tw-ring-inset: ; 535 | --tw-ring-offset-width: 0px; 536 | --tw-ring-offset-color: #fff; 537 | --tw-ring-color: rgb(59 130 246 / 0.5); 538 | --tw-ring-offset-shadow: 0 0 #0000; 539 | --tw-ring-shadow: 0 0 #0000; 540 | --tw-shadow: 0 0 #0000; 541 | --tw-shadow-colored: 0 0 #0000; 542 | --tw-blur: ; 543 | --tw-brightness: ; 544 | --tw-contrast: ; 545 | --tw-grayscale: ; 546 | --tw-hue-rotate: ; 547 | --tw-invert: ; 548 | --tw-saturate: ; 549 | --tw-sepia: ; 550 | --tw-drop-shadow: ; 551 | --tw-backdrop-blur: ; 552 | --tw-backdrop-brightness: ; 553 | --tw-backdrop-contrast: ; 554 | --tw-backdrop-grayscale: ; 555 | --tw-backdrop-hue-rotate: ; 556 | --tw-backdrop-invert: ; 557 | --tw-backdrop-opacity: ; 558 | --tw-backdrop-saturate: ; 559 | --tw-backdrop-sepia: ; 560 | } 561 | 562 | .col-span-2 { 563 | grid-column: span 2 / span 2; 564 | } 565 | 566 | .col-span-10 { 567 | grid-column: span 10 / span 10; 568 | } 569 | 570 | .m-auto { 571 | margin: auto; 572 | } 573 | 574 | .mx-2 { 575 | margin-left: 0.5rem; 576 | margin-right: 0.5rem; 577 | } 578 | 579 | .my-10 { 580 | margin-top: 2.5rem; 581 | margin-bottom: 2.5rem; 582 | } 583 | 584 | .mb-10 { 585 | margin-bottom: 2.5rem; 586 | } 587 | 588 | .mb-1 { 589 | margin-bottom: 0.25rem; 590 | } 591 | 592 | .mt-5 { 593 | margin-top: 1.25rem; 594 | } 595 | 596 | .mr-2 { 597 | margin-right: 0.5rem; 598 | } 599 | 600 | .mt-10 { 601 | margin-top: 2.5rem; 602 | } 603 | 604 | .mr-10 { 605 | margin-right: 2.5rem; 606 | } 607 | 608 | .mb-5 { 609 | margin-bottom: 1.25rem; 610 | } 611 | 612 | .block { 613 | display: block; 614 | } 615 | 616 | .inline-block { 617 | display: inline-block; 618 | } 619 | 620 | .table { 621 | display: table; 622 | } 623 | 624 | .grid { 625 | display: grid; 626 | } 627 | 628 | .h-6 { 629 | height: 1.5rem; 630 | } 631 | 632 | .h-1 { 633 | height: 0.25rem; 634 | } 635 | 636 | .h-screen { 637 | height: 100vh; 638 | } 639 | 640 | .h-5 { 641 | height: 1.25rem; 642 | } 643 | 644 | .w-6 { 645 | width: 1.5rem; 646 | } 647 | 648 | .w-3\/4 { 649 | width: 75%; 650 | } 651 | 652 | .w-5 { 653 | width: 1.25rem; 654 | } 655 | 656 | .w-full { 657 | width: 100%; 658 | } 659 | 660 | .w-1\/4 { 661 | width: 25%; 662 | } 663 | 664 | .cursor-pointer { 665 | cursor: pointer; 666 | } 667 | 668 | .appearance-none { 669 | -webkit-appearance: none; 670 | appearance: none; 671 | } 672 | 673 | .grid-cols-12 { 674 | grid-template-columns: repeat(12, minmax(0, 1fr)); 675 | } 676 | 677 | .gap-10 { 678 | gap: 2.5rem; 679 | } 680 | 681 | .rounded-lg { 682 | border-radius: 0.5rem; 683 | } 684 | 685 | .border { 686 | border-width: 1px; 687 | } 688 | 689 | .border-b { 690 | border-bottom-width: 1px; 691 | } 692 | 693 | .border-red-400 { 694 | --tw-border-opacity: 1; 695 | border-color: rgb(248 113 113 / var(--tw-border-opacity)); 696 | } 697 | 698 | .border-indigo-100 { 699 | --tw-border-opacity: 1; 700 | border-color: rgb(224 231 255 / var(--tw-border-opacity)); 701 | } 702 | 703 | .border-indigo-200 { 704 | --tw-border-opacity: 1; 705 | border-color: rgb(199 210 254 / var(--tw-border-opacity)); 706 | } 707 | 708 | .bg-red-300 { 709 | --tw-bg-opacity: 1; 710 | background-color: rgb(252 165 165 / var(--tw-bg-opacity)); 711 | } 712 | 713 | .bg-gray-300 { 714 | --tw-bg-opacity: 1; 715 | background-color: rgb(209 213 219 / var(--tw-bg-opacity)); 716 | } 717 | 718 | .bg-green-500 { 719 | --tw-bg-opacity: 1; 720 | background-color: rgb(34 197 94 / var(--tw-bg-opacity)); 721 | } 722 | 723 | .bg-red-500 { 724 | --tw-bg-opacity: 1; 725 | background-color: rgb(239 68 68 / var(--tw-bg-opacity)); 726 | } 727 | 728 | .bg-yellow-500 { 729 | --tw-bg-opacity: 1; 730 | background-color: rgb(234 179 8 / var(--tw-bg-opacity)); 731 | } 732 | 733 | .bg-orange-500 { 734 | --tw-bg-opacity: 1; 735 | background-color: rgb(249 115 22 / var(--tw-bg-opacity)); 736 | } 737 | 738 | .bg-gray-400 { 739 | --tw-bg-opacity: 1; 740 | background-color: rgb(156 163 175 / var(--tw-bg-opacity)); 741 | } 742 | 743 | .bg-indigo-600 { 744 | --tw-bg-opacity: 1; 745 | background-color: rgb(79 70 229 / var(--tw-bg-opacity)); 746 | } 747 | 748 | .bg-indigo-900 { 749 | --tw-bg-opacity: 1; 750 | background-color: rgb(49 46 129 / var(--tw-bg-opacity)); 751 | } 752 | 753 | .bg-indigo-800 { 754 | --tw-bg-opacity: 1; 755 | background-color: rgb(55 48 163 / var(--tw-bg-opacity)); 756 | } 757 | 758 | .bg-indigo-100 { 759 | --tw-bg-opacity: 1; 760 | background-color: rgb(224 231 255 / var(--tw-bg-opacity)); 761 | } 762 | 763 | .bg-indigo-700 { 764 | --tw-bg-opacity: 1; 765 | background-color: rgb(67 56 202 / var(--tw-bg-opacity)); 766 | } 767 | 768 | .bg-white { 769 | --tw-bg-opacity: 1; 770 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 771 | } 772 | 773 | .p-5 { 774 | padding: 1.25rem; 775 | } 776 | 777 | .px-2 { 778 | padding-left: 0.5rem; 779 | padding-right: 0.5rem; 780 | } 781 | 782 | .py-1 { 783 | padding-top: 0.25rem; 784 | padding-bottom: 0.25rem; 785 | } 786 | 787 | .px-4 { 788 | padding-left: 1rem; 789 | padding-right: 1rem; 790 | } 791 | 792 | .py-5 { 793 | padding-top: 1.25rem; 794 | padding-bottom: 1.25rem; 795 | } 796 | 797 | .py-2 { 798 | padding-top: 0.5rem; 799 | padding-bottom: 0.5rem; 800 | } 801 | 802 | .px-5 { 803 | padding-left: 1.25rem; 804 | padding-right: 1.25rem; 805 | } 806 | 807 | .py-10 { 808 | padding-top: 2.5rem; 809 | padding-bottom: 2.5rem; 810 | } 811 | 812 | .px-10 { 813 | padding-left: 2.5rem; 814 | padding-right: 2.5rem; 815 | } 816 | 817 | .pb-1 { 818 | padding-bottom: 0.25rem; 819 | } 820 | 821 | .pl-2 { 822 | padding-left: 0.5rem; 823 | } 824 | 825 | .pr-1 { 826 | padding-right: 0.25rem; 827 | } 828 | 829 | .pb-10 { 830 | padding-bottom: 2.5rem; 831 | } 832 | 833 | .pl-5 { 834 | padding-left: 1.25rem; 835 | } 836 | 837 | .text-left { 838 | text-align: left; 839 | } 840 | 841 | .text-center { 842 | text-align: center; 843 | } 844 | 845 | .text-right { 846 | text-align: right; 847 | } 848 | 849 | .text-sm { 850 | font-size: 0.875rem; 851 | line-height: 1.25rem; 852 | } 853 | 854 | .text-3xl { 855 | font-size: 1.875rem; 856 | line-height: 2.25rem; 857 | } 858 | 859 | .text-xl { 860 | font-size: 1.25rem; 861 | line-height: 1.75rem; 862 | } 863 | 864 | .text-2xl { 865 | font-size: 1.5rem; 866 | line-height: 2rem; 867 | } 868 | 869 | .font-bold { 870 | font-weight: 700; 871 | } 872 | 873 | .uppercase { 874 | text-transform: uppercase; 875 | } 876 | 877 | .capitalize { 878 | text-transform: capitalize; 879 | } 880 | 881 | .leading-normal { 882 | line-height: 1.5; 883 | } 884 | 885 | .text-gray-700 { 886 | --tw-text-opacity: 1; 887 | color: rgb(55 65 81 / var(--tw-text-opacity)); 888 | } 889 | 890 | .text-white { 891 | --tw-text-opacity: 1; 892 | color: rgb(255 255 255 / var(--tw-text-opacity)); 893 | } 894 | 895 | .text-indigo-900 { 896 | --tw-text-opacity: 1; 897 | color: rgb(49 46 129 / var(--tw-text-opacity)); 898 | } 899 | 900 | .text-indigo-100 { 901 | --tw-text-opacity: 1; 902 | color: rgb(224 231 255 / var(--tw-text-opacity)); 903 | } 904 | 905 | .text-yellow-200 { 906 | --tw-text-opacity: 1; 907 | color: rgb(254 240 138 / var(--tw-text-opacity)); 908 | } 909 | 910 | .text-yellow-400 { 911 | --tw-text-opacity: 1; 912 | color: rgb(250 204 21 / var(--tw-text-opacity)); 913 | } 914 | 915 | .text-gray-500 { 916 | --tw-text-opacity: 1; 917 | color: rgb(107 114 128 / var(--tw-text-opacity)); 918 | } 919 | 920 | .text-indigo-800 { 921 | --tw-text-opacity: 1; 922 | color: rgb(55 48 163 / var(--tw-text-opacity)); 923 | } 924 | 925 | .text-red-600 { 926 | --tw-text-opacity: 1; 927 | color: rgb(220 38 38 / var(--tw-text-opacity)); 928 | } 929 | 930 | .text-gray-800 { 931 | --tw-text-opacity: 1; 932 | color: rgb(31 41 55 / var(--tw-text-opacity)); 933 | } 934 | 935 | .shadow-lg { 936 | --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 937 | --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); 938 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 939 | } 940 | 941 | .hover\:bg-indigo-900:hover { 942 | --tw-bg-opacity: 1; 943 | background-color: rgb(49 46 129 / var(--tw-bg-opacity)); 944 | } 945 | 946 | .hover\:bg-indigo-600:hover { 947 | --tw-bg-opacity: 1; 948 | background-color: rgb(79 70 229 / var(--tw-bg-opacity)); 949 | } 950 | 951 | .hover\:bg-gray-100:hover { 952 | --tw-bg-opacity: 1; 953 | background-color: rgb(243 244 246 / var(--tw-bg-opacity)); 954 | } 955 | 956 | .hover\:bg-gray-400:hover { 957 | --tw-bg-opacity: 1; 958 | background-color: rgb(156 163 175 / var(--tw-bg-opacity)); 959 | } 960 | 961 | .focus\:outline-none:focus { 962 | outline: 2px solid transparent; 963 | outline-offset: 2px; 964 | } 965 | 966 | @media (min-width: 768px) { 967 | .md\:mt-10 { 968 | margin-top: 2.5rem; 969 | } 970 | 971 | .md\:h-auto { 972 | height: auto; 973 | } 974 | 975 | .md\:w-1\/2 { 976 | width: 50%; 977 | } 978 | } 979 | 980 | @media (min-width: 1024px) { 981 | .lg\:w-1\/4 { 982 | width: 25%; 983 | } 984 | } 985 | -------------------------------------------------------------------------------- /ui/src/assets/css/tailwind.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | -------------------------------------------------------------------------------- /ui/src/assets/images/bg1.svg: -------------------------------------------------------------------------------- 1 | dashboard -------------------------------------------------------------------------------- /ui/src/assets/images/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kadalu/gdash/3713135ee8be7cbf0bc4e90459098b9f175afe8d/ui/src/assets/images/loading.gif -------------------------------------------------------------------------------- /ui/src/components/breadcrumb.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | 5 | export function BreadCrumb({ elements }) { 6 | return ( 7 |
8 | 9 | 10 | 11 | { 12 | elements.map((item, idx) => { 13 | return ( 14 | 15 | / 16 | { 17 | item.url === '' ? 18 | {item.label}: 19 | {item.label} 20 | } 21 | 22 | ) 23 | }) 24 | } 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /ui/src/components/content.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Sidebar } from '../components/sidebar.jsx' 4 | import { LastUpdated } from '../components/last_updated.jsx' 5 | import { BreadCrumb } from '../components/breadcrumb.jsx' 6 | import { Loading } from '../components/loading.jsx' 7 | 8 | function ShowError({ error }) { 9 | return ( 10 |
11 | {error} 12 |
13 | ); 14 | } 15 | 16 | export function Content({ breadcrumb, data, setRefreshRequired, loading, error }) { 17 | return ( 18 |
19 |
20 | 21 |
22 |
23 | 24 | 25 | {loading ? : (error === '' ? <>{data} : )} 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /ui/src/components/helpers.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function brickStatus(volume, brick) { 4 | let cls = 'inline-block px-2 py-1 text-sm rounded-lg '; 5 | let sts = 'down'; 6 | if (volume.status !== 'Started') { 7 | cls += 'bg-gray-300' 8 | } else if (brick.online === true) { 9 | cls += 'bg-green-500 text-white'; 10 | sts = 'up'; 11 | } else { 12 | cls += 'bg-red-500 text-white'; 13 | } 14 | return ( 15 | {sts} 16 | ); 17 | } 18 | 19 | export function volumeStatus(volume) { 20 | let cls = 'inline-block px-2 py-1 text-sm rounded-lg ' 21 | if (volume.status === 'Started') { 22 | if (volume.health === 'up') { 23 | cls += 'bg-green-500 text-white' 24 | } else if (volume.health === 'partial') { 25 | cls += 'bg-yellow-500' 26 | } else if (volume.health === 'down') { 27 | cls += 'bg-red-500 text-white' 28 | } else { 29 | cls += 'bg-orange-500 text-white' 30 | } 31 | } else if (volume.status === 'Created') { 32 | cls += 'bg-gray-300' 33 | } else { 34 | cls += 'bg-gray-400' 35 | } 36 | return ( 37 | 38 | {volume.status}{volume.health === undefined ? '' : ', ' + volume.health} 39 | 40 | ) 41 | } 42 | 43 | export function capitalize(string) { 44 | let parts = string.split('_'); 45 | return parts.map(p => { 46 | return p.charAt(0).toUpperCase() + p.slice(1).toLowerCase(); 47 | }).join(' '); 48 | } 49 | 50 | export function utilization(used, total, suffix) { 51 | let sizeP = used * 100 / total; 52 | 53 | let cls = "h-1 " 54 | if (sizeP > 90) { 55 | cls += "bg-red-500" 56 | } else if (sizeP > 80) { 57 | cls += "bg-orange-500" 58 | } else { 59 | cls += "bg-green-500" 60 | } 61 | 62 | return ( 63 |
64 |
{sizeP.toFixed(2)}%
65 |
66 |
67 |
68 |
{humanReadable(used, suffix)} / {humanReadable(total, suffix)}
69 |
70 | ); 71 | } 72 | 73 | export function sizeUtilization(volume) { 74 | if (volume.status !== undefined && volume.status !== 'Started') { 75 | return <>N/A 76 | } 77 | 78 | if (volume.online !== undefined && volume.online !== true) { 79 | return <>N/A 80 | } 81 | 82 | return utilization(volume.size_used, volume.size_total, 'B'); 83 | } 84 | 85 | export function inodesUtilization(volume) { 86 | if (volume.status !== undefined && volume.status !== 'Started') { 87 | return <>N/A 88 | } 89 | 90 | if (volume.online !== undefined && volume.online !== true) { 91 | return <>N/A 92 | } 93 | 94 | return utilization(volume.inodes_used, volume.inodes_total, ''); 95 | } 96 | 97 | export function humanReadable(input_size, suffix) { 98 | let kib = 1024 99 | let mib = 1024 * kib 100 | let gib = 1024 * mib 101 | let tib = 1024 * gib 102 | let pib = 1024 * tib 103 | let eib = 1024 * pib 104 | let zib = 1024 * eib 105 | let yib = 1024 * zib 106 | 107 | if (input_size < 1024) { 108 | return input_size; 109 | } else { 110 | if (input_size < mib) { 111 | return (input_size/kib).toFixed(2) + " Ki" + suffix; 112 | } else if (input_size < gib) { 113 | return (input_size/mib).toFixed(2) + " Mi" + suffix; 114 | } else if (input_size < tib) { 115 | return (input_size/gib).toFixed(2) + " Gi" + suffix; 116 | } else if (input_size < pib) { 117 | return (input_size/tib).toFixed(2) + " Ti" + suffix; 118 | } else if (input_size < eib) { 119 | return (input_size/pib).toFixed(2) + " Pi" + suffix; 120 | } else if (input_size < zib) { 121 | return (input_size/eib).toFixed(2) + " Ei" + suffix; 122 | } else if (input_size < yib) { 123 | return (input_size/zib).toFixed(2) + " Zi" + suffix; 124 | } else { 125 | return (input_size/yib).toFixed(2) + " Yi" + suffix; 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /ui/src/components/last_updated.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import * as dayjs from 'dayjs'; 3 | import * as relativeTime from 'dayjs/plugin/relativeTime'; 4 | 5 | export function LastUpdated({ setRefreshRequired }) { 6 | dayjs.extend(relativeTime); 7 | 8 | const [autoRefresh, setAutoRefresh] = useState(false); 9 | const [lstUpdated, setLstUpdated] = useState(dayjs()); 10 | const [lstUpdatedDisp, setLstUpdatedDisp] = useState(lstUpdated.fromNow()); 11 | 12 | useEffect(() => { 13 | setLstUpdatedDisp(lstUpdated.fromNow()); 14 | let interval = null; 15 | 16 | if (autoRefresh) { 17 | interval = setInterval(() => { 18 | setLstUpdated(dayjs()); 19 | setRefreshRequired(dayjs()); 20 | }, 60000); 21 | } else { 22 | clearInterval(interval); 23 | interval = null; 24 | } 25 | 26 | return () => clearInterval(interval); 27 | }, [autoRefresh, lstUpdated, setRefreshRequired]); 28 | 29 | function handleClick() { 30 | setLstUpdated(dayjs()); 31 | setRefreshRequired(dayjs()); 32 | } 33 | 34 | function toggleAutoRefresh() { 35 | setAutoRefresh(!autoRefresh); 36 | } 37 | 38 | return ( 39 |

40 | {autoRefresh ? 'Last updated (auto refresh)' : 'Last updated'} {lstUpdatedDisp} 41 | 42 | 48 |

49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /ui/src/components/loading.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import LoadingGif from '../assets/images/loading.gif'; 4 | 5 | export function Loading({ loading }) { 6 | if (!loading) { 7 | return <>; 8 | } 9 | 10 | return ( 11 |
loading... Loading...
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/components/sidebar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | export function Sidebar() { 5 | return ( 6 |
7 |
8 |

gdash

9 |

GlusterFS Dashboard

10 |
11 |
12 |

Menu

13 | 14 | 15 | Dashboard 16 | 17 | 18 | 19 | Volumes 20 | 21 | 22 | 23 | Peers 24 | 25 | 26 | 27 | Bricks 28 | 29 |

Account

30 | 31 | 32 | Logout 33 | 34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /ui/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import './assets/css/main.css' 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want your app to work offline and load faster, you can change 15 | // unregister() to register() below. Note this comes with some pitfalls. 16 | // Learn more about service workers: https://bit.ly/CRA-PWA 17 | serviceWorker.unregister(); 18 | -------------------------------------------------------------------------------- /ui/src/pages/bricks.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import axios from 'axios'; 3 | import * as dayjs from 'dayjs'; 4 | 5 | import { Content } from '../components/content.jsx' 6 | import { brickStatus, sizeUtilization, inodesUtilization } from '../components/helpers'; 7 | 8 | function bricksUI(volumes) { 9 | 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | { 24 | volumes.map((volume, vdx) => { 25 | return volume.subvols.map((subvol, sdx) => { 26 | return subvol.bricks.map((brick, bdx) => { 27 | return ( 28 | 29 | 32 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | }) 42 | }) 43 | }) 44 | } 45 | 46 |
VolumeBrickStateFSSizeInodes
30 | {volume.name}
{volume.uuid}
31 |
33 | {brick.name} 34 | {brickStatus(volume, brick)}{brick.fs_name}{sizeUtilization(brick)}{inodesUtilization(brick)}
47 | ) 48 | } 49 | 50 | export function Bricks({ history }) { 51 | let elements = [ 52 | {label: "Bricks", url: ''} 53 | ]; 54 | 55 | const [error, setError] = useState(""); 56 | const [loading, setLoading] = useState(true); 57 | const [volumes, setVolumes] = useState([]); 58 | const [refreshRequired, setRefreshRequired] = useState(dayjs()); 59 | 60 | useEffect(() => { 61 | axios.get("/api/volumes") 62 | .then((resp) => { 63 | setLoading(false); 64 | setVolumes(resp.data); 65 | }) 66 | .catch(err => { 67 | if (err.response.status === 403) { 68 | window.location = '/login'; 69 | } else { 70 | setLoading(false); 71 | setError("Failed to get data from the server(HTTP Status: " + err.response.status + ")"); 72 | } 73 | }); 74 | }, [refreshRequired, history]); 75 | 76 | return ( 77 | 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /ui/src/pages/dashboard.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import axios from 'axios'; 3 | import * as dayjs from 'dayjs'; 4 | 5 | import { Content } from '../components/content.jsx'; 6 | 7 | function Box({label, value, onClick}) { 8 | return ( 9 |
10 |

{label}

11 |
{value}
12 |
13 | ) 14 | } 15 | 16 | function dashboardUI(history, volumes, peers) { 17 | let numBricks = 0; 18 | let upBricks = 0; 19 | let upPeers = 0; 20 | let upVolumes = 0; 21 | for(var v=0; v 41 | window.location = '/peers'}/> 42 | window.location = '/volumes'}/> 43 | window.location = '/bricks'}/> 44 | 45 | ); 46 | } 47 | 48 | export function Dashboard({ history }) { 49 | let elements = [ 50 | {label: "Dashboard", url: ''} 51 | ]; 52 | 53 | const [error, setError] = useState(""); 54 | const [loading, setLoading] = useState(true); 55 | const [peers, setPeers] = useState([]); 56 | const [volumes, setVolumes] = useState([]); 57 | const [refreshRequired, setRefreshRequired] = useState(dayjs()); 58 | 59 | useEffect(() => { 60 | axios.get("/api/volumes") 61 | .then((resp) => { 62 | setLoading(false); 63 | setVolumes(resp.data); 64 | }) 65 | .catch(err => { 66 | if (err.response.status === 403) { 67 | window.location = '/login'; 68 | } else { 69 | setLoading(false); 70 | setError("Failed to get data from the server(HTTP Status: " + err.response.status + ")"); 71 | } 72 | }); 73 | 74 | axios.get("/api/peers") 75 | .then((resp) => { 76 | setPeers(resp.data); 77 | }) 78 | .catch(err => { 79 | if (err.response.status === 403) { 80 | window.location = '/login'; 81 | } 82 | }); 83 | }, [refreshRequired, history]); 84 | 85 | return ( 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /ui/src/pages/login.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import axios from 'axios'; 3 | 4 | import Bg1 from '../assets/images/bg1.svg'; 5 | 6 | 7 | export function Login({ history }) { 8 | const [username, setUsername] = useState(""); 9 | const [password, setPassword] = useState(""); 10 | const [buttonEnabled, setButtonEnabled] = useState(true); 11 | const [error, setError] = useState(""); 12 | 13 | function buttonClass() { 14 | let cls = "cursor-pointer text-white py-2 px-5 rounded-lg " 15 | return cls + (buttonEnabled ? "bg-indigo-700 hover:bg-indigo-900" : "bg-gray-400 hover:bg-gray-400") 16 | } 17 | 18 | useEffect(() => { 19 | axios.get("/api/login") 20 | .then((resp) => { 21 | window.location = '/dashboard'; 22 | }); 23 | }, [history]); 24 | 25 | function handleLogin(e) { 26 | e.preventDefault() 27 | if (!buttonEnabled) { 28 | return 29 | } 30 | setButtonEnabled(false); 31 | if (username === '' || password === '') { 32 | setButtonEnabled(true); 33 | return 34 | } 35 | axios.post('/api/login', { 36 | username: username, 37 | password: password 38 | }).then(res => { 39 | window.location = '/dashboard'; 40 | }).catch(err => { 41 | if (err.response.status === 403) { 42 | setError("Invalid username/password"); 43 | setButtonEnabled(true); 44 | } else { 45 | setError("Failed to get data from the server(HTTP Status: " + err.response.status + ")"); 46 | setButtonEnabled(true); 47 | } 48 | }); 49 | } 50 | 51 | return ( 52 |
53 |
54 |

55 | GlusterFS Dashboard 56 |

57 | . 58 |
59 |
{error}
60 |
61 | setUsername(e.target.value)}/> 62 | setPassword(e.target.value)}/> 63 | 68 |
69 |
70 |
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /ui/src/pages/logout.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import axios from 'axios'; 3 | 4 | export function Logout({ history }) { 5 | const [message, setMessage] = useState(""); 6 | const [error, setError] = useState(""); 7 | 8 | useEffect(() => { 9 | axios.get("/api/logout") 10 | .then((resp) => { 11 | setMessage("Logged out Successfully..."); 12 | setInterval(() => { 13 | window.location = '/login'; 14 | }, 2000); 15 | }).catch(err => { 16 | setError("Failed to get data from the server(HTTP Status: " + err.response.status + ")"); 17 | }); 18 | }, [history]); 19 | 20 | return ( 21 |
22 | { 23 | error === '' ? 24 |
25 | {message} 26 |
: 27 |
28 | {error} 29 |
30 | } 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /ui/src/pages/peers.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import axios from 'axios'; 3 | import * as dayjs from 'dayjs'; 4 | 5 | import { Content } from '../components/content.jsx' 6 | 7 | function peerStatus(peer) { 8 | let cls = 'inline-block px-2 py-1 text-sm rounded-lg ' 9 | if (peer.connected === 'Connected') { 10 | cls += 'bg-green-500 text-white' 11 | } else { 12 | cls += 'bg-red-500 text-white' 13 | } 14 | return ( 15 | {peer.connected} 16 | ); 17 | } 18 | 19 | function peersUI(peers) { 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | { 30 | peers.map((peer, idx) => { 31 | return ( 32 | 33 | 36 | 37 | 38 | ); 39 | }) 40 | } 41 | 42 |
Peer AddressState
34 | {peer.hostname}
{peer.uuid}
35 |
{peerStatus(peer)}
43 | ) 44 | } 45 | 46 | export function Peers({ history }) { 47 | let elements = [ 48 | {label: "Peers", url: ''} 49 | ]; 50 | 51 | const [error, setError] = useState(""); 52 | const [loading, setLoading] = useState(true); 53 | const [peers, setPeers] = useState([]); 54 | const [refreshRequired, setRefreshRequired] = useState(dayjs()); 55 | 56 | useEffect(() => { 57 | axios.get("/api/peers") 58 | .then((resp) => { 59 | setLoading(false); 60 | setPeers(resp.data); 61 | }) 62 | .catch(err => { 63 | if (err.response.status === 403) { 64 | window.location = '/login'; 65 | } else { 66 | setLoading(false); 67 | setError("Failed to get data from the server(HTTP Status: " + err.response.status + ")"); 68 | } 69 | }); 70 | }, [refreshRequired, history]); 71 | 72 | return ( 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /ui/src/pages/volumeDetail.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import axios from 'axios'; 4 | import * as dayjs from 'dayjs'; 5 | 6 | import { Content } from '../components/content.jsx' 7 | 8 | import { brickStatus, volumeStatus, capitalize, sizeUtilization, inodesUtilization } from '../components/helpers'; 9 | 10 | function volumeDetailUI(volumeId, volumes) { 11 | let volume = {} 12 | for(var i=0; i; 21 | } 22 | 23 | return ( 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 40 | 41 | 44 | 45 | 46 | 47 | 48 |
NameStateTypeSizeInodes
38 | {volume.name}
{volume.uuid}
39 |
{volumeStatus(volume)} 42 | {capitalize(volume.type)}
{volume.subvols.length + ' x ' + volume.subvols[0].bricks.length} 43 |
{sizeUtilization(volume)}{inodesUtilization(volume)}
49 |
50 |

Bricks

51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | { 65 | volume.subvols.map((subvol, sdx) => { 66 | return subvol.bricks.map((brick, bdx) => { 67 | return ( 68 | 69 | 70 | 73 | 74 | 77 | 78 | 79 | 80 | 81 | ); 82 | }) 83 | }) 84 | } 85 | 86 |
SubvolBrickStateFSSizeInodesPID/Port
{sdx+1} 71 | {brick.name} 72 | {brickStatus(volume, brick)} 75 | {brick.fs_name} 76 | {sizeUtilization(brick)}{inodesUtilization(brick)}{brick.pid} / {brick.ports.tcp}
87 |
88 |
89 |

Volume Options

90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | { 99 | volume.options.map((opt, odx) => { 100 | return ( 101 | 102 | 105 | 108 | 109 | ); 110 | }) 111 | } 112 | 113 |
NameValue
103 | {opt.name} 104 | 106 | {opt.value} 107 |
114 |
115 |
116 | ) 117 | } 118 | 119 | export function VolumeDetail({ history }) { 120 | const { volumeId } = useParams(); 121 | const [loading, setLoading] = useState(true); 122 | const [volumes, setVolumes] = useState([]); 123 | const [refreshRequired, setRefreshRequired] = useState(dayjs()); 124 | const [error, setError] = useState(""); 125 | const [elements, setElements] = useState([ 126 | {label: "Volumes", url: '/volumes'} 127 | ]); 128 | 129 | useEffect(() => { 130 | axios.get("/api/volumes") 131 | .then((resp) => { 132 | setLoading(false); 133 | setVolumes(resp.data); 134 | }) 135 | .catch(err => { 136 | if (err.response.status === 403) { 137 | window.location = '/login'; 138 | } else { 139 | setLoading(false); 140 | setError("Failed to get data from the server(HTTP Status: " + err.response.status + ")"); 141 | } 142 | }); 143 | }, [refreshRequired, history]); 144 | 145 | useEffect(() => { 146 | let volume = {}; 147 | for (var i=0; i 162 | ); 163 | } 164 | -------------------------------------------------------------------------------- /ui/src/pages/volumes.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | 3 | import axios from 'axios'; 4 | import * as dayjs from 'dayjs'; 5 | 6 | import { Content } from '../components/content.jsx' 7 | import { volumeStatus, capitalize, sizeUtilization, inodesUtilization } from '../components/helpers'; 8 | 9 | function volumesUI(history, volumes) { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | { 23 | volumes.map((volume, idx) => { 24 | return ( 25 | window.location = '/volumes/' + volume.uuid}> 26 | 29 | 30 | 33 | 34 | 35 | 36 | ); 37 | }) 38 | } 39 | 40 |
NameStateTypeSizeInodes
27 | {volume.name}
{volume.uuid}
28 |
{volumeStatus(volume)} 31 | {capitalize(volume.type)}
{volume.subvols.length + ' x ' + volume.subvols[0].bricks.length} 32 |
{sizeUtilization(volume)}{inodesUtilization(volume)}
41 | ) 42 | } 43 | 44 | export function Volumes({ history }) { 45 | let elements = [ 46 | {label: "Volumes", url: ''} 47 | ]; 48 | 49 | const [error, setError] = useState(""); 50 | const [loading, setLoading] = useState(true); 51 | const [volumes, setVolumes] = useState([]); 52 | const [refreshRequired, setRefreshRequired] = useState(dayjs()); 53 | 54 | useEffect(() => { 55 | axios.get("/api/volumes") 56 | .then((resp) => { 57 | setLoading(false); 58 | setVolumes(resp.data); 59 | }) 60 | .catch(err => { 61 | if (err.response.status === 403) { 62 | window.location = '/login'; 63 | } else { 64 | setLoading(false); 65 | setError("Failed to get data from the server(HTTP Status: " + err.response.status + ")"); 66 | } 67 | }); 68 | }, [refreshRequired, history]); 69 | 70 | return ( 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /ui/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /ui/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /ui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./src/**/*.{html,js,jsx}"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | variants: {}, 7 | plugins: [], 8 | } 9 | --------------------------------------------------------------------------------