├── .dockerignore ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── release.yml └── workflows │ ├── codeql-analysis.yml │ ├── deploy-docs.yml │ ├── deploy-production.yml │ ├── deploy-testing.yml │ ├── electron-test-build.yml │ └── eslint.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── README.md ├── balena.yml ├── builder ├── README.md ├── afterSignHook.js ├── package.json └── yarn.lock ├── config_default.yml ├── docker-compose.yml ├── docs ├── 00-Introduction.md ├── 01-Quick Start.md ├── 02-Developing Locally.md ├── 03-Continuous Integration.md ├── Built-in components │ ├── Backend Endpoints.md │ ├── Cache Timeouts and Queuing.md │ ├── CloudLink Check.md │ ├── File Manager.md │ ├── I18n.md │ ├── Supervisor and SDK.md │ └── System Info.md ├── Customising the Environment │ ├── Architecture.md │ ├── Customising Page Content.md │ ├── Device Configuration.md │ └── Styles.md ├── Useful Extensions.md ├── docusaurus-config.yml └── static │ ├── .keep │ └── favicon.ico ├── expressjs ├── .eslintignore ├── .eslintrc.js ├── .prettierrc ├── package.json ├── src │ ├── boot │ │ ├── index.ts │ │ └── setHostname.ts │ ├── common │ │ ├── axios.ts │ │ └── logger.ts │ ├── index.ts │ ├── middleware │ │ └── queueCache.ts │ ├── routes │ │ └── v1 │ │ │ ├── BalenaSDK.ts │ │ │ ├── CaptivePortal.ts │ │ │ ├── Examples.ts │ │ │ ├── FileManager.ts │ │ │ ├── Supervisor.ts │ │ │ ├── System.ts │ │ │ ├── Tests.ts │ │ │ └── Wifi.ts │ └── usb │ │ ├── entrypoint.sh │ │ ├── scripts │ │ ├── mount.sh │ │ └── unmount.sh │ │ └── udev │ │ └── usb.rules └── tsconfig.json ├── package.json ├── scripts ├── .env_vars └── start.sh ├── ui ├── .editorconfig ├── .eslintignore ├── .eslintrc-treeshake.js ├── .eslintrc.js ├── .postcss.js ├── .prettierrc ├── index.html ├── package.json ├── public │ ├── favicon.ico │ ├── icons │ │ ├── apple-icon-120x120.png │ │ ├── apple-icon-152x152.png │ │ ├── apple-icon-167x167.png │ │ ├── apple-icon-180x180.png │ │ ├── apple-launch-1125x2436.png │ │ ├── apple-launch-1170x2532.png │ │ ├── apple-launch-1242x2208.png │ │ ├── apple-launch-1242x2688.png │ │ ├── apple-launch-1284x2778.png │ │ ├── apple-launch-1536x2048.png │ │ ├── apple-launch-1620x2160.png │ │ ├── apple-launch-1668x2224.png │ │ ├── apple-launch-1668x2388.png │ │ ├── apple-launch-2048x2732.png │ │ ├── apple-launch-750x1334.png │ │ ├── apple-launch-828x1792.png │ │ ├── favicon-128x128.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ ├── icon-128x128.png │ │ ├── icon-192x192.png │ │ ├── icon-256x256.png │ │ ├── icon-384x384.png │ │ ├── icon-512x512.png │ │ ├── ms-icon-144x144.png │ │ └── safari-pinned-tab.svg │ ├── logo_colour.svg │ └── logo_white.svg ├── quasar.conf.js ├── src-electron │ ├── electron-env.d.ts │ ├── electron-flag.d.ts │ ├── electron-main.ts │ ├── electron-preload.ts │ └── icons │ │ ├── icon.icns │ │ ├── icon.ico │ │ └── icon.png ├── src-pwa │ ├── .eslintrc.js │ ├── custom-service-worker.ts │ ├── manifest.json │ ├── pwa-env.d.ts │ ├── pwa-flag.d.ts │ ├── register-service-worker.ts │ └── tsconfig.json ├── src │ ├── App.vue │ ├── api │ │ ├── sdk.ts │ │ ├── supervisor.ts │ │ └── sysInfoCmds.ts │ ├── boot │ │ ├── axios.ts │ │ ├── i18n.ts │ │ ├── pinia.ts │ │ └── ymlImport.ts │ ├── components │ │ ├── ChartsCpuStats.vue │ │ ├── ChartsMemoryStats.vue │ │ ├── DeviceTabSelector.vue │ │ ├── MainLayoutMenuItems.vue │ │ ├── SystemChangeHostname.vue │ │ ├── SystemDeviceInfo.vue │ │ ├── SystemEnvConfig.vue │ │ ├── SystemJournalDLogs.vue │ │ ├── SystemReboot.vue │ │ ├── SystemShutdown.vue │ │ ├── SystemUpdateDevice.vue │ │ ├── ToolsContainerManager.vue │ │ ├── ToolsFileManager.vue │ │ ├── ToolsSystemInfo.vue │ │ ├── WifiConfigurePassword.vue │ │ ├── WifiConfigureSSID.vue │ │ ├── WifiConnect.vue │ │ └── WifiForgetAllWifi.vue │ ├── config │ │ ├── localeOptions.ts │ │ ├── qStyles.ts │ │ └── sideDrawer.ts │ ├── css │ │ ├── app.scss │ │ ├── fonts │ │ │ ├── SourceSansPro-Regular.woff │ │ │ └── SourceSansPro-Regular.woff2 │ │ └── quasar.variables.scss │ ├── env.d.ts │ ├── i18n │ │ ├── ar.json │ │ ├── ca.json │ │ ├── da.json │ │ ├── de.json │ │ ├── en-US.json │ │ ├── es.json │ │ ├── fr.json │ │ ├── it.json │ │ ├── nb-NO.json │ │ ├── pt-BR.json │ │ ├── ru.json │ │ └── tr.json │ ├── layouts │ │ ├── CaptivePortal.vue │ │ ├── ComponentFrame.vue │ │ ├── ElectronLayout.vue │ │ ├── ErrorNotFound.vue │ │ ├── MainLayout.vue │ │ ├── PwaLayout.vue │ │ └── YmlImport.vue │ ├── pages │ │ ├── Configuration.vue │ │ ├── ContainerManager.vue │ │ ├── FileManager.vue │ │ ├── IndexPage.vue │ │ ├── Networking.vue │ │ └── SystemInfo.vue │ ├── quasar.d.ts │ ├── router │ │ ├── index.ts │ │ └── routes.ts │ ├── shims-vue.d.ts │ └── stores │ │ ├── index.ts │ │ ├── store-flag.d.ts │ │ └── system.ts └── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | # Balena reads .dockerignore from the root directory, not 2 | # the context directory like Docker does. It also requires 3 | # the '**/' prefix to ignore throughout all directories (including 4 | # root) not just the filename like seen in .gitignore files. 5 | # Many of these ignores relate specifically to Quasar Framework. 6 | 7 | # Defaults 8 | **/.thumbs.db 9 | **/.DS_Store 10 | **/node_modules 11 | **/.quasar 12 | **/.gitkeep 13 | **/.gitignore 14 | .git 15 | .github 16 | **/dist 17 | 18 | # Yarn 19 | **/.pnp.* 20 | **/.yarn/* 21 | !**/.yarn/patches 22 | !**/.yarn/plugins 23 | !**/.yarn/releases 24 | !**/.yarn/sdks 25 | !**/.yarn/versions 26 | 27 | # Cordova related directories and files 28 | **/src-cordova/node_modules 29 | **/src-cordova/platforms 30 | **/src-cordova/plugins 31 | **/src-cordova/www 32 | 33 | # Capacitor related directories and files 34 | **/src-capacitor/www 35 | **/src-capacitor/node_modules 36 | 37 | # BEX related directories and files 38 | **/src-bex/www 39 | **/src-bex/js/core 40 | 41 | # Log files 42 | **/npm-debug.log* 43 | **/yarn-debug.log* 44 | **/yarn-error.log* 45 | 46 | # Editor directories and files 47 | **/.vscode 48 | **/.idea 49 | **/*.suo 50 | **/*.ntvs* 51 | **/*.njsproj 52 | **/*.sln 53 | **/*.sw? 54 | 55 | # Development components 56 | labs-docs-builder 57 | docs 58 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create an issue to inform of a bug 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Device Type:** 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | authors: 6 | - octocat 7 | - weblate 8 | categories: 9 | - title: Breaking Changes 🛠 10 | labels: 11 | - Semver-Major 12 | - breaking-change 13 | - title: Exciting New Features 🎉 14 | labels: 15 | - Semver-Minor 16 | - enhancement 17 | - title: Documentation 18 | labels: 19 | - Documentation 20 | - docs 21 | - title: Other Changes 22 | labels: 23 | - "*" 24 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [main] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [main] 20 | schedule: 21 | - cron: "26 22 * * 5" 22 | 23 | permissions: 24 | actions: read 25 | contents: read 26 | security-events: write 27 | 28 | jobs: 29 | codeql-analyze: 30 | name: Analyze 31 | runs-on: ubuntu-latest 32 | 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | language: ["javascript"] 37 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 38 | # Learn more: 39 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v3 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v2 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 54 | 55 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 56 | # If this step fails, then you should remove it and run the build manually (see below) 57 | - name: Autobuild 58 | uses: github/codeql-action/autobuild@v2 59 | 60 | # ℹ️ Command-line programs to run using the OS shell. 61 | # 📚 https://git.io/JvXDl 62 | 63 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 64 | # and modify them (or add more) to build your code if your project 65 | # uses a compiled language 66 | 67 | #- run: | 68 | # make bootstrap 69 | # make release 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: "Deploy Docs" 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*.*.*" 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | name: "Deploy-Docs" 13 | runs-on: "ubuntu-latest" 14 | steps: 15 | - name: Checkout main branch 16 | uses: actions/checkout@v3 17 | 18 | # Used to deploy the ./docs folder to GitHub pages. See https://github.com/balena-io-experimental/labs-docs-builder for more 19 | # details on its use. 20 | - name: Build and deploy the docs 21 | uses: "balena-io-experimental/labs-docs-builder@main" 22 | with: 23 | git_pass: ${{ secrets.github_token }} 24 | -------------------------------------------------------------------------------- /.github/workflows/deploy-testing.yml: -------------------------------------------------------------------------------- 1 | # Deploy pull requests to the test fleet automatically 2 | name: "Deploy to development devices" 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - "docs/**" 9 | 10 | env: 11 | PR_NUMBER: ${{ github.event.number }} 12 | 13 | jobs: 14 | build: 15 | timeout-minutes: 60 16 | strategy: 17 | fail-fast: false 18 | # Fleet types to deploy to based on their architecture 19 | matrix: 20 | job_name: ["fin", "generic", "rpi"] 21 | 22 | include: 23 | - job_name: fin 24 | fleet: bdi/bdi-fin 25 | 26 | - job_name: generic 27 | fleet: bdi/bdi-generic 28 | 29 | - job_name: rpi 30 | fleet: bdi/bdi-rpi 31 | 32 | name: ${{ matrix.job_name }} 33 | 34 | runs-on: ubuntu-20.04 35 | 36 | steps: 37 | - uses: actions/checkout@v3 38 | 39 | # Used to deploy to balena. See https://github.com/balena-labs-research/community-cli-action for more 40 | # details. 41 | - name: Balena CLI Action 42 | uses: balena-labs-research/community-cli-action@1.0.0 43 | with: 44 | balena_token: ${{secrets.BALENA_TOKEN}} 45 | balena_cli_commands: > 46 | push ${{ matrix.fleet }} --release-tag PR "${{ env.PR_NUMBER }}" commit-sha "${{ github.event.pull_request.head.sha }}"; 47 | balena_cli_version: 14.5.15 48 | -------------------------------------------------------------------------------- /.github/workflows/electron-test-build.yml: -------------------------------------------------------------------------------- 1 | # GitHub Workflow to manually build the Electron test app. The built assets are not notarised as they are not for distribution 2 | # and they are stored in the workflow as GitHub assets rather than published. 3 | 4 | name: "Build Test Electron App" 5 | 6 | on: 7 | pull_request: 8 | paths-ignore: 9 | - "docs/**" 10 | 11 | env: 12 | ELECTRON_SKIP_NOTARIZATION: true 13 | 14 | jobs: 15 | build-electron-app: 16 | timeout-minutes: 90 17 | env: 18 | ELECTRON_OUTPUT_PATH: "ui/dist/electron/Packaged" 19 | # Specify that this is being built for use locally and not on a device. 20 | ON_DEVICE: false 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | job_name: ["linux", "mac_amd64", mac_arm64, "windows"] 25 | 26 | include: 27 | - job_name: linux 28 | os: ubuntu-latest 29 | 30 | - job_name: mac_amd64 31 | os: macos-latest 32 | apple_signing: true 33 | 34 | - job_name: mac_arm64 35 | os: macos-latest 36 | build_arch: --arch arm64 37 | apple_signing: true 38 | 39 | - job_name: windows 40 | os: windows-latest 41 | 42 | name: ${{ matrix.job_name }} 43 | 44 | runs-on: ${{ matrix.os }} 45 | 46 | steps: 47 | - uses: actions/checkout@v3 48 | 49 | - uses: actions/setup-node@v3 50 | with: 51 | node-version: "18.12.1" 52 | cache: "yarn" 53 | 54 | - name: Install UI packages 55 | run: | 56 | yarn install --frozen-lockfile 57 | 58 | - name: Install Electron Builder packages 59 | run: | 60 | yarn install --frozen-lockfile --cwd builder/ 61 | 62 | - name: Build Electron app 63 | run: yarn build-electron ${{ matrix.build_arch }} 64 | 65 | - name: Upload artifacts to GitHub temporary storage 66 | uses: actions/upload-artifact@v3 67 | with: 68 | name: ${{ matrix.job_name }} 69 | path: | 70 | ${{ env.ELECTRON_OUTPUT_PATH }}/*.exe 71 | ${{ env.ELECTRON_OUTPUT_PATH }}/*.dmg 72 | ${{ env.ELECTRON_OUTPUT_PATH }}/*.AppImage 73 | -------------------------------------------------------------------------------- /.github/workflows/eslint.yml: -------------------------------------------------------------------------------- 1 | name: ESLint 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | eslint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-node@v3 15 | with: 16 | node-version: "18.12.1" 17 | - run: yarn install --frozen-lockfile 18 | - run: yarn lint 19 | - run: yarn formatcheck 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # General 2 | .DS_Store 3 | .thumbs.db 4 | node_modules 5 | .vscode 6 | dist 7 | 8 | # Yarn 9 | **/.pnp.* 10 | **/.yarn/* 11 | !**/.yarn/patches 12 | !**/.yarn/plugins 13 | !**/.yarn/releases 14 | !**/.yarn/sdks 15 | !**/.yarn/versions 16 | 17 | # Quasar core related directories 18 | .quasar 19 | ui/dist 20 | 21 | # Cordova related directories and files 22 | /src-cordova/node_modules 23 | /src-cordova/platforms 24 | /src-cordova/plugins 25 | /src-cordova/www 26 | 27 | # Capacitor related directories and files 28 | /src-capacitor/www 29 | /src-capacitor/node_modules 30 | 31 | # BEX related directories and files 32 | /src-bex/www 33 | /src-bex/js/core 34 | 35 | # Log files 36 | npm-debug.log* 37 | yarn-debug.log* 38 | yarn-error.log* 39 | 40 | # Editor directories and files 41 | .idea 42 | *.suo 43 | *.ntvs* 44 | *.njsproj 45 | *.sln 46 | 47 | # Development components 48 | labs-docs-builder 49 | expressjs/src/routes/v1/storage 50 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | http://forums.balena.io. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ## Build ExpressJS backend and UI frontend 2 | FROM node:18.12.1-alpine3.17 AS build 3 | 4 | WORKDIR /build-context 5 | 6 | # Copy required files for installs 7 | COPY package.json . 8 | COPY yarn.lock . 9 | COPY expressjs/package.json expressjs/package.json 10 | COPY ui/package.json ui/package.json 11 | 12 | # Install packages 13 | RUN yarn install --frozen-lockfile --network-timeout 600000 14 | 15 | # Copy source files to container 16 | COPY expressjs expressjs 17 | COPY ui ui 18 | 19 | # Run lint to ensure build fails if there are code issues 20 | RUN yarn lint 21 | 22 | # Copy the yml config file for building 23 | COPY config*.yml . 24 | 25 | # Build ExpressJS and UI. 26 | RUN yarn build 27 | 28 | # UI build is done, so we now reduce the node_modules folder down 29 | # to the essentials required for ExpressJS. 30 | # Requires moving package.json due to a yarn bug: https://github.com/yarnpkg/yarn/issues/6715 31 | RUN mv expressjs/package.json ./ 32 | RUN yarn install --frozen-lockfile --production --network-timeout 600000 33 | 34 | 35 | ## Primary container 36 | FROM node:18.12.1-alpine3.17 37 | 38 | # Install USB mount requirements 39 | RUN apk add --no-cache \ 40 | findmnt \ 41 | grep \ 42 | udev \ 43 | util-linux 44 | 45 | # Specify that this is for production 46 | ENV NODE_ENV=production 47 | 48 | WORKDIR /app 49 | 50 | # Enable auto mounting of USB drives when they are plugged in 51 | COPY expressjs/src/usb/udev/usb.rules /etc/udev/rules.d/usb.rules 52 | COPY expressjs/src/usb/scripts /usr/src/scripts 53 | COPY expressjs/src/usb/entrypoint.sh . 54 | RUN chmod +x entrypoint.sh 55 | RUN chmod +x /usr/src/scripts/* 56 | 57 | # Copy app to container 58 | COPY --from=build /build-context/ui/dist/spa public 59 | COPY --from=build /build-context/expressjs/dist . 60 | COPY --from=build /build-context/node_modules node_modules 61 | 62 | # Copy startup scripts 63 | COPY scripts . 64 | 65 | # Use entrypoint to setup UDEV required for USB support 66 | ENTRYPOINT ["./entrypoint.sh"] 67 | 68 | # Run the start script 69 | CMD ["sh", "start.sh"] 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Balena Labs Research 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 | # Balena Starter Interface 2 | 3 | wifi 4 | 5 | A community-built device interface for using balena devices. It can be used to interact with your device, or as a starter project to create your own interface. 6 | 7 | See the _Quick Start_ guide in the [documentation](https://balena-labs-research.github.io/starter-interface) for details on how to quickly add this project as an interface to your existing projects, or the [_Developing Locally_](https://balena-labs-research.github.io/starter-interface) guide on how to develop your own interface. 8 | 9 | Core features include: 10 | 11 | - Ability to customise the visible components and add directly to your existing projects 12 | - Online and offline compatibility 13 | - Customisable Electron and Progressive Web applications for interaction with your devices 14 | - Pre-built endpoints for interacting with the Balena SDK or Supervisor 15 | - Connect your device to nearby Wi-Fi networks 16 | - Container manager (list, stop, start, restart) 17 | - File manager (create folders, upload, delete, etc...) 18 | - Set, edit and remove environment variables on the device 19 | - Configure the device hostname 20 | - Configure the device SSID and password 21 | - Captive portal 22 | - Automatic mounting of USB devices into the File Manager 23 | - System info and stats 24 | - I18n language translations 25 | 26 | _Electron App:_ 27 | 28 | electron 29 | 30 | # Documentation 31 | 32 | Configuration instructions and use of the UI can be found in the [documentation](https://balena-labs-research.github.io/starter-interface). 33 | 34 | # See for yourself 35 | 36 | See the components for yourself with a one-click install: 37 | 38 | [![balena deploy button](https://balena.io/deploy.svg)](https://hub.balena.io/organizations/bdi/apps/starter-interface) 39 | -------------------------------------------------------------------------------- /balena.yml: -------------------------------------------------------------------------------- 1 | name: Balena Starter Interface 2 | description: >- 3 | A UI for interacting with Balena devices and a starter project for creating your own UI. 4 | joinable: false 5 | type: sw.application 6 | assets: 7 | repository: 8 | type: blob.asset 9 | data: 10 | url: "https://github.com/balena-labs-research/starter-interface" 11 | logo: 12 | type: blob.asset 13 | data: 14 | url: "https://github.com/balena-labs-research/apps-logo/raw/main/logo.png" 15 | data: 16 | defaultDeviceType: raspberrypi4-64 17 | supportedDeviceTypes: 18 | - raspberry-pi 19 | - raspberry-pi2 20 | - raspberrypi3 21 | - raspberrypi3-64 22 | - raspberrypi4-64 23 | - intel-nuc 24 | - genericx86-64-ext 25 | version: 0.0.1 26 | -------------------------------------------------------------------------------- /builder/README.md: -------------------------------------------------------------------------------- 1 | # Build processes 2 | 3 | Build scripts for the GitHub workflows 4 | -------------------------------------------------------------------------------- /builder/afterSignHook.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { notarize } = require("@electron/notarize"); 4 | const { 5 | ELECTRON_SKIP_NOTARIZATION, 6 | XCODE_APP_LOADER_EMAIL, 7 | XCODE_APP_LOADER_PASSWORD, 8 | } = process.env; 9 | 10 | async function main(context) { 11 | const { electronPlatformName, appOutDir } = context; 12 | 13 | if ( 14 | electronPlatformName !== "darwin" || 15 | ELECTRON_SKIP_NOTARIZATION === "true" || 16 | !XCODE_APP_LOADER_EMAIL || 17 | !XCODE_APP_LOADER_PASSWORD 18 | ) { 19 | console.log("Skipping Apple notarization."); 20 | return; 21 | } 22 | 23 | console.log("Starting Apple notarization."); 24 | 25 | const appName = context.packager.appInfo.productFilename; 26 | 27 | await notarize({ 28 | appBundleId: "io.balena.starterinterface", 29 | appPath: `${appOutDir}/${appName}.app`, 30 | appleId: XCODE_APP_LOADER_EMAIL, 31 | appleIdPassword: XCODE_APP_LOADER_PASSWORD, 32 | }); 33 | } 34 | 35 | exports.default = main; 36 | -------------------------------------------------------------------------------- /builder/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "builder", 3 | "devDependencies": { 4 | "@electron/notarize": "^1.2.3" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /builder/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@electron/notarize@^1.2.3": 6 | version "1.2.3" 7 | resolved "https://registry.yarnpkg.com/@electron/notarize/-/notarize-1.2.3.tgz#38056a629e5a0b5fd56c975c4828c0f74285b644" 8 | integrity sha512-9oRzT56rKh5bspk3KpAVF8lPKHYQrBnRwcgiOeR0hdilVEQmszDaAu0IPCPrwwzJN0ugNs0rRboTreHMt/6mBQ== 9 | dependencies: 10 | debug "^4.1.1" 11 | fs-extra "^9.0.1" 12 | 13 | at-least-node@^1.0.0: 14 | version "1.0.0" 15 | resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" 16 | integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== 17 | 18 | debug@^4.1.1: 19 | version "4.3.4" 20 | resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" 21 | integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== 22 | dependencies: 23 | ms "2.1.2" 24 | 25 | fs-extra@^9.0.1: 26 | version "9.1.0" 27 | resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" 28 | integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== 29 | dependencies: 30 | at-least-node "^1.0.0" 31 | graceful-fs "^4.2.0" 32 | jsonfile "^6.0.1" 33 | universalify "^2.0.0" 34 | 35 | graceful-fs@^4.1.6, graceful-fs@^4.2.0: 36 | version "4.2.10" 37 | resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" 38 | integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== 39 | 40 | jsonfile@^6.0.1: 41 | version "6.1.0" 42 | resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" 43 | integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== 44 | dependencies: 45 | universalify "^2.0.0" 46 | optionalDependencies: 47 | graceful-fs "^4.1.6" 48 | 49 | ms@2.1.2: 50 | version "2.1.2" 51 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" 52 | integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== 53 | 54 | universalify@^2.0.0: 55 | version "2.0.0" 56 | resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" 57 | integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== 58 | -------------------------------------------------------------------------------- /config_default.yml: -------------------------------------------------------------------------------- 1 | # This is the default configuration file. It is not meant to be modified. If you would like to use your own configuration, clone 2 | # this file in to a `config.yml` file in the root of the project, which will override these defaults. 3 | ######## 4 | pages: 5 | ##### 6 | # A list of pages from ui/src/pages to load and the ui/src/components to show on each one. 7 | # 8 | # Example that creates an Environment Variable configuration interface: 9 | # 10 | # ---------------------------------------------------------------------------------------------------- 11 | # IndexPage: 12 | # frames: 13 | # 1: 14 | # components: [SystemEnvConfig] 15 | # icon: settings # The label from Google Material Icons to display in the sidebar: https://fonts.google.com/icons 16 | # label: yml_config.environment_variables # The i18n key from ui/src/i18n/en-US.json to use as sidebar text. Only use `yml_config` keys! 17 | # ---------------------------------------------------------------------------------------------------- 18 | # 19 | # All the other sections are removed so you only see the environment variable configuration 20 | # 21 | ##### 22 | IndexPage: # This page cannot be removed 23 | frames: 24 | 1: 25 | components: [SystemDeviceInfo] 26 | # Path will always be '/' 27 | icon: visibility 28 | label: yml_config.device_info 29 | FileManager: 30 | frames: 31 | 1: 32 | components: [ToolsFileManager] 33 | path: filemanager 34 | icon: folder 35 | label: yml_config.file_manager 36 | Configuration: 37 | frames: 38 | 1: 39 | components: [SystemChangeHostname] 40 | title: yml_config.network_config 41 | 2: 42 | components: [SystemEnvConfig] 43 | title: yml_config.environment_variables 44 | path: configuration 45 | icon: settings 46 | label: yml_config.configuration 47 | ContainerManager: 48 | frames: 49 | 1: 50 | components: [ToolsContainerManager] 51 | path: containermanager 52 | icon: all_inbox 53 | label: yml_config.container_manager 54 | Networking: 55 | frames: 56 | 1: 57 | components: [WifiConnect] 58 | path: networking 59 | icon: router 60 | label: yml_config.networking 61 | SystemInfo: 62 | frames: 63 | 1: 64 | components: [SystemJournalDLogs, ToolsSystemInfo] 65 | path: systeminfo 66 | icon: info 67 | label: yml_config.system_info 68 | captive_portal: 69 | welcome_page: true 70 | styles: 71 | header: 72 | visible: true # Whether the header logo should be visible 73 | language_selector: true # Show the language selector in the header bar 74 | reboot_icon: true # Show the reboot icon in the header bar 75 | shutdown_icon: true # Show the shutdown icon in the header bar 76 | title: # Override the default header 77 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | services: 4 | balena-starter-interface: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | environment: 9 | NETWORK_MODE: bridge # Tell the app if running in `bridge` or `host` network mode 10 | SET_HOSTNAME: "balena" # Optional. Changes the device hostname. UI will become accesible on `balena.local`. 11 | UDEV: on # Enables ability to auto mount USB drives in to the container 12 | restart: always 13 | ports: 14 | - "80:80" 15 | volumes: 16 | - "bdi_db:/app/db" # Stores UI database files 17 | - "bdi_storage:/app/storage" # Storage for the File Manager. See docs for more info. 18 | privileged: true # This can be removed if not using the USB mounting feature 19 | 20 | # If using the File Manager to access volumes on other containers, ensure this container `depends_on` your 21 | # other container using the below: 22 | # depends_on: 23 | # - "your-other-container-name" 24 | 25 | labels: 26 | io.balena.features.supervisor-api: 1 27 | io.balena.features.balena-api: 1 28 | 29 | # Optional Wi-Fi controller: 30 | # https://github.com/balena-labs-research/python-wifi-connect 31 | python-wifi-connect: 32 | image: ghcr.io/balena-labs-research/python-wifi-connect:latest 33 | environment: 34 | # Listening IP and port 35 | PWC_HOST: bridge # `bridge` specifies that this is on the bridge network and to use container name 36 | PWC_PORT: 9090 37 | 38 | # Hotspot details 39 | PWC_HOTSPOT_SSID: "Balena Starter Interface" # Name as it appears in list of WiFi networks 40 | PWC_HOTSPOT_PASSWORD: "balena01" # Optional. Must be 8 characters or more 41 | 42 | # Required system variables 43 | DBUS_SYSTEM_BUS_ADDRESS: "unix:path=/host/run/dbus/system_bus_socket" 44 | network_mode: "host" 45 | restart: on-failure 46 | volumes: 47 | - "pwc_db:/app/db" # Optional if not setting the hotspot ssid and password via the API 48 | labels: 49 | io.balena.features.dbus: "1" 50 | cap_add: 51 | - NET_ADMIN 52 | privileged: true # This can be removed if you do not need the LED connectivity indicator. 53 | 54 | volumes: 55 | bdi_db: 56 | bdi_storage: 57 | pwc_db: 58 | -------------------------------------------------------------------------------- /docs/00-Introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | slug: / 3 | breadcrumbs: true 4 | title: Introduction 5 | --- 6 | 7 | # Balena Starter Interface 8 | 9 | wifi 10 | 11 | A community-built device interface for using balena devices. It can be used to interact with your device, or as a starter project to create your own interface. 12 | 13 | See the _Quick Start_ guide in the sidebar for details on how to add this project as an interface to your existing projects, or the _Developing Locally_ guide on how to develop your own interface. 14 | 15 | Core features include: 16 | 17 | - Ability to customise the visible components and add directly to your existing projects 18 | - Online and offline compatibility 19 | - Customisable Electron and Progressive Web applications for interaction with your devices 20 | - Pre-built endpoints for interacting with the Balena SDK or Supervisor 21 | - Connect your device to nearby Wi-Fi networks 22 | - Container manager (list, stop, start, restart) 23 | - File manager (create folders, upload, delete, etc...) 24 | - Set, edit and remove environment variables on the device 25 | - Configure the device hostname 26 | - Configure the device SSID and password 27 | - Captive portal 28 | - Automatic mounting of USB devices into the File Manager 29 | - System info and stats 30 | - I18n language translations 31 | 32 | _Electron App:_ 33 | 34 | electron 35 | -------------------------------------------------------------------------------- /docs/01-Quick Start.md: -------------------------------------------------------------------------------- 1 | You can try the project with a one-click install from the balena Hub: 2 | 3 | [![balena deploy button](https://balena.io/deploy.svg)](https://hub.balena.io/organizations/bdi/apps/starter-interface) 4 | 5 | Alternatively, get stuck straight in by adding the interface to your existing projects using the guide below. You can configure the components you would like to appear and deploy the prebuilt image alongside your application. 6 | 7 | ## Add to your existing project 8 | 9 | ### 1. Specify your preferred configuration 10 | 11 | The interface is configured by a `config_default.yml` file stored at the root of the project. This configuration file contains all of the components available. You can override this file by adding a `config.yml` file at the root of the project at build time. 12 | 13 | You can also use the prebuilt image, and pass them your own `config.yml` to create your own custom interface: 14 | 15 | 1. Start by cloning the project, and starting the development environment with `yarn dev`. Although there will be some errors displayed because there is no balena Supervisor available you can see the general layout of the interface. 16 | 2. Now create a `config.yml` file at the root of the repository based on the `config_default.yml` template. Edit the file according to the components you would like to appear. You can see guidance on how to do this at the top of the config file. 17 | 3. Stop your development environment and then start it again as before with `yarn dev` and you will see the interface has altered to match your new configuration. 18 | 4. At this stage you can build the project with `yarn build` and then deploy it to your device, or you can use your new config file and apply it to the prebuilt images by following the instructions in the section below. 19 | 20 | ### 2. Add the configuration to your build 21 | 22 | The following `Dockerfile` and `docker-compose.yml` demonstrates how: 23 | 24 | _Dockerfile.bsi:_ 25 | 26 | ```bash 27 | # Dockerfile.bsi 28 | FROM ghcr.io/balena-labs-research/bsi:0.0.17 29 | 30 | COPY config.yml . 31 | 32 | # Optionally copy in your own header logo to replace the default. 33 | COPY logo_colour.svg public/ # Your logo for displaying on white backgrounds 34 | COPY logo_white.svg public/ # Your logo for displaying on coloured background 35 | ``` 36 | 37 | _docker-compose.yml:_ 38 | 39 | ```yml 40 | # docker-compose.yml 41 | version: "2" 42 | 43 | services: 44 | balena-starter-interface: 45 | build: 46 | context: . 47 | dockerfile: Dockerfile.bsi 48 | environment: 49 | SET_HOSTNAME: "balena" # Optional. Changes the device hostname. UI will become accesible on `balena.local`. 50 | UDEV: on # Enables ability to auto mount USB drives in to the container 51 | restart: always 52 | ports: 53 | - "80:80" 54 | volumes: 55 | - "bdi_db:/app/db" # Stores UI database files 56 | - "bdi_storage:/app/storage" # Storage for the File Manager. See docs for more info. 57 | privileged: true # This can be removed if not using the USB mounting feature 58 | labels: 59 | io.balena.features.supervisor-api: 1 60 | io.balena.features.balena-api: 1 61 | 62 | your-original-service: 63 | image: ... 64 | ... 65 | 66 | volumes: 67 | bdi_db: 68 | bdi_storage: 69 | ``` 70 | -------------------------------------------------------------------------------- /docs/02-Developing Locally.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | ## Requisites 6 | 7 | - Node 8 | - Yarn Classic 9 | 10 | ## Install the dependencies 11 | 12 | ```bash 13 | yarn 14 | ``` 15 | 16 | ### Development modes 17 | 18 | #### SPA (Single Page Application) 19 | 20 | `yarn dev` will start the front and backend in development mode with hot reload. The page will open and show the basic layout, although there will be some issues on the page as there is no balena Supervisor available to connect to. This however, is useful for layouts and visual alternations. 21 | 22 | ```bash 23 | yarn dev 24 | ``` 25 | 26 | `yarn dev-remote` allows passing an environment variable to use for the backend. This is useful when you want to do frontend development. You can point to a device on your network that has the Starter Interface deployed, and use the backend on that device to replicate the full backend experience in your hot reload frontend running locally. 27 | 28 | ```bash 29 | DEVICE_HOSTNAME=100.121.162.79 yarn dev-remote 30 | ``` 31 | 32 | #### Electron 33 | 34 | ```bash 35 | yarn dev-electron 36 | ``` 37 | 38 | #### PWA (Progressive Web App) 39 | 40 | ```bash 41 | yarn dev-pwa 42 | ``` 43 | 44 | ### Build the app for production 45 | 46 | #### SPA (Single Page Application) 47 | 48 | ```bash 49 | yarn build 50 | ``` 51 | 52 | #### Electron 53 | 54 | ```bash 55 | yarn build-electron 56 | ``` 57 | 58 | #### PWA (Progressive Web App) 59 | 60 | ```bash 61 | yarn build-pwa 62 | ``` 63 | 64 | ### Linting and formatting 65 | 66 | #### Lint the code 67 | 68 | ```bash 69 | yarn lint 70 | ``` 71 | 72 | #### User [Prettier](https://prettier.io) to format the code 73 | 74 | ```bash 75 | yarn format 76 | ``` 77 | -------------------------------------------------------------------------------- /docs/03-Continuous Integration.md: -------------------------------------------------------------------------------- 1 | ## GitHub Workflows 2 | 3 | GitHub workflow files can be found in `.github/workflows` and are triggered automatically based on their configuration. 4 | 5 | Secrets are required in your GitHub repository for the workflows. 6 | 7 | ### Required GitHub Secrets 8 | 9 | #### Balena secrets: 10 | 11 | A balenaCloud API token with access to the application. Refer to the docs [here](https://github.com/balena-community/community-cli-action) for more info. 12 | 13 | `BALENA_TOKEN` 14 | 15 | #### Apple and Windows signing secrets: 16 | 17 | More information on how to generate these can be found [here](https://gist.github.com/maggie0002/a689fc01737f6a5fd72868f0f07e3d3e). 18 | 19 | _Apple:_ 20 | 21 | ``` 22 | BUILD_CERTIFICATE_BASE64 23 | BUILD_PROVISION_PROFILE_BASE64 24 | KEYCHAIN_PASSWORD 25 | P12_PASSWORD 26 | XCODE_APP_LOADER_EMAIL 27 | XCODE_APP_LOADER_PASSWORD 28 | ``` 29 | 30 | _Windows:_ 31 | 32 | ``` 33 | CSC_LINK 34 | CSC_KEY_PASSWORD 35 | ``` 36 | 37 | ### Test Builds and Deploys 38 | 39 | `deploy-testing.yml` triggers when a pull request is raised against the branch `main`. It deploys the code to the fleets specified in the file. It is recommended to use the same fleet names (i.e. `bdi-rpi`) as code deployed to these fleet names for testing will automatically provide verbose logging for debugging. 40 | 41 | `electron-test-build.yml` builds the Electron apps on each pull request and attaches them as assets to the workflow action found in the `Actions` tab of the repository. 42 | 43 | They will not be signed, and the Mac ARM app will not work as unsigned apps for that platform will not open your system. It is kept to test the build process. To test the app on Mac ARM, you will need to build it locally. 44 | 45 | ### Production Builds and Deploys 46 | 47 | `deploy-production.yml` triggers when [a tag is pushed](https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/managing-commits/managing-tags) and performs the following functions: 48 | 49 | 1. Updates the balena Hub README to match the GitHub README. 50 | 2. Deploys the code to the production fleet specified in the file. 51 | 3. Preloads the specified balenaOS images with the application. 52 | 4. Builds and signs the Electron app for Linux, Windows, Mac AMD64 and ARM. 53 | 5. Signs the Mac and Windows apps with the Apple Developer and Microsoft Developer certificates. To override signing, you can pass the `ELECTRON_SKIP_NOTARIZATION=true` environment variable to the build. You can skip Electron app build by removing the section from the workflow file. 54 | 6. Deploys the app as Docker images to GitHub container registry. 55 | 7. Creates a release on GitHub with the images and Electron apps attached. 56 | 57 | ### Linting and Code Security 58 | 59 | `codeql-analysis.yml` and `eslint.yml` are triggered on each pull request and merge. They perform code security and linting checks. Ensure `node-version: "x.x.x"` matches the version in your Dockerfiles to ensure it lints with the correct version. 60 | 61 | ### Documentation 62 | 63 | `deploy-docs.yml` is used to build and deploy the documentation you are viewing. It is also free to use for your own projects by editing the documentation in the `./docs` folder. More info including how to use hot-reload can be found in the documentation for the docs project [here](https://github.com/balena-io-experimental/labs-docs-builder). 64 | -------------------------------------------------------------------------------- /docs/Built-in components/Backend Endpoints.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | A backend for performing functions on the device is included. This is used to interact with the Balena Supervisor, SDK and other endpoints. You can however, also add your own backend endpoints to interact with other peripherals or software. An example can be found in `expressjs/src/routes/v1/Examples.ts` 6 | -------------------------------------------------------------------------------- /docs/Built-in components/Cache Timeouts and Queuing.md: -------------------------------------------------------------------------------- 1 | In the ExpressJS backend there is middleware available for caching and queueing requests. This prevents unnecessary calls to the Supervisor or SDK and also prevents multiple requests to the same endpoint from being made at the same time to speed up responses and reduce traffic. It follows a number of general rules: 2 | 3 | 1. Each endpoint has its own queue. For example, a request to `/ping` will not wait for a request from `/v1/device`, it will only wait for other requests to finish on `/ping`. 4 | 2. When two requests are made on the same endpoint - and the middleware is applied to the endpoints - it will wait for the first response to return before the second is executed. 5 | 3. If caching is enabled on the endpoint and the last request was returned in less than the passed timeout integer it will return the cached content on the second request, instead of making a new request. 6 | 4. Caching can only be enabled for endpoints with the `type: 'GET'` parameter passed or those requested as an Axios `GET` request. Caching `POST` or `PATCH` requests would defeat the object of requesting a change on the device if it was cached. 7 | 8 | To use the middleware, include it in your route as follows: 9 | 10 | ```typescript 11 | router.post('/v1/filemanager/delete', queueCache, (async (req: Request, res: Response) => { 12 | ... 13 | }) as RequestHandler) 14 | ``` 15 | 16 | Configure the endpoints as follows, which will return the response from the cache if the last response was returned in less than the number of milliseconds specified in `cacheTimeout`: 17 | 18 | ```typescript 19 | device_name() { 20 | return expressApi.post(apiPath + supervisorPath, { 21 | type: 'GET', 22 | path: 'v2/device/name', 23 | params: false, 24 | cacheTimeout: 5000 25 | }) 26 | }, 27 | ``` 28 | 29 | ## Examples 30 | 31 | Using default cache expiry set in `expressjs/src/index.js`: 32 | 33 | ```typescript 34 | cacheTimeout: 0; 35 | ``` 36 | 37 | Return previously fetched content if less than 2 seconds old: 38 | 39 | ```typescript 40 | cacheTimeout: 2000; 41 | ``` 42 | -------------------------------------------------------------------------------- /docs/Built-in components/CloudLink Check.md: -------------------------------------------------------------------------------- 1 | On first load of the app the ExpressJS backend will check to see if you have a connection to balena CloudLink. You can get the results of this check with the following code: 2 | 3 | ```typescript 4 | import { networkSettings } from "stores/system"; 5 | 6 | const networkStore = networkSettings(); 7 | 8 | // Check to see the last reported CloudLink status 9 | function checkCloudlinkStatus() { 10 | if (networkStore.isCloudlink) { 11 | return "You are connected to Cloudlink."; 12 | } else if (!networkStore.isCloudlink) { 13 | return "You are not connected to CloudLink."; 14 | } else if (networkStore.isCloudlink === undefined) { 15 | return "The very first CloudLink check at launch hasn't completed yet."; 16 | } 17 | } 18 | ``` 19 | 20 | If you need to do another check at some point after the app has loaded, you can use the following code: 21 | 22 | ```typescript 23 | import { networkSettings } from "stores/system"; 24 | 25 | const networkStore = networkSettings(); 26 | 27 | // Rechecks the CloudLink status and returns boolean response 28 | // (true=connected, false=not connected) 29 | function recheckCloudlinkStatus() { 30 | return networkStore.checkCloudlink(); 31 | } 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/Built-in components/File Manager.md: -------------------------------------------------------------------------------- 1 | A file manager is built into the UI. It can be used for any purpose you deem fit, but by default is pointed at an empty directory in a volume where you can upload, access and delete files and folders. 2 | 3 | When you plug a USB drive in to your device it will be mounted in to this same directory and be visible through the File Manager. 4 | 5 | An alternative use case is accessing the content of volumes from other containers. Change the `docker-compose.yml` file to point to the same volume as your other container uses, and you will see the volume container in the UI device manager: 6 | 7 | ```diff 8 | services: 9 | balena-starter-interface: 10 | + volumes: 11 | + - "your-existing-volume:/app/storage" 12 | + depends_on: 13 | + - "your-other-container-name" 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/Built-in components/I18n.md: -------------------------------------------------------------------------------- 1 | ## Add or edit languages 2 | 3 | Support is included for multiple languages (i18n), and language strings are set in `/ui/src/i18n`. 4 | 5 | To insert or remove languages you can see an example in [this commit](https://github.com/balena-labs-research/starter-interface/commit/c474241e25fe0cbcd46ced0caf4ac61138b60c1e). 6 | 7 | ## Contribute a translation 8 | 9 | I18n language files used in this repository are managed by Weblate. If you are able to contribute a language to this repository, or improve a string you can edit entries or contribute new translations [here](https://hosted.weblate.org/projects/learners-block/balena-device-ui/). 10 | -------------------------------------------------------------------------------- /docs/Built-in components/Supervisor and SDK.md: -------------------------------------------------------------------------------- 1 | A number of endpoints are already included for communicating with the SDK and Supervisor. 2 | 3 | # Supervisor 4 | 5 | Here is an example of using the `ping` endpoint on the balena Supervisor to check the Supervisor is available: 6 | 7 | ```typescript 8 | import { supervisorRequests } from "src/api/supervisor"; 9 | 10 | async function ping() { 11 | response.value = await supervisorRequests.ping(); 12 | console.log(`Response from Supervisor is ${response.value.data}`); 13 | } 14 | ``` 15 | 16 | A full list of available endpoints are available in: `ui/src/api/supervisor.ts`. 17 | 18 | # SDK 19 | 20 | Here is an example of using the `getEnv` endpoint on the balena Supervisor to fetch the set environment variables: 21 | 22 | ```typescript 23 | import { sdk } from "src/api/sdk"; 24 | 25 | async function getEnv() { 26 | getEnvResponse.value = await sdk.getEnv(); 27 | } 28 | ``` 29 | 30 | A full list of available endpoints is available in: `ui/src/api/sdk.ts`. 31 | -------------------------------------------------------------------------------- /docs/Built-in components/System Info.md: -------------------------------------------------------------------------------- 1 | ## Accessing System Info 2 | 3 | There is the ability to get local system information provided by the [SystemInfo package](https://systeminformation.io). This example gets information on device memory and is returned in JSON format: 4 | 5 | ```typescript 6 | import { expressApi } from "boot/axios"; 7 | 8 | function memStats() { 9 | return expressApi.post("/v1/system/systeminfo", { 10 | id: "m", 11 | }); 12 | } 13 | ``` 14 | 15 | A list of the available calls and the code to pass to use them (i.e. in the example above we used `m` for memory) is available in `expressjs/src/routes/v1/System.ts`. 16 | -------------------------------------------------------------------------------- /docs/Customising the Environment/Architecture.md: -------------------------------------------------------------------------------- 1 | This project is a monorepo, built around a front and backend. Both are compiled together at build time, serving the frontend from the Express backend. The below outlines the structure of the project, highlighting only the key areas where interaction tends to be needed: 2 | 3 | ```bash 4 | ./ # Docker build files and top level package.json 5 | ├── expressjs/ # Backend 6 | ├── src/ # ExpressJS source 7 | ├── boot/ # Boot files for the Express app 8 | ├── routes/ # ExpressJS routes for handling API endpoints 9 | ├── ui/ # Vue app 10 | ├── public/ # Static files available from the URL after being built 11 | ├── src/ # Vue source 12 | ├── api/ # Supervisor and SDK request functions 13 | ├── boot/ # Boot files for the Vue app 14 | ├── components/ # Vue components 15 | ├── layouts/ # Vue layouts 16 | ├── config/ # Language and style configuration of the app 17 | ├── i18n/ # Translation files 18 | ├── pages/ # Vue pages 19 | ``` 20 | 21 | Each of the components of the project have their own build and development processes, but each can access and share the same Vue page sources all stored in `ui/src/pages` and `ui/src/layouts`. Due to the different purposes of the SPA, Electron and PWA apps, each has it's own entry point where you can configure how each should start and be used. 22 | 23 | ## SPA (Single Page Application) 24 | 25 | Single page applications are the default build process, run with `yarn dev` and built with `yarn build`. The entry point for these applications is `ui/src/App.vue`. 26 | 27 | ### Captive portal 28 | 29 | The Captive portal is only useful on the SPA builds. It is stored in `ui/layouts/CaptivePortal.vue`. 30 | 31 | ## Electron App 32 | 33 | The Electron App has a different entry point, as the purpose of this app is distinct from the SPA. It's entry point is `ui/layouts/ElectronLayout.vue`. It is run with `yarn dev-electron` and built with `yarn build-electron`. 34 | 35 | ## PWA App 36 | 37 | The PWA App also has it's own entry point: `ui/layouts/PwaLayout.vue`. It is run with `yarn dev-pwa` and built with `yarn build-pwa`. 38 | -------------------------------------------------------------------------------- /docs/Customising the Environment/Customising Page Content.md: -------------------------------------------------------------------------------- 1 | ## Adding pages 2 | 3 | New pages can be added in `ui/src/pages`. Simply clone the `IndexPage.vue` and rename it to something that explains its content. That's all that is required. You can now refer to that page and add it into the sidebar in to your interface using `config.yml`. 4 | 5 | ## Adding components to a page 6 | 7 | Each page available in the sidebar is able to display any number of components. Components are kept in `ui/src/components`. Components include things such as environment variable configuration, WiFi connection control panels and Container Managers. 8 | 9 | You can add a new component by creating a file in the same folder and naming it according to the feature you are adding. You can then choose which page that component should be displayed on and where on the page by including it in your `config.yml` file. 10 | 11 | Component pages can be built using [Quasar](https://quasar.dev) components which has extensive documentation. 12 | 13 | ## Static vs dynamic rendering at runtime 14 | 15 | Content displayed is configured through the `config.yml` file (or `config_default.yml` when `config.yml` is absent). When `config.yml` is present, the UI is fixed to its specified state at build time, and adding in a `config.yml` file to the environment later will not impact the interface. When building from `config_default.yml` the interface can be configured at a later date by adding a `config.yml` file. 16 | 17 | When using `config_default.yml`, the frontend has to make a request to the backend for the specified configuration file, which adds a small delay to the render (approximately 200ms). For this reason, we default to static pages when `config.yml` is provided as we assume the user has already decided on how they would want their page to appear. 18 | -------------------------------------------------------------------------------- /docs/Customising the Environment/Device Configuration.md: -------------------------------------------------------------------------------- 1 | ## USB Mounting 2 | 3 | By default when a USB device is plugged in it will be mounted into the container and visible via the File Manager. 4 | 5 | To disable this functionality, change `UDEV` to `off` in the `docker-compose.yml` file: 6 | 7 | ``` 8 | UDEV: off 9 | ``` 10 | 11 | ## Configuring default WiFi and Network Settings 12 | 13 | Default WiFi configuration is set in the `docker-compose.yml` file. The following parameters are configurable: 14 | 15 | ``` 16 | PWC_HOTSPOT_SSID: "Balena Starter Interface" # Name as it appears in list of WiFi networks 17 | PWC_HOTSPOT_PASSWORD: "balena01" # Optional. Must be 8 characters or more 18 | SET_HOSTNAME: "balena" # Optional. Changes the device hostname. UI will become accesible on `balena.local` 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/Customising the Environment/Styles.md: -------------------------------------------------------------------------------- 1 | ## Adding assets (such as favicons and header images) 2 | 3 | Assets are kept in `ui/src/public`. Replace `favicon.ico` and the logo files according to your needs. 4 | 5 | ## Changing colour themes 6 | 7 | Colour themes can be configured by changing `ui/src/css/quasar.variables.scss`. A background colour is also available in `ui/src/css/app.scss`. 8 | 9 | ## Changing fonts 10 | 11 | Fonts can be configured in `ui/src/css/app.scss`. 12 | 13 | ## Global Styles 14 | 15 | Style configuration of things like buttons and header logos can be done from `ui/src/config/qStyles.ts`: 16 | 17 | ```typescript 18 | // Global button configuration 19 | export const qBtnStyle = { 20 | color: "secondary", 21 | outline: false, 22 | rounded: true, 23 | unelevated: true, 24 | size: "sm", 25 | "no-caps": true, 26 | }; 27 | 28 | // Header configuration 29 | export const qHeaderStyle = { 30 | header: { 31 | elevated: true, 32 | }, 33 | // Remove the logo lines to have no logo 34 | logo_coloured: "logo_colour.svg", // Logo for displaying on white backgrounds. 35 | logo_white: "logo_white.svg", // Logo for displaying on coloured backgrounds. 36 | title: { class: "text-subtitle1" }, 37 | }; 38 | 39 | // Global avatar configuration, for small icons or buttons used throughout the interface 40 | export const qAvatarStyle = { 41 | size: "lg", 42 | color: "accent", 43 | "text-color": "primary", 44 | }; 45 | 46 | // Global spinner configuration, used for things like loading indicators 47 | export const qSpinnerStyle = { 48 | class: "text-accent", 49 | size: "6em", 50 | }; 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/Useful Extensions.md: -------------------------------------------------------------------------------- 1 | Here is a list of useful extensions that can be easily integrated in to the project. [Balena Hub](https://hub.balena.io/blocks) has an array of useful blocks, these are just a few projects we felt are worth highlighting: 2 | 3 | Useful extensions 4 | 5 | - [MDNS Advertise](https://github.com/nucleardreamer/mdns-advertise) - Advertise your device with mDNS on your local network. This block talks to Avahi over its DBUS API to advertise your IP address on a .local domain name. 6 | -------------------------------------------------------------------------------- /docs/docusaurus-config.yml: -------------------------------------------------------------------------------- 1 | project: 2 | # Set the name that will appear in the header of the documentation 3 | name: "Balena Starter Interface" 4 | -------------------------------------------------------------------------------- /docs/static/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/docs/static/.keep -------------------------------------------------------------------------------- /docs/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/docs/static/favicon.ico -------------------------------------------------------------------------------- /expressjs/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .eslintrc.js 4 | -------------------------------------------------------------------------------- /expressjs/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | ignorePatterns: ['.eslintrc.js'], 4 | parserOptions: { 5 | parser: '@typescript-eslint/parser', 6 | project: './tsconfig.json', 7 | tsconfigRootDir: __dirname 8 | }, 9 | 10 | plugins: ['@typescript-eslint'], 11 | extends: [ 12 | 'eslint:recommended', 13 | 'plugin:@typescript-eslint/recommended', 14 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 15 | 'airbnb-base', 16 | 'prettier' 17 | ], 18 | 19 | rules: { 20 | // ESLint 21 | 'import/order': 'off', 22 | 23 | // AirBnB 24 | 'no-shadow': 'off', 25 | 'no-use-before-define': 'off', 26 | 'no-console': 'off', 27 | 'no-void': 'off', 28 | 'import/no-unresolved': 'off', 29 | 'import/extensions': 'off', 30 | 'no-param-reassign': 'off', 31 | 32 | // Naming conventions 33 | '@typescript-eslint/naming-convention': [ 34 | 'error', 35 | { 36 | selector: 'variableLike', 37 | format: ['camelCase'], 38 | leadingUnderscore: 'allow' 39 | }, 40 | { 41 | selector: 'variable', 42 | types: ['boolean'], 43 | format: ['PascalCase'], 44 | prefix: ['is'] 45 | } 46 | ], 47 | 48 | // General 49 | '@typescript-eslint/no-shadow': 'error' 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /expressjs/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "none" 5 | } 6 | -------------------------------------------------------------------------------- /expressjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expressjs", 3 | "license": "MIT", 4 | "author": "maggie0002", 5 | "version": "0.0.1", 6 | "repository": "https://github.com/balena-labs-research/starter-interface", 7 | "scripts": { 8 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 9 | "format": "prettier --write \"**/*.{js,ts,vue,scss,html,md}\" --ignore-path ../.gitignore", 10 | "formatcheck": "prettier \"**/*.{js,ts,vue,scss,html,md}\" --ignore-path ../.gitignore --check", 11 | "test": "echo \"No test specified\" && exit 0", 12 | "dev": "nodemon -r tsconfig-paths/register ./src/index.ts", 13 | "start": "node ./dist/index.js", 14 | "build": "yarn tsc -p tsconfig.json && tsc-alias -p tsconfig.json" 15 | }, 16 | "dependencies": { 17 | "axios": "^1.2.3", 18 | "balena-sdk": "^16.32.1", 19 | "compression": "^1.7.4", 20 | "cors": "^2.8.5", 21 | "express": "^5.0.0-beta.1", 22 | "express-slow-down": "^1.5.0", 23 | "formidable": "^2.0.1", 24 | "fs-extra": "^11.1.0", 25 | "js-yaml": "^4.1.0", 26 | "klaw-sync": "^6.0.0", 27 | "lockfile": "^1.0.4", 28 | "systeminformation": "^5.17.3", 29 | "winston": "^3.8.2" 30 | }, 31 | "devDependencies": { 32 | "@types/compression": "^1.7.2", 33 | "@types/cors": "^2.8.12", 34 | "@types/express": "^4.17.13", 35 | "@types/express-slow-down": "^1.3.2", 36 | "@types/formidable": "^2.0.5", 37 | "@types/fs-extra": "^11.0.1", 38 | "@types/klaw-sync": "^6.0.1", 39 | "@types/lockfile": "^1.0.2", 40 | "@types/node": "^18.11.2", 41 | "@typescript-eslint/eslint-plugin": "^5.48.2", 42 | "@typescript-eslint/parser": "^5.48.2", 43 | "eslint": "^8.32.0", 44 | "eslint-config-airbnb-base": "^15.0.0", 45 | "eslint-config-prettier": "^8.6.0", 46 | "eslint-plugin-import": "^2.27.5", 47 | "nodemon": "^2.0.20", 48 | "prettier": "^2.8.3", 49 | "ts-node": "^10.9.1", 50 | "tsc-alias": "^1.7.0", 51 | "tsconfig-paths": "^4.1.2", 52 | "typescript": "^4.8.4" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /expressjs/src/boot/index.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Functions to call on boot 3 | // 4 | 5 | import checkHostname from '@/boot/setHostname' 6 | 7 | export default function boot() { 8 | // List of functions to call on first boot 9 | void checkHostname() 10 | } 11 | -------------------------------------------------------------------------------- /expressjs/src/boot/setHostname.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Set the hostname on boot based on the hostname specified in the docker-compose file 3 | // 4 | 5 | import createAxiosInstance from '@/common/axios' 6 | import Logger from '@/common/logger' 7 | import { AxiosResponse } from 'axios' 8 | import fse from 'fs-extra' 9 | import process from 'process' 10 | 11 | interface HostConfigHostnameReq { 12 | network: { hostname: string } 13 | } 14 | 15 | // Create normal Axios instance and set defaults. 16 | const hostnameAxios = createAxiosInstance() 17 | hostnameAxios.defaults.timeout = 8000 18 | hostnameAxios.defaults.baseURL = process.env.BALENA_SUPERVISOR_ADDRESS 19 | hostnameAxios.defaults.headers.common.Authorization = `Bearer ${ 20 | process.env.BALENA_SUPERVISOR_API_KEY as string 21 | }` 22 | 23 | // Set default storage location for hostname lockfile 24 | const hostnameLockFile = '/app/db/hostname_set.lock' 25 | 26 | async function setHostnameLockfile() { 27 | // Create a file used to indicate that hostname has been set and to avoid 28 | // trying to set the hostname again. This is technically not a lockfile, just 29 | // an empty file in the db volume that can be queried on boot because it is 30 | // required forever. 31 | try { 32 | await fse.ensureFile(hostnameLockFile) 33 | Logger.debug('Hostname lockfile created.') 34 | } catch (error) { 35 | Logger.error('Failed setting hostname lockfile.') 36 | Logger.error(error) 37 | } 38 | } 39 | 40 | // Use the Supervisor endpoint to set the device hostname 41 | async function setNewHostname() { 42 | try { 43 | await hostnameAxios.patch('v1/device/host-config', { 44 | network: { 45 | hostname: process.env.SET_HOSTNAME 46 | } 47 | }) 48 | await setHostnameLockfile() 49 | Logger.warn( 50 | 'Hostname changed. If your device is not accessible on your new hostname, try restarting the device.' 51 | ) 52 | } catch (error) { 53 | Logger.error(error) 54 | Logger.error('Error setting new hostname.') 55 | } 56 | } 57 | 58 | // Check whether the user wants a hostname change, and whether it has been done 59 | // already (i.e. whether this is the first boot) 60 | export default async function checkHostname() { 61 | // If there is an env variable specifying a new hostname and hostname has not already been set 62 | if ( 63 | process.env.NODE_ENV === 'production' && 64 | process.env.SET_HOSTNAME && 65 | !fse.pathExistsSync(hostnameLockFile) 66 | ) { 67 | // Get the current hostname and check if it needs changing, rather than force a new request 68 | // unnecessarily which could result in a Balena engine restart. 69 | try { 70 | const response: AxiosResponse = 71 | await hostnameAxios.get('v1/device/host-config') 72 | 73 | // If the current hostname is not the same as the provided var 74 | if (response.data.network.hostname !== process.env.SET_HOSTNAME) { 75 | Logger.warn(`Changing hostname to ${process.env.SET_HOSTNAME}.`) 76 | void setNewHostname() 77 | } else { 78 | // If the hostname is already set correctly, create the lockfile to save having 79 | // to run this process again. 80 | Logger.debug('Hostname was already set correctly.') 81 | await setHostnameLockfile() 82 | } 83 | } catch (error) { 84 | Logger.error(error) 85 | Logger.error('Error fetching current hostname.') 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /expressjs/src/common/axios.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Axios configuration 3 | // 4 | 5 | import Logger from '@/common/logger' 6 | import axios, { AxiosError } from 'axios' 7 | 8 | // Axios instance generator. It allows instances to be created in the file using it. By 9 | // using a generator the different instances can share common interceptors but still have 10 | // their own independent instance with custom timeouts and other configuration. 11 | export default function createAxiosInstance() { 12 | const newInstance = axios.create() 13 | 14 | // Axios request interceptor 15 | newInstance.interceptors.request.use( 16 | (config) => config, 17 | (error: Error | AxiosError) => { 18 | if (axios.isAxiosError(error) && error.request) { 19 | // The request was made but no response was received 20 | // `error.request` is an instance of XMLHttpRequest in the browser and an instance of 21 | // http.ClientRequest in node.js 22 | Logger.error('Axios received no response.') 23 | Logger.error(error.request) 24 | } else { 25 | Logger.error(error) 26 | } 27 | // Reject with Axios error 28 | return Promise.reject(error) 29 | } 30 | ) 31 | 32 | // Axios response interceptor 33 | newInstance.interceptors.response.use( 34 | (response) => response, 35 | (error: Error | AxiosError) => { 36 | if (axios.isAxiosError(error) && error.response) { 37 | // The request was made and the server responded with a status code 38 | // that falls out of the range of 2xx 39 | Logger.error(`Axios returned status code: ${error.response.status}.`) 40 | Logger.error(error.response.data) 41 | Logger.debug(error.response.headers) 42 | } else { 43 | Logger.error(error) 44 | } 45 | // Reject with Axios error 46 | return Promise.reject(error) 47 | } 48 | ) 49 | return newInstance 50 | } 51 | -------------------------------------------------------------------------------- /expressjs/src/common/logger.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Winston logger configuration to allow setting different debug levels 3 | // 4 | 5 | import process from 'process' 6 | import winston from 'winston' 7 | 8 | // Define your severity levels. 9 | // With them, You can create log files, 10 | // see or hide levels based on the running ENV. 11 | const levels = { 12 | error: 0, 13 | warn: 1, 14 | info: 2, 15 | http: 3, 16 | debug: 4 17 | } 18 | 19 | // This method sets the current severity based on 20 | // the current NODE_ENV: show all the log levels 21 | // if the server was run in development mode; otherwise, 22 | // if it was run in production, show only warn and error messages. 23 | const level = () => { 24 | const env = process.env.NODE_ENV || 'development' 25 | const isDevelopment = 26 | env === 'development' || 27 | process.env.BALENA_APP_NAME === 'bdi-generic' || 28 | process.env.BALENA_APP_NAME === 'bdi-rpi' 29 | return isDevelopment ? 'debug' : 'warn' 30 | } 31 | 32 | // Define different colors for each level. 33 | // Colors make the log message more visible, 34 | // adding the ability to focus or ignore messages. 35 | const colors = { 36 | error: 'red', 37 | warn: 'yellow', 38 | info: 'green', 39 | http: 'magenta', 40 | debug: 'white' 41 | } 42 | 43 | // Tell winston that you want to link the colors 44 | // defined above to the severity levels. 45 | winston.addColors(colors) 46 | 47 | // Chose the aspect of your log customizing the log format. 48 | const format = winston.format.combine( 49 | // Add the message timestamp with the preferred format 50 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }), 51 | // Tell Winston that the logs must be colored 52 | winston.format.colorize({ all: true }), 53 | // Define the format of the message showing the timestamp, the level and the message 54 | winston.format.printf( 55 | (info) => 56 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 57 | `ExpressJS: ${info.timestamp as string} ${info.level}: ${info.message}` 58 | ) 59 | ) 60 | 61 | // Define which transports the logger must use to print out messages. 62 | // In this example, we are using three different transports 63 | const transports = [ 64 | // Allow the use the console to print the messages 65 | new winston.transports.Console() 66 | ] 67 | 68 | // Create the logger instance that has to be exported 69 | // and used to log messages. 70 | const logger = winston.createLogger({ 71 | level: level(), 72 | levels, 73 | format, 74 | transports 75 | }) 76 | 77 | export default logger 78 | -------------------------------------------------------------------------------- /expressjs/src/index.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Primary config for the backend 3 | // 4 | 5 | import boot from '@/boot' 6 | import BalenaSDK from '@/routes/v1/BalenaSDK' 7 | import CaptivePortal from '@/routes/v1/CaptivePortal' 8 | // import Examples from '@/routes/v1/Examples' 9 | import FileManager from '@/routes/v1/FileManager' 10 | import Supervisor from '@/routes/v1/Supervisor' 11 | import System from '@/routes/v1/System' 12 | import Tests from '@/routes/v1/Tests' 13 | import Wifi from '@/routes/v1/Wifi' 14 | import compression from 'compression' 15 | import cors from 'cors' 16 | import express from 'express' 17 | import slowDown from 'express-slow-down' 18 | import process from 'process' 19 | 20 | // Run custom boot processes 21 | boot() 22 | 23 | // Set backend port number 24 | const port = process.env.BACKEND_PORT || 80 25 | 26 | // Speed limit API requests to prevent abuse of the API. 27 | const speedLimiter = slowDown({ 28 | windowMs: 1 * 60 * 1000, // X minutes 29 | delayAfter: 100, // allow X requests per X minutes, then... 30 | delayMs: 200 // begin adding X ms of delay per request above X 31 | }) 32 | 33 | // Initiate ExpressJS 34 | const app = express() 35 | 36 | /** 37 | ========================================================== 38 | ExpressJS setup. Order of these functions is important. 39 | ========================================================== 40 | */ 41 | 42 | // Default Balena UI middleware cache timeout. 0 is disabled. 43 | app.locals.defaultCacheTimeout = 0 44 | // Allow CORS 45 | app.use(cors()) 46 | // Import JSON functionality 47 | app.use(express.json()) 48 | app.use(express.urlencoded({ extended: true })) 49 | // Add a low compression, considerate of low resource devices. Level 1 can reduce 50 | // some asset sizes by 50%. Gains from 2 and up are marginal but require more hardware 51 | // resources to achieve. 52 | app.use(compression({ level: 1 })) // 53 | // Enable sharing of static files 54 | app.use(express.static('public')) 55 | 56 | // ========================================================== 57 | 58 | // Import routes 59 | app.use(BalenaSDK) 60 | app.use(CaptivePortal) 61 | // app.use(Examples) 62 | app.use(FileManager) 63 | app.use(Supervisor) 64 | app.use(System) 65 | app.use(Wifi) 66 | 67 | // Add test routes when outside of production environment 68 | if (process.env.NODE_ENV !== 'production') { 69 | app.use(Tests) 70 | } 71 | 72 | // Add speed limiter that was configured above 73 | app.use(speedLimiter) 74 | 75 | // Start and listen for requests 76 | app.listen(port, () => { 77 | console.log(`ExpressJS: listening on port ${port}`) 78 | }) 79 | -------------------------------------------------------------------------------- /expressjs/src/routes/v1/BalenaSDK.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Use the Balena SDK to perform functions 3 | // 4 | 5 | import logger from '@/common/logger' 6 | import { getSdk } from 'balena-sdk' 7 | import express, { Request, RequestHandler } from 'express' 8 | import lockFile from 'lockfile' 9 | import process from 'process' 10 | 11 | interface LockfileError { 12 | code: string 13 | } 14 | 15 | // Get the ExpressJS main router process 16 | const router = express.Router() 17 | 18 | // Get the Balena SDK instance 19 | const sdk = getSdk() 20 | 21 | // Specify the lockfile path used by Balena Supervisor/OS 22 | const balenaLockfilePath = '/tmp/balena/updates.lock' 23 | 24 | // Set the required Balena info from environment variables inside the running container 25 | // which are set by the Balena Supervisor 26 | const apiKey = process.env.BALENA_API_KEY || '' 27 | const uuid = process.env.BALENA_DEVICE_UUID || '' 28 | 29 | // Initiate a login to the Balena Cloud using the API key stored on the device 30 | void logIn() 31 | async function logIn() { 32 | try { 33 | await sdk.auth.logout() 34 | await sdk.auth.loginWithToken(apiKey) 35 | } catch (error) { 36 | logger.error(error) 37 | logger.error('Error logging in to Balena SDK') 38 | } 39 | } 40 | 41 | // Return the device UUID 42 | router.get('/v1/sdk/uuid', (_req, res) => res.json(uuid)) 43 | 44 | // Return device info from the SDK 45 | router.get('/v1/sdk/device', (async (_req, res) => { 46 | res.json(await sdk.models.device.get(uuid)) 47 | }) as RequestHandler) 48 | 49 | // Get all environment variables for the device specified. When using this 50 | // endpoint the Cloud will trigger a restart of the affected containers 51 | router.get('/v1/sdk/envVars', (async (_req, res) => { 52 | const envVars = await sdk.models.device.envVar.getAllByDevice(uuid) 53 | 54 | const omittedEnvVars = envVars.map((vars) => ({ 55 | name: vars.name, 56 | value: vars.value 57 | })) 58 | 59 | return res.json(omittedEnvVars) 60 | }) as RequestHandler) 61 | 62 | // Delete an environment variable 63 | router.delete('/v1/sdk/envVars', (async (req, res) => { 64 | await lock() 65 | 66 | await Promise.all( 67 | Object.entries(req.body as Request).map(async ([key]) => { 68 | await sdk.models.device.envVar.remove(uuid, key) 69 | }) 70 | ) 71 | 72 | await unlock() 73 | 74 | return res.json({ message: 'done' }) 75 | }) as RequestHandler) 76 | 77 | // Set an environment variable, or change it if it exists already 78 | router.post('/v1/sdk/envVars', (async (req, res) => { 79 | await lock() 80 | 81 | await Promise.all( 82 | Object.entries(req.body as Request).map(async ([key, val]) => { 83 | if (val) { 84 | await sdk.models.device.envVar.set(uuid, key, val as string) 85 | } 86 | }) 87 | ) 88 | 89 | await unlock() 90 | 91 | return res.json({ message: 'done' }) 92 | }) as RequestHandler) 93 | 94 | // Check whether logged in to the Balena Cloud or not. Used on the UI to identify whether 95 | // it should be fetching content from the Balena Cloud or not. 96 | router.get('/v1/sdk/loggedIn', (async (_req, res) => { 97 | try { 98 | return res.json({ loggedIn: !!(await sdk.auth.getToken()) }) 99 | } catch { 100 | return res.json({ loggedIn: false }) 101 | } 102 | }) as RequestHandler) 103 | 104 | // Set the Balena lock file to avoid Supervisor making changes on the device when 105 | // making a change through the SDK. 106 | function lock() { 107 | try { 108 | return lockFile.lockSync(balenaLockfilePath) 109 | } catch (error) { 110 | const err = error as LockfileError 111 | 112 | // Check the error code and if the lockfile already exists then continue. This avoids a 113 | // permanent lockup that can occur if the lockfile exists and the UI refuses to continue 114 | // which is required to remove the lockfile. 115 | if (err.code === 'EEXIST') { 116 | return Promise.resolve() 117 | } 118 | return Promise.reject(error) 119 | } 120 | } 121 | 122 | // Remove the Balena lockfile. 123 | function unlock() { 124 | try { 125 | return lockFile.unlockSync(balenaLockfilePath) 126 | } catch (error) { 127 | return Promise.reject(error) 128 | } 129 | } 130 | 131 | export default router 132 | -------------------------------------------------------------------------------- /expressjs/src/routes/v1/CaptivePortal.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Display the captive portal when connected to the devices hotspot 3 | // 4 | 5 | import Logger from '@/common/logger' 6 | import express from 'express' 7 | 8 | // Get the ExpressJS main router process 9 | const router = express.Router() 10 | 11 | // Captive portals on devices work by trying an online path to check to see if it receives 12 | // a response. By intercepting the request to that path, we can make a captive portal appear. 13 | // This list constitutes the routes we are aware of. 14 | 15 | // Android 16 | router.get('/connectivitycheck.gstatic.com', (_req, res) => { 17 | Logger.info('Redirecting to captive portal.') 18 | res.redirect(302, '/#/captiveportal') 19 | }) 20 | router.get('/gen_204', (_req, res) => { 21 | Logger.info('Redirecting to captive portal.') 22 | res.redirect(302, '/#/captiveportal') 23 | }) 24 | router.get('/generate_204', (_req, res) => { 25 | Logger.info('Redirecting to captive portal.') 26 | res.redirect(302, '/#/captiveportal') 27 | }) 28 | router.get('/mobile/status.php', (_req, res) => { 29 | Logger.info('Redirecting to captive portal.') 30 | res.redirect(302, '/#/captiveportal') 31 | }) 32 | router.get('connectivitycheck.android.com', (_req, res) => { 33 | Logger.info('Redirecting to captive portal.') 34 | res.redirect(302, '/#/captiveportal') 35 | }) 36 | router.get('clients3.google.com', (_req, res) => { 37 | Logger.info('Redirecting to captive portal.') 38 | res.redirect(302, '/#/captiveportal') 39 | }) 40 | 41 | // iOS/OSX 42 | router.get('/success.html', (_req, res) => { 43 | Logger.info('Redirecting to captive portal.') 44 | res.redirect(302, '/#/captiveportal') 45 | }) 46 | router.get('/hotspotdetect.html', (_req, res) => { 47 | Logger.info('Redirecting to captive portal.') 48 | res.redirect(302, '/#/captiveportal') 49 | }) 50 | router.get('/hotspot-detect.html', (_req, res) => { 51 | Logger.info('Redirecting to captive portal.') 52 | res.redirect(302, '/#/captiveportal') 53 | }) 54 | 55 | // Microsoft 56 | router.get('/ncsi.txt', (_req, res) => { 57 | Logger.info('Redirecting to captive portal.') 58 | res.redirect(302, '/#/captiveportal') 59 | }) 60 | router.get('msftconnecttest.com', (_req, res) => { 61 | Logger.info('Redirecting to captive portal.') 62 | res.redirect(302, '/#/captiveportal') 63 | }) 64 | router.get('msftncsi.com', (_req, res) => { 65 | Logger.info('Redirecting to captive portal.') 66 | res.redirect(302, '/#/captiveportal') 67 | }) 68 | 69 | // Kindle 70 | router.get('/wifiredirect.html', (_req, res) => { 71 | Logger.info('Redirecting to captive portal.') 72 | res.redirect(302, '/#/captiveportal') 73 | }) 74 | 75 | // FireFox 76 | router.get('detectportal.firefox.com/canonical.html', (_req, res) => { 77 | Logger.info('Redirecting to captive portal.') 78 | res.redirect(302, '/#/captiveportal') 79 | }) 80 | 81 | // Misc 82 | router.get('/blank.html', (_req, res) => { 83 | Logger.info('Redirecting to captive portal.') 84 | res.redirect(302, '/#/captiveportal') 85 | }) 86 | 87 | export default router 88 | -------------------------------------------------------------------------------- /expressjs/src/routes/v1/Examples.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Example endpoints for users to create there own. These are not imported by default. Instead 3 | // the user must specify to import this file from expressjs/src/index.ts 4 | // 5 | 6 | import Logger from '@/common/logger' 7 | import express from 'express' 8 | 9 | interface MyData { 10 | mySentData: string 11 | } 12 | 13 | // Get the ExpressJS main router process 14 | const router = express.Router() 15 | 16 | // Define your custom GET route path and actions 17 | router.get('/v1/example_route_get', (_req, res) => { 18 | // Log to the console that this is running 19 | Logger.warn('Running content of example_route_get route.') 20 | // Actions to take when this route is visited go here. 21 | res.send('I am being sent back to the browser') 22 | }) 23 | 24 | // Define your custom POST route 25 | router.post('/v1/example_route_post', (req, res) => { 26 | const reqBody = req.body as MyData 27 | // Store the value of parameter `mySentData` as `mySentDataVariable` 28 | const mySentDataVariable = reqBody.mySentData 29 | // Log the message to the console 30 | Logger.info(mySentDataVariable) 31 | // Return a message to the caller 32 | res.json({ message: 'All done.' }) 33 | }) 34 | 35 | export default router 36 | -------------------------------------------------------------------------------- /expressjs/src/routes/v1/FileManager.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Upload, edit, delete, list etc. for the UI file manager 3 | // 4 | 5 | import Logger from '@/common/logger' 6 | import queueCache from '@/middleware/queueCache' 7 | import express, { Request, RequestHandler, Response } from 'express' 8 | import formidable from 'formidable' 9 | import fse from 'fs-extra' 10 | import klawSync, { Item } from 'klaw-sync' 11 | import path from 'path' 12 | 13 | // Extend the klaw types to allow for a custom string added 14 | // in to store whether the item is a file or folder 15 | interface ExtendKlawItem extends klawSync.Item { 16 | type: string 17 | } 18 | 19 | // Interface for the payload 20 | interface BodyDataReq { 21 | currentPath: string 22 | currentPathArray: Array 23 | newFolderName: string 24 | selectedPaths: Array<{ path: string }> 25 | } 26 | 27 | // Get the ExpressJS main router process 28 | const router = express.Router() 29 | 30 | // Set local directory for file and folder storage. Match the same means of 31 | // creating directory paths as used in the development environment. 32 | let rootDir = path.resolve(path.join('/app/storage')) 33 | 34 | if (process.env.LOCAL_MODE === 'true') { 35 | rootDir = path.resolve(path.join(__dirname, '/storage')) 36 | } 37 | 38 | // Check the storage directory exists and if not create it 39 | try { 40 | void fse.ensureDir(rootDir) 41 | } catch (error) { 42 | Logger.error(error) 43 | } 44 | 45 | // Prevent Directory Traversal and Null Bytes 46 | // https://nodejs.org/en/knowledge/file-system/security/introduction/#preventing-directory-traversal 47 | // https://nodejs.org/en/knowledge/file-system/security/introduction/#poison-null-bytes 48 | function validatePath(checkPath: string) { 49 | if (checkPath.indexOf(rootDir) !== 0 || checkPath.indexOf('\0') !== -1) { 50 | throw new Error('User attempting to reach out of the root dir?') 51 | } 52 | return checkPath 53 | } 54 | 55 | // Ignore hidden directories and files by filtering the results 56 | const filterFn = (item: Item) => { 57 | const basename = path.basename(item.path) 58 | return basename === '.' || basename[0] !== '.' 59 | } 60 | 61 | // Fetch files and folders 62 | function fetchList(currentPathArray: Array) { 63 | // Fetch list of files 64 | const files = klawSync( 65 | validatePath(path.join(rootDir, currentPathArray.join('/'))), 66 | { 67 | depthLimit: 0, 68 | nodir: true, 69 | filter: filterFn 70 | } 71 | ) as ExtendKlawItem[] 72 | 73 | // Add 'file' tag to all files in our custom 'type' interface extended from klaw 74 | files.forEach((file) => { 75 | file.type = 'file' 76 | }) 77 | 78 | // Fetch list of folders 79 | const folders = klawSync( 80 | validatePath(path.join(rootDir, currentPathArray.join('/'))), 81 | { 82 | depthLimit: 0, 83 | nofile: true, 84 | filter: filterFn 85 | } 86 | ) as ExtendKlawItem[] 87 | 88 | // Add 'folder' tag to all files in our custom 'type' interface extended from klaw 89 | folders.forEach((folder) => { 90 | folder.type = 'folder' 91 | }) 92 | 93 | // Return the combined list of folders and files 94 | return folders.concat(files) 95 | } 96 | 97 | // Routes // 98 | router.post('/v1/filemanager/delete', queueCache, (async ( 99 | req: Request, 100 | res: Response 101 | ) => { 102 | const reqBody = req.body as BodyDataReq 103 | 104 | if (reqBody.currentPath) { 105 | // If only one item to delete 106 | try { 107 | await fse.remove(validatePath(path.join(reqBody.currentPath))) 108 | } catch (error) { 109 | Logger.error(error) 110 | } 111 | } else { 112 | // If multiple items to delete 113 | await Promise.all( 114 | reqBody.selectedPaths.map(async (item) => { 115 | try { 116 | await fse.remove(validatePath(path.join(item.path))) 117 | } catch (error) { 118 | Logger.error(error) 119 | } 120 | }) 121 | ) 122 | } 123 | res.json({ message: 'success' }) 124 | }) as RequestHandler) 125 | 126 | // Send a requested file to the user 127 | router.get('/v1/filemanager/download', (req: Request, res: Response) => { 128 | res.download(validatePath(path.join(req.query.currentPath as string))) 129 | }) 130 | 131 | // List the contents of a directory 132 | router.post('/v1/filemanager/list', (req: Request, res: Response) => { 133 | const reqBody = req.body as BodyDataReq 134 | res.json({ list: fetchList(reqBody.currentPathArray), rootDir }) 135 | }) 136 | 137 | // Create a new folder 138 | router.post('/v1/filemanager/newfolder', (async ( 139 | req: Request, 140 | res: Response 141 | ) => { 142 | const reqBody = req.body as BodyDataReq 143 | const newFolder = validatePath( 144 | path.join( 145 | rootDir, 146 | reqBody.currentPathArray.join('/'), 147 | reqBody.newFolderName 148 | ) 149 | ) 150 | 151 | // Using `await` here when creating the direcotry as immediately after receiving a response, 152 | // the UI will refresh the page. If it refreshes before this completes, it could end up showing 153 | // without the new folder 154 | try { 155 | // Create the requested directory 156 | await fse.ensureDir(newFolder) 157 | res.json({ message: 'success' }) 158 | } catch (error) { 159 | Logger.error(error) 160 | } 161 | }) as RequestHandler) 162 | 163 | // Save file uploaded through the UI in to the specified folder 164 | router.post('/v1/filemanager/upload', (req: Request, res: Response) => { 165 | const form = new formidable.IncomingForm({ 166 | maxFileSize: 5000 * 1024 * 1024 167 | }) 168 | 169 | form.on('error', (error) => { 170 | Logger.error(error) 171 | }) 172 | 173 | form.on('fileBegin', (_name, file) => { 174 | file.filepath = validatePath( 175 | path.join( 176 | rootDir, 177 | // Uploader headers passed from the UI must be lowercase, not CamelCase 178 | req.headers.currentpath as string, 179 | file.originalFilename || file.newFilename 180 | ) 181 | ) 182 | }) 183 | 184 | form.parse(req, () => { 185 | res.send('success') 186 | }) 187 | }) 188 | 189 | export default router 190 | -------------------------------------------------------------------------------- /expressjs/src/routes/v1/Supervisor.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Interact with the Balena Supervisor on the local device 3 | // 4 | 5 | import createAxiosInstance from '@/common/axios' 6 | import Logger from '@/common/logger' 7 | import queueCache from '@/middleware/queueCache' 8 | import request, { AxiosRequestConfig } from 'axios' 9 | import express, { Request, RequestHandler, Response } from 'express' 10 | import path from 'path' 11 | import process from 'process' 12 | 13 | // Interface for the payload 14 | interface BodyDataReq { 15 | params: Array 16 | path: string 17 | path2: string 18 | type: string 19 | } 20 | 21 | // Get the ExpressJS main router process 22 | const router = express.Router() 23 | 24 | // Create Axios instance and set defaults 25 | const supervisorAxios = createAxiosInstance() 26 | supervisorAxios.defaults.timeout = 20000 27 | supervisorAxios.defaults.baseURL = process.env.BALENA_SUPERVISOR_ADDRESS 28 | supervisorAxios.defaults.headers.common.Authorization = `Bearer ${ 29 | process.env.BALENA_SUPERVISOR_API_KEY as string 30 | }` 31 | 32 | // -- Routes -- // 33 | // Note that this route uses the queueCache middleware set by `queueCache`. 34 | // Be aware of its implications when doing development. 35 | router.post('/v1/supervisor', queueCache, (async ( 36 | req: Request, 37 | res: Response 38 | ) => { 39 | const reqBody = req.body as BodyDataReq 40 | 41 | // Unfortunately the Supervisor URLs are not currently standardised 42 | // and this provides a workaround. If Balena App ID is required as 43 | // part of the Supervisor URL then add it to the payload. 44 | let url 45 | if (reqBody.path2) { 46 | url = path.join( 47 | reqBody.path, 48 | process.env.BALENA_APP_ID as string, 49 | reqBody.path2 50 | ) 51 | } else { 52 | url = reqBody.path 53 | } 54 | 55 | // Construct the payload 56 | const payload = { 57 | data: reqBody.params, 58 | method: reqBody.type, 59 | url 60 | } 61 | 62 | // Send the request 63 | try { 64 | const response = await supervisorAxios(payload as AxiosRequestConfig) 65 | 66 | // Return the same http code as the one Supervisor returned 67 | res.status(response.status) 68 | // Return the Supervisor response to the UI 69 | res.json(response.data) 70 | Logger.debug('Returned Supervisor data.') 71 | } catch (error) { 72 | // Mirror the Axios status code 73 | if (request.isAxiosError(error) && error.response) { 74 | res.status(error.response.status) 75 | } else { 76 | res.status(500) 77 | } 78 | // Return the error to the UI 79 | res.json(error) 80 | } 81 | }) as RequestHandler) 82 | 83 | export default router 84 | -------------------------------------------------------------------------------- /expressjs/src/routes/v1/Tests.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Some test endpoints available only when running in development mode. 3 | // 4 | 5 | import Logger from '@/common/logger' 6 | import queueCache from '@/middleware/queueCache' 7 | import express from 'express' 8 | 9 | // Get the ExpressJS main router process 10 | const router = express.Router() 11 | 12 | // Cache tests 13 | router.get('/v1/test/cache', queueCache, (_req, res) => { 14 | Logger.warn('Running test route.') 15 | res.send({ test: 'Testing Cache' }) 16 | }) 17 | 18 | // Cache tests 19 | router.get('/v1/test/cache2', queueCache, (_req, res) => { 20 | Logger.warn('Running test route.') 21 | res.send({ test2: 'Testing Cache 2' }) 22 | }) 23 | 24 | export default router 25 | -------------------------------------------------------------------------------- /expressjs/src/routes/v1/Wifi.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Interact with Python WiFi Connect (https://github.com/balena-labs-research/python-wifi-connect) to 3 | // modify WiFi status of the device. 4 | // 5 | 6 | import createAxiosInstance from '@/common/axios' 7 | import Logger from '@/common/logger' 8 | import request, { AxiosRequestConfig } from 'axios' 9 | import express, { Request, RequestHandler, Response } from 'express' 10 | import process from 'process' 11 | 12 | // Interface for the WiFi payload 13 | interface BodyDataReq { 14 | params: Array 15 | path: string 16 | type: string 17 | } 18 | 19 | // Get the ExpressJS main router process 20 | const router = express.Router() 21 | 22 | // Set Axios defaults 23 | const wifiAxios = createAxiosInstance() 24 | wifiAxios.defaults.timeout = 30000 25 | 26 | if (process.env.WIFI_CONNECT_BASEURL) { 27 | // If there is a specific base URL set in the compose file 28 | wifiAxios.defaults.baseURL = process.env.WIFI_CONNECT_BASEURL 29 | } else if (process.env.NETWORK_MODE?.toLowerCase() === 'bridge') { 30 | // If the user specified to use the bridge network in the compose file then 31 | // fetch it from the environment variable set in the start.sh script 32 | wifiAxios.defaults.baseURL = `http://${ 33 | process.env.BRIDGE_NETWORK_IP as string 34 | }:9090/` 35 | } else { 36 | // If nothing set, use a default 37 | wifiAxios.defaults.baseURL = `http://127.0.0.1:9090/` 38 | } 39 | 40 | // -- Routes -- // 41 | router.post('/v1/wifi', (async (req: Request, res: Response) => { 42 | // Construct the payload from the passed variables ready for submitting to the Python WiFi 43 | // container 44 | const reqBody = req.body as BodyDataReq 45 | const payload = { 46 | data: reqBody.params, 47 | method: reqBody.type, 48 | url: reqBody.path 49 | } as AxiosRequestConfig 50 | 51 | // Send the request 52 | try { 53 | const response = await wifiAxios(payload) 54 | 55 | Logger.debug('Returning wifi-connect request.') 56 | 57 | // Set to the same http code as request to WiFi container 58 | res.status(response.status) 59 | 60 | // Return the data received from WiFi container to the UI 61 | res.json(response.data) 62 | } catch (error) { 63 | // Mirror the Axios status code 64 | if (request.isAxiosError(error) && error.response) { 65 | res.status(error.response.status) 66 | } else { 67 | res.status(500) 68 | } 69 | 70 | // Return the error to the users browser 71 | res.json(error) 72 | } 73 | }) as RequestHandler) 74 | 75 | export default router 76 | -------------------------------------------------------------------------------- /expressjs/src/usb/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | tmp_mount='/tmp/_balena' 4 | mkdir -p "$tmp_mount" 5 | 6 | # This script only works in a privileged container and is checked here 7 | if mount -t devtmpfs none "$tmp_mount" > /dev/null 2>&1; then 8 | PRIVILEGED=true 9 | umount "$tmp_mount" 10 | else 11 | PRIVILEGED=false 12 | fi 13 | rm -rf "$tmp_mount" 14 | 15 | mount_dev() 16 | { 17 | tmp_dir='/tmp/tmpmount' 18 | mkdir -p "$tmp_dir" 19 | mount -t devtmpfs none "$tmp_dir" 20 | mkdir -p "$tmp_dir/shm" 21 | mount --move /dev/shm "$tmp_dir/shm" 22 | mkdir -p "$tmp_dir/mqueue" 23 | mount --move /dev/mqueue "$tmp_dir/mqueue" 24 | mkdir -p "$tmp_dir/pts" 25 | mount --move /dev/pts "$tmp_dir/pts" 26 | touch "$tmp_dir/console" 27 | mount --move /dev/console "$tmp_dir/console" 28 | umount /dev || true 29 | mount --move "$tmp_dir" /dev 30 | 31 | # Since the devpts is mounted with -o newinstance by Docker, we need to make 32 | # /dev/ptmx point to its ptmx. 33 | # ref: https://www.kernel.org/doc/Documentation/filesystems/devpts.txt 34 | ln -sf /dev/pts/ptmx /dev/ptmx 35 | 36 | sysfs_dir='/sys/kernel/debug' 37 | 38 | # When using io.balena.features.sysfs the mount point will already exist. We check it here. 39 | if ! mountpoint -q "$sysfs_dir"; then 40 | mount -t debugfs nodev "$sysfs_dir" 41 | fi 42 | } 43 | 44 | start_udev() 45 | { 46 | # Check that the UDEV env is set in the compose file. 47 | if [ "$UDEV" = "on" ]; then 48 | if $PRIVILEGED; then 49 | mount_dev 50 | # Start udev to listen for USB devices 51 | unshare --net udevd --daemon > /dev/null 2>&1 52 | # Check for devices connected to the host before the container started and mount them 53 | udevadm trigger --action add --subsystem-match=block --type=devices --property=DEVTYPE=partition > /dev/null 2>&1 54 | else 55 | echo "Unable to enable USB mounting support, container must be run in privileged mode." 56 | fi 57 | fi 58 | } 59 | 60 | UDEV=$(echo "$UDEV" | awk '{print tolower($0)}') 61 | 62 | case "$UDEV" in 63 | '1' | 'true') 64 | UDEV='on' 65 | ;; 66 | esac 67 | 68 | start_udev 69 | exec "$@" 70 | -------------------------------------------------------------------------------- /expressjs/src/usb/scripts/mount.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # This script gets executed by a UDev rule whenever an external drive is plugged in. 4 | # The following env variables are set by UDev, but can be obtained if the script is executed outside of UDev context: 5 | # - DEVNAME: Device node name (i.e: /dev/sda1) 6 | # - ID_BUS: Bus type (i.e: usb) 7 | # - ID_FS_TYPE: Device filesystem (i.e: vfat) 8 | # - ID_FS_UUID_ENC: Partition's UUID (i.e: 498E-12EF) 9 | # - ID_FS_LABEL_ENC: Partition's label (i.e: YOURDEVICENAME) 10 | 11 | # Make sure we have a device name 12 | DEVNAME=${DEVNAME:=$1} 13 | if [ -z "$DEVNAME" ]; then 14 | echo "usb-mount: Invalid device name: $DEVNAME" > /proc/1/fd/2 15 | exit 1 16 | fi 17 | 18 | # Check if DEVNAME starts with /dev/sd 19 | if [ "${DEVNAME:0:7}" != "/dev/sd" ]; then 20 | # Device is not a USB drive, skipping 21 | exit 0 22 | fi 23 | 24 | # Get required device information 25 | ID_FS_TYPE=${ID_FS_TYPE:=$(udevadm info -n "$DEVNAME" | awk -F "=" '/ID_FS_TYPE/{ print $2 }')} 26 | ID_FS_UUID_ENC=${ID_FS_UUID_ENC:=$(udevadm info -n "$DEVNAME" | awk -F "=" '/ID_FS_UUID_ENC/{ print $2 }')} 27 | ID_FS_LABEL_ENC=${ID_FS_LABEL_ENC:=$(udevadm info -n "$DEVNAME" | awk -F "=" '/ID_FS_LABEL_ENC/{ print $2 }')} 28 | 29 | # If UUID is empty add alternative 30 | if [ -z "$ID_FS_UUID_ENC" ]; then 31 | ID_FS_UUID_ENC="none" 32 | fi 33 | 34 | # Check all the variables are now full 35 | if [ -z "$ID_FS_TYPE" ] || [ -z "$ID_FS_UUID_ENC" ] || [ -z "$ID_FS_LABEL_ENC" ]; then 36 | echo "usb-mount: Could not get required device information for: $DEVNAME. Skipping." > /proc/1/fd/2 37 | exit 1 38 | fi 39 | 40 | # Construct the mount point path 41 | MOUNT_POINT=/app/storage/USB-"$ID_FS_LABEL_ENC"-"$ID_FS_UUID_ENC" 42 | 43 | # Abort if file system is not supported by the kernel 44 | if ! grep -qw "$ID_FS_TYPE" /proc/filesystems; then 45 | echo "usb-mount: File system not supported: $ID_FS_TYPE" > /proc/1/fd/2 46 | exit 1 47 | fi 48 | 49 | # Mount device 50 | if findmnt -rno SOURCE,TARGET "$DEVNAME" >/dev/null; then 51 | echo "Device $DEVNAME is already mounted" > /proc/1/fd/2 52 | else 53 | echo "usb-mount: Mounting device: $DEVNAME" > /proc/1/fd/1 54 | mkdir -p "$MOUNT_POINT" 55 | mount -t "$ID_FS_TYPE" -o rw "$DEVNAME" "$MOUNT_POINT" 56 | fi 57 | -------------------------------------------------------------------------------- /expressjs/src/usb/scripts/unmount.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # This script gets executed by a UDev rule whenever an external drive is unplugged. 4 | # The following env variables are set by UDev, but can be obtained if the script is executed outside of UDev context: 5 | # - DEVNAME: Device node name (i.e: /dev/sda1) 6 | # - ID_BUS: Bus type (i.e: usb) 7 | # - ID_FS_TYPE: Device filesystem (i.e: vfat) 8 | # - ID_FS_UUID_ENC: Partition's UUID (i.e: 498E-12EF) 9 | # - ID_FS_LABEL_ENC: Partition's label (i.e: YOURDEVICENAME) 10 | 11 | # Make sure we have a device name 12 | DEVNAME=${DEVNAME:=$1} 13 | if [ -z "$DEVNAME" ]; then 14 | echo "usb-mount: Invalid device name: $DEVNAME" > /proc/1/fd/2 15 | exit 1 16 | fi 17 | 18 | # Check if DEVNAME starts with /dev/sd 19 | if [ "${DEVNAME:0:7}" != "/dev/sd" ]; then 20 | # Device is not a USB drive, skipping 21 | exit 0 22 | fi 23 | 24 | # Get required device information 25 | ID_FS_UUID_ENC=${ID_FS_UUID_ENC:=$(udevadm info -n "$DEVNAME" | awk -F "=" '/ID_FS_UUID_ENC/{ print $2 }')} 26 | ID_FS_LABEL_ENC=${ID_FS_LABEL_ENC:=$(udevadm info -n "$DEVNAME" | awk -F "=" '/ID_FS_LABEL_ENC/{ print $2 }')} 27 | 28 | # If UUID is empty add alternative 29 | if [ -z "$ID_FS_UUID_ENC" ]; then 30 | ID_FS_UUID_ENC="none" 31 | fi 32 | 33 | # Check all the variables are now full 34 | if [ -z "$ID_FS_UUID_ENC" ] || [ -z "$ID_FS_LABEL_ENC" ]; then 35 | echo "usb-mount: Could not get required device information for: $DEVNAME. Skipping." > /proc/1/fd/2 36 | exit 1 37 | fi 38 | 39 | # Construct the mount point path 40 | MOUNT_POINT=/app/storage/USB-"$ID_FS_LABEL_ENC"-"$ID_FS_UUID_ENC" 41 | 42 | # Unmount the device 43 | if findmnt -rno SOURCE,TARGET "$DEVNAME" >/dev/null; then 44 | echo "usb-mount: Unmounting device: $DEVNAME" > /proc/1/fd/1 45 | umount -f -l "$MOUNT_POINT" 46 | rmdir "$MOUNT_POINT" 47 | else 48 | echo "usb-mount: No mount point found for device $DEVNAME." > /proc/1/fd/2 49 | fi 50 | -------------------------------------------------------------------------------- /expressjs/src/usb/udev/usb.rules: -------------------------------------------------------------------------------- 1 | ACTION=="add", SUBSYSTEM=="block", ENV{DEVTYPE}=="partition", RUN+="/bin/sh -c '/usr/src/scripts/mount.sh'" 2 | ACTION=="remove", SUBSYSTEM=="block", ENV{DEVTYPE}=="partition", RUN+="/bin/sh -c '/usr/src/scripts/unmount.sh'" 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "balena-starter-interface", 3 | "private": true, 4 | "workspaces": [ 5 | "expressjs", 6 | "ui" 7 | ], 8 | "scripts": { 9 | "lint": "yarn -p -i workspaces foreach run lint", 10 | "format": "yarn -p -i workspaces foreach run format", 11 | "formatcheck": "yarn -p -i workspaces foreach run formatcheck", 12 | "dev": "LOCAL_MODE=true yarn workspace expressjs dev & DEVICE_HOSTNAME=localhost:80 yarn workspace ui dev", 13 | "dev-remote": "yarn workspace ui dev", 14 | "dev-electron": "ON_DEVICE=false yarn workspace ui dev -m electron", 15 | "dev-pwa": "ON_DEVICE=false yarn workspace ui dev -m pwa", 16 | "build": "yarn -p -i workspaces foreach run build", 17 | "build-pwa": "yarn workspace ui build-pwa", 18 | "build-electron": "yarn install --frozen-lockfile --cwd builder/ && yarn workspace ui build-electron -P never" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /scripts/.env_vars: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Exports the current bridge network IP address as an environment variable 4 | export BRIDGE_NETWORK_IP=$(ip route | awk '/default / { print $3 }' | head -n 1) 5 | -------------------------------------------------------------------------------- /scripts/start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # This start script is used to set global environment variables required by the app before 4 | # starting it 5 | 6 | # Import env variables from the file 7 | . .env_vars 8 | 9 | # Start the interface 10 | exec node index.js 11 | -------------------------------------------------------------------------------- /ui/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /ui/.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | /.quasar 4 | /src-bex/www 5 | /src-capacitor 6 | /src-cordova 7 | /src-ssr 8 | .eslintrc-treeshake.js 9 | .eslintrc.js 10 | quasar.conf.js 11 | -------------------------------------------------------------------------------- /ui/.eslintrc-treeshake.js: -------------------------------------------------------------------------------- 1 | // This file is used to run eslint --fix at build time to remove unused code. For 2 | // example any unused i18n strings that may no longer be needed as the components 3 | // that used them were deleted. 4 | 5 | module.exports = { 6 | // Files not to lint 7 | ignorePatterns: ['.eslintrc.js', '.eslintrc-treeshake.js', 'quasar.conf.js'], 8 | 9 | // Parser 10 | parserOptions: { 11 | parser: require.resolve('@typescript-eslint/parser'), 12 | extraFileExtensions: ['.vue'], 13 | tsconfigRootDir: __dirname, 14 | project: './tsconfig.json' 15 | }, 16 | 17 | // Extensions 18 | extends: ['plugin:@typescript-eslint/base', 'plugin:vue/base'], 19 | 20 | // Overrides to ensure json files are handled correctly 21 | overrides: [ 22 | { 23 | files: ['*.json'], 24 | extends: ['plugin:@intlify/vue-i18n/base'] 25 | } 26 | ], 27 | 28 | // Rules 29 | rules: { 30 | '@intlify/vue-i18n/no-unused-keys': [ 31 | 'error', 32 | { 33 | src: './src', 34 | extensions: ['.js', '.vue', '.ts'], 35 | ignores: ['/yml_config/'], 36 | enableFix: true 37 | } 38 | ] 39 | }, 40 | settings: { 41 | 'vue-i18n': { 42 | localeDir: './src/i18n/*.json', 43 | 44 | // Specify the version of `vue-i18n` you are using. 45 | // If not specified, the message will be parsed twice. 46 | messageSyntaxVersion: '^9.0.0' 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /ui/.postcss.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // https://github.com/michael-ciniawsky/postcss-load-config 3 | 4 | module.exports = { 5 | plugins: [ 6 | // https://github.com/postcss/autoprefixer 7 | require('autoprefixer')({ 8 | overrideBrowserslist: [ 9 | 'last 4 Chrome versions', 10 | 'last 4 Firefox versions', 11 | 'last 4 Edge versions', 12 | 'last 4 Safari versions', 13 | 'last 4 Android versions', 14 | 'last 4 ChromeAndroid versions', 15 | 'last 4 FirefoxAndroid versions', 16 | 'last 4 iOS versions' 17 | ] 18 | }) 19 | 20 | // https://github.com/elchininet/postcss-rtlcss 21 | // If you want to support RTL css, then 22 | // 1. yarn/npm install postcss-rtlcss 23 | // 2. optionally set quasar.config.js > framework > lang to an RTL language 24 | // 3. uncomment the following line: 25 | // require('postcss-rtlcss') 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /ui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "none" 5 | } 6 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= productName %> 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 16 | 22 | 28 | 34 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui", 3 | "productName": "Balena Starter Interface", 4 | "description": "Balena Starter Interface", 5 | "license": "MIT", 6 | "author": "maggie0002", 7 | "version": "0.0.1", 8 | "repository": "https://github.com/balena-labs-research/starter-interface", 9 | "scripts": { 10 | "lint": "eslint --ext .js,.ts,.vue,.json ./", 11 | "format": "prettier --write \"**/*.{js,ts,vue,scss,html,md}\" --ignore-path ../.gitignore", 12 | "formatcheck": "prettier \"**/*.{js,ts,vue,scss,html,md}\" --ignore-path ../.gitignore --check", 13 | "test": "echo \"No test specified\" && exit 0", 14 | "dev": "quasar dev", 15 | "build": "yarn lint -c .eslintrc-treeshake.js --no-eslintrc --fix && quasar build", 16 | "build-pwa": "yarn lint -c .eslintrc-treeshake.js --no-eslintrc --fix && quasar build -m pwa", 17 | "build-electron": "yarn lint -c .eslintrc-treeshake.js --no-eslintrc --fix && quasar build -m electron" 18 | }, 19 | "dependencies": { 20 | "@electron/remote": "^2.0.8", 21 | "@quasar/extras": "^1.15.10", 22 | "axios": "^1.2.3", 23 | "chart.js": "^4.2.0", 24 | "js-file-download": "^0.4.12", 25 | "pinia": "^2.0.29", 26 | "quasar": "^2.11.5", 27 | "vue": "^3.2.41", 28 | "vue-chart-3": "^3.1.8", 29 | "vue-i18n": "^9.2.2", 30 | "vue-router": "^4.1.5" 31 | }, 32 | "devDependencies": { 33 | "@intlify/eslint-plugin-vue-i18n": "^2.0.0", 34 | "@intlify/vite-plugin-vue-i18n": "^6.0.3", 35 | "@quasar/app-vite": "^1.1.3", 36 | "@types/node": "^18.11.2", 37 | "@typescript-eslint/eslint-plugin": "^5.48.2", 38 | "@typescript-eslint/parser": "^5.48.2", 39 | "autoprefixer": "^10.4.12", 40 | "electron": "22.0.3", 41 | "electron-builder": "^23.6.0", 42 | "eslint": "^8.32.0", 43 | "eslint-config-airbnb-base": "^15.0.0", 44 | "eslint-config-prettier": "^8.6.0", 45 | "eslint-plugin-vue": "^9.9.0", 46 | "js-yaml": "^4.1.0", 47 | "prettier": "^2.8.3", 48 | "typescript": "^4.8.4", 49 | "workbox-build": "^6.5.4", 50 | "workbox-cacheable-response": "^6.5.4", 51 | "workbox-core": "^6.5.4", 52 | "workbox-expiration": "^6.5.4", 53 | "workbox-precaching": "^6.5.4", 54 | "workbox-routing": "^6.5.4", 55 | "workbox-strategies": "^6.5.4" 56 | }, 57 | "engines": { 58 | "node": "^18", 59 | "npm": ">= 6.13.4", 60 | "yarn": ">= 1.21.1" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/public/icons/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-icon-120x120.png -------------------------------------------------------------------------------- /ui/public/icons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-icon-152x152.png -------------------------------------------------------------------------------- /ui/public/icons/apple-icon-167x167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-icon-167x167.png -------------------------------------------------------------------------------- /ui/public/icons/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-icon-180x180.png -------------------------------------------------------------------------------- /ui/public/icons/apple-launch-1125x2436.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-launch-1125x2436.png -------------------------------------------------------------------------------- /ui/public/icons/apple-launch-1170x2532.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-launch-1170x2532.png -------------------------------------------------------------------------------- /ui/public/icons/apple-launch-1242x2208.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-launch-1242x2208.png -------------------------------------------------------------------------------- /ui/public/icons/apple-launch-1242x2688.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-launch-1242x2688.png -------------------------------------------------------------------------------- /ui/public/icons/apple-launch-1284x2778.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-launch-1284x2778.png -------------------------------------------------------------------------------- /ui/public/icons/apple-launch-1536x2048.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-launch-1536x2048.png -------------------------------------------------------------------------------- /ui/public/icons/apple-launch-1620x2160.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-launch-1620x2160.png -------------------------------------------------------------------------------- /ui/public/icons/apple-launch-1668x2224.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-launch-1668x2224.png -------------------------------------------------------------------------------- /ui/public/icons/apple-launch-1668x2388.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-launch-1668x2388.png -------------------------------------------------------------------------------- /ui/public/icons/apple-launch-2048x2732.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-launch-2048x2732.png -------------------------------------------------------------------------------- /ui/public/icons/apple-launch-750x1334.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-launch-750x1334.png -------------------------------------------------------------------------------- /ui/public/icons/apple-launch-828x1792.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/apple-launch-828x1792.png -------------------------------------------------------------------------------- /ui/public/icons/favicon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/favicon-128x128.png -------------------------------------------------------------------------------- /ui/public/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/favicon-16x16.png -------------------------------------------------------------------------------- /ui/public/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/favicon-32x32.png -------------------------------------------------------------------------------- /ui/public/icons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/favicon-96x96.png -------------------------------------------------------------------------------- /ui/public/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/icon-128x128.png -------------------------------------------------------------------------------- /ui/public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /ui/public/icons/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/icon-256x256.png -------------------------------------------------------------------------------- /ui/public/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/icon-384x384.png -------------------------------------------------------------------------------- /ui/public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /ui/public/icons/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/public/icons/ms-icon-144x144.png -------------------------------------------------------------------------------- /ui/public/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/public/logo_colour.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | 10 | 13 | 15 | 16 | 18 | 21 | 23 | 24 | 26 | 30 | 33 | 34 | -------------------------------------------------------------------------------- /ui/public/logo_white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | 10 | 13 | 15 | 16 | 18 | 21 | 23 | 24 | 26 | 30 | 33 | 34 | -------------------------------------------------------------------------------- /ui/src-electron/electron-env.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | declare namespace NodeJS { 4 | interface ProcessEnv { 5 | QUASAR_ELECTRON_PRELOAD: string 6 | APP_URL: string 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ui/src-electron/electron-flag.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // THIS FEATURE-FLAG FILE IS AUTOGENERATED, 3 | // REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING 4 | import 'quasar/dist/types/feature-flag' 5 | 6 | declare module 'quasar/dist/types/feature-flag' { 7 | interface QuasarFeatureFlags { 8 | electron: true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ui/src-electron/electron-main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, nativeTheme } from 'electron' 2 | import { initialize, enable } from '@electron/remote/main' 3 | import path from 'path' 4 | import os from 'os' 5 | 6 | initialize() 7 | 8 | // needed in case process is undefined under Linux 9 | const platform = process.platform || os.platform() 10 | 11 | try { 12 | if (platform === 'win32' && nativeTheme.shouldUseDarkColors === true) { 13 | require('fs').unlinkSync( 14 | path.join(app.getPath('userData'), 'DevTools Extensions') 15 | ) 16 | } 17 | } catch (_) {} 18 | 19 | let mainWindow: BrowserWindow | undefined 20 | 21 | function createWindow() { 22 | /** 23 | * Initial window options 24 | */ 25 | mainWindow = new BrowserWindow({ 26 | icon: path.resolve(__dirname, 'icons/icon.png'), // tray icon 27 | minWidth: 550, 28 | width: 1000, 29 | height: 600, 30 | useContentSize: true, 31 | frame: false, 32 | webPreferences: { 33 | contextIsolation: true, 34 | nodeIntegration: true, 35 | // More info: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/electron-preload-script 36 | preload: path.resolve(__dirname, process.env.QUASAR_ELECTRON_PRELOAD) 37 | } 38 | }) 39 | 40 | enable(mainWindow.webContents) 41 | 42 | mainWindow.loadURL(process.env.APP_URL) 43 | 44 | if (process.env.DEBUGGING) { 45 | // if on DEV or Production with debug enabled 46 | mainWindow.webContents.openDevTools() 47 | } else { 48 | // we're on production; no access to devtools pls 49 | mainWindow.webContents.on('devtools-opened', () => { 50 | mainWindow?.webContents.closeDevTools() 51 | }) 52 | } 53 | 54 | mainWindow.on('closed', () => { 55 | mainWindow = undefined 56 | }) 57 | } 58 | 59 | app.whenReady().then(createWindow) 60 | 61 | app.on('window-all-closed', () => { 62 | if (platform !== 'darwin') { 63 | app.quit() 64 | } 65 | }) 66 | 67 | app.on('activate', () => { 68 | if (mainWindow === undefined) { 69 | createWindow() 70 | } 71 | }) 72 | -------------------------------------------------------------------------------- /ui/src-electron/electron-preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge } from 'electron' 2 | import { BrowserWindow } from '@electron/remote' 3 | 4 | contextBridge.exposeInMainWorld('myWindowAPI', { 5 | minimize() { 6 | BrowserWindow.getFocusedWindow()?.minimize() 7 | }, 8 | 9 | toggleMaximize() { 10 | const win = BrowserWindow.getFocusedWindow() 11 | 12 | if (win?.isMaximized()) { 13 | win.unmaximize() 14 | } else { 15 | win?.maximize() 16 | } 17 | }, 18 | 19 | close() { 20 | BrowserWindow.getFocusedWindow()?.close() 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /ui/src-electron/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/src-electron/icons/icon.icns -------------------------------------------------------------------------------- /ui/src-electron/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/src-electron/icons/icon.ico -------------------------------------------------------------------------------- /ui/src-electron/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/src-electron/icons/icon.png -------------------------------------------------------------------------------- /ui/src-pwa/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path') 2 | 3 | module.exports = { 4 | parserOptions: { 5 | project: resolve(__dirname, './tsconfig.json') 6 | }, 7 | 8 | overrides: [ 9 | { 10 | files: ['custom-service-worker.ts'], 11 | 12 | env: { 13 | serviceworker: true 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /ui/src-pwa/custom-service-worker.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This file (which will be your service worker) 3 | * is picked up by the build system ONLY if 4 | * quasar.config.js > pwa > workboxMode is set to "injectManifest" 5 | */ 6 | 7 | declare const self: ServiceWorkerGlobalScope & typeof globalThis 8 | 9 | import { clientsClaim } from 'workbox-core' 10 | import { 11 | precacheAndRoute, 12 | cleanupOutdatedCaches, 13 | createHandlerBoundToURL 14 | } from 'workbox-precaching' 15 | import { registerRoute, NavigationRoute } from 'workbox-routing' 16 | 17 | self.skipWaiting() 18 | clientsClaim() 19 | 20 | // Use with precache injection 21 | precacheAndRoute(self.__WB_MANIFEST) 22 | 23 | cleanupOutdatedCaches() 24 | 25 | // Non-SSR fallback to index.html 26 | // Production SSR fallback to offline.html (except for dev) 27 | if (process.env.MODE !== 'ssr' || process.env.PROD) { 28 | registerRoute( 29 | new NavigationRoute( 30 | createHandlerBoundToURL(process.env.PWA_FALLBACK_HTML), 31 | { denylist: [/sw\.js$/, /workbox-(.)*\.js$/] } 32 | ) 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /ui/src-pwa/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Balena Starter Interface", 3 | "short_name": "Balena Starter Interface", 4 | "description": "A quick start interface for interacting with Balena devices", 5 | "icons": [ 6 | { 7 | "src": "icons/icon-128x128.png", 8 | "sizes": "128x128", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "icons/icon-192x192.png", 13 | "sizes": "192x192", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "icons/icon-256x256.png", 18 | "sizes": "256x256", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "icons/icon-384x384.png", 23 | "sizes": "384x384", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "icons/icon-512x512.png", 28 | "sizes": "512x512", 29 | "type": "image/png" 30 | } 31 | ], 32 | "display": "standalone", 33 | "orientation": "portrait", 34 | "background_color": "#ffc600", 35 | "theme_color": "#ffc600" 36 | } 37 | -------------------------------------------------------------------------------- /ui/src-pwa/pwa-env.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | declare namespace NodeJS { 4 | interface ProcessEnv { 5 | SERVICE_WORKER_FILE: string 6 | PWA_FALLBACK_HTML: string 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ui/src-pwa/pwa-flag.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // THIS FEATURE-FLAG FILE IS AUTOGENERATED, 3 | // REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING 4 | import 'quasar/dist/types/feature-flag' 5 | 6 | declare module 'quasar/dist/types/feature-flag' { 7 | interface QuasarFeatureFlags { 8 | pwa: true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ui/src-pwa/register-service-worker.ts: -------------------------------------------------------------------------------- 1 | import { register } from 'register-service-worker' 2 | 3 | // The ready(), registered(), cached(), updatefound() and updated() 4 | // events passes a ServiceWorkerRegistration instance in their arguments. 5 | // ServiceWorkerRegistration: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration 6 | 7 | register(process.env.SERVICE_WORKER_FILE, { 8 | // The registrationOptions object will be passed as the second argument 9 | // to ServiceWorkerContainer.register() 10 | // https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register#Parameter 11 | 12 | // registrationOptions: { scope: './' }, 13 | 14 | ready(/* registration */) { 15 | // console.log('Service worker is active.') 16 | }, 17 | 18 | registered(/* registration */) { 19 | // console.log('Service worker has been registered.') 20 | }, 21 | 22 | cached(/* registration */) { 23 | // console.log('Content has been cached for offline use.') 24 | }, 25 | 26 | updatefound(/* registration */) { 27 | // console.log('New content is downloading.') 28 | }, 29 | 30 | updated(/* registration */) { 31 | // console.log('New content is available; please refresh.') 32 | }, 33 | 34 | offline() { 35 | // console.log('No internet connection found. App is running in offline mode.') 36 | }, 37 | 38 | error(/* err */) { 39 | // console.error('Error during service worker registration:', err) 40 | } 41 | }) 42 | -------------------------------------------------------------------------------- /ui/src-pwa/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["WebWorker", "ESNext"] 5 | }, 6 | "include": ["*.ts", "*.d.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/App.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 44 | -------------------------------------------------------------------------------- /ui/src/api/sdk.ts: -------------------------------------------------------------------------------- 1 | // Functions for communication from the users browser, to the ExpressJS backend, and 2 | // then from the backend to the SDK. This is more secure than having to store API keys 3 | // in the users browser. 4 | // 5 | // Balena SDK Documentation for reference: https://www.balena.io/docs/reference/sdk/node-sdk/ 6 | 7 | import { expressApi } from 'boot/axios' 8 | 9 | const apiPathV1 = '/v1/sdk' as string 10 | 11 | interface EnvVar { 12 | [key: string]: string 13 | } 14 | 15 | export const sdk = { 16 | deleteEnv(objectOfKeys: object) { 17 | // Axios delete method is not the same as get or post, so 'data' option is needed in the body 18 | return expressApi.delete(`${apiPathV1}/envVars`, { 19 | data: objectOfKeys 20 | }) 21 | }, 22 | 23 | device() { 24 | return expressApi.get(`${apiPathV1}/device`) 25 | }, 26 | 27 | getEnv() { 28 | return expressApi.get(`${apiPathV1}/envVars`) 29 | }, 30 | 31 | setEnv(newKey: string, newValue: string) { 32 | const payload: EnvVar = {} 33 | payload[newKey] = newValue 34 | return expressApi.post(`${apiPathV1}/envVars`, payload) 35 | }, 36 | 37 | uuid() { 38 | return expressApi.get(`${apiPathV1}/uuid`) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /ui/src/api/sysInfoCmds.ts: -------------------------------------------------------------------------------- 1 | // This file acts as a single source of truth for `systeminformation` commands used by 2 | // the menu selector on the System Info page. Changes to labels can be done here, but 3 | // cmd and id require changes on the backend and throughout the UI where ids are used. 4 | 5 | export default [ 6 | { id: '/', cmd: 'getAllData', label: 'All Data' }, 7 | { id: 'a', cmd: 'audio', label: 'Audio' }, 8 | { id: 'B', cmd: 'baseboard', label: 'Baseboard' }, 9 | { id: 'Y', cmd: 'battery', label: 'Battery' }, 10 | { id: 'b', cmd: 'bios', label: 'Bios' }, 11 | { id: 'e', cmd: 'blockDevices', label: 'Block Devices' }, 12 | { id: 'h', cmd: 'bluetoothDevices', label: 'Bluetooth Devices' }, 13 | { id: 'C', cmd: 'chassis', label: 'Chassis' }, 14 | { id: 'c', cmd: 'cpu', label: 'CPU' }, 15 | { id: 'j', cmd: 'cpuCurrentSpeed', label: 'CPU Current Speed' }, 16 | { id: 'T', cmd: 'cpuTemperature', label: 'CPU Temperature' }, 17 | { id: 'l', cmd: 'currentLoad', label: 'Current Load' }, 18 | { id: 'd', cmd: 'diskLayout', label: 'Disk Layout' }, 19 | { id: 'D', cmd: 'disksIO', label: 'Disks IO' }, 20 | { 21 | id: '0', 22 | cmd: 'dockerContainerProcesses', 23 | label: 'Docker Container Processes' 24 | }, 25 | { id: '8', cmd: 'dockerContainers', label: 'Docker Containers' }, 26 | { id: '9', cmd: 'dockerContainerStats', label: 'Docker Container Stats' }, 27 | { id: '7', cmd: 'dockerImages', label: 'Docker Images' }, 28 | { id: '6', cmd: 'dockerInfo', label: 'Docker Info' }, 29 | { id: '+', cmd: 'dockerVolumes', label: 'Docker Volumes' }, 30 | { id: 'E', cmd: 'fsOpenFiles', label: 'Fs Open Files' }, 31 | { id: 'f', cmd: 'fsSize', label: 'Fs Size' }, 32 | { id: 'F', cmd: 'fsStats', label: 'Fs Stats' }, 33 | { id: 'L', cmd: 'fullLoad', label: 'Full Load' }, 34 | { id: '.', cmd: 'getStaticData', label: 'Get Static Data' }, 35 | { id: 'g', cmd: 'graphics', label: 'Graphics' }, 36 | { id: 'i', cmd: 'inetLatency', label: 'Inet Latency' }, 37 | { id: 'm', cmd: 'mem', label: 'Mem' }, 38 | { id: 'M', cmd: 'memLayout', label: 'Mem Layout' }, 39 | { id: '5', cmd: 'networkConnections', label: 'Network Connections' }, 40 | { id: '2', cmd: 'networkGatewayDefault', label: 'Network Gateway Default' }, 41 | { 42 | id: '1', 43 | cmd: 'networkInterfaceDefault', 44 | label: 'Network Interface Default' 45 | }, 46 | { id: '3', cmd: 'networkInterfaces', label: 'Network Interfaces' }, 47 | { id: '4', cmd: 'networkStats', label: 'Network Stats' }, 48 | { id: 'o', cmd: 'osInfo', label: 'Os Info' }, 49 | { id: 'r', cmd: 'printer', label: 'Printer' }, 50 | { id: 'p', cmd: 'processes', label: 'Processes' }, 51 | { id: 'S', cmd: 'shell', label: 'Shell' }, 52 | { id: 'y', cmd: 'system', label: 'System' }, 53 | { id: 'w', cmd: 'wifiNetworks', label: 'Wifi Networks' }, 54 | { id: 'u', cmd: 'usb', label: 'USB' }, 55 | { id: 'z', cmd: 'users', label: 'Users' }, 56 | { id: 'U', cmd: 'uuid', label: 'UUID' }, 57 | { id: 'V', cmd: 'vboxInfo', label: 'VBox Info' }, 58 | { id: 'v', cmd: 'versions', label: 'Versions' }, 59 | { id: 'x', cmd: 'wifiConnections', label: 'Wifi Connections' }, 60 | { id: 'W', cmd: 'wifiInterfaces', label: 'Wifi Interfaces' } 61 | ] 62 | -------------------------------------------------------------------------------- /ui/src/boot/axios.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from 'axios' 2 | import { i18n } from 'boot/i18n' 3 | import { Notify } from 'quasar' 4 | import { axiosSettings } from 'stores/system' 5 | import { boot } from 'quasar/wrappers' 6 | 7 | // eslint-disable-next-line @typescript-eslint/unbound-method 8 | const { t } = i18n.global 9 | 10 | const expressApi = axios.create({ 11 | timeout: 60000 // Sets a high timeout unlikely to be reached. More specific timeouts are set in the ExpressJS backend. 12 | }) 13 | 14 | export default boot(() => { 15 | const axiosBaseUrl = axiosSettings() 16 | 17 | // Set the device address to use for backend API requests based on available 18 | // DEVICE_HOSTNAME environment variable 19 | if (process.env.DEVICE_HOSTNAME) { 20 | expressApi.defaults.baseURL = `http://${process.env.DEVICE_HOSTNAME}` 21 | } else { 22 | // Generate URL from self 23 | const currentURL = new URL(window.location.href) 24 | // Set backend URL to URL currently in browser (including port where applicable) 25 | expressApi.defaults.baseURL = currentURL.origin 26 | } 27 | 28 | // Store the default URL in the Pinia store. The Axios instance is not reactive 29 | // so we use a store instead 30 | axiosBaseUrl.setUrl(expressApi.defaults.baseURL) 31 | 32 | // Axios request interceptor 33 | expressApi.interceptors.request.use( 34 | (config) => { 35 | // Override the default baseURL based on stored path from electron app 36 | const currentBaseUrl = axiosBaseUrl.$state.axiosBaseUrl 37 | 38 | if (currentBaseUrl) { 39 | config.baseURL = currentBaseUrl 40 | } 41 | return config 42 | }, 43 | (error: AxiosError) => { 44 | if (error && error.request) { 45 | // The request was made but no response was received 46 | // `error.request` is an instance of XMLHttpRequest in the browser and an instance of 47 | // http.ClientRequest in node.js 48 | Notify.create({ 49 | type: 'negative', 50 | message: t('system.errors.request_error') 51 | }) 52 | } 53 | // Reject with UI Axios error 54 | return Promise.reject(error) 55 | } 56 | ) 57 | 58 | // Axios response interceptor 59 | expressApi.interceptors.response.use( 60 | (response) => response, 61 | (error: AxiosError) => { 62 | if (error && error.response) { 63 | // Any status codes that falls outside the range of 2xx cause this function to trigger 64 | Notify.create({ 65 | type: 'negative', 66 | message: `${t('general.error')}: ${t('system.errors.no_backend')}` 67 | }) 68 | console.error(`Axios error. Status code: ${error.response.status}`) 69 | } 70 | // Reject with UI Axios error 71 | return Promise.reject(error) 72 | } 73 | ) 74 | }) 75 | 76 | export { expressApi } 77 | -------------------------------------------------------------------------------- /ui/src/boot/i18n.ts: -------------------------------------------------------------------------------- 1 | import enUS from 'src/i18n/en-US.json' 2 | import { qLangList } from 'src/config/localeOptions' 3 | import { LocalStorage, Quasar, QuasarLanguage } from 'quasar' 4 | import { boot } from 'quasar/wrappers' 5 | import { createI18n } from 'vue-i18n' 6 | 7 | // Set default language 8 | const defaultLang = 'en-US' 9 | const loadedLanguages = [defaultLang] 10 | 11 | // Store all the i18n files in glob. This allows language files to be deleted and 12 | // added without the need for changes to the code. 13 | const langGlob = import.meta.glob('src/i18n/*.json') 14 | 15 | // Create i18n instance 16 | const i18n = createI18n({ 17 | globalInjection: true, 18 | legacy: false, 19 | locale: 'en-US', 20 | messages: { 21 | 'en-US': enUS 22 | } 23 | }) 24 | 25 | // Set language to previously chosen according to local storage cookie, otherwise 26 | // use browser default 27 | if (LocalStorage.getItem('lang')) { 28 | void loadLanguageAsync(LocalStorage.getItem('lang') as string) 29 | } else if (langGlob[`../i18n/${Quasar.lang.getLocale() as string}.json`]) { 30 | void loadLanguageAsync(Quasar.lang.getLocale() as string) 31 | } 32 | 33 | async function setLanguage(isoName: string) { 34 | // Set the UI language 35 | i18n.global.locale.value = isoName as 'en-US' 36 | // Load and set the selected Quasar language pack 37 | try { 38 | const lang = await qLangList[ 39 | `../../../node_modules/quasar/lang/${isoName}.mjs` 40 | ]() 41 | Quasar.lang.set(lang.default as QuasarLanguage) 42 | } catch (error) { 43 | // Requested Quasar Language Pack does not exist, 44 | // let's not break the app, so catching error 45 | } 46 | } 47 | 48 | // i18n languages are lazy loaded to improve inital load time, and therefore 49 | // when changing language we need to check if it has already been loaded and 50 | // load it if it isn't. 51 | export async function loadLanguageAsync(isoName: string) { 52 | // Store the chosen language in local storage 53 | LocalStorage.set('lang', isoName) 54 | 55 | // If the language was already loaded 56 | if (loadedLanguages.includes(isoName)) { 57 | return Promise.resolve(setLanguage(isoName)) 58 | } 59 | 60 | // If the language hasn't been loaded yet 61 | try { 62 | const messages = await langGlob[`../i18n/${isoName}.json`]() 63 | i18n.global.setLocaleMessage(isoName, messages.default) 64 | loadedLanguages.push(isoName) 65 | 66 | return Promise.resolve(setLanguage(isoName)) 67 | } catch (error) { 68 | return Promise.reject(error) 69 | } 70 | } 71 | 72 | export default boot(({ app }) => { 73 | // Set i18n instance on app 74 | app.use(i18n) 75 | }) 76 | 77 | export { i18n } 78 | -------------------------------------------------------------------------------- /ui/src/boot/pinia.ts: -------------------------------------------------------------------------------- 1 | // Pinia store actions to call on first boot 2 | import { networkSettings } from 'stores/system' 3 | import { boot } from 'quasar/wrappers' 4 | 5 | export default boot(() => { 6 | // Get Cloudlink status if running on a device (for example when not an Electron app) 7 | if (process.env.ON_DEVICE?.toLowerCase() === 'true') { 8 | const networkStore = networkSettings() 9 | void networkStore.checkCloudlink() 10 | } 11 | }) 12 | -------------------------------------------------------------------------------- /ui/src/boot/ymlImport.ts: -------------------------------------------------------------------------------- 1 | // Import the config.yml file as a JSON object and make it available to other processes as a reactive object. 2 | import { expressApi } from 'boot/axios' 3 | import { reactive } from 'vue' 4 | import { boot } from 'quasar/wrappers' 5 | 6 | interface ymlConfig { 7 | captive_portal: { welcome_page: boolean } 8 | pages: { 9 | [index: string]: { 10 | frames: { 11 | [index: string]: { 12 | components: Array 13 | rows: boolean 14 | title: string 15 | } 16 | } 17 | icon: string 18 | label: string 19 | path: string 20 | } 21 | } 22 | styles: { 23 | header: { 24 | language_selector: boolean 25 | reboot_icon: boolean 26 | shutdown_icon: boolean 27 | title: string 28 | visible: boolean 29 | } 30 | } 31 | } 32 | 33 | const configYml = reactive( 34 | JSON.parse(process.env.CONFIG_YML as string) 35 | ) as ymlConfig 36 | 37 | export default boot(async () => { 38 | if (process.env.LIVE_CONFIG) { 39 | try { 40 | const response = await expressApi.get('/v1/system/config_yml', { 41 | timeout: 2000 42 | }) 43 | 44 | // Update the reactive object with the new data. Adds `message` if no config.yml file found. 45 | Object.assign(configYml, response.data as ymlConfig) 46 | } catch (error) { 47 | console.error('Failed processing yml config file. Trying to continue.') 48 | console.error(error) 49 | } 50 | } 51 | }) 52 | 53 | export { configYml } 54 | -------------------------------------------------------------------------------- /ui/src/components/ChartsMemoryStats.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 195 | -------------------------------------------------------------------------------- /ui/src/components/DeviceTabSelector.vue: -------------------------------------------------------------------------------- 1 | 111 | 112 | 181 | -------------------------------------------------------------------------------- /ui/src/components/MainLayoutMenuItems.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 47 | -------------------------------------------------------------------------------- /ui/src/components/SystemChangeHostname.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 53 | -------------------------------------------------------------------------------- /ui/src/components/SystemJournalDLogs.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 41 | -------------------------------------------------------------------------------- /ui/src/components/SystemReboot.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 81 | -------------------------------------------------------------------------------- /ui/src/components/SystemShutdown.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 81 | -------------------------------------------------------------------------------- /ui/src/components/SystemUpdateDevice.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 46 | -------------------------------------------------------------------------------- /ui/src/components/ToolsContainerManager.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 205 | -------------------------------------------------------------------------------- /ui/src/components/ToolsSystemInfo.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 64 | -------------------------------------------------------------------------------- /ui/src/components/WifiConfigurePassword.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /ui/src/components/WifiConfigureSSID.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /ui/src/components/WifiForgetAllWifi.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 95 | -------------------------------------------------------------------------------- /ui/src/config/localeOptions.ts: -------------------------------------------------------------------------------- 1 | // Languages for the language switcher menu. 2 | 3 | // They will display in the same order as given here. 4 | 5 | // Value = name of the json file stored in the i18n folder (without extension). Files must 6 | // follow the standard naming used by browsers to benefit from auto detection of locale. 7 | 8 | // Label = the name to display in the language switcher for that language. 9 | 10 | export default [ 11 | { value: 'en-US', label: 'English' }, 12 | { value: 'es', label: 'Español' }, 13 | { value: 'fr', label: 'Français' }, 14 | { value: 'it', label: 'Italiana' }, 15 | { value: 'pt-BR', label: 'Português' }, 16 | { value: 'ru', label: 'русский' }, 17 | { value: 'tr', label: 'Türk' } 18 | ] 19 | 20 | // Quasar has its own language packs that are used as generic labels for things like tables. To keep 21 | // the build small, only the language packs for active languages are imported. When adding additional 22 | // languages, change the glob in the import statement to include the new language pack. Entires 23 | // should match those included in the default export above. 24 | 25 | // '../../../node_modules/quasar/lang/(en-US|fr).mjs' <-- imports English and French 26 | // '../../../node_modules/quasar/lang/(en-US|fr|it).mjs' <-- imports English, French and Italian 27 | 28 | export const qLangList = import.meta.glob( 29 | '../../../node_modules/quasar/lang/(en-US|es|fr|it|pt-BR|ru|tr).mjs' 30 | ) 31 | -------------------------------------------------------------------------------- /ui/src/config/qStyles.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Configuration file for various visual effects, headers and colours 3 | // 4 | 5 | // Global button configuration 6 | export const qBtnStyle = { 7 | color: 'secondary', 8 | outline: false, 9 | rounded: true, 10 | unelevated: true, 11 | size: 'sm', 12 | 'no-caps': true 13 | } 14 | 15 | // Header configuration 16 | export const qHeaderStyle = { 17 | header: { 18 | elevated: true 19 | }, 20 | // Remove the logo lines to have no logo 21 | logo_coloured: 'logo_colour.svg', // Logo for displaying on white backgrounds. 22 | logo_white: 'logo_white.svg', // Logo for displaying on coloured backgrounds. 23 | title: { class: 'text-subtitle1' } 24 | } 25 | 26 | // Global avatar configuration, for small icons or buttons used throughout the interface 27 | export const qAvatarStyle = { 28 | size: 'lg', 29 | color: 'accent', 30 | 'text-color': 'primary' 31 | } 32 | 33 | // Global spinner configuration, used for things like loading indicators 34 | export const qSpinnerStyle = { 35 | class: 'text-accent', 36 | size: '6em' 37 | } 38 | -------------------------------------------------------------------------------- /ui/src/config/sideDrawer.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from 'boot/i18n' 2 | import { configYml } from 'src/boot/ymlImport' 3 | import { computed } from 'vue' 4 | 5 | // eslint-disable-next-line @typescript-eslint/unbound-method 6 | const { t } = i18n.global 7 | 8 | // For each item page in config yml file, create an object 9 | const sideDrawerItems = Object.entries(configYml.pages).map((ymlArray) => ({ 10 | icon: ymlArray[1].icon, 11 | label: t(ymlArray[1].label), 12 | path: ymlArray[0] 13 | })) 14 | 15 | // List of menu items to display in left hand navigation bar 16 | export default computed(() => sideDrawerItems) 17 | -------------------------------------------------------------------------------- /ui/src/css/app.scss: -------------------------------------------------------------------------------- 1 | // app global css in SCSS form 2 | @font-face { 3 | font-family: 'Source Sans Pro'; 4 | src: url(./fonts/SourceSansPro-Regular.woff) format('woff'); 5 | src: url(./fonts/SourceSansPro-Regular.woff2) format('woff2'); 6 | } 7 | 8 | * { 9 | font-family: 'Source Sans Pro', Helvetica, sans-serif; 10 | } 11 | 12 | body { 13 | background-color: #f8f9fd; 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/css/fonts/SourceSansPro-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/src/css/fonts/SourceSansPro-Regular.woff -------------------------------------------------------------------------------- /ui/src/css/fonts/SourceSansPro-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/balena-io-experimental/starter-interface/5cc4965c6caecdad07875942ab209c8092ab6910/ui/src/css/fonts/SourceSansPro-Regular.woff2 -------------------------------------------------------------------------------- /ui/src/css/quasar.variables.scss: -------------------------------------------------------------------------------- 1 | // Quasar SCSS (& Sass) Variables 2 | // -------------------------------------------------- 3 | // To customize the look and feel of this app, you can override 4 | // the Sass/SCSS variables found in Quasar's source Sass/SCSS files. 5 | 6 | // Check documentation for full list of Quasar variables 7 | 8 | // Your own variables (that are declared here) and Quasar's own 9 | // ones will be available out of the box in your .vue/.scss/.sass files 10 | 11 | // It's highly recommended to change the default colors 12 | // to match your app's branding. 13 | // Tip: Use the "Theme Builder" on Quasar's documentation website. 14 | 15 | $primary: #2a506f; 16 | $secondary: #09acef; 17 | $accent: #ffc600; 18 | 19 | $dark: #1d1d1d; 20 | 21 | $positive: #21ba45; 22 | $negative: #c10015; 23 | $info: #31ccec; 24 | $warning: #f2c037; 25 | -------------------------------------------------------------------------------- /ui/src/env.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface ProcessEnv { 3 | NODE_ENV: string 4 | VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined 5 | VUE_ROUTER_BASE: string | undefined 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/i18n/ca.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /ui/src/i18n/da.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /ui/src/i18n/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "general": { 3 | "password": "Passwort", 4 | "are_you_sure": "Sind Sie sicher?", 5 | "close": "Schließen" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/i18n/nb-NO.json: -------------------------------------------------------------------------------- 1 | { 2 | "general": { 3 | "are_you_sure": "Er du sikker?" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/layouts/CaptivePortal.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 119 | -------------------------------------------------------------------------------- /ui/src/layouts/ComponentFrame.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 35 | 36 | 72 | -------------------------------------------------------------------------------- /ui/src/layouts/ElectronLayout.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 88 | -------------------------------------------------------------------------------- /ui/src/layouts/ErrorNotFound.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 34 | -------------------------------------------------------------------------------- /ui/src/layouts/PwaLayout.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 98 | -------------------------------------------------------------------------------- /ui/src/layouts/YmlImport.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 61 | -------------------------------------------------------------------------------- /ui/src/pages/Configuration.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | -------------------------------------------------------------------------------- /ui/src/pages/ContainerManager.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | -------------------------------------------------------------------------------- /ui/src/pages/FileManager.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | -------------------------------------------------------------------------------- /ui/src/pages/IndexPage.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | -------------------------------------------------------------------------------- /ui/src/pages/Networking.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | -------------------------------------------------------------------------------- /ui/src/pages/SystemInfo.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | -------------------------------------------------------------------------------- /ui/src/quasar.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // Forces TS to apply `@quasar/app-vite` augmentations of `quasar` package 4 | // Removing this would break `quasar/wrappers` imports as those typings are declared 5 | // into `@quasar/app-vite` 6 | // As a side effect, since `@quasar/app-vite` reference `quasar` to augment it, 7 | // this declaration also apply `quasar` own 8 | // augmentations (eg. adds `$q` into Vue component context) 9 | /// 10 | -------------------------------------------------------------------------------- /ui/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { Loading } from 'quasar' 2 | import { route } from 'quasar/wrappers' 3 | import { 4 | createMemoryHistory, 5 | createRouter, 6 | createWebHashHistory, 7 | createWebHistory 8 | } from 'vue-router' 9 | import routes from './routes' 10 | 11 | export default route((/* { store, ssrContext } */) => { 12 | const createHistory = process.env.SERVER 13 | ? createMemoryHistory 14 | : process.env.VUE_ROUTER_MODE === 'history' 15 | ? createWebHistory 16 | : createWebHashHistory 17 | 18 | const router = createRouter({ 19 | // Scroll to original position on page when returning back 20 | scrollBehavior(_to, _from, savedPosition) { 21 | if (savedPosition) { 22 | return savedPosition 23 | } 24 | return { left: 0, top: 0 } 25 | }, 26 | routes, 27 | 28 | // Leave this as is and make changes in quasar.conf.js instead! 29 | // quasar.conf.js -> build -> vueRouterMode 30 | // quasar.conf.js -> build -> publicPath 31 | history: createHistory(process.env.VUE_ROUTER_BASE) 32 | }) 33 | 34 | router.beforeEach(() => { 35 | // Start the animation of the loading indicator after page change. 36 | Loading.show() 37 | }) 38 | 39 | router.afterEach(() => { 40 | // Stop the animation of the loading indicator after page change. 41 | Loading.hide() 42 | }) 43 | 44 | return router 45 | }) 46 | -------------------------------------------------------------------------------- /ui/src/router/routes.ts: -------------------------------------------------------------------------------- 1 | import { configYml } from 'src/boot/ymlImport' 2 | import { RouteRecordRaw } from 'vue-router' 3 | 4 | const vuePages = import.meta.glob('pages/*.vue') 5 | 6 | // For each item in configYml.sideDrawer create an object 7 | const ymlRoutes = Object.entries(configYml.pages).map((ymlArray) => ({ 8 | name: ymlArray[0], 9 | component: vuePages[`../pages/${ymlArray[0]}.vue`], 10 | path: ymlArray[1].path 11 | })) 12 | 13 | ymlRoutes.forEach((item, i) => { 14 | if (item.name.toLowerCase() === 'indexpage') ymlRoutes[i].path = '' 15 | }) 16 | 17 | const routes: RouteRecordRaw[] = [ 18 | { 19 | path: '/', 20 | component: () => import('layouts/MainLayout.vue'), 21 | children: ymlRoutes 22 | }, 23 | 24 | // Always leave this as last one, or remove it entirely 25 | { 26 | path: '/:catchAll(.*)*', 27 | component: () => import('src/layouts/ErrorNotFound.vue') 28 | } 29 | ] 30 | 31 | export default routes 32 | -------------------------------------------------------------------------------- /ui/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | /// 4 | /// 5 | 6 | // Mocks all files ending in `.vue` showing them as plain Vue instances 7 | declare module '*.vue' { 8 | import type { DefineComponent } from 'vue' 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 10 | const component: DefineComponent<{}, {}, any> 11 | export default component 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/stores/index.ts: -------------------------------------------------------------------------------- 1 | import { store } from 'quasar/wrappers' 2 | import { createPinia } from 'pinia' 3 | 4 | /* 5 | * If not building with SSR mode, you can 6 | * directly export the Store instantiation; 7 | * 8 | * The function below can be async too; either use 9 | * async/await or return a Promise which resolves 10 | * with the Store instance. 11 | */ 12 | 13 | export default store((/* { ssrContext } */) => { 14 | const pinia = createPinia() 15 | 16 | // You can add Pinia plugins here 17 | // pinia.use(SomePiniaPlugin) 18 | 19 | return pinia 20 | }) 21 | -------------------------------------------------------------------------------- /ui/src/stores/store-flag.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // THIS FEATURE-FLAG FILE IS AUTOGENERATED, 3 | // REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING 4 | import 'quasar/dist/types/feature-flag' 5 | 6 | declare module 'quasar/dist/types/feature-flag' { 7 | interface QuasarFeatureFlags { 8 | store: true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ui/src/stores/system.ts: -------------------------------------------------------------------------------- 1 | import { expressApi } from 'boot/axios' 2 | import { defineStore } from 'pinia' 3 | 4 | interface CloudlinkConnectionRes { 5 | loggedIn: boolean 6 | } 7 | 8 | // Store Axios settings in Pinia store as they are not reactive within the Axios instance. 9 | export const axiosSettings = defineStore('axiosSettings', { 10 | state: () => ({ axiosBaseUrl: '' }), 11 | actions: { 12 | setUrl(newUrl: string) { 13 | this.axiosBaseUrl = newUrl 14 | } 15 | } 16 | }) 17 | 18 | // Store Cloudlink status and allow retesting 19 | export const networkSettings = defineStore('networkSettings', { 20 | state: () => ({ 21 | isCloudlink: undefined as boolean | undefined 22 | }), 23 | 24 | actions: { 25 | async checkCloudlink() { 26 | try { 27 | const response = await expressApi.get( 28 | '/v1/sdk/loggedIn', 29 | { timeout: 15000 } 30 | ) 31 | this.isCloudlink = response.data.loggedIn 32 | return Promise.resolve() 33 | } catch (error) { 34 | console.error('Failed fetching Cloudlink status.') 35 | console.error(error) 36 | this.isCloudlink = false 37 | return Promise.reject(error) 38 | } 39 | } 40 | } 41 | }) 42 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@quasar/app-vite/tsconfig-preset", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "src/*": ["src/*"], 7 | "src-electron/*": ["src/*"], 8 | "app/*": ["*"], 9 | "components/*": ["src/components/*"], 10 | "layouts/*": ["src/layouts/*"], 11 | "pages/*": ["src/pages/*"], 12 | "assets/*": ["src/assets/*"], 13 | "boot/*": ["src/boot/*"], 14 | "stores/*": ["src/stores/*"] 15 | } 16 | }, 17 | "include": [ 18 | "src-electron/**/*.ts", 19 | "src-electron/**/*.d.ts", 20 | "src-pwa/**/*.ts", 21 | "src-pwa/**/*.d.ts", 22 | "src/**/*.ts", 23 | "src/**/*.d.ts", 24 | "src/**/*.json", 25 | "src/**/*.tsx", 26 | "src/**/*.vue" 27 | ] 28 | } 29 | --------------------------------------------------------------------------------