├── .DS_Store ├── .dockerignore ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── README.md ├── __tests__ ├── app.test.js └── server.test.js ├── docker-compose-dev.yml ├── docker-compose.yml ├── index.d.ts ├── package-lock.json ├── package.json ├── prometheus.yml ├── pull_request_template.md ├── src ├── .DS_Store ├── client │ ├── .DS_Store │ ├── components │ │ ├── Chart.tsx │ │ ├── ChartCompound.tsx │ │ ├── ImagePreview.tsx │ │ ├── Legend.tsx │ │ ├── LiquidGauge.jsx │ │ └── metrics │ │ │ ├── Cpu.jsx │ │ │ ├── CpuPer.tsx │ │ │ ├── MemPer.tsx │ │ │ └── Memory.jsx │ ├── containers │ │ ├── Carousel.tsx │ │ ├── Environments.tsx │ │ ├── Links.tsx │ │ ├── Logs.tsx │ │ └── SystemMetrics.jsx │ ├── index.html │ ├── index.js │ ├── pages │ │ ├── App.jsx │ │ └── data.json │ └── public │ │ ├── .DS_Store │ │ ├── assets │ │ ├── favicon.ico │ │ ├── logo.ico │ │ └── logo.png │ │ └── stylesheets │ │ ├── _variables.scss │ │ └── styles.scss └── server │ ├── controllers │ ├── containerController.ts │ └── promQueryController.ts │ ├── server.ts │ └── utils │ └── dockerCliParser.ts ├── tsconfig.json ├── types.ts ├── webpack.config.js └── webpack_back.config.js /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dockwell/4041f37558fb3959559ef61e34b8b8b1a9aaee48/.DS_Store -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/__tests__ 3 | **/coverage -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/test", "**/__tests__"], 4 | "env": { 5 | "node": true, 6 | "browser": true, 7 | "es2021": true 8 | }, 9 | "plugins": ["react"], 10 | "extends": ["eslint:recommended", "plugin:react/recommended"], 11 | "parserOptions": { 12 | "sourceType": "module", 13 | "ecmaFeatures": { 14 | "jsx": true 15 | } 16 | }, 17 | "rules": { 18 | "indent": ["warn", 2], 19 | "no-unused-vars": ["off", { "vars": "local" }], 20 | "no-case-declarations": "off", 21 | "prefer-const": "warn", 22 | "quotes": ["warn", "single"], 23 | "react/prop-types": "off", 24 | "semi": ["warn", "always"], 25 | "space-infix-ops": "warn" 26 | }, 27 | "settings": { 28 | "react": { "version": "detect" } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | coverage/ 7 | *.DS_Store 8 | 9 | # User-specific files 10 | *.rsuser 11 | *.suo 12 | *.user 13 | *.userosscache 14 | *.sln.docstates 15 | 16 | # User-specific files (MonoDevelop/Xamarin Studio) 17 | *.userprefs 18 | 19 | # Mono auto generated files 20 | mono_crash.* 21 | 22 | # Build results 23 | [Dd]ebug/ 24 | [Dd]ebugPublic/ 25 | [Rr]elease/ 26 | [Rr]eleases/ 27 | x64/ 28 | x86/ 29 | [Aa][Rr][Mm]/ 30 | [Aa][Rr][Mm]64/ 31 | bld/ 32 | [Bb]in/ 33 | [Oo]bj/ 34 | [Ll]og/ 35 | [Ll]ogs/ 36 | 37 | # Visual Studio 2015/2017 cache/options directory 38 | .vs/ 39 | # Uncomment if you have tasks that create the project's static files in wwwroot 40 | #wwwroot/ 41 | 42 | # Visual Studio 2017 auto generated files 43 | Generated\ Files/ 44 | 45 | # MSTest test Results 46 | [Tt]est[Rr]esult*/ 47 | [Bb]uild[Ll]og.* 48 | 49 | # NUnit 50 | *.VisualState.xml 51 | TestResult.xml 52 | nunit-*.xml 53 | 54 | # Build Results of an ATL Project 55 | [Dd]ebugPS/ 56 | [Rr]eleasePS/ 57 | dlldata.c 58 | 59 | # Benchmark Results 60 | BenchmarkDotNet.Artifacts/ 61 | 62 | # .NET Core 63 | project.lock.json 64 | project.fragment.lock.json 65 | artifacts/ 66 | 67 | # StyleCop 68 | StyleCopReport.xml 69 | 70 | # Files built by Visual Studio 71 | *_i.c 72 | *_p.c 73 | *_h.h 74 | *.ilk 75 | *.meta 76 | *.obj 77 | *.iobj 78 | *.pch 79 | *.pdb 80 | *.ipdb 81 | *.pgc 82 | *.pgd 83 | *.rsp 84 | *.sbr 85 | *.tlb 86 | *.tli 87 | *.tlh 88 | *.tmp 89 | *.tmp_proj 90 | *_wpftmp.csproj 91 | *.log 92 | *.vspscc 93 | *.vssscc 94 | .builds 95 | *.pidb 96 | *.svclog 97 | *.scc 98 | 99 | # Chutzpah Test files 100 | _Chutzpah* 101 | 102 | # Visual C++ cache files 103 | ipch/ 104 | *.aps 105 | *.ncb 106 | *.opendb 107 | *.opensdf 108 | *.sdf 109 | *.cachefile 110 | *.VC.db 111 | *.VC.VC.opendb 112 | 113 | # Visual Studio profiler 114 | *.psess 115 | *.vsp 116 | *.vspx 117 | *.sap 118 | 119 | # Visual Studio Trace Files 120 | *.e2e 121 | 122 | # TFS 2012 Local Workspace 123 | $tf/ 124 | 125 | # Guidance Automation Toolkit 126 | *.gpState 127 | 128 | # ReSharper is a .NET coding add-in 129 | _ReSharper*/ 130 | *.[Rr]e[Ss]harper 131 | *.DotSettings.user 132 | 133 | # TeamCity is a build add-in 134 | _TeamCity* 135 | 136 | # DotCover is a Code Coverage Tool 137 | *.dotCover 138 | 139 | # AxoCover is a Code Coverage Tool 140 | .axoCover/* 141 | !.axoCover/settings.json 142 | 143 | # Visual Studio code coverage results 144 | *.coverage 145 | *.coveragexml 146 | 147 | # NCrunch 148 | _NCrunch_* 149 | .*crunch*.local.xml 150 | nCrunchTemp_* 151 | 152 | # MightyMoose 153 | *.mm.* 154 | AutoTest.Net/ 155 | 156 | # Web workbench (sass) 157 | .sass-cache/ 158 | 159 | # Installshield output folder 160 | [Ee]xpress/ 161 | 162 | # DocProject is a documentation generator add-in 163 | DocProject/buildhelp/ 164 | DocProject/Help/*.HxT 165 | DocProject/Help/*.HxC 166 | DocProject/Help/*.hhc 167 | DocProject/Help/*.hhk 168 | DocProject/Help/*.hhp 169 | DocProject/Help/Html2 170 | DocProject/Help/html 171 | 172 | # Click-Once directory 173 | publish/ 174 | 175 | # Publish Web Output 176 | *.[Pp]ublish.xml 177 | *.azurePubxml 178 | # Note: Comment the next line if you want to checkin your web deploy settings, 179 | # but database connection strings (with potential passwords) will be unencrypted 180 | *.pubxml 181 | *.publishproj 182 | 183 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 184 | # checkin your Azure Web App publish settings, but sensitive information contained 185 | # in these scripts will be unencrypted 186 | PublishScripts/ 187 | 188 | # NuGet Packages 189 | *.nupkg 190 | # NuGet Symbol Packages 191 | *.snupkg 192 | # The packages folder can be ignored because of Package Restore 193 | **/[Pp]ackages/* 194 | # except build/, which is used as an MSBuild target. 195 | !**/[Pp]ackages/build/ 196 | # Uncomment if necessary however generally it will be regenerated when needed 197 | #!**/[Pp]ackages/repositories.config 198 | # NuGet v3's project.json files produces more ignorable files 199 | *.nuget.props 200 | *.nuget.targets 201 | 202 | # Microsoft Azure Build Output 203 | csx/ 204 | *.build.csdef 205 | 206 | # Microsoft Azure Emulator 207 | ecf/ 208 | rcf/ 209 | 210 | # Windows Store app package directories and files 211 | AppPackages/ 212 | BundleArtifacts/ 213 | Package.StoreAssociation.xml 214 | _pkginfo.txt 215 | *.appx 216 | *.appxbundle 217 | *.appxupload 218 | 219 | # Visual Studio cache files 220 | # files ending in .cache can be ignored 221 | *.[Cc]ache 222 | # but keep track of directories ending in .cache 223 | !?*.[Cc]ache/ 224 | 225 | # Others 226 | ClientBin/ 227 | ~$* 228 | *~ 229 | *.dbmdl 230 | *.dbproj.schemaview 231 | *.jfm 232 | *.pfx 233 | *.publishsettings 234 | orleans.codegen.cs 235 | 236 | # Including strong name files can present a security risk 237 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 238 | #*.snk 239 | 240 | # Since there are multiple workflows, uncomment next line to ignore bower_components 241 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 242 | #bower_components/ 243 | 244 | # RIA/Silverlight projects 245 | Generated_Code/ 246 | 247 | # Backup & report files from converting an old project file 248 | # to a newer Visual Studio version. Backup files are not needed, 249 | # because we have git ;-) 250 | _UpgradeReport_Files/ 251 | Backup*/ 252 | UpgradeLog*.XML 253 | UpgradeLog*.htm 254 | ServiceFabricBackup/ 255 | *.rptproj.bak 256 | 257 | # SQL Server files 258 | *.mdf 259 | *.ldf 260 | *.ndf 261 | 262 | # Business Intelligence projects 263 | *.rdl.data 264 | *.bim.layout 265 | *.bim_*.settings 266 | *.rptproj.rsuser 267 | *- [Bb]ackup.rdl 268 | *- [Bb]ackup ([0-9]).rdl 269 | *- [Bb]ackup ([0-9][0-9]).rdl 270 | 271 | # Microsoft Fakes 272 | FakesAssemblies/ 273 | 274 | # GhostDoc plugin setting file 275 | *.GhostDoc.xml 276 | 277 | # Node.js Tools for Visual Studio 278 | .ntvs_analysis.dat 279 | node_modules/ 280 | build/ 281 | 282 | # Visual Studio 6 build log 283 | *.plg 284 | 285 | # Visual Studio 6 workspace options file 286 | *.opt 287 | 288 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 289 | *.vbw 290 | 291 | # Visual Studio LightSwitch build output 292 | **/*.HTMLClient/GeneratedArtifacts 293 | **/*.DesktopClient/GeneratedArtifacts 294 | **/*.DesktopClient/ModelManifest.xml 295 | **/*.Server/GeneratedArtifacts 296 | **/*.Server/ModelManifest.xml 297 | _Pvt_Extensions 298 | 299 | # Paket dependency manager 300 | .paket/paket.exe 301 | paket-files/ 302 | 303 | # FAKE - F# Make 304 | .fake/ 305 | 306 | # CodeRush personal settings 307 | .cr/personal 308 | 309 | # Python Tools for Visual Studio (PTVS) 310 | __pycache__/ 311 | *.pyc 312 | 313 | # Cake - Uncomment if you are using it 314 | # tools/** 315 | # !tools/packages.config 316 | 317 | # Tabs Studio 318 | *.tss 319 | 320 | # Telerik's JustMock configuration file 321 | *.jmconfig 322 | 323 | # BizTalk build output 324 | *.btp.cs 325 | *.btm.cs 326 | *.odx.cs 327 | *.xsd.cs 328 | 329 | # OpenCover UI analysis results 330 | OpenCover/ 331 | 332 | # Azure Stream Analytics local run output 333 | ASALocalRun/ 334 | 335 | # MSBuild Binary and Structured Log 336 | *.binlog 337 | 338 | # NVidia Nsight GPU debugger configuration file 339 | *.nvuser 340 | 341 | # MFractors (Xamarin productivity tool) working folder 342 | .mfractor/ 343 | 344 | # Local History for Visual Studio 345 | .localhistory/ 346 | 347 | # BeatPulse healthcheck temp database 348 | healthchecksdb 349 | 350 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 351 | MigrationBackup/ 352 | 353 | # Ionide (cross platform F# VS Code tools) working folder 354 | .ionide/ 355 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at {{ email }}. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | #add bash to node alpine 2 | FROM node:16.19-alpine3.16 as alp-base 3 | RUN apk add --no-cache bash 4 | 5 | #install docker 6 | FROM alp-base as docker-base 7 | RUN apk add --update docker openrc 8 | RUN rc-update add docker boot 9 | 10 | #build node modules for dev & for building 11 | FROM docker-base as npm-base 12 | WORKDIR /usr/src 13 | COPY package*.json ./ 14 | RUN npm install 15 | 16 | #DEVELOPMENT 17 | #docker build --target dockwell_dev -t dockwell_dev . 18 | FROM npm-base as dockwell_dev 19 | EXPOSE 7070 20 | 21 | #webpack builds bundle.js 22 | FROM npm-base as build 23 | WORKDIR /usr/src 24 | COPY . . 25 | ENV NODE_ENV=production 26 | RUN npm run build:docker 27 | 28 | #PRODUCTION 29 | #docker build -t dockwellhub/dwh-prod . 30 | FROM docker-base as prod 31 | WORKDIR /usr/src 32 | COPY package*.json ./ 33 | RUN npm ci --omit=dev 34 | COPY /src/server ./src/server 35 | COPY /prometheus.yml ./ 36 | COPY --from=build /usr/src/build ./build 37 | EXPOSE 3535 38 | # ENTRYPOINT node ./src/server/server.js 39 | ENTRYPOINT npx ts-node ./src/server/server.ts 40 | 41 | # docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t dockwellhub/dwh-prod:latest --push . -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 OSLabs Beta 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dockwell · ![Github Repo Size](https://img.shields.io/github/repo-size/oslabs-beta/Dockwell) ![GitHub License](https://img.shields.io/github/license/oslabs-beta/Dockwell) ![GitHub Commit](https://img.shields.io/github/last-commit/oslabs-beta/Dockwell) ![GitHub Stars](https://img.shields.io/github/stars/oslabs-beta/Dockwell) 2 | 3 | --- 4 | 5 | ## About 6 | https://dockwell.tech/ 7 | --- 8 | Dockwell is an intuitive and comprehensive GUI that tracks, monitors, and displays all of a user's Docker containers. 9 | 10 | We made Dockwell so that developers can quickly visualize container metrics to assist in their debugging and development process. 11 | 12 | With the needs of developers in mind, Dockwell uses Prometheus and cAdvisor to scrape real-time metrics from the host machine, all from its own containerized environment. 13 | 14 | Dockwell is an open source project and we would love to hear your feedback! 15 | 16 | 17 | https://user-images.githubusercontent.com/105250729/214631275-7a81bddb-1fbd-4cc6-a478-97a6a3efad06.mov 18 | 19 | 20 | 21 | ## Table of Contents 22 | 23 | Application usage/documentation 24 | 25 | - [Features](#features) 26 | - [Prerequisites](#prerequisites) 27 | 28 | Installation Guides 29 | 30 | - [Installation](#installation) 31 | - [How to Contribute](#how-to-contribute) 32 | 33 | Contributers and other info 34 | 35 | - [Contributors](#contributors) 36 | - [Made With](#made-with) 37 | 38 | ## Features: 39 | 40 | Live reloading of metric data displayed in simple to read charts. 41 | - Memory and CPU usage data are displayed graphically for each individual container. 42 | - Memory and CPU usage data for _all containers_ are also displaed so that users can quickly compare metrics 43 | 44 | Environments section which displays all of a users active and inactive containers with the functionality to start, pause, and kill containers. 45 | 46 | System metrics section which displays how much CPU and Memory Docker is currently using 47 | - A liguid guage shows the percent of each metric being used by all containers 48 | - A pie chart depicts the ratio metric usage by container 49 | 50 | Logs section that allows a user to check and refresh the logs from each container's environment. 51 | 52 | All charts are dynamic and update every 1000ms 53 | 54 | [↥Back to top](#table-of-contents) 55 | 56 | ## Prerequisites: 57 | - The host computer running Dockwell must have: 58 | 59 | - A Docker [Account](https://www.docker.com/ 'Download Docker') 60 | 61 | [↥Back to top](#table-of-contents) 62 | 63 | ## Installation: 64 | 65 | - All you have to do is visit [this](https://github.com/oslabs-beta/dockwell/tree/SetupInstall) repository and follow the instructions in the Readme 66 | 67 | [↥Back to top](#table-of-contents) 68 | 69 | ## How to Contribute: 70 | 71 | - Contributions are always welcome! 72 | - To contribute please fork the repository and then create a pull request. 73 | 74 | [↥Back to top](#table-of-contents) 75 | 76 | ## Contributors: 77 | 78 | - Kyle Saunders [LinkedIn](https://www.linkedin.com/in/kylersaunders/) | [Github](https://github.com/kylersaunders) 79 | - Sami Messai [LinkedIn](https://www.linkedin.com/in/sami-messai-682873ab/) | [Github](https://github.com/samessai14) 80 | - Aalok Shah [LinkedIn](https://www.linkedin.com/in/kolashah/) | [Github](https://github.com/kolashah) 81 | - Josh Paynter [LinkedIn](https://www.linkedin.com/in/josh-paynter-192a9b234/) | [Github](https://github.com/jip1029) 82 | 83 | [↥Back to top](#table-of-contents) 84 | 85 | ## Made With 86 | 87 | ### FrontEnd 88 | 89 | ![Webpack](https://img.shields.io/badge/webpack-%238DD6F9.svg?style=for-the-badge&logo=webpack&logoColor=black) 90 | ![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB) 91 | ![React Hook Form](https://img.shields.io/badge/React%20Hook%20Form-%23EC5990.svg?style=for-the-badge&logo=reacthookform&logoColor=white) 92 | 93 | ### BackEnd 94 | 95 | ![NodeJS](https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white) 96 | ![Express.js](https://img.shields.io/badge/express.js-%23404d59.svg?style=for-the-badge&logo=express&logoColor=%2361DAFB) 97 | 98 | ### Monitoring and Data Visualization 99 | 100 | ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) 101 | ![Prometheus](https://img.shields.io/badge/Prometheus-E6522C?style=for-the-badge&logo=Prometheus&logoColor=white) 102 | 103 | [↥Back to top](#table-of-contents) 104 | -------------------------------------------------------------------------------- /__tests__/app.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | fireEvent, 3 | render, 4 | renderHook, 5 | screen, 6 | waitFor, 7 | } from '@testing-library/react'; 8 | import React, { useEffect, useState, useLayoutEffect } from 'react'; 9 | import { unmountComponentAtNode } from 'react-dom'; 10 | import { act } from 'react-dom/test-utils'; 11 | import axios from 'axios'; 12 | import renderer from 'react-test-renderer'; 13 | import getAppStats from '../src/client/pages/App'; 14 | 15 | jest.mock('axios'); 16 | 17 | // axios utils function to text axios functionality 18 | describe('AXIOS', () => { 19 | describe('when the call is successful', () => { 20 | it('should return users list', async () => { 21 | const fakeUser = ['bgte', 'arch']; //memory location 0 22 | 23 | axios.get.mockResolvedValueOnce(fakeUser); 24 | // axios.get.mockResolvedValueOnce(['bgte', 'arch']); //memory location 1 25 | 26 | const result = await fetchUsers(); 27 | console.debug(result === fakeUser); 28 | 29 | expect(axios.get).toHaveBeenCalledWith('http://localhost:3000/user'); 30 | expect(result).toEqual(fakeUser); //YES //memory location 0 31 | expect(result).toBe(fakeUser); //YES //memory location 0 32 | // expect(result).toEqual(['bgte', 'arch']); //YES //memory location 2 33 | // expect(result).toBe(['bgte', 'arch']); //NO //memory location 3 34 | }); 35 | describe('when API call fails', () => { 36 | it('should return empty users list', async () => { 37 | // given 38 | const message = 'Network Error'; 39 | axios.get.mockRejectedValueOnce(new Error(message)); 40 | 41 | // when 42 | const result = await fetchUsers(); 43 | 44 | // then 45 | expect(axios.get).toHaveBeenCalledWith('http://localhost:3000/user'); 46 | expect(result).toEqual([]); 47 | }); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /__tests__/server.test.js: -------------------------------------------------------------------------------- 1 | const server = require('../server/server'); 2 | const request = require('supertest'); 3 | // import app from '../src/client/pages/App'; 4 | 5 | //test ideas: tests for all our server routes 6 | // middleware tests 7 | // 8 | // const server = 'http://localhost:3535'; 9 | 10 | describe('Route integration', () => { 11 | describe('GET requests', () => { 12 | it('responds with 200 status and text/html content type', () => { 13 | return request(server) 14 | .get('/') 15 | .expect('Content-Type', /text\/html/) 16 | .expect(200); 17 | }); 18 | 19 | it('responds with 200 status and text/html content type', () => { 20 | return request(server) 21 | .get('/getStats') 22 | .expect('Content-Type', /application\/json/) 23 | .expect(200); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | #DEVELOPMENT 2 | #docker compose -f docker-compose-dev.yml up 3 | version: '3' 4 | volumes: 5 | prometheus-data: 6 | driver: local 7 | grafana-data: 8 | driver: local 9 | node_modules: {} 10 | services: 11 | prometheus: 12 | image: prom/prometheus:latest 13 | container_name: prometheus 14 | ports: 15 | - 9090:9090 16 | volumes: 17 | - ./prometheus.yml:/etc/prometheus/prometheus.yml 18 | - prometheus-data:/prometheus 19 | command: 20 | - --config.file=/etc/prometheus/prometheus.yml 21 | depends_on: 22 | - cadvisor 23 | cadvisor: 24 | image: gcr.io/cadvisor/cadvisor:v0.45.0 25 | container_name: cadvisor 26 | ports: 27 | - 8080:8080 28 | volumes: 29 | - /:/rootfs:ro 30 | - /var/run:/var/run:rw 31 | - /sys:/sys:ro 32 | - /var/lib/docker/:/var/lib/docker:ro 33 | #redis is only here for demonstration 34 | redis: 35 | image: redis:latest 36 | container_name: redis 37 | ports: 38 | - 6379:6379 39 | grafana: 40 | image: grafana/grafana-oss:latest 41 | container_name: grafana 42 | ports: 43 | - '3000:3000' 44 | volumes: 45 | - grafana-data:/var/lib/grafana 46 | dockwell-dev: 47 | image: dockwell_dev:latest 48 | container_name: dockwell-dev 49 | ports: 50 | - 7070:7070 51 | volumes: 52 | - .:/usr/src 53 | - node_modules:/usr/src/node_modules 54 | - /var/run/docker.sock:/var/run/docker.sock 55 | environment: 56 | - PROXY_HOST=0.0.0.0 57 | - NODE_ENV=development 58 | command: 'npm run dev:docker' 59 | depends_on: 60 | - prometheus 61 | - cadvisor 62 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | volumes: 3 | prometheus-data: 4 | driver: local 5 | grafana-data: 6 | driver: local 7 | services: 8 | prometheus: 9 | image: prom/prometheus:latest 10 | container_name: prometheus 11 | ports: 12 | - 9090:9090 13 | volumes: 14 | - ./prometheus.yml:/etc/prometheus/prometheus.yml 15 | - prometheus-data:/prometheus 16 | command: 17 | - --config.file=/etc/prometheus/prometheus.yml 18 | depends_on: 19 | - cadvisor 20 | cadvisor: 21 | image: gcr.io/cadvisor/cadvisor:v0.45.0 22 | container_name: cadvisor 23 | ports: 24 | - 8080:8080 25 | volumes: 26 | - /:/rootfs:ro 27 | - /var/run:/var/run:rw 28 | - /sys:/sys:ro 29 | - /var/lib/docker/:/var/lib/docker:ro 30 | grafana: 31 | image: grafana/grafana-oss:latest 32 | container_name: grafana 33 | ports: 34 | - 3000:3000 35 | volumes: 36 | - grafana-data:/var/lib/grafana 37 | dockwell: 38 | image: dockwellhub/dwh-prod:latest 39 | container_name: dockwell 40 | ports: 41 | - 3535:3535 42 | volumes: 43 | - /var/run/docker.sock:/var/run/docker.sock 44 | depends_on: 45 | - prometheus 46 | - cadvisor 47 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'cors'; 2 | declare module 'cookie-parser' 3 | declare module 'util' 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dockwell", 3 | "version": "2.3.0", 4 | "description": "", 5 | "main": "webpack.config.js", 6 | "scripts": { 7 | "dev:docker": "nodemon ./src/server/server.ts & webpack-dev-server --open --hot", 8 | "build:docker": "webpack", 9 | "dev": "concurrently \"cross-env NODE_ENV=development PROXY_HOST=localhost webpack-dev-server --open \" \"nodemon ./src/server/server.js\"", 10 | "build": "cross-env NODE_ENV=production webpack", 11 | "start": "cross-env NODE_ENV=production nodemon ./src/server/server.js", 12 | "test": "NODE_ENV=test jest --verbose --updateSnapshot --coverage" 13 | }, 14 | "nodemonConfig": { 15 | "ignore": [ 16 | "build", 17 | "client" 18 | ] 19 | }, 20 | "author": "", 21 | "license": "ISC", 22 | "dependencies": { 23 | "axios": "^1.2.1", 24 | "babel": "^6.23.0", 25 | "bootstrap": "^5.2.3", 26 | "bootstrap-icons": "^1.10.3", 27 | "cookie-parser": "^1.4.1", 28 | "cors": "^2.8.5", 29 | "d3-color": "^3.1.0", 30 | "d3-interpolate": "^3.0.1", 31 | "docker-cli-js": "^2.10.0", 32 | "eslint-plugin-react": "^7.32.1", 33 | "express": "^4.17.1", 34 | "html-webpack-plugin": "^5.5.0", 35 | "prometheus-query": "^3.3.0", 36 | "prop-types": "^15.6.1", 37 | "react": "^18.2.0", 38 | "react-bootstrap": "^2.7.0", 39 | "react-bootstrap-icons": "^1.10.2", 40 | "react-chartjs-2": "^5.2.0", 41 | "react-dom": "^18.2.0", 42 | "react-gauge-chart": "^0.4.1", 43 | "react-liquid-gauge": "^1.2.4", 44 | "react-modal": "^3.16.1", 45 | "react-router-dom": "^6.4.5", 46 | "ts-node": "^10.9.1", 47 | "util": "^0.12.5", 48 | "typescript": "^4.9.4", 49 | "@types/cookie-parser": "^1.4.3", 50 | "@types/cors": "^2.8.13", 51 | "@types/express": "^4.17.15", 52 | "@types/node": "^18.11.18" 53 | }, 54 | "devDependencies": { 55 | "@babel/core": "^7.20.5", 56 | "@babel/preset-env": "^7.20.2", 57 | "@babel/preset-react": "^7.18.6", 58 | "@types/d3-color": "^3.1.0", 59 | "@types/d3-interpolate": "^3.0.1", 60 | "autoprefixer": "^10.4.13", 61 | "babel-loader": "^9.1.0", 62 | "body-parser": "^1.20.1", 63 | "browserify": "^17.0.0", 64 | "browserify-zlib": "^0.2.0", 65 | "concurrently": "^6.0.2", 66 | "cross-env": "^7.0.3", 67 | "css-loader": "^6.7.3", 68 | "file-loader": "^6.2.0", 69 | "isomorphic-fetch": "^3.0.0", 70 | "jest": "^29.3.1", 71 | "jest-environment-jsdom": "^28.1.3", 72 | "jest-fetch-mock": "^3.0.3", 73 | "nodemon": "^2.0.20", 74 | "path-browserify": "^1.0.1", 75 | "postcss-loader": "^7.0.2", 76 | "react-test-renderer": "^18.2.0", 77 | "sass": "^1.57.1", 78 | "sass-loader": "^13.2.0", 79 | "source-map-loader": "^4.0.1", 80 | "style-loader": "^3.3.1", 81 | "supertest": "^6.3.3", 82 | "ts-loader": "^9.4.2", 83 | "webpack": "^5.57.1", 84 | "webpack-cli": "^4.8.0", 85 | "webpack-dev-server": "^4.11.1", 86 | "webpack-hot-middleware": "^2.24.3" 87 | }, 88 | "peerDependencies": { 89 | "chart.js": "^4.1.1" 90 | }, 91 | "homepage": "https://github.com/oslabs-beta/dockwell" 92 | } 93 | -------------------------------------------------------------------------------- /prometheus.yml: -------------------------------------------------------------------------------- 1 | # my global config 2 | global: 3 | scrape_interval: 1s # Set the scrape interval to every 15 seconds. Default is every 1 minute. 4 | evaluation_interval: 1s # Evaluate rules every 15 seconds. The default is every 1 minute. 5 | # scrape_timeout is set to the global default (10s). 6 | 7 | # Attach these labels to any time series or alerts when communicating with 8 | # external systems (federation, remote storage, Alertmanager). 9 | external_labels: 10 | monitor: 'codelab-monitor' 11 | 12 | # Load rules once and periodically evaluate them according to the global 'evaluation_interval'. 13 | rule_files: 14 | # - "first.rules" 15 | # - "second.rules" 16 | 17 | # A scrape configuration containing exactly one endpoint to scrape: 18 | # Here it's Prometheus itself. 19 | scrape_configs: 20 | # The job name is added as a label `job=` to any timeseries scraped from this config. 21 | - job_name: 'prometheus' 22 | 23 | # metrics_path defaults to '/metrics' 24 | # scheme defaults to 'http'. 25 | 26 | static_configs: 27 | - targets: ['host.docker.internal:9090'] # Only works on Docker Desktop for Mac 28 | 29 | - job_name: 30 | 'docker' 31 | # metrics_path defaults to '/metrics' 32 | # scheme defaults to 'http'. 33 | 34 | static_configs: 35 | - targets: ['host.docker.internal:9323'] 36 | 37 | - job_name: 'cadvisor' 38 | static_configs: 39 | - targets: ['host.docker.internal:8080'] 40 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the changes and the related issue. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] This change requires a documentation update 15 | 16 | # How Has This Been Tested? 17 | 18 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration 19 | 20 | - [ ] Test A 21 | - [ ] Test B 22 | 23 | 24 | # Checklist: 25 | 26 | - [ ] I have performed a self-review of my code 27 | - [ ] I have commented my code, particularly in hard-to-understand areas 28 | - [ ] My changes generate no new warnings 29 | - [ ] I have added tests that prove my fix is effective or that my feature works 30 | - [ ] New and existing unit tests pass locally with my changes 31 | -------------------------------------------------------------------------------- /src/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dockwell/4041f37558fb3959559ef61e34b8b8b1a9aaee48/src/.DS_Store -------------------------------------------------------------------------------- /src/client/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dockwell/4041f37558fb3959559ef61e34b8b8b1a9aaee48/src/client/.DS_Store -------------------------------------------------------------------------------- /src/client/components/Chart.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Chart as ChartJS, 4 | CategoryScale, 5 | LinearScale, 6 | PointElement, 7 | LineElement, 8 | Title, 9 | Tooltip, 10 | Legend, 11 | } from 'chart.js'; 12 | import { Line } from 'react-chartjs-2'; 13 | 14 | ChartJS.register( 15 | CategoryScale, 16 | LinearScale, 17 | PointElement, 18 | LineElement, 19 | Title, 20 | Tooltip, 21 | Legend 22 | ); 23 | 24 | export default function ChartComponent({ 25 | metric, 26 | activeContainer, 27 | dataLength, 28 | }: any) { 29 | let x, y; 30 | if (activeContainer.value.length > dataLength) { 31 | y = activeContainer.value.slice(activeContainer.value.length - dataLength); 32 | x = activeContainer.time.slice(activeContainer.time.length - dataLength); 33 | } else { 34 | y = activeContainer.value; 35 | x = activeContainer.time; 36 | } 37 | const options = { 38 | plugins: { 39 | legend: { 40 | display: false, 41 | }, 42 | title: { 43 | display: true, 44 | text: `${metric}`, 45 | }, 46 | }, 47 | }; 48 | 49 | const data = { 50 | labels: x, 51 | datasets: [ 52 | { 53 | label: '', 54 | data: y, 55 | borderColor: '#f8f2e7', 56 | backgroundColor: '#ffffff', 57 | }, 58 | ], 59 | }; 60 | 61 | return ; 62 | } 63 | -------------------------------------------------------------------------------- /src/client/components/ChartCompound.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Chart as ChartJS, 4 | CategoryScale, 5 | LinearScale, 6 | PointElement, 7 | LineElement, 8 | Title, 9 | Tooltip, 10 | Legend, 11 | } from 'chart.js'; 12 | import { Line } from 'react-chartjs-2'; 13 | 14 | ChartJS.register( 15 | CategoryScale, 16 | LinearScale, 17 | PointElement, 18 | LineElement, 19 | Title, 20 | Tooltip, 21 | Legend 22 | ); 23 | const colors = [ 24 | 'rgba(102, 103, 171)', 25 | 'rgba(84, 121, 85)', 26 | 'rgba(234, 103, 89)', 27 | 'rgba(248, 143, 88)', 28 | 'rgba(243, 198, 95)', 29 | 'rgba(241, 138, 173)', 30 | ]; 31 | 32 | export default function ChartCompound({ 33 | metric, 34 | allActiveContainers, 35 | dataLength, 36 | metricName, 37 | }: any) { 38 | const options = { 39 | plugins: { 40 | title: { 41 | display: true, 42 | text: `${metricName}`, 43 | }, 44 | }, 45 | }; 46 | 47 | const allXAxis = allActiveContainers.map((container: any) => { 48 | let x; 49 | if (container[metric].value.length > dataLength) { 50 | x = container[metric].time.slice( 51 | container[metric].time.length - dataLength 52 | ); 53 | } else { 54 | x = container[metric].time; 55 | } 56 | return x; 57 | }); 58 | 59 | const allYAxis = allActiveContainers.map((container: any, i: number) => { 60 | let y; 61 | if (container[metric].value.length > dataLength) { 62 | y = container[metric].value.slice( 63 | container[metric].value.length - dataLength 64 | ); 65 | } else { 66 | y = container[metric].value; 67 | } 68 | 69 | const randColor = 70 | '#' + ((Math.random() * 0xffffff) << 0).toString(16).padStart(6, '0'); 71 | return { 72 | label: container.Names, 73 | data: y, 74 | borderColor: colors[i], 75 | backgroundColor: colors[i], 76 | }; 77 | }); 78 | 79 | const data = { 80 | labels: allXAxis[0], 81 | datasets: allYAxis, 82 | }; 83 | 84 | return ; 85 | } 86 | -------------------------------------------------------------------------------- /src/client/components/ImagePreview.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import axios from 'axios'; 3 | 4 | //this will be small previews of the users individual docker containers with buttons to start and stop each one 5 | const ImagePreview = ({ obj, getFastStats }: any) => { 6 | const { Names, State, Ports, CreatedAt, Image, Status } = obj; 7 | const date = CreatedAt.substring(0, 19); 8 | const port = Ports.substring(8, 18); 9 | let badgeColor; 10 | 11 | const toggleClick = (cmd: string) => { 12 | State === 'paused' && cmd === 'start' ? (cmd = 'unpause') : ''; 13 | axios 14 | .get(`/api/control/${cmd}/${Names}`) 15 | .then((data) => {}) 16 | .catch((err) => { 17 | console.error('Error sending start/stop commands: ', err); 18 | }); 19 | getFastStats(); 20 | }; 21 | 22 | if (State === 'running') { 23 | badgeColor = 'blueBadge'; 24 | } else if (State === 'exited') { 25 | badgeColor = 'redBadge'; 26 | } else if (State === 'paused') { 27 | badgeColor = 'greyBadge'; 28 | } 29 | 30 | return ( 31 |
32 |
33 |
34 |

{`${State}`}

35 |
36 |

{`Created at: ${date}`}

37 |

{`Published Ports: ${[port]}`}

38 |
39 |

{`${Names}`}

40 |

{`Image: ${Image}`}

41 |
42 |
43 | {(State === 'stopped' || State === 'exited' || State === 'paused') && ( 44 | 56 | )} 57 | {State === 'running' && ( 58 | 70 | )} 71 | {(State === 'running' || State === 'paused') && ( 72 | 84 | )} 85 | {(State === 'running' || State === 'paused') && ( 86 | 98 | )} 99 |
100 |
101 | ); 102 | }; 103 | 104 | export default ImagePreview; 105 | -------------------------------------------------------------------------------- /src/client/components/Legend.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Legend = ({ names }: { names: String[] }) => { 4 | const namesArray: JSX.Element[] = []; 5 | for (let i = 0; i < names.length; i++) { 6 | names[i] = names[i].charAt(0).toUpperCase() + names[i].slice(1); 7 | } 8 | for (let i = 0; i < names.length; i++) { 9 | namesArray.push( 10 |
11 | {names[i]} 12 |
13 | ); 14 | } 15 | 16 | return
{namesArray}
; 17 | }; 18 | 19 | export default Legend; 20 | -------------------------------------------------------------------------------- /src/client/components/LiquidGauge.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { color } from 'd3-color'; 3 | import { interpolateRgb } from 'd3-interpolate'; 4 | import LiquidFillGauge from 'react-liquid-gauge'; 5 | 6 | const LiquidGauge = (props) => { 7 | const [initial, setInitial] = useState(0); 8 | 9 | const gaugeVal = props.percent ? props.percent : initial; 10 | const startColor = '#FFFFFF'; // 11 | const endColor = '#FFFFFF'; 12 | const interpolate = interpolateRgb(startColor, endColor); 13 | const fillColor = interpolate(gaugeVal / 100); 14 | const gradientStops = [ 15 | { 16 | key: '0%', 17 | stopColor: color(fillColor).darker(0.5).toString(), 18 | stopOpacity: 1, 19 | offset: '0%', 20 | }, 21 | { 22 | key: '50%', 23 | stopColor: fillColor, 24 | stopOpacity: 0.75, 25 | offset: '50%', 26 | }, 27 | { 28 | key: '100%', 29 | stopColor: color(fillColor).brighter(0.5).toString(), 30 | stopOpacity: 0.5, 31 | offset: '100%', 32 | }, 33 | ]; 34 | return ( 35 |
36 | 55 |

56 |

{props.label}

57 |
58 | ); 59 | }; 60 | 61 | export default LiquidGauge; 62 | -------------------------------------------------------------------------------- /src/client/components/metrics/Cpu.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import GaugeChart from 'react-gauge-chart'; 3 | import LiquidGuage from '../LiquidGauge.jsx'; 4 | 5 | const cpu = (props) => { 6 | const totalCpuPerc = props.totals.totalCpuPercentage || 0; 7 | 8 | return ( 9 |
10 | 11 |
12 | ); 13 | }; 14 | 15 | export default cpu; 16 | -------------------------------------------------------------------------------- /src/client/components/metrics/CpuPer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'; 3 | import { Pie } from 'react-chartjs-2'; 4 | 5 | ChartJS.register(ArcElement, Tooltip, Legend); 6 | export function CpuPer({ cpuData, cpuLabels }: any) { 7 | const data = { 8 | datasets: [ 9 | { 10 | label: 'CPU Usage (%)', 11 | data: cpuData, 12 | backgroundColor: [ 13 | 'rgba(102, 103, 171, .8)', 14 | 'rgba(84, 121, 85, .8)', 15 | 'rgba(234, 103, 89, .8)', 16 | 'rgba(248, 143, 88, .8)', 17 | 'rgba(243, 198, 95, .8)', 18 | 'rgba(241, 138, 173, .8)', 19 | ], 20 | borderColor: [ 21 | 'rgba(102, 103, 171, 1)', 22 | 'rgba(84, 121, 85, .1)', 23 | 'rgba(234, 103, 89, 1)', 24 | 'rgba(248, 143, 88, 1)', 25 | 'rgba(243, 198, 95, 1)', 26 | 'rgba(241, 138, 173, 1)', 27 | ], 28 | borderWidth: 1, 29 | }, 30 | ], 31 | }; 32 | 33 | return ( 34 |
35 | 40 |
41 | ); 42 | } 43 | 44 | export default CpuPer; 45 | -------------------------------------------------------------------------------- /src/client/components/metrics/MemPer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'; 3 | import { Pie } from 'react-chartjs-2'; 4 | 5 | ChartJS.register(ArcElement, Tooltip, Legend); 6 | export function CpuPer({ memData, memLabels }: any) { 7 | const data = { 8 | options: { 9 | plugins: { 10 | datalabels: { 11 | display: true, 12 | align: 'left', 13 | }, 14 | }, 15 | }, 16 | datasets: [ 17 | { 18 | label: 'MB Used', 19 | data: memData, 20 | backgroundColor: [ 21 | 'rgba(102, 103, 171, .8)', 22 | 'rgba(84, 121, 85, .8)', 23 | 'rgba(234, 103, 89, .8)', 24 | 'rgba(248, 143, 88, .8)', 25 | 'rgba(243, 198, 95, .8)', 26 | 'rgba(241, 138, 173, .8)', 27 | ], 28 | borderColor: [ 29 | 'rgba(102, 103, 171, 1)', 30 | 'rgba(84, 121, 85, 1)', 31 | 'rgba(234, 103, 89, 1)', 32 | 'rgba(248, 143, 88, 1)', 33 | 'rgba(243, 198, 95, 1)', 34 | 'rgba(241, 138, 173, 1)', 35 | ], 36 | borderWidth: 1, 37 | }, 38 | ], 39 | }; 40 | 41 | return ( 42 |
43 | 48 |
49 | ); 50 | } 51 | 52 | export default CpuPer; 53 | -------------------------------------------------------------------------------- /src/client/components/metrics/Memory.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import GaugeChart from 'react-gauge-chart'; 3 | import LiquidGuage from '../LiquidGauge'; 4 | 5 | const memory = (props) => { 6 | const totalMemPerc = props.totals['totalMemPercentage'] 7 | ? props.totals.totalMemPercentage 8 | : 0; 9 | 10 | return ( 11 |
12 | 13 |
14 | ); 15 | }; 16 | 17 | export default memory; 18 | -------------------------------------------------------------------------------- /src/client/containers/Carousel.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-no-comment-textnodes */ 2 | import React, { useState, useRef } from 'react'; 3 | import Carousel from 'react-bootstrap/Carousel'; 4 | import Chart from '../components/Chart'; 5 | import ChartCompound from '../components/ChartCompound'; 6 | // import { ArrowDownSquareFill } from 'react-bootstrap-icons'; 7 | 8 | function CarouselDisplay(props: any) { 9 | const selector = useRef(null); 10 | const [index, setIndex] = useState(0); 11 | const [dataLength, setDataLength] = useState(25); 12 | const interval = 50000000; 13 | 14 | const handleSelect = (selectedIndex: number) => { 15 | setIndex(selectedIndex); 16 | }; 17 | 18 | const dropDown = ( 19 | <> 20 | 21 | 22 | 37 | 38 | ); 39 | const memFail = []; 40 | for (let index = 0; index < props.activeContainers.length; index++) { 41 | memFail.push(props.activeContainers[index].memFailures.value[0]); 42 | } 43 | const totalMemFail = memFail.reduce((a, b) => { 44 | return a + b; 45 | }, 0); 46 | 47 | return ( 48 | 56 | 61 |
62 |
63 | Total Memory Failures: {totalMemFail} 64 |
65 |

{ 68 | e.preventDefault(); 69 | const output: any[] = []; 70 | props.activeContainers.forEach((x: any) => { 71 | const timestamp = [x.Names, 'timestamp']; 72 | x.memory.time.forEach((y: any) => timestamp.push(y)); 73 | const memory = [x.Names, 'memory usage (MB)']; 74 | x.memory.value.forEach((y: any) => memory.push(y)); 75 | const cpu = [x.Names, 'cpu usage (%)']; 76 | x.cpu.value.forEach((y: any) => cpu.push(y)); 77 | output.push(timestamp, memory, cpu); 78 | }); 79 | const csvContent = 80 | 'data:text/csv;charset=utf-8,' + 81 | output.map((e) => e.join(',')).join('\n'); 82 | const encodedUri = encodeURI(csvContent); 83 | var link = document.createElement('a'); 84 | link.setAttribute('href', encodedUri); 85 | link.setAttribute('download', 'allContainersData.csv'); 86 | document.body.appendChild(link); 87 | link.click(); 88 | link.remove(); 89 | }} 90 | > 91 | Overview 92 |

93 | {dropDown} 94 |
95 | 101 | 107 |
108 | {props.activeContainers.map((obj: any, i: number) => ( 109 | 110 |
111 |
112 | Memory Failures: {obj.memFailures.value[0]} 113 |
114 |

{ 116 | e.preventDefault(); 117 | const output = [ 118 | ['timestamp'], 119 | ['memory usage (MB)'], 120 | ['cpu usage (%)'], 121 | ]; 122 | obj.memory.time.forEach((x: any) => output[0].push(x)); 123 | obj.cpu.value.forEach((x: any) => output[1].push(x)); 124 | obj.memory.value.forEach((x: any) => output[2].push(x)); 125 | const csvContent = 126 | 'data:text/csv;charset=utf-8,' + 127 | output.map((e) => e.join(',')).join('\n'); 128 | const encodedUri = encodeURI(csvContent); 129 | var link = document.createElement('a'); 130 | link.setAttribute('href', encodedUri); 131 | link.setAttribute('download', `${obj.Names}.csv`); 132 | document.body.appendChild(link); 133 | link.click(); 134 | link.remove(); 135 | }} 136 | > 137 | {obj.Names} 138 |

139 | {dropDown} 140 |
141 | 147 | 153 |
154 | ))} 155 |
156 | ); 157 | } 158 | 159 | export default CarouselDisplay; 160 | -------------------------------------------------------------------------------- /src/client/containers/Environments.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import ImagePreview from '../components/ImagePreview'; 3 | import axios from 'axios'; 4 | 5 | const Environments = (props: any) => { 6 | const [fastState, setFastState] = useState({}); 7 | 8 | const getFastStats = () => { 9 | axios 10 | .get('/api/getFastStats') 11 | .then((data) => { 12 | setFastState(data.data); 13 | }) 14 | .catch((err) => { 15 | console.error('Error fetching high-level container metrics: ', err); 16 | }); 17 | return getFastStats; 18 | }; 19 | 20 | useEffect(() => { 21 | setInterval(getFastStats(), 10000); 22 | }, []); 23 | 24 | const previewArray = Object.values(fastState).map((obj, i) => ( 25 | 30 | )); 31 | 32 | return
{previewArray}
; 33 | }; 34 | 35 | export default Environments; 36 | -------------------------------------------------------------------------------- /src/client/containers/Links.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Links = () => { 4 | return ( 5 | <> 6 |
7 | 15 | 16 | 17 | 18 | About 19 | 20 | 28 | 29 | 30 | 34 | GitHub | 35 | 36 | 43 | 44 | 51 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | Grafana 62 | 63 |
64 | 65 | ); 66 | }; 67 | export default Links; 68 | -------------------------------------------------------------------------------- /src/client/containers/Logs.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import axios from 'axios'; 3 | 4 | const Logs = (props: any) => { 5 | const [selectedContainer, setSelectedContainer] = useState(null); 6 | const [logs, setLogs] = useState(null); 7 | 8 | async function getLogs(name: string) { 9 | try { 10 | if (!name) { 11 | setLogs(null); 12 | return; 13 | } 14 | const output = await axios.get(`/api/control/logs/${name}`); 15 | if (typeof output.data.stdout === 'object') { 16 | setLogs(output.data.stdout); 17 | } else { 18 | setLogs(output.data.stderr); 19 | } 20 | } catch (err) { 21 | const output = err; 22 | setLogs(output.data); 23 | } 24 | } 25 | 26 | let logsJSX = [
  • No LOGS
  • ]; 27 | const logsByMostRecent = []; 28 | if (logs !== null) { 29 | for (let i = logs.length - 1; i >= 0; i--) { 30 | logsByMostRecent.push(logs[i]); 31 | } 32 | logsJSX = logsByMostRecent.map((log, i) => ( 33 |
  • 34 | {' '} 35 | {typeof log === 'string' ? log : JSON.stringify(log)} 36 |
  • 37 | )); 38 | } 39 | 40 | return ( 41 | <> 42 | {(props.activeContainers.length !== 0 ? true : false) && ( 43 |
    44 |

    45 | 62 | {(selectedContainer ? true : false) && ( 63 | { 67 | e.preventDefault(); 68 | getLogs(selectedContainer); 69 | }} 70 | /> 71 | )} 72 |
    73 | )} 74 | {(logs ? true : false) &&
      {logsJSX}
    } 75 | 76 | ); 77 | }; 78 | export default Logs; 79 | -------------------------------------------------------------------------------- /src/client/containers/SystemMetrics.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import axios from 'axios'; 3 | import CPU from '../components/metrics/Cpu'; 4 | import Memory from '../components/metrics/Memory'; 5 | import CpuPer from '../components/metrics/CpuPer'; 6 | import MemPer from '../components/metrics/MemPer'; 7 | import Legend from '../components/Legend'; 8 | 9 | const systemMetrics = ({ activeContainers }) => { 10 | const [totalMetrics, setTotalsData] = useState({}); 11 | const memPieData = []; 12 | const memPieLabels = []; 13 | const cpuPieData = []; 14 | const cpuPieLabels = []; 15 | const legend = []; 16 | const getTotalsFunc = () => { 17 | axios 18 | .get('/api/getTotals') 19 | .then((res) => { 20 | setTotalsData(res.data); 21 | }) 22 | .catch((err) => { 23 | console.log('Error getting totals: ' + err); 24 | }); 25 | return getTotalsFunc; 26 | }; 27 | useEffect(() => { 28 | setInterval(getTotalsFunc(), 5000); 29 | }, []); 30 | 31 | for (let i = 0; i < activeContainers.length; i++) { 32 | memPieLabels.push(activeContainers[i].Names); 33 | cpuPieLabels.push(activeContainers[i].Names); 34 | legend.push(activeContainers[i].Names); 35 | const memArr = activeContainers[i].memory.value; 36 | const cpuArr = activeContainers[i].cpu.value; 37 | memPieData.push(memArr[memArr.length - 1]); 38 | cpuPieData.push(cpuArr[cpuArr.length - 1]); 39 | } 40 | // const totalmetrics = totals ? totals : {}; 41 | const healthFail = totalMetrics.dockerHealthFailures; 42 | 43 | const healthColor = healthFail === 0 ? 'green' : 'red'; 44 | 45 | return ( 46 | <> 47 |
    48 |
    49 | 50 |
    51 | 52 | 53 |
    54 |
    55 |
    56 | 57 |
    58 | 59 | 60 |
    61 |
    62 |
    63 |
    64 |
    65 |
    Health Failures:
    66 |
    67 | {healthFail} 68 |
    69 |
    70 |
    71 |
    72 | 73 |
    74 |
    75 |
    76 | 77 | ); 78 | }; 79 | 80 | export default systemMetrics; 81 | -------------------------------------------------------------------------------- /src/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 16 | 17 | 21 | 22 | 23 | 27 | Dockwell 28 | 33 | 34 | 35 |
    36 | 37 | 38 | -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | // import { render } from 'react-dom'; 4 | import App from './pages/App.jsx'; 5 | import { BrowserRouter } from 'react-router-dom'; 6 | // import 'bootstrap/dist/css/bootstrap.min.css'; 7 | 8 | // webpack bundles styles 9 | import styles from './public/stylesheets/styles.scss'; 10 | const root = ReactDOM.createRoot(document.getElementById('root')); 11 | root.render( 12 | 13 | 14 | 15 | ); 16 | -------------------------------------------------------------------------------- /src/client/pages/App.jsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import React, { useEffect, useState } from 'react'; 3 | import SystemMetrics from '../containers/SystemMetrics'; 4 | import Environments from '../containers/Environments'; 5 | import Carousel from '../containers/Carousel'; 6 | import Logs from '../containers/Logs'; 7 | import LiquidGauge from '../components/LiquidGauge'; 8 | import Links from '../containers/Links'; 9 | 10 | const App = () => { 11 | const [queryData, setQueryData] = useState({}); 12 | const [loader, setLoader] = useState(0); 13 | //filters running containers 14 | const [activeContainers, setActiveContainers] = useState([]); 15 | const [loadingScreen, setLoadingScreen] = useState(true); 16 | 17 | const getStatsFunc = () => { 18 | axios 19 | .get('/api/getStats') 20 | .then((res) => { 21 | setQueryData((prev) => { 22 | const newQueryState = { ...prev }; 23 | for (const key in res.data) { 24 | if (!(key in newQueryState)) { 25 | newQueryState[key] = res.data[key]; 26 | } else if ( 27 | newQueryState[key].State === 'running' && 28 | newQueryState[key].cpu && 29 | newQueryState[key].memory && 30 | res.data[key].cpu && 31 | res.data[key].memory 32 | ) { 33 | newQueryState[key].memory.time = [ 34 | ...prev[key].memory.time, 35 | ...res.data[key].memory.time, 36 | ]; 37 | newQueryState[key].memory.value = [ 38 | ...prev[key].memory.value, 39 | ...res.data[key].memory.value, 40 | ]; 41 | newQueryState[key].cpu.time = [ 42 | ...prev[key].cpu.time, 43 | ...res.data[key].cpu.time, 44 | ]; 45 | newQueryState[key].cpu.value = [ 46 | ...prev[key].cpu.value, 47 | ...res.data[key].cpu.value, 48 | ]; 49 | } else { 50 | //update all keys' values (these shouldn't have metrics) 51 | newQueryState[key] = res.data[key]; 52 | } 53 | } 54 | 55 | return newQueryState; 56 | }); 57 | }) 58 | .catch((err) => console.error('Error updating main metrics: ', err)); 59 | return getStatsFunc; 60 | }; 61 | 62 | useEffect(() => { 63 | //tell it to repeat 64 | setInterval(getStatsFunc(), 4000); 65 | }, []); 66 | 67 | useEffect(() => { 68 | const activeContainers = []; 69 | for (const key in queryData) { 70 | if (key !== 'totals') { 71 | if ( 72 | queryData[key].State === 'running' && 73 | queryData[key].cpu && 74 | queryData[key].memory 75 | ) { 76 | setTimeout(() => { 77 | setLoader(82); 78 | }, 400); 79 | setTimeout(() => { 80 | setLoadingScreen(false); 81 | }, 2000); 82 | activeContainers.push(queryData[key]); 83 | } 84 | } 85 | } 86 | setActiveContainers(activeContainers); 87 | }, [queryData]); 88 | 89 | return ( 90 |
    91 | {loadingScreen && ( 92 | <> 93 |
    94 | 100 |
    101 | 102 | )} 103 | {!loadingScreen && ( 104 |
    105 |
    106 |
    107 |
    108 |

    Dockwell.

    109 |

    A docker visualizer

    110 | 111 |
    112 | {/* */} 113 |
    114 | 115 |
    116 |
    117 |
    118 | 122 |
    123 |
    124 |
    125 | 126 |
    127 | 131 |
    132 |
    133 |
    134 | )} 135 |
    136 | ); 137 | }; 138 | 139 | export default App; 140 | -------------------------------------------------------------------------------- /src/client/pages/data.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dockwell/4041f37558fb3959559ef61e34b8b8b1a9aaee48/src/client/pages/data.json -------------------------------------------------------------------------------- /src/client/public/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dockwell/4041f37558fb3959559ef61e34b8b8b1a9aaee48/src/client/public/.DS_Store -------------------------------------------------------------------------------- /src/client/public/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dockwell/4041f37558fb3959559ef61e34b8b8b1a9aaee48/src/client/public/assets/favicon.ico -------------------------------------------------------------------------------- /src/client/public/assets/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dockwell/4041f37558fb3959559ef61e34b8b8b1a9aaee48/src/client/public/assets/logo.ico -------------------------------------------------------------------------------- /src/client/public/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/dockwell/4041f37558fb3959559ef61e34b8b8b1a9aaee48/src/client/public/assets/logo.png -------------------------------------------------------------------------------- /src/client/public/stylesheets/_variables.scss: -------------------------------------------------------------------------------- 1 | $background: #1b263b; 2 | $panels: #415a77e3; 3 | $carouselPanel: #AEBDCA; 4 | $tertiary: #AEBDCA; 5 | $primary: #e0e1dd; 6 | $secondary: #AEBDCA; 7 | $green: #5f995e; 8 | $red: rgba(216, 96, 96, 0.571); 9 | $lightred: rgb(199, 64, 64); 10 | $blue: #277BC0; 11 | $yellow: #daca3d9a; 12 | $yellowfont: #daca3d; 13 | $paused: #6c757d; 14 | $legend1: rgba(102, 103, 171); 15 | $legend2: rgb(84, 121, 85); 16 | $legend3: rgba(171, 74, 63); 17 | $legend4: rgba(178, 102, 61); 18 | $legend5: rgba(174, 142, 68); 19 | $legend6: rgb(171, 97, 121); 20 | $carouselActive: #343a3f; 21 | $logbackground: #1b263b; 22 | $logborder: #778da8; 23 | 24 | // 'rgba(102, 103, 171, 1)', 25 | // 'rgba(241, 138, 173, 1)', 26 | // 'rgba(234, 103, 89, 1)', 27 | // 'rgba(248, 143, 88, 1)', 28 | // 'rgba(243, 198, 95, 1)', 29 | // 'rgba(139, 194, 140, 1)', -------------------------------------------------------------------------------- /src/client/public/stylesheets/styles.scss: -------------------------------------------------------------------------------- 1 | @import 'variables.scss'; 2 | 3 | .loadGauge { 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | height: 100%; 8 | 9 | .loading-center { 10 | width: 100%; 11 | text-align: center; 12 | } 13 | } 14 | 15 | * { 16 | box-sizing: border-box; 17 | } 18 | 19 | body, 20 | html { 21 | margin: 0; 22 | padding: 0; 23 | font-family: 'Poppins', sans-serif; 24 | } 25 | 26 | body { 27 | background: $background; 28 | margin: 0 auto; 29 | color: $tertiary; 30 | background-image: linear-gradient( 31 | rgba(45, 45, 45, 0.7) 0.1em, 32 | transparent 0.1em 33 | ), 34 | linear-gradient(90deg, rgba(45, 45, 45, 0.7) 0.1em, transparent 0.1em); 35 | background-size: 1em 1em; 36 | padding: 20px; 37 | position: relative; 38 | display: flex; 39 | justify-content: center; 40 | align-items: center; 41 | // width: 100vh; 42 | max-width: 1700px; 43 | 44 | .links { 45 | display: flex; 46 | justify-content: center; 47 | align-items: baseline; 48 | position: absolute; 49 | top: 15px; 50 | right: 40px; 51 | } 52 | 53 | .App { 54 | @media screen and (max-width: 1600px) { 55 | transform: scale(0.9); 56 | } 57 | .main { 58 | width: fit-content; 59 | display: grid; 60 | grid-template-columns: 2fr 4fr 2fr; 61 | padding: 30px; 62 | margin-top: 20px; 63 | } 64 | } 65 | 66 | .left { 67 | display: grid; 68 | grid-template-rows: 1fr 6fr; 69 | align-items: center; 70 | height: fit-content; 71 | padding-bottom: 30px; 72 | // position: relative; 73 | img { 74 | width: 110px; 75 | position: relative; 76 | bottom: 34px; 77 | right: 36px; 78 | } 79 | 80 | .top { 81 | display: flex; 82 | justify-content: space-between; 83 | } 84 | .title { 85 | width: 100%; 86 | font-size: 50; 87 | font-family: 'Kanit', sans-serif; 88 | 89 | // height: 170.78; 90 | 91 | a { 92 | color: $tertiary; 93 | font-size: 1.5rem; 94 | } 95 | 96 | svg { 97 | margin: auto 0; 98 | color: $tertiary; 99 | } 100 | 101 | .grafana { 102 | background-image: linear-gradient( 103 | 180deg, 104 | rgba(239, 90, 39, 1) 0%, 105 | rgba(251, 197, 26, 1) 85% 106 | ); 107 | -webkit-background-clip: text; 108 | -webkit-text-fill-color: transparent; 109 | // font-family:'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 110 | } 111 | .grafana:hover { 112 | background-image: linear-gradient( 113 | 180deg, 114 | rgb(250, 133, 94) 0%, 115 | rgb(255, 219, 99) 85% 116 | ); 117 | } 118 | } 119 | 120 | h1 { 121 | font-size: 50px; 122 | position: relative; 123 | bottom: 20px; 124 | left: 120px; 125 | } 126 | 127 | h2 { 128 | font-size: 1.3rem; 129 | position: relative; 130 | bottom: 30px; 131 | left: 130px; 132 | } 133 | } 134 | 135 | .SystemMetrics { 136 | display: flex; 137 | flex-direction: column; 138 | margin: 0 auto; 139 | width: 100%; 140 | 141 | .mem, 142 | .cpu { 143 | background-color: $panels; 144 | display: flex; 145 | flex-direction: column; 146 | height: 230px; 147 | padding: 10px; 148 | margin-bottom: 20px; 149 | border-radius: 10px; 150 | 151 | label { 152 | margin: 0 auto 10px auto; 153 | font-size: 1.2rem; 154 | color: $tertiary; 155 | } 156 | 157 | .circles { 158 | display: flex; 159 | justify-content: space-evenly; 160 | 161 | .liquidGauge { 162 | margin-top: 10px; 163 | } 164 | } 165 | } 166 | 167 | .bottom { 168 | display: flex; 169 | 170 | .errors { 171 | display: flex; 172 | flex-direction: column; 173 | 174 | .healthfail { 175 | background-color: $panels; 176 | font-weight: 600; 177 | width: 135px; 178 | margin: 0 15 15 0; 179 | border-radius: 10px; 180 | padding: 2.5px; 181 | display: flex; 182 | flex-direction: column; 183 | justify-content: center; 184 | text-align: center; 185 | 186 | #title { 187 | font-size: 25; 188 | padding-bottom: 10px; 189 | } 190 | 191 | #num { 192 | font-size: 40; 193 | } 194 | 195 | .green { 196 | color: $green; 197 | } 198 | 199 | .red { 200 | color: $red; 201 | } 202 | } 203 | } 204 | 205 | .legendInner { 206 | background-color: $panels; 207 | width: 285px; 208 | padding: 15px; 209 | border-radius: 10px; 210 | font-size: 1.2rem; 211 | overflow: scroll; 212 | 213 | .legendItem1, 214 | .legendItem2, 215 | .legendItem3, 216 | .legendItem4, 217 | .legendItem5, 218 | .legendItem6 { 219 | color: $secondary; 220 | border-radius: 15px; 221 | margin: 4px 2px 0px 2px; 222 | padding: 3px 10px; 223 | height: max-content; 224 | text-align: center; 225 | } 226 | 227 | .legendItem1 { 228 | background-color: $legend1; 229 | } 230 | 231 | .legendItem2 { 232 | background-color: $legend2; 233 | } 234 | 235 | .legendItem3 { 236 | background-color: $legend3; 237 | } 238 | 239 | .legendItem4 { 240 | background-color: $legend4; 241 | } 242 | 243 | .legendItem5 { 244 | background-color: $legend5; 245 | } 246 | 247 | .legendItem6 { 248 | background-color: $legend6; 249 | } 250 | } 251 | } 252 | } 253 | } 254 | 255 | .middle { 256 | display: flex; 257 | flex-direction: column; 258 | padding: 30px 0; 259 | margin: 0 auto; 260 | height: fit-content; 261 | width: 765px; 262 | 263 | .CarouselDiv { 264 | background-color: $carouselPanel; 265 | overflow: scroll; 266 | padding: 10px 30px; 267 | margin: 0 15px; 268 | border-radius: 10px; 269 | height: 80vh; 270 | @media screen and (max-width: 1600px) { 271 | height: 90vh; 272 | } 273 | .carousel-control-next, 274 | .carousel-control-prev { 275 | margin-top: 50px; 276 | height: 700px; 277 | } 278 | 279 | .carousel-control-next-icon { 280 | position: relative; 281 | left: 66px; 282 | top: 24px; 283 | color: $carouselActive; 284 | } 285 | 286 | .carousel-control-prev-icon { 287 | position: relative; 288 | right: 66px; 289 | top: 24px; 290 | } 291 | 292 | button { 293 | width: 50px; 294 | height: 5px; 295 | margin: 0 10px; 296 | border: none; 297 | } 298 | } 299 | 300 | .carousel-indicators { 301 | position: absolute; 302 | top: 730px; 303 | 304 | button.active { 305 | background-color: $carouselActive; 306 | } 307 | } 308 | 309 | .header { 310 | display: flex; 311 | margin: auto 10px; 312 | justify-content: space-between; 313 | 314 | .badge { 315 | height: min-content; 316 | padding: 10px; 317 | margin: auto 0; 318 | width: 180px; 319 | } 320 | 321 | h2 { 322 | font-size: 1.75em; 323 | overflow: scroll; 324 | align-self: center; 325 | color: $panels; 326 | font-weight: 600; 327 | margin: auto 0; 328 | &:hover { 329 | color: $red; 330 | cursor: pointer; 331 | } 332 | } 333 | 334 | select { 335 | margin: 0 auto; 336 | width: 160px; 337 | padding: 0 10px; 338 | height: 32px; 339 | margin: auto 0; 340 | border-radius: 50px; 341 | cursor: pointer; 342 | background-color: $carouselActive; 343 | color: $carouselPanel; 344 | font-size: 0.8rem; 345 | text-align: center; 346 | // appearance: none; 347 | font-weight: 600; 348 | } 349 | } 350 | } 351 | 352 | .right { 353 | display: flex; 354 | flex-direction: column; 355 | 356 | padding: 30px 0; 357 | // margin: 0 10px; 358 | height: fit-content; 359 | 360 | .Environments { 361 | overflow: scroll; 362 | height: 50vh; 363 | margin-bottom: 15px; 364 | 365 | .ImagePreview { 366 | display: flex; 367 | flex-direction: row; 368 | justify-content: space-between; 369 | height: fit-content; 370 | width: 390px; 371 | padding: 10px; 372 | border-radius: 10px; 373 | color: $secondary; 374 | background-color: $panels; 375 | margin-bottom: 20px; 376 | 377 | .information { 378 | width: 290px; 379 | // flex-shrink: 1; 380 | 381 | // .labels { 382 | // word-wrap: break-word; 383 | // } 384 | 385 | p { 386 | margin: 0; 387 | } 388 | 389 | .small { 390 | font-size: 0.85rem; 391 | } 392 | 393 | .space { 394 | height: 15px; 395 | } 396 | 397 | .large { 398 | color: $yellowfont; 399 | font-size: 1.3rem; 400 | // white-space: nowrap; 401 | // word-wrap: break-word; 402 | } 403 | } 404 | 405 | .blueBadge, 406 | .redBadge, 407 | .greyBadge { 408 | border-radius: 15px; 409 | width: min-content; 410 | padding: 1px 8px; 411 | margin-bottom: 2px; 412 | } 413 | 414 | .blueBadge { 415 | background-color: $blue; 416 | color: $primary; 417 | } 418 | 419 | .redBadge { 420 | background-color: $lightred; 421 | color: $primary; 422 | } 423 | 424 | .greyBadge { 425 | background-color: $paused; 426 | color: $primary; 427 | } 428 | 429 | #buttons { 430 | display: flex; 431 | flex-direction: column; 432 | justify-content: space-evenly; 433 | 434 | width: 100px; 435 | 436 | button { 437 | border: 0; 438 | color: $primary; 439 | } 440 | 441 | .btn-success { 442 | background-color: $green; 443 | } 444 | 445 | .btn-danger { 446 | background-color: $red; 447 | } 448 | 449 | .btn-warning { 450 | background-color: $yellow; 451 | } 452 | } 453 | } 454 | } 455 | 456 | form { 457 | display: flex; 458 | margin-top: 20px; 459 | } 460 | 461 | .logs { 462 | background-color: $panels; 463 | padding: 0 10px 10px 10px; 464 | display: flex; 465 | flex-direction: column; 466 | border-radius: 10px; 467 | max-width: 390.5px; 468 | 469 | input, 470 | select { 471 | margin: 0 12px; 472 | text-align: center; 473 | display: flex; 474 | justify-content: center; 475 | width: 100%; 476 | padding: 0.2rem; 477 | cursor: pointer; 478 | border-radius: 5px; 479 | background-color: #ddd; 480 | list-style: none; 481 | border: none; 482 | } 483 | 484 | .logs-list { 485 | background-color: $logbackground; 486 | border: 2px solid $logborder; 487 | margin: 0; 488 | padding: 2px; 489 | height: 200px; 490 | overflow: scroll; 491 | 492 | .logs-list-item { 493 | margin: 0; 494 | padding: 0; 495 | font-size: 10px; 496 | list-style-type: none; 497 | border-bottom: 2px solid $logborder; 498 | word-wrap: break-word; 499 | } 500 | } 501 | } 502 | } 503 | -------------------------------------------------------------------------------- /src/server/controllers/containerController.ts: -------------------------------------------------------------------------------- 1 | const { promisify } = require('util'); 2 | const { exec } = require('child_process'); 3 | const execProm = promisify(exec); 4 | const cliParser = require('../utils/dockerCliParser.ts'); 5 | import { Request, Response, NextFunction, RequestHandler } from 'express'; 6 | 7 | const controlContainer: any = {}; 8 | 9 | controlContainer.dockerTaskName = async ( 10 | req: Request, 11 | res: Response, 12 | next: NextFunction 13 | ) => { 14 | try { 15 | const { name, task } = req.params; 16 | let { stdout, stderr } = await execProm(`docker ${task} ${name}`); 17 | stdout ? (stdout = cliParser(stdout)) : ''; 18 | stderr ? (stderr = cliParser(stderr)) : ''; 19 | res.locals.container = { stdout, stderr }; 20 | return next(); 21 | } catch (err) { 22 | return next({ 23 | log: 24 | 'controlContainer.dockerTaskName - error in the docker task containername command: ' + 25 | err, 26 | status: 500, 27 | message: { 28 | err: 'Expected metrics for running containers were not found', 29 | }, 30 | }); 31 | } 32 | }; 33 | 34 | module.exports = controlContainer; 35 | -------------------------------------------------------------------------------- /src/server/controllers/promQueryController.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction, RequestHandler } from 'express'; 2 | const { promisify } = require('util'); 3 | const { exec } = require('child_process'); 4 | const execProm = promisify(exec); 5 | const cliParser = require('../utils/dockerCliParser'); 6 | const { Metric } = require('prometheus-query'); 7 | const { container } = require('webpack'); 8 | 9 | const PrometheusDriver = require('prometheus-query').PrometheusDriver; 10 | // default options 11 | const options: any = { 12 | machineName: null, // uses local docker 13 | currentWorkingDirectory: null, // uses current working directory 14 | echo: true, // echo command output to stdout/stderr 15 | }; 16 | 17 | // for use when app is run locally, sans container: 18 | // const endpoint = 'http://localhost:9090'; 19 | 20 | // for use when hosted in a container (localhost of a given docker container): 21 | const endpoint = 'http://host.docker.internal:9090'; 22 | 23 | const prom = new PrometheusDriver({ 24 | endpoint: endpoint, 25 | baseURL: '/api/v1', // default value 26 | }); 27 | 28 | const queries = { 29 | cpu: 'rate(container_cpu_usage_seconds_total[1m])', 30 | memory: 'container_memory_usage_bytes', 31 | memFailures: 'container_memory_failures_total', 32 | healthFailures: 'engine_daemon_health_checks_failed_total', 33 | transmitErrs: 'container_network_transmit_errors_total', 34 | receiveErrs: 'container_network_receive_errors_total', 35 | }; 36 | 37 | const promQueryController: any = {}; 38 | 39 | //to grab all the containers and their status in docker and formating that into an object of objects, where each object has the info on each container from the docker cli command docker ps 40 | promQueryController.getContainers = async ( 41 | req: Request, 42 | res: Response, 43 | next: NextFunction 44 | ) => { 45 | try { 46 | //executes command in user's terminal 47 | const { stdout } = await execProm('docker ps --all --format "{{json .}}"'); 48 | //parse incoming data 49 | const data = cliParser(stdout).map((container: any) => { 50 | return { 51 | ID: container.ID, 52 | Names: container.Names, 53 | Ports: container.Ports, 54 | CreatedAt: container.CreatedAt, 55 | Image: container.Image, 56 | Status: container.Status, 57 | }; 58 | }); 59 | //goes through the array from the map above, and creates an object where each key is a container ID, and the value is the object created in the step above. 60 | const finalData: any = {}; 61 | for (const metricObj of data) { 62 | if ( 63 | true 64 | // metricObj.Names !== 'prometheus' && 65 | // metricObj.Names !== 'cadvisor' && 66 | // metricObj.Names !== 'dockwell-dev' 67 | // metricObj.Names !== 'dockwell' 68 | ) { 69 | finalData[metricObj.ID] = metricObj; 70 | } 71 | } 72 | 73 | res.locals.containers = finalData; 74 | return next(); 75 | } catch (err) { 76 | return next({ 77 | log: 78 | 'promQLController.getContainers - error in the docker ps --all format JSON command: ' + 79 | err, 80 | status: 500, 81 | message: { 82 | err: 'Expected metrics for running containers were not found', 83 | }, 84 | }); 85 | } 86 | }; 87 | 88 | promQueryController.getContainerState = async ( 89 | req: Request, 90 | res: Response, 91 | next: NextFunction 92 | ) => { 93 | try { 94 | for (const container in res.locals.containers) { 95 | const { stdout } = await execProm( 96 | `docker inspect ${res.locals.containers[container].Names} --format "{{json .}}"` 97 | ); 98 | const data = cliParser(stdout); 99 | const containerState = data[0].State.Status; 100 | res.locals.containers[container].State = containerState; 101 | } 102 | return next(); 103 | } catch (err) { 104 | return next({ 105 | log: 'promQLController.getContainerState - error in the docker inspect containername command', 106 | status: 500, 107 | message: { err }, 108 | }); 109 | } 110 | }; 111 | 112 | promQueryController.cpuQuery = ( 113 | req: Request, 114 | res: Response, 115 | next: NextFunction 116 | ) => { 117 | const q = queries['cpu']; 118 | const { containers } = res.locals; 119 | prom 120 | .instantQuery(q) 121 | .then((data: any) => { 122 | const series = data.result; 123 | for (const metricObj of series) { 124 | const short_id = metricObj.metric.labels.id.substr(8, 12); 125 | containers[short_id] && 126 | (containers[short_id].cpu = { 127 | time: [metricObj.value.time.toString().slice(16, 24)], 128 | value: [metricObj.value.value], 129 | }); 130 | } 131 | if (containers) { 132 | for (const key in containers) { 133 | if (!containers[key].cpu) 134 | res.locals.containers[key].cpu = { time: ['00:00:00'], value: [0] }; 135 | } 136 | } 137 | return next(); 138 | }) 139 | .catch((err: Error) => { 140 | return next({ 141 | log: 142 | 'promQLController.cpuQuery - error in the promQL CPU query: ' + err, 143 | status: 500, 144 | message: { 145 | err: 'Expected metrics for running containers were not found', 146 | }, 147 | }); 148 | }); 149 | }; 150 | 151 | promQueryController.memoryQuery = ( 152 | req: Request, 153 | res: Response, 154 | next: NextFunction 155 | ) => { 156 | const q = queries['memory']; 157 | const { containers } = res.locals; 158 | prom 159 | .instantQuery(q) 160 | .then((y: any) => { 161 | const series = y.result; 162 | for (const metricObj of series) { 163 | const short_id = metricObj.metric.labels.id.substr(8, 12); 164 | containers[short_id] && 165 | (containers[short_id].memory = { 166 | time: [metricObj.value.time.toString().slice(16, 24)], 167 | value: [(metricObj.value.value / 1000000).toFixed(3)], 168 | }); 169 | } 170 | if (containers) { 171 | for (const key in containers) { 172 | if (!containers[key].memory) 173 | res.locals.containers[key].memory = { 174 | time: ['00:00:00'], 175 | value: [0], 176 | }; 177 | } 178 | } 179 | return next(); 180 | }) 181 | .catch((err: Error) => { 182 | return next({ 183 | log: 184 | 'promQLController.memoryQuery - error in the promQL MEMORY query: ' + 185 | err, 186 | status: 500, 187 | message: { 188 | err: 'Expected metrics for running containers were not found', 189 | }, 190 | }); 191 | }); 192 | }; 193 | 194 | promQueryController.memFailuresQuery = ( 195 | req: Request, 196 | res: Response, 197 | next: NextFunction 198 | ) => { 199 | const q = queries['memFailures']; 200 | const { containers } = res.locals; 201 | prom 202 | .instantQuery(q) 203 | .then((data: any) => { 204 | const series = data.result; 205 | for (const metricObj of series) { 206 | const short_id = metricObj.metric.labels.id.substr(8, 12); 207 | //if substring is a key in getcontainers object 208 | containers[short_id] && 209 | (containers[short_id].memFailures = { 210 | value: [metricObj.value.value], 211 | }); 212 | } 213 | if (containers) { 214 | for (const key in containers) { 215 | if (!containers[key].memFailures) 216 | res.locals.containers[key].memFailures = { 217 | value: [0], 218 | }; 219 | } 220 | } 221 | return next(); 222 | }) 223 | .catch((err: Error) => { 224 | return next({ 225 | log: 226 | 'promQLController.memFailuresQuery - error in the promQL MEM FAILURES query: ' + 227 | err, 228 | status: 500, 229 | message: { 230 | err: 'Expected metrics for running containers were not found', 231 | }, 232 | }); 233 | }); 234 | }; 235 | 236 | promQueryController.getTotals = async ( 237 | req: Request, 238 | res: Response, 239 | next: NextFunction 240 | ) => { 241 | try { 242 | const { stdout } = await execProm( 243 | 'docker stats --no-stream --format "{{json .}}"' 244 | ); 245 | const data = cliParser(stdout).map((container: any) => { 246 | return { 247 | ID: container.ID, 248 | Name: container.Name, 249 | CPUPercentage: container.CPUPerc, 250 | MemPercentage: container.MemPerc, 251 | MemUsage: container.MemUsage, 252 | }; 253 | }); 254 | const totalsFinal = { 255 | totalCpuPercentage: 0, 256 | totalMemPercentage: 0, 257 | memLimit: '0GiB', 258 | }; 259 | for (const container of data) { 260 | if ( 261 | true 262 | // container.Name !== 'prometheus' && 263 | // container.Name !== 'cadvisor' && 264 | // container.Name !== 'dockwell-dev' 265 | // container.Name !== 'dockwell' 266 | ) { 267 | const memStr = container.MemPercentage.replace('%', ''); 268 | const cpuStr = container.CPUPercentage.replace('%', ''); 269 | totalsFinal.totalMemPercentage += Number(memStr); 270 | totalsFinal.totalCpuPercentage += Number(cpuStr); 271 | } 272 | } 273 | const memLimit = data[0].MemUsage.split('/'); 274 | totalsFinal.memLimit = memLimit[1]; 275 | res.locals.finalResult = { totals: totalsFinal }; 276 | 277 | return next(); 278 | } catch (err) { 279 | return next({ 280 | log: 'promQLController.getTotals - error in the parser: ' + err, 281 | status: 500, 282 | message: { 283 | err: 'Expected metrics for running containers were not found', 284 | }, 285 | }); 286 | } 287 | }; 288 | 289 | promQueryController.healthFailureQuery = async ( 290 | req: Request, 291 | res: Response, 292 | next: NextFunction 293 | ) => { 294 | const q = queries['healthFailures']; 295 | const { finalResult } = res.locals; 296 | prom 297 | .instantQuery(q) 298 | .then((data: any) => { 299 | finalResult.totals.dockerHealthFailures = data.result[0].value.value; 300 | return next(); 301 | }) 302 | .catch((err: Error) => { 303 | return next({ 304 | log: 305 | 'promQLController.healthFailureQuery - error in the promQL HEALTH FAILURES query: ' + 306 | err, 307 | status: 500, 308 | message: { 309 | err: 'Expected metrics for running containers were not found', 310 | }, 311 | }); 312 | }); 313 | }; 314 | 315 | module.exports = promQueryController; 316 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | import express, { 2 | Request, 3 | Response, 4 | NextFunction, 5 | RequestHandler, 6 | } from 'express'; 7 | import cors from 'cors'; 8 | import cookieParser from 'cookie-parser'; 9 | import path from 'path'; 10 | 11 | const app = express(); 12 | 13 | const { 14 | cpuQuery, 15 | memoryQuery, 16 | getContainers, 17 | getContainerState, 18 | getTotals, 19 | memFailuresQuery, 20 | healthFailureQuery, 21 | } = require('./controllers/promQueryController.ts'); 22 | const controlContainer = require('./controllers/containerController.ts'); 23 | const PORT = 3535; 24 | 25 | app.use(cookieParser()).use(express.json()).use(cors()); 26 | 27 | app.use(express.static(path.join(__dirname, '../../build'))); 28 | 29 | app.get('/api/getFastStats', getContainers, getContainerState, (req, res) => { 30 | res.status(200).json(res.locals.containers); 31 | }); 32 | 33 | app.get('/api/getTotals', getTotals, healthFailureQuery, (req, res) => { 34 | res.status(200).json(res.locals.finalResult.totals); 35 | }); 36 | 37 | app.get( 38 | '/api/getStats', 39 | getContainers, 40 | getContainerState, 41 | memoryQuery, 42 | cpuQuery, 43 | memFailuresQuery, 44 | (req: Request, res: Response) => { 45 | res.status(200).json(res.locals.containers); 46 | } 47 | ); 48 | 49 | app.get( 50 | '/api/control/:task/:name', 51 | controlContainer.dockerTaskName, 52 | (req: Request, res: Response) => { 53 | res.status(200).json(res.locals.container); 54 | } 55 | ); 56 | 57 | app.get('/', (req: Request, res: Response) => { 58 | console.log('sending file'); 59 | res.sendFile('../../build/index.html'); 60 | }); 61 | 62 | //404 handler 63 | app.use((req: Request, res: Response) => { 64 | return res.sendStatus(404); 65 | }); 66 | 67 | //global error handler 68 | app.use((err: Error, _req: Request, res: Response, next: NextFunction) => { 69 | const defaultErr = { 70 | log: 'Express error handler caught an unknown middleware error', 71 | status: 500, 72 | message: { err: 'An unknown server error occured.' }, 73 | }; 74 | const { log, status, message } = Object.assign(defaultErr, err); 75 | console.log('ERROR: ', log); 76 | return res.status(status).json(message); 77 | }); 78 | 79 | app.listen(PORT, () => { 80 | console.log(`Dockwell server listening on ${PORT}`); 81 | }); 82 | -------------------------------------------------------------------------------- /src/server/utils/dockerCliParser.ts: -------------------------------------------------------------------------------- 1 | module.exports = (input: any) => { 2 | const output = []; 3 | let clean = input.trim(); 4 | clean = clean.split('\n'); 5 | 6 | for (let i = 0; i < clean.length; i++) { 7 | if (clean[i] !== '') { 8 | try { 9 | output.push(JSON.parse(clean[i])); 10 | } catch (err) { 11 | output.push(clean[i]); 12 | } 13 | } 14 | } 15 | return output; 16 | }; 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "noImplicitAny": true, 5 | "esModuleInterop": true, 6 | "target": "es5", 7 | "sourceMap": true 8 | }, 9 | "exclude": ["node_modules"], 10 | "include": ["src/**/*"] 11 | } 12 | 13 | // { 14 | // "compilerOptions": { 15 | // "sourceMap": true, 16 | // "noImplicitAny": true, 17 | // "module": "commonjs", 18 | // "target": "es5", 19 | // "jsx": "react", 20 | // "allowJs": true, 21 | // // "strict": true, 22 | // "moduleResolution": "node", 23 | // "esModuleInterop": true, 24 | // "skipLibCheck": true 25 | // }, 26 | // "exclude": ["node_modules"], 27 | // "include": ["src/**/*", "index.d.ts"] 28 | // } 29 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | export type ServerError = { 2 | log: string; 3 | status?: number; 4 | message: { err: string }; 5 | }; 6 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | const path = require('path'); 5 | 6 | //this export included as a potential compiler for the TS server 7 | //can compile front end & backend as separate bundles, then pack into docker image 8 | //currently, TS server is dockerized as TS, then TS-node transpiles to JS on docker run 9 | module.exports = { 10 | // mode: process.env.NODE_ENV || 'production', 11 | entry: './src/client/index.js', 12 | devtool: 'inline-source-map', 13 | output: { 14 | path: path.join(__dirname, '/build'), 15 | publicPath: '/', 16 | filename: 'bundle.js', 17 | clean: true, 18 | }, 19 | plugins: [ 20 | new HtmlWebpackPlugin({ 21 | template: './src/client/index.html', 22 | filename: './index.html', 23 | favicon: path.resolve( 24 | __dirname, 25 | './src/client/public/assets/favicon.ico' 26 | ), 27 | logo: path.resolve(__dirname, './src/client/public/assets/logo.png'), 28 | }), 29 | ], 30 | devServer: { 31 | static: { 32 | directory: path.join(__dirname, '/src/client'), 33 | }, 34 | proxy: { 35 | '/': 'http://localhost:3535/', 36 | secure: false, 37 | }, 38 | compress: true, 39 | host: process.env.PROXY_HOST, 40 | port: 7070, 41 | //enable HMR on the devServer 42 | hot: true, 43 | // fallback to root for other urls 44 | historyApiFallback: true, 45 | }, 46 | module: { 47 | rules: [ 48 | { 49 | test: /\.jsx?/, 50 | exclude: /node_modules/, 51 | use: { 52 | loader: 'babel-loader', 53 | options: { 54 | presets: ['@babel/preset-react', '@babel/preset-env'], 55 | }, 56 | }, 57 | }, 58 | { 59 | test: /\.tsx?$/, 60 | exclude: /node_modules/, 61 | use: ['ts-loader'], 62 | }, 63 | { 64 | test: /\.js$/, 65 | enforce: 'pre', 66 | use: ['source-map-loader'], 67 | }, 68 | { 69 | test: /\.s[ac]ss$/i, 70 | use: ['style-loader', 'css-loader', 'sass-loader'], 71 | exclude: [/node_modules/], 72 | }, 73 | { 74 | test: /\.(png)$/i, 75 | loader: 'file-loader', 76 | options: { 77 | name: '/public/assets/logo.png', 78 | }, 79 | }, 80 | { 81 | test: /\.module\.css$/, 82 | use: [ 83 | 'style-loader', 84 | { 85 | loader: 'css-loader', 86 | options: { 87 | modules: true, 88 | }, 89 | }, 90 | { 91 | loader: 'postcss-loader', 92 | options: { 93 | postcssOptions: { 94 | plugins: () => [require('autoprefixer')], 95 | }, 96 | }, 97 | }, 98 | ], 99 | }, 100 | ], 101 | }, 102 | resolve: { 103 | extensions: ['.jsx', '.js', '.ts', '.tsx'], 104 | }, 105 | }; 106 | -------------------------------------------------------------------------------- /webpack_back.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | const path = require('path'); 4 | 5 | module.exports = { 6 | target: 'node', 7 | // mode: process.env.NODE_ENV, //default is production 8 | entry: './src/server/server.ts', 9 | output: { 10 | path: path.join(__dirname, '/src/server'), 11 | filename: 'server.js', 12 | clean: true, 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.tsx?$/, 18 | exclude: /node_modules/, 19 | use: ['ts-loader'], 20 | }, 21 | ], 22 | }, 23 | resolve: { 24 | extensions: ['.jsx', '.js', '.ts', '.tsx'], 25 | }, 26 | }; 27 | --------------------------------------------------------------------------------