├── .dockerignore ├── .gitattributes ├── .github ├── DISCUSSION_TEMPLATE │ └── feature-request.yml ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── config.yml ├── dependabot.yml └── workflows │ ├── codeberg-mirror.yml │ ├── dependabot-automerge.yml │ ├── go-test.yml │ └── release.yml ├── .gitignore ├── Dockerfile ├── Dockerfile.dev ├── LICENSE ├── README.md ├── assets ├── account-dark.webp ├── account-light.webp ├── gopher_affinity_design.afdesign ├── home-dark.webp ├── home-light.webp ├── new-manual-dark.webp ├── new-manual-light.webp ├── new-scan-dark.webp ├── new-scan-light.webp ├── pngToWebp.sh ├── settings-dark.webp ├── settings-light.webp ├── users-dark.webp └── users-light.webp ├── backend ├── .goreleaser.yaml ├── cronjobs │ └── cronjobs.go ├── go.mod ├── go.sum ├── logger │ └── logger.go ├── main.go ├── migrations │ ├── 1735377462_collections_snapshot.go │ ├── 1735379875_updated__superusers.go │ ├── 1735381989_updated__superusers.go │ ├── 1735385555_updated_users.go │ ├── 1737069556_url_to_string_devices.go │ ├── 1737071155_url_to_string_ports.go │ ├── 1737546920_updated_devices.go │ ├── 1737552965_updated_devices.go │ ├── 1741216171_updated_devices.go │ └── 1741216190_updated_devices.go ├── networking │ ├── magicpacket.go │ ├── magicpacket_test.go │ ├── ping.go │ ├── ping_test.go │ ├── root.go │ ├── root_windows.go │ ├── shutdown.go │ ├── shutdown_other.go │ ├── shutdown_windows.go │ ├── sleep.go │ └── wake.go ├── pb │ ├── handlers.go │ ├── middlewares.go │ └── pb.go └── pb_public │ └── .gitkeep ├── crowdin.yml ├── docker-compose.dev.yml ├── docker-compose.yml └── frontend ├── .env ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .npmrc ├── .prettierignore ├── .prettierrc ├── README.md ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── project.inlang ├── .gitignore ├── project_id └── settings.json ├── src ├── app.css ├── app.d.ts ├── app.html ├── hooks.server.ts ├── hooks.ts ├── lib │ ├── components │ │ ├── DeviceCard.svelte │ │ ├── DeviceCardNic.svelte │ │ ├── DeviceForm.svelte │ │ ├── DeviceFormPort.svelte │ │ ├── Navbar.svelte │ │ ├── NetworkScan.svelte │ │ ├── PageLoading.svelte │ │ └── Transition.svelte │ ├── helpers │ │ ├── cron.ts │ │ └── forms.ts │ ├── stores │ │ ├── locale.ts │ │ ├── pocketbase.ts │ │ └── settings.ts │ └── types │ │ ├── device.ts │ │ ├── permission.ts │ │ ├── scan.ts │ │ ├── settings.ts │ │ └── user.ts └── routes │ ├── +layout.svelte │ ├── +layout.ts │ ├── +page.svelte │ ├── account │ └── +page.svelte │ ├── device │ ├── [id] │ │ └── +page.svelte │ └── new │ │ └── +page.svelte │ ├── login │ └── +page.svelte │ ├── settings │ └── +page.svelte │ ├── users │ └── +page.svelte │ └── welcome │ └── +page.svelte ├── static ├── avatars │ ├── avatar0.svg │ ├── avatar1.svg │ ├── avatar2.svg │ ├── avatar3.svg │ ├── avatar4.svg │ ├── avatar5.svg │ ├── avatar6.svg │ ├── avatar7.svg │ ├── avatar8.svg │ └── avatar9.svg ├── gopher.svg ├── icon_192.png ├── icon_512.png ├── manifest.webmanifest ├── maskable_192.png └── maskable_512.png ├── svelte.config.js ├── translations ├── de-DE.json ├── en-US.json ├── es-ES.json ├── fr-FR.json ├── id-ID.json ├── it-IT.json ├── ja-JP.json ├── ko-KR.json ├── nl-NL.json ├── pl-PL.json ├── pt-PT.json ├── zh-CN.json └── zh-TW.json ├── tsconfig.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | .dockerignore 4 | Dockerfile 5 | Dockerfile.dev 6 | data 7 | README.md 8 | 9 | frontend/.svelte-kit 10 | frontend/node_modules 11 | frontend/build 12 | 13 | backend/pb_data 14 | backend/pb_public 15 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/DISCUSSION_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | title: "" 2 | labels: ["feature-request"] 3 | 4 | body: 5 | - type: textarea 6 | id: feature 7 | attributes: 8 | label: The feature 9 | validations: 10 | required: true 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: seriousm4x 2 | ko_fi: seriousm4x 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: 🕷️ Bug report 2 | description: Create a bug report to help the project improve 3 | labels: ["bug"] 4 | title: "[BUG] " 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | This issue form is for reporting bugs only! 10 | 11 | If you have a feature or enhancement request, please use the [feature request][fr] section of our [GitHub Discussions][fr]. 12 | 13 | [fr]: https://github.com/seriousm4x/UpSnap/discussions/new?category=feature-requests 14 | 15 | - type: textarea 16 | validations: 17 | required: true 18 | attributes: 19 | label: The bug 20 | description: >- 21 | Describe the issue you are experiencing here. Tell us what you were trying to do and what happened. 22 | 23 | Provide a clear and concise description of what the problem is. 24 | 25 | - type: markdown 26 | attributes: 27 | value: | 28 | ## Environment 29 | 30 | - type: input 31 | validations: 32 | required: true 33 | attributes: 34 | label: The OS that UpSnap is running on 35 | placeholder: Ubuntu 22.10, Debian, Arch... 36 | 37 | - type: input 38 | id: version 39 | validations: 40 | required: true 41 | attributes: 42 | label: Version of UpSnap 43 | placeholder: 4.2.0 44 | 45 | - type: textarea 46 | validations: 47 | required: true 48 | attributes: 49 | label: Your docker-compose.yml content 50 | render: YAML 51 | 52 | - type: textarea 53 | id: repro 54 | attributes: 55 | label: Reproduction steps 56 | description: "How do you trigger this bug? Please walk us through it step by step." 57 | value: | 58 | 1. 59 | 2. 60 | 3. 61 | ... 62 | render: bash 63 | validations: 64 | required: true 65 | 66 | - type: textarea 67 | attributes: 68 | label: Additional information 69 | description: > 70 | If you have any additional information for us, use the field below. 71 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: I have a question or need support 4 | url: https://github.com/seriousm4x/UpSnap/discussions/categories/help-wanted 5 | about: Please use our GitHub Discussion if you need help. 6 | - name: Feature Request 7 | url: https://github.com/seriousm4x/UpSnap/discussions/categories/feature-requests 8 | about: Please use our GitHub Discussion for making feature requests. 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: gomod 5 | directory: "/backend" 6 | schedule: 7 | interval: daily 8 | open-pull-requests-limit: 10 9 | commit-message: 10 | prefix: go-dep 11 | ignore: 12 | - dependency-name: "*" 13 | update-types: ["version-update:semver-major"] 14 | - package-ecosystem: npm 15 | directory: "/frontend" 16 | schedule: 17 | interval: daily 18 | open-pull-requests-limit: 10 19 | commit-message: 20 | prefix: npm-dep 21 | ignore: 22 | - dependency-name: "*" 23 | update-types: ["version-update:semver-major"] 24 | - package-ecosystem: github-actions 25 | directory: "/" 26 | schedule: 27 | interval: daily 28 | open-pull-requests-limit: 10 29 | commit-message: 30 | prefix: gh-action 31 | -------------------------------------------------------------------------------- /.github/workflows/codeberg-mirror.yml: -------------------------------------------------------------------------------- 1 | # Sync repo to the Codeberg mirror 2 | name: codeberg mirror 3 | on: 4 | push: 5 | branches: ["master"] 6 | tags: 7 | - "*" 8 | workflow_dispatch: 9 | jobs: 10 | codeberg: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - uses: pixta-dev/repository-mirroring-action@v1 17 | with: 18 | target_repo_url: "git@codeberg.org:seriousm4x/UpSnap.git" 19 | ssh_private_key: ${{ secrets.CODEBERG_SSH }} 20 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-automerge.yml: -------------------------------------------------------------------------------- 1 | name: dependabot automerge 2 | on: 3 | pull_request: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | jobs: 11 | dependabot: 12 | runs-on: ubuntu-latest 13 | if: ${{ github.actor == 'dependabot[bot]' }} 14 | steps: 15 | - name: Dependabot metadata 16 | id: metadata 17 | uses: dependabot/fetch-metadata@v2 18 | with: 19 | github-token: "${{ secrets.GITHUB_TOKEN }}" 20 | - name: Enable auto-merge for Dependabot PRs 21 | if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} || ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} 22 | run: gh pr merge --auto --rebase "$PR_URL" 23 | env: 24 | PR_URL: ${{github.event.pull_request.html_url}} 25 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 26 | -------------------------------------------------------------------------------- /.github/workflows/go-test.yml: -------------------------------------------------------------------------------- 1 | name: go test 2 | 3 | on: 4 | push: 5 | paths: backend/** 6 | 7 | jobs: 8 | check-runner: 9 | runs-on: ubuntu-latest 10 | outputs: 11 | runner-label: ${{ steps.set-runner.outputs.runner-label }} 12 | steps: 13 | - name: Set runner 14 | id: set-runner 15 | run: | 16 | runners=$(curl -s -H "Accept: application/vnd.github+json" -H "Authorization: token ${{ secrets.REPO_ACCESS_TOKEN }}" "https://api.github.com/repos/${{ github.repository }}/actions/runners") 17 | available=$(echo "$runners" | jq '.runners[] | select(.status == "online" and .busy == false and .labels[] .name == "self-hosted")') 18 | if [ -n "$available" ]; then 19 | echo "runner-label=self-hosted" >> "$GITHUB_OUTPUT" 20 | else 21 | echo "runner-label=ubuntu-latest" >> "$GITHUB_OUTPUT" 22 | fi 23 | 24 | build: 25 | needs: check-runner 26 | runs-on: ${{ needs.check-runner.outputs.runner-label }} 27 | steps: 28 | - uses: actions/checkout@v4 29 | - name: Set up Go 30 | uses: actions/setup-go@v5 31 | with: 32 | go-version-file: "./backend/go.mod" 33 | cache-dependency-path: "./backend/go.sum" 34 | - name: Test 35 | run: | 36 | cd backend 37 | go test -exec sudo -v ./... 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*.*.*" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | check-runner: 11 | runs-on: ubuntu-latest 12 | outputs: 13 | runner-label: ${{ steps.set-runner.outputs.runner-label }} 14 | steps: 15 | - name: Set runner 16 | id: set-runner 17 | run: | 18 | runners=$(curl -s -H "Accept: application/vnd.github+json" -H "Authorization: token ${{ secrets.REPO_ACCESS_TOKEN }}" "https://api.github.com/repos/${{ github.repository }}/actions/runners") 19 | available=$(echo "$runners" | jq '.runners[] | select(.status == "online" and .busy == false and .labels[] .name == "self-hosted")') 20 | if [ -n "$available" ]; then 21 | echo "runner-label=self-hosted" >> "$GITHUB_OUTPUT" 22 | else 23 | echo "runner-label=ubuntu-latest" >> "$GITHUB_OUTPUT" 24 | fi 25 | 26 | goreleaser: 27 | needs: check-runner 28 | runs-on: ${{ needs.check-runner.outputs.runner-label }} 29 | steps: 30 | # pull code 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 35 | 36 | # install latest node 37 | - name: Set up Node.js 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: latest 41 | 42 | # build sveltekit frontend 43 | - name: Build frontend 44 | run: | 45 | npm i -g pnpm 46 | pnpm --prefix=./frontend i 47 | PUBLIC_VERSION=${GITHUB_REF##*/} pnpm --prefix=./frontend run build 48 | cp -r ./frontend/build/* ./backend/pb_public/ 49 | git reset --hard 50 | 51 | # setup go 52 | - name: Set up Go 53 | uses: actions/setup-go@v5 54 | with: 55 | go-version-file: "./backend/go.mod" 56 | cache-dependency-path: "./backend/go.sum" 57 | 58 | # build 59 | - name: Run GoReleaser 60 | uses: goreleaser/goreleaser-action@v6 61 | with: 62 | distribution: goreleaser 63 | version: "~> v2" 64 | args: release --clean 65 | workdir: backend 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | AUR_KEY: ${{ secrets.AUR_KEY }} 69 | 70 | docker: 71 | needs: 72 | - goreleaser 73 | - check-runner 74 | runs-on: ${{ needs.check-runner.outputs.runner-label }} 75 | steps: 76 | # pull code 77 | - name: Check Out Repo 78 | uses: actions/checkout@v4 79 | 80 | # image tags 81 | - name: Docker meta 82 | id: meta 83 | uses: docker/metadata-action@v5 84 | with: 85 | images: | 86 | ghcr.io/seriousm4x/UpSnap 87 | seriousm4x/UpSnap 88 | tags: | 89 | type=ref,event=tag 90 | type=semver,pattern={{version}} 91 | type=semver,pattern={{major}} 92 | type=semver,pattern={{major}}.{{minor}} 93 | 94 | # qemu 95 | - name: Set up QEMU 96 | uses: docker/setup-qemu-action@v3 97 | 98 | # docker buildx 99 | - name: Set up Docker Buildx 100 | id: buildx 101 | uses: docker/setup-buildx-action@v3 102 | 103 | # ghcr.io login 104 | - name: Log in to the Container registry 105 | uses: docker/login-action@v3 106 | with: 107 | registry: ghcr.io 108 | username: ${{ github.actor }} 109 | password: ${{ secrets.GITHUB_TOKEN }} 110 | 111 | # ducker hub login 112 | - name: Login to Docker Hub 113 | uses: docker/login-action@v3 114 | with: 115 | username: ${{ vars.DOCKERHUB_USERNAME }} 116 | password: ${{ secrets.DOCKERHUB_TOKEN }} 117 | 118 | # build and push 119 | - name: Build and push 120 | id: docker_build 121 | uses: docker/build-push-action@v6 122 | with: 123 | context: . 124 | platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 125 | builder: ${{ steps.buildx.outputs.name }} 126 | push: true 127 | tags: ${{ steps.meta.outputs.tags }} 128 | build-args: | 129 | VERSION=${{github.ref_name}} 130 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/go,node,svelte,visualstudiocode,macos 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=go,node,svelte,visualstudiocode,macos 3 | 4 | ### Go ### 5 | # If you prefer the allow list template instead of the deny list, see community template: 6 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 7 | # 8 | # Binaries for programs and plugins 9 | *.exe 10 | *.exe~ 11 | *.dll 12 | *.so 13 | *.dylib 14 | 15 | # Test binary, built with `go test -c` 16 | *.test 17 | 18 | # Output of the go coverage tool, specifically when used with LiteIDE 19 | *.out 20 | 21 | # Dependency directories (remove the comment below to include it) 22 | # vendor/ 23 | 24 | # Go workspace file 25 | go.work 26 | 27 | ### macOS ### 28 | # General 29 | .DS_Store 30 | .AppleDouble 31 | .LSOverride 32 | 33 | # Icon must end with two \r 34 | Icon 35 | 36 | 37 | # Thumbnails 38 | ._* 39 | 40 | # Files that might appear in the root of a volume 41 | .DocumentRevisions-V100 42 | .fseventsd 43 | .Spotlight-V100 44 | .TemporaryItems 45 | .Trashes 46 | .VolumeIcon.icns 47 | .com.apple.timemachine.donotpresent 48 | 49 | # Directories potentially created on remote AFP share 50 | .AppleDB 51 | .AppleDesktop 52 | Network Trash Folder 53 | Temporary Items 54 | .apdisk 55 | 56 | ### macOS Patch ### 57 | # iCloud generated files 58 | *.icloud 59 | 60 | ### Node ### 61 | # Logs 62 | logs 63 | *.log 64 | npm-debug.log* 65 | yarn-debug.log* 66 | yarn-error.log* 67 | lerna-debug.log* 68 | .pnpm-debug.log* 69 | 70 | # Diagnostic reports (https://nodejs.org/api/report.html) 71 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 72 | 73 | # Runtime data 74 | pids 75 | *.pid 76 | *.seed 77 | *.pid.lock 78 | 79 | # Directory for instrumented libs generated by jscoverage/JSCover 80 | lib-cov 81 | 82 | # Coverage directory used by tools like istanbul 83 | coverage 84 | *.lcov 85 | 86 | # nyc test coverage 87 | .nyc_output 88 | 89 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 90 | .grunt 91 | 92 | # Bower dependency directory (https://bower.io/) 93 | bower_components 94 | 95 | # node-waf configuration 96 | .lock-wscript 97 | 98 | # Compiled binary addons (https://nodejs.org/api/addons.html) 99 | build/Release 100 | 101 | # Dependency directories 102 | node_modules/ 103 | jspm_packages/ 104 | 105 | # Snowpack dependency directory (https://snowpack.dev/) 106 | web_modules/ 107 | 108 | # TypeScript cache 109 | *.tsbuildinfo 110 | 111 | # Optional npm cache directory 112 | .npm 113 | 114 | # Optional eslint cache 115 | .eslintcache 116 | 117 | # Optional stylelint cache 118 | .stylelintcache 119 | 120 | # Microbundle cache 121 | .rpt2_cache/ 122 | .rts2_cache_cjs/ 123 | .rts2_cache_es/ 124 | .rts2_cache_umd/ 125 | 126 | # Optional REPL history 127 | .node_repl_history 128 | 129 | # Output of 'npm pack' 130 | *.tgz 131 | 132 | # Yarn Integrity file 133 | .yarn-integrity 134 | 135 | # dotenv environment variable files 136 | .env 137 | .env.development.local 138 | .env.test.local 139 | .env.production.local 140 | .env.local 141 | 142 | # parcel-bundler cache (https://parceljs.org/) 143 | .cache 144 | .parcel-cache 145 | 146 | # Next.js build output 147 | .next 148 | out 149 | 150 | # Nuxt.js build / generate output 151 | .nuxt 152 | dist 153 | 154 | # Gatsby files 155 | .cache/ 156 | # Comment in the public line in if your project uses Gatsby and not Next.js 157 | # https://nextjs.org/blog/next-9-1#public-directory-support 158 | # public 159 | 160 | # vuepress build output 161 | .vuepress/dist 162 | 163 | # vuepress v2.x temp and cache directory 164 | .temp 165 | 166 | # Docusaurus cache and generated files 167 | .docusaurus 168 | 169 | # Serverless directories 170 | .serverless/ 171 | 172 | # FuseBox cache 173 | .fusebox/ 174 | 175 | # DynamoDB Local files 176 | .dynamodb/ 177 | 178 | # TernJS port file 179 | .tern-port 180 | 181 | # Stores VSCode versions used for testing VSCode extensions 182 | .vscode-test 183 | 184 | # yarn v2 185 | .yarn/cache 186 | .yarn/unplugged 187 | .yarn/build-state.yml 188 | .yarn/install-state.gz 189 | .pnp.* 190 | 191 | ### Node Patch ### 192 | # Serverless Webpack directories 193 | .webpack/ 194 | 195 | # Optional stylelint cache 196 | 197 | # SvelteKit build / generate output 198 | .svelte-kit 199 | 200 | ### Svelte ### 201 | # gitignore template for the SvelteKit, frontend web component framework 202 | # website: https://kit.svelte.dev/ 203 | 204 | .svelte-kit/ 205 | package 206 | 207 | ### VisualStudioCode ### 208 | .vscode/* 209 | !.vscode/settings.json 210 | !.vscode/tasks.json 211 | !.vscode/launch.json 212 | !.vscode/extensions.json 213 | !.vscode/*.code-snippets 214 | 215 | # Local History for Visual Studio Code 216 | .history/ 217 | 218 | # Built Visual Studio Code Extensions 219 | *.vsix 220 | 221 | ### VisualStudioCode Patch ### 222 | # Ignore all local history of files 223 | .history 224 | .ionide 225 | 226 | # End of https://www.toptal.com/developers/gitignore/api/go,node,svelte,visualstudiocode,macos 227 | 228 | build 229 | data 230 | 231 | backend/pb_data 232 | backend/pb_public/* 233 | !backend/pb_public/.gitkeep 234 | 235 | dist/ 236 | vite.config.js.timestamp* 237 | 238 | frontend/src/lib/paraglide 239 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3 AS downloader 2 | ARG TARGETOS 3 | ARG TARGETARCH 4 | ARG TARGETVARIANT 5 | ARG VERSION 6 | ENV BUILDX_ARCH="${TARGETOS:-linux}_${TARGETARCH:-amd64}${TARGETVARIANT}" 7 | WORKDIR /app 8 | RUN wget https://github.com/seriousm4x/UpSnap/releases/download/${VERSION}/UpSnap_${VERSION}_${BUILDX_ARCH}.zip &&\ 9 | unzip UpSnap_${VERSION}_${BUILDX_ARCH}.zip &&\ 10 | rm -f UpSnap_${VERSION}_${BUILDX_ARCH}.zip &&\ 11 | chmod +x upsnap &&\ 12 | apk update &&\ 13 | apk add --no-cache libcap &&\ 14 | setcap 'cap_net_raw=+ep' ./upsnap 15 | 16 | FROM alpine:3 17 | RUN apk update &&\ 18 | apk add --no-cache tzdata ca-certificates nmap samba samba-common-tools openssh sshpass curl &&\ 19 | rm -rf /var/cache/apk/* 20 | WORKDIR /app 21 | COPY --from=downloader /app/upsnap upsnap 22 | HEALTHCHECK --interval=10s \ 23 | CMD curl -fs "http://localhost:8090/api/health" || exit 1 24 | ENTRYPOINT ["./upsnap", "serve", "--http=0.0.0.0:8090"] 25 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node:alpine AS node 2 | WORKDIR /app 3 | COPY frontend/ . 4 | RUN npm i -g pnpm &&\ 5 | pnpm i &&\ 6 | pnpm run build 7 | 8 | FROM golang:alpine AS go 9 | WORKDIR /app 10 | COPY backend/ . 11 | COPY --from=node /app/build ./pb_public 12 | ENV CGO_ENABLED=0 13 | RUN go build -o upsnap main.go &&\ 14 | chmod +x upsnap &&\ 15 | apk update &&\ 16 | apk add --no-cache libcap &&\ 17 | setcap 'cap_net_raw=+ep' ./upsnap 18 | 19 | FROM alpine:3 20 | RUN apk update &&\ 21 | apk add --no-cache tzdata ca-certificates nmap samba samba-common-tools openssh sshpass curl &&\ 22 | rm -rf /var/cache/apk/* 23 | WORKDIR /app 24 | COPY --from=go /app/upsnap upsnap 25 | HEALTHCHECK --interval=10s \ 26 | CMD curl -fs "http://localhost:8090/api/health" || exit 1 27 | ENTRYPOINT ["./upsnap", "serve", "--http=0.0.0.0:8090"] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 seriousm4x 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 | <div align="center" width="100%"> 2 | <img src="frontend/static/gopher.svg" width="150" /> 3 | </div> 4 | 5 | <div align="center" width="100%"> 6 | <h2>UpSnap</h2> 7 | <p>A simple wake on lan web app written with SvelteKit, Go and PocketBase.</p> 8 | <div> 9 | <a target="_blank" href="https://github.com/seriousm4x/upsnap"><img src="https://img.shields.io/github/stars/seriousm4x/UpSnap?style=flat&label=Stars" /></a> 10 | <a target="_blank" href="https://github.com/seriousm4x/UpSnap/pkgs/container/upsnap"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fipitio%2Fbackage%2Findex%2Fseriousm4x%2FUpSnap%2Fupsnap.json&query=downloads&label=ghcr.io%20pulls" /></a> 11 | <a target="_blank" href="https://hub.docker.com/r/seriousm4x/upsnap"><img src="https://img.shields.io/docker/pulls/seriousm4x/upsnap?label=docker%20hub%20pulls" /></a> 12 | <a target="_blank" href="https://github.com/seriousm4x/UpSnap/releases"><img src="https://img.shields.io/github/downloads/seriousm4x/upsnap/total?label=binary%20downloads" /></a> 13 | </div> 14 | <div> 15 | <a target="_blank" href="https://github.com/seriousm4x/UpSnap/releases"><img src="https://img.shields.io/github/go-mod/go-version/seriousm4x/UpSnap?filename=backend/go.mod" /></a> 16 | <a target="_blank" href="https://github.com/seriousm4x/UpSnap/releases"><img src="https://img.shields.io/github/v/release/seriousm4x/upsnap?display_name=tag&label=Latest%20release" /></a> 17 | <a target="_blank" href="https://github.com/seriousm4x/UpSnap/actions"><img src="https://github.com/seriousm4x/upsnap/actions/workflows/release.yml/badge.svg?event=push" /></a> 18 | <a target="_blank" href="https://github.com/seriousm4x/UpSnap/commits/master"><img src="https://img.shields.io/github/last-commit/seriousm4x/upsnap" /></a> 19 | </div> 20 | </div> 21 | 22 | ## ✨ Features 23 | 24 | - 🚀 One-Click Device Wake-Up Dashboard 25 | - ⏰ Timed Events via Cron for Automation 26 | - 🔌 Ping Any Port You Choose 27 | - 🔍 Discover Devices with Network Scanning (nmap required) 28 | - 👤 Secured User Management 29 | - 🌐 i18n support for [these](/frontend/translations) languages 30 | - 🎨 35 Themes 31 | - 🐳 [Docker images](https://github.com/seriousm4x/UpSnap/pkgs/container/upsnap) for amd64, arm64, arm/v7, arm/v6 32 | - 🏠 Self-Hostable 33 | 34 | ## 📸 Screenshots 35 | 36 | | Silk | Dim | 37 | | ---------------------------------- | --------------------------------- | 38 | | ![](/assets/home-light.webp) | ![](/assets/home-dark.webp) | 39 | | ![](/assets/account-light.webp) | ![](/assets/account-dark.webp) | 40 | | ![](/assets/new-manual-light.webp) | ![](/assets/new-manual-dark.webp) | 41 | | ![](/assets/new-scan-light.webp) | ![](/assets/new-scan-dark.webp) | 42 | | ![](/assets/settings-light.webp) | ![](/assets/settings-dark.webp) | 43 | | ![](/assets/users-light.webp) | ![](/assets/users-dark.webp) | 44 | 45 | ## 🚀 Run the binary 46 | 47 | Just download the latest binary from the [release page](https://github.com/seriousm4x/UpSnap/releases) and run it. 48 | 49 | ### Root: 50 | 51 | ```bash 52 | sudo ./upsnap serve --http=0.0.0.0:8090 53 | ``` 54 | 55 | ### Non-root: 56 | 57 | ```bash 58 | sudo setcap cap_net_raw=+ep ./upsnap # only once after downloading 59 | ./upsnap serve --http=0.0.0.0:8090 60 | ``` 61 | 62 | For more options check `./upsnap --help` or visit [PocketBase documentation](https://pocketbase.io/docs). 63 | 64 | If you want to use network discovery, make sure to have `nmap` installed and run UpSnap as root/admin. 65 | 66 | ## 🐳 Run in docker 67 | 68 | You can use the [docker-compose](docker-compose.yml) example. See the comments in the file for customization. 69 | 70 | ### Non-root docker user: 71 | 72 | You will loose the ability to add network devices via the scan page. 73 | 74 | Create the mount point first: 75 | 76 | ```bash 77 | mkdir data 78 | ``` 79 | 80 | Then add `user: 1000:1000` to the docker-compose file (or whatever your $UID:$GID is). 81 | 82 | ### Change port 83 | 84 | If you want to change the port from 8090 to something else, change the following (5000 in this case): 85 | 86 | ```yml 87 | entrypoint: /bin/sh -c "./upsnap serve --http 0.0.0.0:5000" 88 | healthcheck: 89 | test: curl -fs "http://localhost:5000/api/health" || exit 1 90 | ``` 91 | 92 | ### Install additional packages for shutdown cmd 93 | 94 | ```yml 95 | entrypoint: /bin/sh -c "apk update && apk add --no-cache <YOUR_PACKAGE> && rm -rf /var/cache/apk/* && ./upsnap serve --http 0.0.0.0:8090" 96 | ``` 97 | 98 | You can search for your needed package [here](https://pkgs.alpinelinux.org/packages). 99 | 100 | ### Reverse Proxy 101 | 102 | **Caddy example** 103 | 104 | ``` 105 | upsnap.example.com { 106 | reverse_proxy localhost:8090 107 | } 108 | ``` 109 | 110 | ## 🐧 Install from the [AUR](https://aur.archlinux.org/packages/upsnap-bin) 111 | 112 | ```bash 113 | yay -Sy upsnap-bin 114 | ``` 115 | 116 | ## 🔒 User permissions 117 | 118 | UpSnap offers unique access for each user, per device. While admins have all permissions, they can assign specific rights to users such as displaying/hiding a device, accessing device editing, deleting and powering devices on/off. See the last screenshot in the [📸 Screenshots section](#-screenshots). 119 | 120 | ## 🌍 Exposing to the open web 121 | 122 | Although UpSnap has user authorisation, it is **not recommended to expose it to the open web** and make it accessible by everyone! 123 | 124 | **Reason**: The shutdown device command is basically a command piped to #sh (root if you run docker). If anyone gains unauthorized access and can abuse this api route in any way, the attacker has access to a (root) shell on your local network. 125 | 126 | **Recommended**: If you need access from outside your network, please use a vpn. Wireguard or OpenVPN is your way to go. 127 | 128 | ## 🌐 Help translating 129 | 130 | UpSnap is available in the following languages so far: 131 | 132 | - 🇩🇪 **German** (de-DE) 133 | - 🇺🇸 **English** (en-US) 134 | - 🇪🇸 **Spanish** (es-ES) 135 | - 🇫🇷 **French** (fr-FR) 136 | - 🇮🇩 **Bahasa Indonesia** (id-ID) 137 | - 🇮🇹 **Italian** (it-IT) 138 | - 🇯🇵 **Japanese** (ja-JP) 139 | - 🇳🇱 **Dutch** (nl-NL) 140 | - 🇵🇱 **Polish** (pl-PL) 141 | - 🇵🇹 **Portuguese** (pt-PT) 142 | - 🇹🇼 **Chinese (Taiwan)** (zh-TW) 143 | - 🇨🇳 **Chinese** (zh-CN) 144 | 145 | **If you want to contribute and help translating, check the wiki: [How to add languages](https://github.com/seriousm4x/UpSnap/wiki/How-to-add-languages)** 146 | 147 | ## 🔧 Help developing 148 | 149 | Fork this branch and clone it. 150 | 151 | 1. Start backend 152 | 153 | ```sh 154 | cd backend 155 | go mod tidy 156 | go run main.go serve 157 | ``` 158 | 159 | 2. Start frontend 160 | 161 | ```sh 162 | cd frontend 163 | pnpm i 164 | pnpm run dev 165 | ``` 166 | 167 | Open up [http://localhost:5173/](http://localhost:5173/), create an admin user and add some devices. 168 | 169 | ## 🌟 Star History 170 | 171 | [![Star History Chart](https://api.star-history.com/svg?repos=seriousm4x/UpSnap&type=Date&theme=dark)](https://star-history.com/#seriousm4x/UpSnap&Date) 172 | -------------------------------------------------------------------------------- /assets/account-dark.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/assets/account-dark.webp -------------------------------------------------------------------------------- /assets/account-light.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/assets/account-light.webp -------------------------------------------------------------------------------- /assets/gopher_affinity_design.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/assets/gopher_affinity_design.afdesign -------------------------------------------------------------------------------- /assets/home-dark.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/assets/home-dark.webp -------------------------------------------------------------------------------- /assets/home-light.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/assets/home-light.webp -------------------------------------------------------------------------------- /assets/new-manual-dark.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/assets/new-manual-dark.webp -------------------------------------------------------------------------------- /assets/new-manual-light.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/assets/new-manual-light.webp -------------------------------------------------------------------------------- /assets/new-scan-dark.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/assets/new-scan-dark.webp -------------------------------------------------------------------------------- /assets/new-scan-light.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/assets/new-scan-light.webp -------------------------------------------------------------------------------- /assets/pngToWebp.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | for i in *.png; do 4 | magick -quality 100 "$i" "$(basename "$i" .png)".webp 5 | rm "$i" 6 | done 7 | -------------------------------------------------------------------------------- /assets/settings-dark.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/assets/settings-dark.webp -------------------------------------------------------------------------------- /assets/settings-light.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/assets/settings-light.webp -------------------------------------------------------------------------------- /assets/users-dark.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/assets/users-dark.webp -------------------------------------------------------------------------------- /assets/users-light.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/assets/users-light.webp -------------------------------------------------------------------------------- /backend/.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | release: 3 | draft: false 4 | prerelease: auto 5 | before: 6 | hooks: 7 | - go mod tidy 8 | builds: 9 | - binary: upsnap 10 | env: 11 | - CGO_ENABLED=0 12 | ldflags: 13 | - -s -w -X github.com/seriousm4x/upsnap/pb.Version={{ .Tag }} 14 | goos: 15 | - linux 16 | - windows 17 | - darwin 18 | - freebsd 19 | goarch: 20 | - amd64 21 | - arm64 22 | - arm 23 | goarm: 24 | - "6" 25 | - "7" 26 | ignore: 27 | - goos: windows 28 | goarch: arm 29 | - goos: darwin 30 | goarch: arm 31 | - goos: freebsd 32 | goarch: arm 33 | archives: 34 | - formats: [ 'zip' ] 35 | checksum: 36 | name_template: "checksums.txt" 37 | snapshot: 38 | version_template: "{{ .Version }}" 39 | changelog: 40 | use: github 41 | sort: asc 42 | groups: 43 | - title: Features 44 | regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' 45 | order: 0 46 | - title: "Bug fixes" 47 | regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' 48 | order: 1 49 | - title: "Go dependencies" 50 | regexp: '^.*?go-dep(\([[:word:]]+\))??!?:.+$' 51 | order: 3 52 | - title: "Npm dependencies" 53 | regexp: '^.*?npm-dep(\([[:word:]]+\))??!?:.+$' 54 | order: 4 55 | - title: "Github Actions" 56 | regexp: '^.*?gh-action(\([[:word:]]+\))??!?:.+$' 57 | order: 5 58 | - title: Others 59 | order: 2 60 | git: 61 | ignore_tags: 62 | - "{{ if not .Prerelease}}*beta*{{ end }}" 63 | aurs: 64 | - name: upsnap-bin 65 | homepage: https://github.com/seriousm4x/UpSnap 66 | description: "A simple wake on lan web app written with SvelteKit, Go and PocketBase." 67 | maintainers: 68 | - "SeriousM4x <maxi at quoss dot org>" 69 | license: "MIT" 70 | private_key: "{{ .Env.AUR_KEY }}" 71 | git_url: "ssh://aur@aur.archlinux.org/upsnap-bin.git" 72 | package: |- 73 | install -Dm755 "./upsnap" "${pkgdir}/usr/bin/upsnap" 74 | skip_upload: auto 75 | optdepends: 76 | - "nmap: to scan for network devices" 77 | commit_author: 78 | name: goreleaserbot 79 | email: bot@goreleaser.com 80 | -------------------------------------------------------------------------------- /backend/cronjobs/cronjobs.go: -------------------------------------------------------------------------------- 1 | package cronjobs 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase" 5 | "github.com/pocketbase/pocketbase/core" 6 | "github.com/robfig/cron/v3" 7 | "github.com/seriousm4x/upsnap/logger" 8 | "github.com/seriousm4x/upsnap/networking" 9 | ) 10 | 11 | var ( 12 | PingRunning = false 13 | WakeShutdownRunning = false 14 | CronPing = cron.New(cron.WithParser(cron.NewParser( 15 | cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow, 16 | ))) 17 | CronWakeShutdown = cron.New(cron.WithParser(cron.NewParser( 18 | cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow, 19 | ))) 20 | ) 21 | 22 | func SetPingJobs(app *pocketbase.PocketBase) { 23 | // remove existing jobs 24 | for _, job := range CronPing.Entries() { 25 | CronPing.Remove(job.ID) 26 | } 27 | 28 | settingsPrivateRecords, err := app.FindAllRecords("settings_private") 29 | if err != nil { 30 | logger.Error.Println(err) 31 | } 32 | 33 | CronPing.AddFunc(settingsPrivateRecords[0].GetString("interval"), func() { 34 | // skip cron if no realtime clients connected and lazy_ping is turned on 35 | realtimeClients := len(app.SubscriptionsBroker().Clients()) 36 | if realtimeClients == 0 && settingsPrivateRecords[0].GetBool("lazy_ping") { 37 | return 38 | } 39 | 40 | devices, err := app.FindAllRecords("devices") 41 | if err != nil { 42 | logger.Error.Println(err) 43 | return 44 | } 45 | 46 | // expand ports field 47 | expandFetchFunc := func(c *core.Collection, ids []string) ([]*core.Record, error) { 48 | return app.FindRecordsByIds(c.Id, ids, nil) 49 | } 50 | merr := app.ExpandRecords(devices, []string{"ports"}, expandFetchFunc) 51 | if len(merr) > 0 { 52 | return 53 | } 54 | 55 | for _, device := range devices { 56 | // ping device 57 | go func(d *core.Record) { 58 | status := d.GetString("status") 59 | if status == "pending" { 60 | return 61 | } 62 | isUp, err := networking.PingDevice(d) 63 | if err != nil { 64 | logger.Error.Println(err) 65 | } 66 | if isUp { 67 | if status == "online" { 68 | return 69 | } 70 | d.Set("status", "online") 71 | if err := app.Save(d); err != nil { 72 | logger.Error.Println("Failed to save record:", err) 73 | } 74 | } else { 75 | if status == "offline" { 76 | return 77 | } 78 | d.Set("status", "offline") 79 | if err := app.Save(d); err != nil { 80 | logger.Error.Println("Failed to save record:", err) 81 | } 82 | } 83 | }(device) 84 | 85 | // ping ports 86 | go func(d *core.Record) { 87 | ports, err := app.FindRecordsByIds("ports", d.GetStringSlice("ports")) 88 | if err != nil { 89 | logger.Error.Println(err) 90 | } 91 | for _, port := range ports { 92 | isUp, err := networking.CheckPort(d.GetString("ip"), port.GetString("number")) 93 | if err != nil { 94 | logger.Error.Println("Failed to check port:", err) 95 | } 96 | if isUp != port.GetBool("status") { 97 | port.Set("status", isUp) 98 | if err := app.Save(port); err != nil { 99 | logger.Error.Println("Failed to save record:", err) 100 | } 101 | } 102 | } 103 | }(device) 104 | } 105 | }) 106 | } 107 | 108 | func SetWakeShutdownJobs(app *pocketbase.PocketBase) { 109 | // remove existing jobs 110 | for _, job := range CronWakeShutdown.Entries() { 111 | CronWakeShutdown.Remove(job.ID) 112 | } 113 | 114 | devices, err := app.FindAllRecords("devices") 115 | if err != nil { 116 | logger.Error.Println(err) 117 | return 118 | } 119 | for _, dev := range devices { 120 | wake_cron := dev.GetString("wake_cron") 121 | wake_cron_enabled := dev.GetBool("wake_cron_enabled") 122 | shutdown_cron := dev.GetString("shutdown_cron") 123 | shutdown_cron_enabled := dev.GetBool("shutdown_cron_enabled") 124 | 125 | if wake_cron_enabled && wake_cron != "" { 126 | _, err := CronWakeShutdown.AddFunc(wake_cron, func() { 127 | d, err := app.FindRecordById("devices", dev.Id) 128 | if err != nil { 129 | logger.Error.Println(err) 130 | return 131 | } 132 | if d.GetString("status") == "pending" { 133 | return 134 | } 135 | isOnline, err := networking.PingDevice(dev) 136 | if err != nil { 137 | logger.Error.Println(err) 138 | return 139 | } 140 | if isOnline { 141 | return 142 | } 143 | d.Set("status", "pending") 144 | if err := app.Save(d); err != nil { 145 | logger.Error.Println("Failed to save record:", err) 146 | return 147 | } 148 | if err := networking.WakeDevice(d); err != nil { 149 | logger.Error.Println(err) 150 | d.Set("status", "offline") 151 | } else { 152 | d.Set("status", "online") 153 | } 154 | if err := app.Save(d); err != nil { 155 | logger.Error.Println("Failed to save record:", err) 156 | } 157 | }) 158 | if err != nil { 159 | logger.Error.Printf("device %s: %+v", dev.GetString("name"), err) 160 | } 161 | } 162 | 163 | if shutdown_cron_enabled && shutdown_cron != "" { 164 | _, err := CronWakeShutdown.AddFunc(shutdown_cron, func() { 165 | d, err := app.FindRecordById("devices", dev.Id) 166 | if err != nil { 167 | logger.Error.Println(err) 168 | return 169 | } 170 | if d.GetString("status") == "pending" { 171 | return 172 | } 173 | isOnline, err := networking.PingDevice(dev) 174 | if err != nil { 175 | logger.Error.Println(err) 176 | return 177 | } 178 | if !isOnline { 179 | return 180 | } 181 | status := d.GetString("status") 182 | if status != "online" { 183 | return 184 | } 185 | d.Set("status", "pending") 186 | if err := app.Save(d); err != nil { 187 | logger.Error.Println("Failed to save record:", err) 188 | } 189 | if err := networking.ShutdownDevice(d); err != nil { 190 | logger.Error.Println(err) 191 | d.Set("status", "online") 192 | } else { 193 | d.Set("status", "offline") 194 | } 195 | if err := app.Save(d); err != nil { 196 | logger.Error.Println("Failed to save record:", err) 197 | } 198 | }) 199 | if err != nil { 200 | logger.Error.Printf("device %s: %+v", dev.GetString("name"), err) 201 | } 202 | } 203 | } 204 | } 205 | 206 | func StartWakeShutdown() { 207 | WakeShutdownRunning = true 208 | go CronWakeShutdown.Run() 209 | 210 | } 211 | 212 | func StopWakeShutdown() { 213 | if WakeShutdownRunning { 214 | logger.Info.Println("Stopping wake/shutdown cronjob") 215 | CronWakeShutdown.Stop() 216 | } 217 | WakeShutdownRunning = false 218 | } 219 | 220 | func StartPing() { 221 | PingRunning = true 222 | go CronPing.Run() 223 | } 224 | 225 | func StopPing() { 226 | if PingRunning { 227 | logger.Info.Println("Stopping wake/shutdown cronjob") 228 | CronPing.Stop() 229 | } 230 | PingRunning = false 231 | } 232 | 233 | func StopAll() { 234 | StopPing() 235 | StopWakeShutdown() 236 | } 237 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/seriousm4x/upsnap 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/mdlayher/wol v0.0.0-20220221231636-b763a792253a 7 | github.com/pocketbase/dbx v1.11.0 8 | github.com/pocketbase/pocketbase v0.28.2 9 | github.com/prometheus-community/pro-bing v0.7.0 10 | github.com/robfig/cron/v3 v3.0.1 11 | golang.org/x/sys v0.33.0 12 | ) 13 | 14 | require ( 15 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 16 | github.com/disintegration/imaging v1.6.2 // indirect 17 | github.com/domodwyer/mailyak/v3 v3.6.2 // indirect 18 | github.com/dustin/go-humanize v1.0.1 // indirect 19 | github.com/fatih/color v1.18.0 // indirect 20 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect 21 | github.com/ganigeorgiev/fexpr v0.5.0 // indirect 22 | github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect 23 | github.com/go-sql-driver/mysql v1.8.1 // indirect 24 | github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 25 | github.com/google/go-cmp v0.7.0 // indirect 26 | github.com/google/uuid v1.6.0 // indirect 27 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 28 | github.com/josharian/native v1.1.0 // indirect 29 | github.com/mattn/go-colorable v0.1.14 // indirect 30 | github.com/mattn/go-isatty v0.0.20 // indirect 31 | github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118 // indirect 32 | github.com/mdlayher/packet v1.1.2 // indirect 33 | github.com/mdlayher/socket v0.5.1 // indirect 34 | github.com/ncruces/go-strftime v0.1.9 // indirect 35 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 36 | github.com/spf13/cast v1.8.0 // indirect 37 | github.com/spf13/cobra v1.9.1 // indirect 38 | github.com/spf13/pflag v1.0.6 // indirect 39 | github.com/stretchr/testify v1.8.1 // indirect 40 | golang.org/x/crypto v0.38.0 // indirect 41 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect 42 | golang.org/x/image v0.27.0 // indirect 43 | golang.org/x/net v0.40.0 // indirect 44 | golang.org/x/oauth2 v0.30.0 // indirect 45 | golang.org/x/sync v0.14.0 // indirect 46 | golang.org/x/text v0.25.0 // indirect 47 | modernc.org/libc v1.65.7 // indirect 48 | modernc.org/mathutil v1.7.1 // indirect 49 | modernc.org/memory v1.11.0 // indirect 50 | modernc.org/sqlite v1.37.1 // indirect 51 | ) 52 | -------------------------------------------------------------------------------- /backend/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | const ( 9 | flags = log.Ldate | log.Ltime | log.Lshortfile 10 | ) 11 | 12 | var ( 13 | Info = log.New(os.Stdout, "[INFO] ", flags) 14 | Debug = log.New(os.Stdout, "[DEBUG] ", flags) 15 | Warning = log.New(os.Stdout, "[WARNING] ", flags) 16 | Error = log.New(os.Stderr, "[ERROR] ", flags) 17 | ) 18 | 19 | func init() { 20 | // TODO: decide if you want to log to a file to max 21 | // output := io.MultiWriter(os.Stdout, logFile) 22 | output := os.Stdout 23 | Info.SetOutput(output) 24 | Error.SetOutput(output) 25 | Debug.SetOutput(output) 26 | 27 | log.SetOutput(Debug.Writer()) 28 | log.SetPrefix("[DEBUG]") 29 | log.SetFlags(flags) 30 | } 31 | -------------------------------------------------------------------------------- /backend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | 6 | "github.com/pocketbase/pocketbase/apis" 7 | "github.com/seriousm4x/upsnap/pb" 8 | ) 9 | 10 | //go:embed all:pb_public 11 | var distDir embed.FS 12 | var distDirFS = apis.MustSubFS(distDir, "pb_public") 13 | 14 | func main() { 15 | pb.StartPocketBase(distDirFS) 16 | } 17 | -------------------------------------------------------------------------------- /backend/migrations/1735379875_updated__superusers.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase/core" 5 | m "github.com/pocketbase/pocketbase/migrations" 6 | ) 7 | 8 | func init() { 9 | m.Register(func(app core.App) error { 10 | collection, err := app.FindCollectionByNameOrId("pbc_3142635823") 11 | if err != nil { 12 | return err 13 | } 14 | 15 | // add field 16 | if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{ 17 | "hidden": false, 18 | "id": "number376926767", 19 | "max": 9, 20 | "min": 0, 21 | "name": "avatar", 22 | "onlyInt": false, 23 | "presentable": false, 24 | "required": false, 25 | "system": false, 26 | "type": "number" 27 | }`)); err != nil { 28 | return err 29 | } 30 | 31 | return app.Save(collection) 32 | }, func(app core.App) error { 33 | collection, err := app.FindCollectionByNameOrId("pbc_3142635823") 34 | if err != nil { 35 | return err 36 | } 37 | 38 | // remove field 39 | collection.Fields.RemoveById("number376926767") 40 | 41 | return app.Save(collection) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /backend/migrations/1735381989_updated__superusers.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase/core" 5 | m "github.com/pocketbase/pocketbase/migrations" 6 | ) 7 | 8 | func init() { 9 | m.Register(func(app core.App) error { 10 | collection, err := app.FindCollectionByNameOrId("pbc_3142635823") 11 | if err != nil { 12 | return err 13 | } 14 | 15 | // update field 16 | if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{ 17 | "hidden": false, 18 | "id": "number376926767", 19 | "max": 9, 20 | "min": 0, 21 | "name": "avatar", 22 | "onlyInt": true, 23 | "presentable": false, 24 | "required": false, 25 | "system": false, 26 | "type": "number" 27 | }`)); err != nil { 28 | return err 29 | } 30 | 31 | return app.Save(collection) 32 | }, func(app core.App) error { 33 | collection, err := app.FindCollectionByNameOrId("pbc_3142635823") 34 | if err != nil { 35 | return err 36 | } 37 | 38 | // update field 39 | if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{ 40 | "hidden": false, 41 | "id": "number376926767", 42 | "max": 9, 43 | "min": 0, 44 | "name": "avatar", 45 | "onlyInt": false, 46 | "presentable": false, 47 | "required": false, 48 | "system": false, 49 | "type": "number" 50 | }`)); err != nil { 51 | return err 52 | } 53 | 54 | return app.Save(collection) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /backend/migrations/1735385555_updated_users.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/pocketbase/pocketbase/core" 7 | m "github.com/pocketbase/pocketbase/migrations" 8 | ) 9 | 10 | func init() { 11 | m.Register(func(app core.App) error { 12 | collection, err := app.FindCollectionByNameOrId("27do0wbcuyfmbmx") 13 | if err != nil { 14 | return err 15 | } 16 | 17 | // update collection data 18 | if err := json.Unmarshal([]byte(`{ 19 | "createRule": "" 20 | }`), &collection); err != nil { 21 | return err 22 | } 23 | 24 | return app.Save(collection) 25 | }, func(app core.App) error { 26 | collection, err := app.FindCollectionByNameOrId("27do0wbcuyfmbmx") 27 | if err != nil { 28 | return err 29 | } 30 | 31 | // update collection data 32 | if err := json.Unmarshal([]byte(`{ 33 | "createRule": "@request.body.password:isset = false" 34 | }`), &collection); err != nil { 35 | return err 36 | } 37 | 38 | return app.Save(collection) 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /backend/migrations/1737069556_url_to_string_devices.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase/core" 5 | m "github.com/pocketbase/pocketbase/migrations" 6 | ) 7 | 8 | func init() { 9 | m.Register(func(app core.App) error { 10 | // Up migration: Change `url` field type from URL to string 11 | collectionName := "devices" 12 | 13 | return app.RunInTransaction(func(txApp core.App) error { 14 | // Step 1: Fetch the collection 15 | collection, err := txApp.FindCollectionByNameOrId(collectionName) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | // Step 2: Add a new text field 21 | newField := &core.TextField{ 22 | Name: "new_field", 23 | Required: false, 24 | } 25 | collection.Fields.AddAt(6, newField) 26 | 27 | if err := txApp.Save(collection); err != nil { 28 | return err 29 | } 30 | 31 | // Step 3: Migrate data from `url` to `new_field` 32 | records, err := txApp.FindAllRecords(collectionName) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | for _, record := range records { 38 | url := record.GetString("link") 39 | record.Set("new_field", url) 40 | if err := txApp.Save(record); err != nil { 41 | return err 42 | } 43 | } 44 | 45 | // Step 4: Remove the old `url` field 46 | collection.Fields.RemoveByName("link") 47 | if err := txApp.Save(collection); err != nil { 48 | return err 49 | } 50 | 51 | // Step 5: Rename `new_field` to `url` 52 | renamedField := collection.Fields.GetByName("new_field") 53 | renamedField.SetName("link") 54 | 55 | return txApp.Save(collection) 56 | }) 57 | }, func(app core.App) error { 58 | // Down migration: Restore the `url` field 59 | collectionName := "devices" 60 | 61 | return app.RunInTransaction(func(txApp core.App) error { 62 | // Step 1: Fetch the collection 63 | collection, err := txApp.FindCollectionByNameOrId(collectionName) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | // Step 2: Add a new `url` field 69 | newField := &core.URLField{ 70 | Name: "new_field", 71 | Required: false, 72 | } 73 | collection.Fields.AddAt(6, newField) 74 | 75 | if err := txApp.Save(collection); err != nil { 76 | return err 77 | } 78 | 79 | // Step 3: Migrate data from `url` to `new_field` 80 | records, err := txApp.FindAllRecords(collectionName) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | for _, record := range records { 86 | text := record.GetString("link") 87 | record.Set("new_field", text) 88 | if err := txApp.Save(record); err != nil { 89 | return err 90 | } 91 | } 92 | 93 | // Step 4: Remove the old `url` field 94 | collection.Fields.RemoveByName("link") 95 | if err := txApp.Save(collection); err != nil { 96 | return err 97 | } 98 | 99 | // Step 5: Rename `new_field` to `url` 100 | renamedField := collection.Fields.GetByName("new_field") 101 | renamedField.SetName("link") 102 | 103 | return txApp.Save(collection) 104 | }) 105 | }) 106 | } 107 | -------------------------------------------------------------------------------- /backend/migrations/1737071155_url_to_string_ports.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase/core" 5 | m "github.com/pocketbase/pocketbase/migrations" 6 | ) 7 | 8 | func init() { 9 | m.Register(func(app core.App) error { 10 | // Up migration: Change `url` field type from URL to string 11 | collectionName := "ports" 12 | 13 | return app.RunInTransaction(func(txApp core.App) error { 14 | // Step 1: Fetch the collection 15 | collection, err := txApp.FindCollectionByNameOrId(collectionName) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | // Step 2: Add a new text field 21 | newField := &core.TextField{ 22 | Name: "new_field", 23 | Required: false, 24 | } 25 | collection.Fields.AddAt(5, newField) 26 | 27 | if err := txApp.Save(collection); err != nil { 28 | return err 29 | } 30 | 31 | // Step 3: Migrate data from `url` to `new_field` 32 | records, err := txApp.FindAllRecords(collectionName) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | for _, record := range records { 38 | url := record.GetString("link") 39 | record.Set("new_field", url) 40 | if err := txApp.Save(record); err != nil { 41 | return err 42 | } 43 | } 44 | 45 | // Step 4: Remove the old `url` field 46 | collection.Fields.RemoveByName("link") 47 | if err := txApp.Save(collection); err != nil { 48 | return err 49 | } 50 | 51 | // Step 5: Rename `new_field` to `url` 52 | renamedField := collection.Fields.GetByName("new_field") 53 | renamedField.SetName("link") 54 | 55 | return txApp.Save(collection) 56 | }) 57 | }, func(app core.App) error { 58 | // Down migration: Restore the `url` field 59 | collectionName := "ports" 60 | 61 | return app.RunInTransaction(func(txApp core.App) error { 62 | // Step 1: Fetch the collection 63 | collection, err := txApp.FindCollectionByNameOrId(collectionName) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | // Step 2: Add a new `url` field 69 | newField := &core.URLField{ 70 | Name: "new_field", 71 | Required: false, 72 | } 73 | collection.Fields.AddAt(5, newField) 74 | 75 | if err := txApp.Save(collection); err != nil { 76 | return err 77 | } 78 | 79 | // Step 3: Migrate data from `url` to `new_field` 80 | records, err := txApp.FindAllRecords(collectionName) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | for _, record := range records { 86 | text := record.GetString("link") 87 | record.Set("new_field", text) 88 | if err := txApp.Save(record); err != nil { 89 | return err 90 | } 91 | } 92 | 93 | // Step 4: Remove the old `url` field 94 | collection.Fields.RemoveByName("link") 95 | if err := txApp.Save(collection); err != nil { 96 | return err 97 | } 98 | 99 | // Step 5: Rename `new_field` to `url` 100 | renamedField := collection.Fields.GetByName("new_field") 101 | renamedField.SetName("link") 102 | 103 | return txApp.Save(collection) 104 | }) 105 | }) 106 | } 107 | -------------------------------------------------------------------------------- /backend/migrations/1737546920_updated_devices.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase/core" 5 | m "github.com/pocketbase/pocketbase/migrations" 6 | ) 7 | 8 | func init() { 9 | m.Register(func(app core.App) error { 10 | collection, err := app.FindCollectionByNameOrId("z5lghx2r3tm45n1") 11 | if err != nil { 12 | return err 13 | } 14 | 15 | // add field 16 | if err := collection.Fields.AddMarshaledJSONAt(5, []byte(`{ 17 | "autogeneratePattern": "", 18 | "hidden": false, 19 | "id": "text1843675174", 20 | "max": 0, 21 | "min": 0, 22 | "name": "description", 23 | "pattern": "", 24 | "presentable": false, 25 | "primaryKey": false, 26 | "required": false, 27 | "system": false, 28 | "type": "text" 29 | }`)); err != nil { 30 | return err 31 | } 32 | 33 | return app.Save(collection) 34 | }, func(app core.App) error { 35 | collection, err := app.FindCollectionByNameOrId("z5lghx2r3tm45n1") 36 | if err != nil { 37 | return err 38 | } 39 | 40 | // remove field 41 | collection.Fields.RemoveById("text1843675174") 42 | 43 | return app.Save(collection) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /backend/migrations/1737552965_updated_devices.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase/core" 5 | m "github.com/pocketbase/pocketbase/migrations" 6 | ) 7 | 8 | func init() { 9 | m.Register(func(app core.App) error { 10 | collection, err := app.FindCollectionByNameOrId("z5lghx2r3tm45n1") 11 | if err != nil { 12 | return err 13 | } 14 | 15 | // add field 16 | if err := collection.Fields.AddMarshaledJSONAt(8, []byte(`{ 17 | "hidden": false, 18 | "id": "select355930381", 19 | "maxSelect": 1, 20 | "name": "link_open", 21 | "presentable": false, 22 | "required": false, 23 | "system": false, 24 | "type": "select", 25 | "values": [ 26 | "same_tab", 27 | "new_tab" 28 | ] 29 | }`)); err != nil { 30 | return err 31 | } 32 | 33 | return app.Save(collection) 34 | }, func(app core.App) error { 35 | collection, err := app.FindCollectionByNameOrId("z5lghx2r3tm45n1") 36 | if err != nil { 37 | return err 38 | } 39 | 40 | // remove field 41 | collection.Fields.RemoveById("select355930381") 42 | 43 | return app.Save(collection) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /backend/migrations/1741216171_updated_devices.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase/core" 5 | m "github.com/pocketbase/pocketbase/migrations" 6 | ) 7 | 8 | func init() { 9 | m.Register(func(app core.App) error { 10 | collection, err := app.FindCollectionByNameOrId("z5lghx2r3tm45n1") 11 | if err != nil { 12 | return err 13 | } 14 | 15 | // add field 16 | if err := collection.Fields.AddMarshaledJSONAt(11, []byte(`{ 17 | "hidden": false, 18 | "id": "number361076486", 19 | "max": null, 20 | "min": 1, 21 | "name": "wake_timeout", 22 | "onlyInt": true, 23 | "presentable": false, 24 | "required": false, 25 | "system": false, 26 | "type": "number" 27 | }`)); err != nil { 28 | return err 29 | } 30 | 31 | return app.Save(collection) 32 | }, func(app core.App) error { 33 | collection, err := app.FindCollectionByNameOrId("z5lghx2r3tm45n1") 34 | if err != nil { 35 | return err 36 | } 37 | 38 | // remove field 39 | collection.Fields.RemoveById("number361076486") 40 | 41 | return app.Save(collection) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /backend/migrations/1741216190_updated_devices.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase/core" 5 | m "github.com/pocketbase/pocketbase/migrations" 6 | ) 7 | 8 | func init() { 9 | m.Register(func(app core.App) error { 10 | collection, err := app.FindCollectionByNameOrId("z5lghx2r3tm45n1") 11 | if err != nil { 12 | return err 13 | } 14 | 15 | // add field 16 | if err := collection.Fields.AddMarshaledJSONAt(15, []byte(`{ 17 | "hidden": false, 18 | "id": "number2068100152", 19 | "max": null, 20 | "min": 1, 21 | "name": "shutdown_timeout", 22 | "onlyInt": true, 23 | "presentable": false, 24 | "required": false, 25 | "system": false, 26 | "type": "number" 27 | }`)); err != nil { 28 | return err 29 | } 30 | 31 | return app.Save(collection) 32 | }, func(app core.App) error { 33 | collection, err := app.FindCollectionByNameOrId("z5lghx2r3tm45n1") 34 | if err != nil { 35 | return err 36 | } 37 | 38 | // remove field 39 | collection.Fields.RemoveById("number2068100152") 40 | 41 | return app.Save(collection) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /backend/networking/magicpacket.go: -------------------------------------------------------------------------------- 1 | package networking 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | 8 | "github.com/mdlayher/wol" 9 | "github.com/pocketbase/pocketbase/core" 10 | ) 11 | 12 | func SendMagicPacket(device *core.Record) error { 13 | ip := device.GetString("ip") 14 | mac := device.GetString("mac") 15 | netmask := device.GetString("netmask") 16 | password := device.GetString("password") 17 | 18 | // parse inputs 19 | parsedMac, err := net.ParseMAC(mac) 20 | if err != nil { 21 | return err 22 | } 23 | var bytePassword []byte 24 | if len(password) == 0 || len(password) == 4 || len(password) == 6 { 25 | bytePassword = []byte(password) 26 | } else { 27 | return fmt.Errorf("error: password must be 0, 4 or 6 characters long") 28 | } 29 | 30 | // get target addr 31 | broadcastIp, err := getBroadcastIp(ip, netmask) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | // send wake via udp port 9 37 | if err := wakeUDP(broadcastIp, parsedMac, bytePassword); err != nil { 38 | return err 39 | } 40 | 41 | return nil 42 | } 43 | 44 | func wakeUDP(broadcastIp string, target net.HardwareAddr, password []byte) error { 45 | c, err := wol.NewClient() 46 | if err != nil { 47 | return err 48 | } 49 | defer c.Close() 50 | 51 | // send 4 magic packets to different addresses to enhance change of waking up 52 | destinations := []string{ 53 | // default user-calculated broadcast to port 9 54 | fmt.Sprintf("%s:9", broadcastIp), 55 | // user-calculated broadcast to port alternative port 7 56 | fmt.Sprintf("%s:7", broadcastIp), 57 | // broadcast to port 9 58 | "255.255.255.255:9", 59 | // broadcast to alternative port 7 60 | "255.255.255.255:7", 61 | } 62 | 63 | for _, dest := range destinations { 64 | if err := c.WakePassword(dest, target, password); err != nil { 65 | return err 66 | } 67 | } 68 | 69 | return nil 70 | } 71 | 72 | func getBroadcastIp(ipStr, maskStr string) (string, error) { 73 | ip := net.ParseIP(ipStr) 74 | if ip == nil { 75 | return "", errors.New("ip not a valid ipv4 address") 76 | } 77 | ip = ip.To4() 78 | if ip == nil { 79 | return "", errors.New("ip not a valid ipv4 address") 80 | } 81 | 82 | mask := net.ParseIP(maskStr) 83 | if mask == nil { 84 | return "", errors.New("subnet mask not a valid ipv4 address") 85 | } 86 | mask = mask.To4() 87 | ip = ip.To4() 88 | if ip == nil { 89 | return "", errors.New("subnet mask not a valid ipv4 address") 90 | } 91 | 92 | broadcast := make(net.IP, 4) 93 | for i := range ip { 94 | broadcast[i] = ip[i] | ^mask[i] 95 | } 96 | 97 | return broadcast.String(), nil 98 | } 99 | -------------------------------------------------------------------------------- /backend/networking/magicpacket_test.go: -------------------------------------------------------------------------------- 1 | package networking 2 | 3 | import ( 4 | "net" 5 | "testing" 6 | 7 | "github.com/pocketbase/pocketbase/core" 8 | ) 9 | 10 | type Device struct { 11 | IP string 12 | MAC string 13 | Netmask string 14 | Password string 15 | ExpectFailPassword bool 16 | } 17 | 18 | func TestSendMagicPacket(t *testing.T) { 19 | testCases := []struct { 20 | name string 21 | ip string 22 | mac string 23 | netmask string 24 | password string 25 | wantError bool 26 | }{ 27 | // Valid case: all inputs correct 28 | { 29 | name: "Valid Case", 30 | ip: "192.168.1.100", 31 | mac: "00:11:22:33:44:55", 32 | netmask: "255.255.255.0", 33 | password: "secret", 34 | wantError: false, 35 | }, 36 | // Invalid MAC address 37 | { 38 | name: "Invalid MAC", 39 | ip: "192.168.1.100", 40 | mac: "invalid", 41 | netmask: "255.255.255.0", 42 | password: "secret", 43 | wantError: true, 44 | }, 45 | // Password too short 46 | { 47 | name: "Password Too Short", 48 | ip: "192.168.1.100", 49 | mac: "00:11:22:33:44:55", 50 | netmask: "255.255.255.0", 51 | password: "s", // length 1 52 | wantError: true, 53 | }, 54 | // Password too long 55 | { 56 | name: "Password Too Long", 57 | ip: "192.168.1.100", 58 | mac: "00:11:22:33:44:55", 59 | netmask: "255.255.255.0", 60 | password: "password", // length 9 61 | wantError: true, 62 | }, 63 | // Invalid IP address 64 | { 65 | name: "Invalid IP", 66 | ip: "256.1.1.1", // invalid 67 | mac: "00:11:22:33:44:55", 68 | netmask: "255.255.255.0", 69 | password: "secret", 70 | wantError: true, 71 | }, 72 | // Invalid netmask 73 | { 74 | name: "Invalid Netmask", 75 | ip: "192.168.1.100", 76 | mac: "00:11:22:33:44:55", 77 | netmask: "300.255.255.0", // invalid 78 | password: "secret", 79 | wantError: true, 80 | }, 81 | } 82 | 83 | collection := &core.Collection{} 84 | 85 | for _, tc := range testCases { 86 | t.Run(tc.name, func(t *testing.T) { 87 | device := core.NewRecord(collection) 88 | device.Set("ip", tc.ip) 89 | device.Set("mac", tc.mac) 90 | device.Set("netmask", tc.netmask) 91 | device.Set("password", tc.password) 92 | 93 | err := SendMagicPacket(device) 94 | if tc.wantError { 95 | if err == nil { 96 | t.Errorf("Expected error but got none") 97 | } 98 | } else { 99 | if err != nil { 100 | t.Errorf("Got unexpected error: %v", err) 101 | } 102 | } 103 | }) 104 | } 105 | } 106 | 107 | func TestWakeUDP(t *testing.T) { 108 | testCases := []struct { 109 | name string 110 | broadcastIp string 111 | targetMac string 112 | password string 113 | wantError bool 114 | }{ 115 | // Valid case: all inputs correct 116 | { 117 | name: "Valid Case", 118 | broadcastIp: "192.168.1.255", 119 | targetMac: "00:11:22:33:44:55", 120 | password: "secret", 121 | wantError: false, 122 | }, 123 | // Invalid MAC address 124 | { 125 | name: "Invalid MAC", 126 | broadcastIp: "192.168.1.255", 127 | targetMac: "invalid", 128 | password: "secret", 129 | wantError: true, 130 | }, 131 | // Password too short 132 | { 133 | name: "Password Too Short", 134 | broadcastIp: "192.168.1.255", 135 | targetMac: "00:11:22:33:44:55", 136 | password: "s", // length 1 137 | wantError: true, 138 | }, 139 | // Password too long 140 | { 141 | name: "Password Too Long", 142 | broadcastIp: "192.168.1.255", 143 | targetMac: "00:11:22:33:44:55", 144 | password: "password", // length 9 145 | wantError: true, 146 | }, 147 | } 148 | 149 | for _, tc := range testCases { 150 | t.Run(tc.name, func(t *testing.T) { 151 | mac, err := net.ParseMAC(tc.targetMac) 152 | if err != nil && !tc.wantError { 153 | t.Errorf("Unexpected error parsing MAC: %v", err) 154 | } else if err != nil { 155 | return 156 | } 157 | 158 | // Now test the wakeUDP function 159 | err = wakeUDP(tc.broadcastIp, mac, []byte(tc.password)) 160 | if tc.wantError { 161 | if err == nil { 162 | t.Errorf("Expected error but got none") 163 | } 164 | } 165 | }) 166 | } 167 | } 168 | 169 | func TestGetBroadcastIp(t *testing.T) { 170 | testCases := []struct { 171 | name string 172 | ip string 173 | netmask string 174 | wantIp string 175 | wantError bool 176 | }{ 177 | // Valid case: all inputs correct 178 | { 179 | name: "Valid Case", 180 | ip: "192.168.1.100", 181 | netmask: "255.255.255.0", 182 | wantIp: "192.168.1.255", 183 | wantError: false, 184 | }, 185 | // Invalid IP address 186 | { 187 | name: "Invalid IP", 188 | ip: "256.1.1.1", // invalid 189 | netmask: "255.255.255.0", 190 | wantIp: "", 191 | wantError: true, 192 | }, 193 | // Invalid netmask 194 | { 195 | name: "Invalid Netmask", 196 | ip: "192.168.1.100", 197 | netmask: "300.255.255.0", // invalid 198 | wantIp: "", 199 | wantError: true, 200 | }, 201 | } 202 | 203 | for _, tc := range testCases { 204 | t.Run(tc.name, func(t *testing.T) { 205 | ip := net.ParseIP(tc.ip) 206 | if ip == nil && !tc.wantError { 207 | t.Errorf("Unexpected error parsing IP: %v", ip) 208 | } else if ip == nil { 209 | return 210 | } 211 | 212 | mask := net.ParseIP(tc.netmask) 213 | if mask == nil && !tc.wantError { 214 | t.Errorf("Unexpected error parsing netmask: %v", mask) 215 | } else if mask == nil { 216 | return 217 | } 218 | 219 | broadcast, err := getBroadcastIp(tc.ip, tc.netmask) 220 | if tc.wantError { 221 | if err == nil { 222 | t.Errorf("Expected error but got none") 223 | } 224 | } else { 225 | if broadcast != tc.wantIp { 226 | t.Errorf("Broadcast IP mismatch: expected %s, got %s", tc.wantIp, broadcast) 227 | } 228 | } 229 | }) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /backend/networking/ping.go: -------------------------------------------------------------------------------- 1 | package networking 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "os" 7 | "os/exec" 8 | "runtime" 9 | "strconv" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/pocketbase/pocketbase/core" 14 | probing "github.com/prometheus-community/pro-bing" 15 | ) 16 | 17 | func isNoRouteOrDownError(err error) bool { 18 | opErr, ok := err.(*net.OpError) 19 | if !ok { 20 | return false 21 | } 22 | syscallErr, ok := opErr.Err.(*os.SyscallError) 23 | if !ok { 24 | return false 25 | } 26 | return syscallErr.Err == syscall.EHOSTUNREACH || syscallErr.Err == syscall.EHOSTDOWN 27 | } 28 | 29 | func PingDevice(device *core.Record) (bool, error) { 30 | ping_cmd := device.GetString("ping_cmd") 31 | if ping_cmd == "" { 32 | pinger, err := probing.NewPinger(device.GetString("ip")) 33 | if err != nil { 34 | return false, err 35 | } 36 | pinger.Count = 1 37 | pinger.Timeout = 500 * time.Millisecond 38 | 39 | privileged := isRoot() 40 | privilegedEnv := os.Getenv("UPSNAP_PING_PRIVILEGED") 41 | if privilegedEnv != "" { 42 | privileged, err = strconv.ParseBool(privilegedEnv) 43 | if err != nil { 44 | privileged = false 45 | } 46 | } 47 | pinger.SetPrivileged(privileged) 48 | 49 | err = pinger.Run() 50 | if err != nil { 51 | if isNoRouteOrDownError(err) { 52 | return false, nil 53 | } 54 | return false, err 55 | } 56 | stats := pinger.Statistics() 57 | return stats.PacketLoss == 0, nil 58 | } else { 59 | var shell string 60 | var shell_arg string 61 | if runtime.GOOS == "windows" { 62 | shell = "cmd" 63 | shell_arg = "/C" 64 | } else { 65 | shell = "/bin/sh" 66 | shell_arg = "-c" 67 | } 68 | 69 | cmd := exec.Command(shell, shell_arg, ping_cmd) 70 | err := cmd.Run() 71 | 72 | return err == nil, err 73 | } 74 | } 75 | 76 | func CheckPort(host string, port string) (bool, error) { 77 | timeout := 500 * time.Millisecond 78 | conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), timeout) 79 | if err != nil { 80 | // treat "host unreachable", "connection refused" and "timeout" as no error 81 | var netErr *net.OpError 82 | if errors.As(err, &netErr) { 83 | if errors.Is(netErr.Err, syscall.EHOSTUNREACH) || 84 | errors.Is(netErr.Err, syscall.ECONNREFUSED) || 85 | netErr.Timeout() { 86 | return false, nil 87 | } 88 | } 89 | return false, err 90 | } 91 | defer conn.Close() 92 | return conn != nil, nil 93 | } 94 | -------------------------------------------------------------------------------- /backend/networking/ping_test.go: -------------------------------------------------------------------------------- 1 | package networking 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/pocketbase/pocketbase/core" 7 | ) 8 | 9 | func TestPingDevice(t *testing.T) { 10 | testCases := []struct { 11 | name string 12 | ip string 13 | ping_cmd string 14 | wantError bool 15 | }{ 16 | // Valid case: all inputs correct 17 | { 18 | name: "Valid Case", 19 | ip: "8.8.8.8", 20 | ping_cmd: "exit 0", 21 | wantError: false, 22 | }, 23 | // Invalid ping command 24 | { 25 | name: "Invalid Ping Command", 26 | ip: "8.8.8.8", 27 | ping_cmd: "exit 1", 28 | wantError: true, 29 | }, 30 | } 31 | 32 | collection := &core.Collection{} 33 | 34 | for _, tc := range testCases { 35 | t.Run(tc.name, func(t *testing.T) { 36 | device := core.NewRecord(collection) 37 | device.Set("ip", tc.ip) 38 | device.Set("ping_cmd", tc.ping_cmd) 39 | 40 | _, err := PingDevice(device) 41 | if err == nil && tc.wantError { 42 | t.Errorf("Expected error but got none") 43 | } else if err != nil && !tc.wantError { 44 | t.Errorf("Got unexpected error: %v", err) 45 | } 46 | }) 47 | } 48 | } 49 | 50 | func TestCheckPort(t *testing.T) { 51 | testCases := []struct { 52 | name string 53 | host string 54 | port string 55 | wantError bool 56 | }{ 57 | // Valid case: all inputs correct 58 | { 59 | name: "Valid Case", 60 | host: "8.8.8.8", 61 | port: "443", 62 | wantError: false, 63 | }, 64 | // Invalid IP address 65 | { 66 | name: "Invalid IP", 67 | host: "256.1.1.1", // invalid 68 | port: "443", 69 | wantError: true, 70 | }, 71 | // Invalid port number 72 | { 73 | name: "Invalid Port", 74 | host: "8.8.8.8", 75 | port: "70000", // invalid 76 | wantError: true, 77 | }, 78 | // Port zero 79 | { 80 | name: "Port Zero", 81 | host: "8.8.8.8", 82 | port: "0", 83 | wantError: false, 84 | }, 85 | } 86 | 87 | for _, tc := range testCases { 88 | t.Run(tc.name, func(t *testing.T) { 89 | _, err := CheckPort(tc.host, tc.port) 90 | if err == nil && tc.wantError { 91 | t.Errorf("Expected error but got none") 92 | } else if err != nil && !tc.wantError { 93 | t.Errorf("Got unexpected error: %v", err) 94 | } 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /backend/networking/root.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package networking 4 | 5 | import "os" 6 | 7 | func isRoot() bool { 8 | return os.Geteuid() == 0 9 | } 10 | -------------------------------------------------------------------------------- /backend/networking/root_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package networking 4 | 5 | import "golang.org/x/sys/windows" 6 | 7 | func isRoot() bool { 8 | var sid *windows.SID 9 | sid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) 10 | if err != nil { 11 | return false 12 | } 13 | token := windows.GetCurrentProcessToken() 14 | isAdmin, err := token.IsMember(sid) 15 | return err == nil && isAdmin 16 | } 17 | -------------------------------------------------------------------------------- /backend/networking/shutdown.go: -------------------------------------------------------------------------------- 1 | package networking 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os/exec" 8 | "runtime" 9 | "time" 10 | 11 | "github.com/pocketbase/pocketbase/core" 12 | "github.com/seriousm4x/upsnap/logger" 13 | ) 14 | 15 | func ShutdownDevice(device *core.Record) error { 16 | logger.Info.Println("Shutdown triggered for", device.GetString("name")) 17 | shutdown_cmd := device.GetString("shutdown_cmd") 18 | if shutdown_cmd == "" { 19 | return fmt.Errorf("%s: no shutdown_cmd definded", device.GetString("name")) 20 | } 21 | 22 | var shell string 23 | var shell_arg string 24 | if runtime.GOOS == "windows" { 25 | shell = "cmd" 26 | shell_arg = "/C" 27 | } else { 28 | shell = "/bin/sh" 29 | shell_arg = "-c" 30 | } 31 | 32 | ctx := context.Background() 33 | ctx, cancel := context.WithCancel(ctx) 34 | defer cancel() 35 | 36 | cmd := exec.CommandContext(ctx, shell, shell_arg, shutdown_cmd) 37 | SetProcessAttributes(cmd) 38 | 39 | var stderr bytes.Buffer 40 | cmd.Stderr = &stderr 41 | 42 | if err := cmd.Start(); err != nil { 43 | logger.Error.Println(err) 44 | } 45 | 46 | done := make(chan error, 1) 47 | go func() { 48 | done <- cmd.Wait() 49 | }() 50 | 51 | shutdownTimeout := device.GetInt("shutdown_timeout") 52 | if shutdownTimeout <= 0 { 53 | shutdownTimeout = 120 54 | } 55 | 56 | start := time.Now() 57 | 58 | for { 59 | select { 60 | case <-time.After(1 * time.Second): 61 | if time.Since(start) >= time.Duration(shutdownTimeout)*time.Second { 62 | if err := KillProcess(cmd.Process); err != nil { 63 | logger.Error.Println(err) 64 | } 65 | return fmt.Errorf("%s not offline after %d seconds", device.GetString("name"), shutdownTimeout) 66 | } 67 | isOnline, err := PingDevice(device) 68 | if err != nil { 69 | logger.Error.Println(err) 70 | return err 71 | } 72 | if !isOnline { 73 | if err := KillProcess(cmd.Process); err != nil { 74 | logger.Error.Println(err) 75 | } 76 | return nil 77 | } 78 | case err := <-done: 79 | if err != nil { 80 | if err := KillProcess(cmd.Process); err != nil { 81 | logger.Error.Println(err) 82 | } 83 | return fmt.Errorf("%s", stderr.String()) 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /backend/networking/shutdown_other.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package networking 5 | 6 | import ( 7 | "os" 8 | "os/exec" 9 | 10 | "github.com/seriousm4x/upsnap/logger" 11 | "golang.org/x/sys/unix" 12 | ) 13 | 14 | // Set platform specifiy custom process attributes 15 | func SetProcessAttributes(cmd *exec.Cmd) { 16 | cmd.SysProcAttr = &unix.SysProcAttr{Setpgid: true} 17 | } 18 | 19 | // Kills child processes on Linux. Windows doesn't provide a direct way to kill child processes, so we kill just the main process. 20 | func KillProcess(process *os.Process) error { 21 | logger.Warning.Println("Your command didn't finish in time. It will be killed.") 22 | pgid, err := unix.Getpgid(process.Pid) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | err = unix.Kill(-pgid, unix.SIGTERM) 28 | if err != nil { 29 | return unix.Kill(-pgid, unix.SIGKILL) 30 | } 31 | 32 | _, err = process.Wait() 33 | 34 | return err 35 | } 36 | -------------------------------------------------------------------------------- /backend/networking/shutdown_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package networking 5 | 6 | import ( 7 | "os" 8 | "os/exec" 9 | 10 | "golang.org/x/sys/windows" 11 | ) 12 | 13 | // Set platform specifiy custom process attributes 14 | func SetProcessAttributes(cmd *exec.Cmd) {} 15 | 16 | // Kills child processes on Linux. Windows doesn't provide a direct way to kill child processes, so we kill just the main process. 17 | func KillProcess(process *os.Process) error { 18 | return windows.GenerateConsoleCtrlEvent(windows.CTRL_BREAK_EVENT, uint32(process.Pid)) 19 | } 20 | -------------------------------------------------------------------------------- /backend/networking/sleep.go: -------------------------------------------------------------------------------- 1 | package networking 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | 10 | "github.com/pocketbase/pocketbase/core" 11 | "github.com/seriousm4x/upsnap/logger" 12 | ) 13 | 14 | type SolResponse struct { 15 | Message string `json:"message"` 16 | } 17 | 18 | func SleepDevice(device *core.Record) (SolResponse, error) { 19 | logger.Info.Println("Sleep triggered for", device.GetString("name")) 20 | 21 | var solResp SolResponse 22 | var url string 23 | 24 | if device.GetBool("sol_auth") { 25 | url = fmt.Sprintf("http://%s:%s@%s:%d/sleep?format=JSON", 26 | device.GetString("sol_user"), device.GetString("sol_password"), device.GetString("ip"), device.GetInt("sol_port")) 27 | } else { 28 | url = fmt.Sprintf("http://%s:%d/sleep?format=JSON", device.GetString("ip"), device.GetInt("sol_port")) 29 | } 30 | 31 | resp, err := http.Get(url) 32 | if err != nil { 33 | return solResp, err 34 | } 35 | defer resp.Body.Close() 36 | 37 | if resp.StatusCode != 200 { 38 | body, err := io.ReadAll(resp.Body) 39 | if err != nil { 40 | return solResp, err 41 | } 42 | 43 | var solResp SolResponse 44 | if err := json.Unmarshal(body, &solResp); err != nil { 45 | return solResp, err 46 | } 47 | 48 | return solResp, errors.New("status code was not 200") 49 | } 50 | 51 | return solResp, nil 52 | } 53 | -------------------------------------------------------------------------------- /backend/networking/wake.go: -------------------------------------------------------------------------------- 1 | package networking 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os/exec" 8 | "runtime" 9 | "time" 10 | 11 | "github.com/pocketbase/pocketbase/core" 12 | "github.com/seriousm4x/upsnap/logger" 13 | ) 14 | 15 | func WakeDevice(device *core.Record) error { 16 | logger.Info.Println("Wake triggered for", device.GetString("name")) 17 | 18 | wakeTimeout := device.GetInt("wake_timeout") 19 | if wakeTimeout <= 0 { 20 | wakeTimeout = 120 21 | } 22 | 23 | wake_cmd := device.GetString("wake_cmd") 24 | if wake_cmd != "" { 25 | var shell string 26 | var shell_arg string 27 | if runtime.GOOS == "windows" { 28 | shell = "cmd" 29 | shell_arg = "/C" 30 | } else { 31 | shell = "/bin/sh" 32 | shell_arg = "-c" 33 | } 34 | 35 | ctx := context.Background() 36 | ctx, cancel := context.WithCancel(ctx) 37 | defer cancel() 38 | 39 | cmd := exec.CommandContext(ctx, shell, shell_arg, wake_cmd) 40 | SetProcessAttributes(cmd) 41 | 42 | var stderr bytes.Buffer 43 | cmd.Stderr = &stderr 44 | 45 | if err := cmd.Start(); err != nil { 46 | logger.Error.Println(err) 47 | } 48 | 49 | done := make(chan error, 1) 50 | go func() { 51 | done <- cmd.Wait() 52 | }() 53 | 54 | start := time.Now() 55 | 56 | for { 57 | select { 58 | case <-time.After(1 * time.Second): 59 | if time.Since(start) >= time.Duration(wakeTimeout)*time.Second { 60 | if err := KillProcess(cmd.Process); err != nil { 61 | logger.Error.Println(err) 62 | } 63 | return fmt.Errorf("%s not online after %d seconds", device.GetString("name"), wakeTimeout) 64 | } 65 | isOnline, err := PingDevice(device) 66 | if err != nil { 67 | logger.Error.Println(err) 68 | return err 69 | } 70 | if isOnline { 71 | if err := KillProcess(cmd.Process); err != nil { 72 | logger.Error.Println(err) 73 | } 74 | return nil 75 | } 76 | case err := <-done: 77 | if err != nil { 78 | if err := KillProcess(cmd.Process); err != nil { 79 | logger.Error.Println(err) 80 | } 81 | return fmt.Errorf("%s", stderr.String()) 82 | } 83 | } 84 | } 85 | } else { 86 | err := SendMagicPacket(device) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | start := time.Now() 92 | for { 93 | time.Sleep(1 * time.Second) 94 | isOnline, err := PingDevice(device) 95 | if err != nil { 96 | logger.Error.Println(err) 97 | return err 98 | } 99 | if isOnline { 100 | return nil 101 | } 102 | if time.Since(start) >= time.Duration(wakeTimeout)*time.Second { 103 | break 104 | } 105 | } 106 | return fmt.Errorf("%s not online after %d seconds", device.GetString("name"), wakeTimeout) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /backend/pb/middlewares.go: -------------------------------------------------------------------------------- 1 | package pb 2 | 3 | import ( 4 | "github.com/pocketbase/dbx" 5 | "github.com/pocketbase/pocketbase/apis" 6 | "github.com/pocketbase/pocketbase/core" 7 | "github.com/pocketbase/pocketbase/tools/hook" 8 | ) 9 | 10 | func RequireUpSnapPermission() *hook.Handler[*core.RequestEvent] { 11 | return &hook.Handler[*core.RequestEvent]{ 12 | Func: func(e *core.RequestEvent) error { 13 | if e.HasSuperuserAuth() { 14 | return e.Next() 15 | } 16 | 17 | user := e.Auth 18 | if user == nil { 19 | return apis.NewUnauthorizedError("The request requires superuser or record authorization token to be set.", nil) 20 | } 21 | 22 | deviceId := e.Request.PathValue("id") 23 | 24 | // find record where user has device with power permission 25 | res, err := e.App.FindFirstRecordByFilter("permissions", "user.id = {:userId} && power.id ?= {:deviceId}", dbx.Params{ 26 | "userId": user.Id, 27 | "deviceId": deviceId, 28 | }) 29 | if res == nil || err != nil { 30 | return apis.NewForbiddenError("You are not allowed to perform this request.", nil) 31 | } 32 | 33 | return e.Next() 34 | }, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /backend/pb_public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/backend/pb_public/.gitkeep -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | files: 2 | - source: /frontend/translations/en-US.json 3 | translation: /frontend/translations/%locale%.json 4 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | upsnap: 3 | container_name: upsnap 4 | build: 5 | dockerfile: Dockerfile.dev 6 | network_mode: host 7 | restart: unless-stopped 8 | volumes: 9 | - ./data:/app/pb_data 10 | # # To use a non-root user, create the mountpoint first before starting 11 | # # the container (mkdir data) so that it has the right permissions. 12 | # user: 1000:1000 13 | # environment: 14 | # - TZ=Europe/Berlin # Set container timezone for cron schedules 15 | # - UPSNAP_INTERVAL=*/10 * * * * * # Sets the interval in which the devices are pinged 16 | # - UPSNAP_SCAN_RANGE=192.168.1.0/24 # Scan range is used for device discovery on local network 17 | # - UPSNAP_WEBSITE_TITLE=Custom name # Custom website title 18 | # # dns is used for name resolution during network scan 19 | # dns: 20 | # - 192.18.0.1 21 | # - 192.18.0.2 22 | # # you can change the listen ip:port inside the container like this: 23 | # entrypoint: /bin/sh -c "./upsnap serve --http 0.0.0.0:5000" 24 | # healthcheck: 25 | # test: curl -fs "http://localhost:5000/api/health" || exit 1 26 | # interval: 10s 27 | # # or install custom packages for shutdown 28 | # entrypoint: /bin/sh -c "apk update && apk add --no-cache <YOUR_PACKAGE> && rm -rf /var/cache/apk/* && ./upsnap serve --http 0.0.0.0:8090" 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | upsnap: 3 | container_name: upsnap 4 | image: ghcr.io/seriousm4x/upsnap:5 # images are also available on docker hub: seriousm4x/upsnap:5 5 | network_mode: host 6 | restart: unless-stopped 7 | volumes: 8 | - ./data:/app/pb_data 9 | # # To use a non-root user, create the mountpoint first (mkdir data) so that it has the right permission. 10 | # user: 1000:1000 11 | # environment: 12 | # - TZ=Europe/Berlin # Set container timezone for cron schedules 13 | # - UPSNAP_INTERVAL=*/10 * * * * * # Sets the interval in which the devices are pinged 14 | # - UPSNAP_SCAN_RANGE=192.168.1.0/24 # Scan range is used for device discovery on local network 15 | # - UPSNAP_SCAN_TIMEOUT=500ms # Scan timeout is nmap's --host-timeout value to wait for devices (https://nmap.org/book/man-performance.html) 16 | # - UPSNAP_PING_PRIVILEGED=true # Set to false if you don't have root user permissions 17 | # - UPSNAP_WEBSITE_TITLE=Custom name # Custom website title 18 | # # dns is used for name resolution during network scan 19 | # dns: 20 | # - 192.18.0.1 21 | # - 192.18.0.2 22 | # # you can change the listen ip:port inside the container like this: 23 | # entrypoint: /bin/sh -c "./upsnap serve --http 0.0.0.0:5000" 24 | # healthcheck: 25 | # test: curl -fs "http://localhost:5000/api/health" || exit 1 26 | # interval: 10s 27 | # # or install custom packages for shutdown 28 | # entrypoint: /bin/sh -c "apk update && apk add --no-cache <YOUR_PACKAGE> && rm -rf /var/cache/apk/* && ./upsnap serve --http 0.0.0.0:8090" 29 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | PUBLIC_VERSION="" 2 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env.* 7 | !.env.example 8 | vite.config.js.timestamp-* 9 | vite.config.ts.timestamp-* 10 | -------------------------------------------------------------------------------- /frontend/.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /frontend/.husky/pre-commit: -------------------------------------------------------------------------------- 1 | cd frontend && npx lint-staged 2 | -------------------------------------------------------------------------------- /frontend/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | resolution-mode=highest 3 | -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], 7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }], 8 | "endOfLine": "lf" 9 | } 10 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # create-svelte 2 | 3 | Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte). 4 | 5 | ## Creating a project 6 | 7 | If you're seeing this, you've probably already done this step. Congrats! 8 | 9 | ```bash 10 | # create a new project in the current directory 11 | npm create svelte@latest 12 | 13 | # create a new project in my-app 14 | npm create svelte@latest my-app 15 | ``` 16 | 17 | ## Developing 18 | 19 | Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 20 | 21 | ```bash 22 | npm run dev 23 | 24 | # or start the server and open the app in a new browser tab 25 | npm run dev -- --open 26 | ``` 27 | 28 | ## Building 29 | 30 | To create a production version of your app: 31 | 32 | ```bash 33 | npm run build 34 | ``` 35 | 36 | You can preview the production build with `npm run preview`. 37 | 38 | > To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. 39 | -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import ts from 'typescript-eslint'; 3 | import svelte from 'eslint-plugin-svelte'; 4 | import prettier from 'eslint-config-prettier'; 5 | import globals from 'globals'; 6 | import { includeIgnoreFile } from '@eslint/compat'; 7 | import path from 'node:path'; 8 | import { fileURLToPath } from 'node:url'; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | const gitignorePath = path.resolve(__dirname, '.gitignore'); 13 | 14 | /** @type {import('eslint').Linter.Config[]} */ 15 | export default [ 16 | includeIgnoreFile(gitignorePath), 17 | js.configs.recommended, 18 | ...ts.configs.recommended, 19 | ...svelte.configs['flat/recommended'], 20 | prettier, 21 | ...svelte.configs['flat/prettier'], 22 | { 23 | languageOptions: { 24 | globals: { 25 | ...globals.browser, 26 | ...globals.node 27 | } 28 | } 29 | }, 30 | { 31 | files: ['**/*.svelte'], 32 | languageOptions: { 33 | parserOptions: { 34 | parser: ts.parser 35 | } 36 | } 37 | }, 38 | { 39 | ignores: ['build/', '.svelte-kit/', 'dist/'] 40 | } 41 | ]; 42 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "upsnap-frontend", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "vite": "vite dev", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 11 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 12 | "lint": "eslint --fix --cache .", 13 | "format": "prettier --write --cache --cache-strategy content --ignore-path ../.gitignore .", 14 | "prepare": "svelte-kit sync && cd .. && husky frontend/.husky", 15 | "machine-translate": "inlang machine translate --project project.inlang" 16 | }, 17 | "devDependencies": { 18 | "@eslint/compat": "^1.2.9", 19 | "@eslint/js": "^9.27.0", 20 | "@inlang/cli": "^3.0.11", 21 | "@inlang/paraglide-js": "2.0.13", 22 | "@sveltejs/adapter-static": "^3.0.8", 23 | "@sveltejs/kit": "^2.21.1", 24 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 25 | "daisyui": "^5.0.42", 26 | "eslint": "^9.28.0", 27 | "eslint-config-prettier": "^10.1.5", 28 | "eslint-plugin-svelte": "^2.46.1", 29 | "globals": "^15.15.0", 30 | "husky": "^9.1.7", 31 | "prettier": "^3.5.3", 32 | "prettier-plugin-svelte": "^3.4.0", 33 | "prettier-plugin-tailwindcss": "^0.6.11", 34 | "svelte": "^5.33.4", 35 | "svelte-check": "^4.2.1", 36 | "tailwindcss": "^4.1.7", 37 | "tslib": "^2.8.1", 38 | "typescript": "^5.8.3", 39 | "typescript-eslint": "^8.33.0", 40 | "vite": "^6.3.5" 41 | }, 42 | "type": "module", 43 | "lint-staged": { 44 | "*.{js,svelte}": "eslint --fix --cache .", 45 | "*.{js,css,md,svelte,scss}": "prettier --write --cache --cache-strategy content --ignore-path ../.gitignore ." 46 | }, 47 | "dependencies": { 48 | "@fortawesome/free-solid-svg-icons": "^6.7.2", 49 | "@tailwindcss/postcss": "^4.1.7", 50 | "cron-parser": "^4.9.0", 51 | "date-fns": "^4.1.0", 52 | "pocketbase": "^0.26.0", 53 | "postcss": "^8.5.3", 54 | "svelte-fa": "^4.0.4", 55 | "svelte-french-toast": "github:seriousm4x/svelte-french-toast#fix-72-build", 56 | "theme-change": "^2.5.0" 57 | }, 58 | "pnpm": { 59 | "onlyBuiltDependencies": [ 60 | "@tailwindcss/oxide", 61 | "esbuild" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /frontend/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | '@tailwindcss/postcss': {} 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/project.inlang/.gitignore: -------------------------------------------------------------------------------- 1 | cache -------------------------------------------------------------------------------- /frontend/project.inlang/project_id: -------------------------------------------------------------------------------- 1 | StfXY3tZWvr4FQntlM -------------------------------------------------------------------------------- /frontend/project.inlang/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://inlang.com/schema/project-settings", 3 | "baseLocale": "en-US", 4 | "locales": [ 5 | "de-DE", 6 | "en-US", 7 | "es-ES", 8 | "fr-FR", 9 | "id-ID", 10 | "it-IT", 11 | "ja-JP", 12 | "ko-KR", 13 | "nl-NL", 14 | "pl-PL", 15 | "pt-PT", 16 | "zh-TW", 17 | "zh-CN" 18 | ], 19 | "modules": [ 20 | "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", 21 | "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" 22 | ], 23 | "plugin.inlang.messageFormat": { 24 | "pathPattern": "./translations/{locale}.json" 25 | }, 26 | "telemetry": "off" 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @plugin "daisyui" { 4 | themes: all; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface Platform {} 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /frontend/src/app.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <html lang="%lang%"> 3 | <head> 4 | <meta charset="utf-8" /> 5 | <meta name="viewport" content="width=device-width" /> 6 | <link rel="manifest" href="/manifest.webmanifest" /> 7 | %sveltekit.head% 8 | <script> 9 | const darkTheme = 'dim'; 10 | const lightTheme = 'silk'; 11 | const preferesDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 12 | const theme = localStorage.getItem('theme') 13 | ? localStorage.getItem('theme') 14 | : preferesDark 15 | ? darkTheme 16 | : lightTheme; 17 | document.documentElement.setAttribute('data-theme', theme); 18 | 19 | window 20 | .matchMedia('(prefers-color-scheme: dark)') 21 | .addEventListener('change', ({ matches }) => { 22 | matches 23 | ? document.documentElement.setAttribute('data-theme', darkTheme) 24 | : document.documentElement.setAttribute('data-theme', lightTheme); 25 | }); 26 | </script> 27 | </head> 28 | 29 | <body data-sveltekit-preload-data="hover"> 30 | <div style="display: contents">%sveltekit.body%</div> 31 | </body> 32 | </html> 33 | -------------------------------------------------------------------------------- /frontend/src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { paraglideMiddleware } from '$lib/paraglide/server'; 2 | import { localeStore } from '$lib/stores/locale'; 3 | import type { Handle } from '@sveltejs/kit'; 4 | 5 | // creating a handle to use the paraglide middleware 6 | const paraglideHandle: Handle = ({ event, resolve }) => 7 | paraglideMiddleware(event.request, ({ locale }) => { 8 | localeStore.set(locale); 9 | return resolve(event, { 10 | transformPageChunk: ({ html }) => html.replace('%lang%', locale) 11 | }); 12 | }); 13 | 14 | export const handle: Handle = paraglideHandle; 15 | -------------------------------------------------------------------------------- /frontend/src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { deLocalizeUrl } from '$lib/paraglide/runtime'; 2 | import type { Reroute } from '@sveltejs/kit'; 3 | 4 | export const reroute: Reroute = (request) => { 5 | return deLocalizeUrl(request.url).pathname; 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/src/lib/components/DeviceCard.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { goto } from '$app/navigation'; 3 | import { nextCronDate } from '$lib/helpers/cron'; 4 | import { m } from '$lib/paraglide/messages'; 5 | import { dateFnsLocale } from '$lib/stores/locale'; 6 | import { backendUrl, permission, pocketbase } from '$lib/stores/pocketbase'; 7 | import { type Device } from '$lib/types/device'; 8 | import { 9 | faBed, 10 | faCircleArrowDown, 11 | faCircleArrowUp, 12 | faLock, 13 | faPen, 14 | faRotateLeft 15 | } from '@fortawesome/free-solid-svg-icons'; 16 | import { formatDistance, parseISO } from 'date-fns'; 17 | import Fa from 'svelte-fa'; 18 | import toast from 'svelte-french-toast'; 19 | import { scale } from 'svelte/transition'; 20 | import DeviceCardNic from './DeviceCardNic.svelte'; 21 | 22 | export let device: Device; 23 | 24 | let modalReboot: HTMLDialogElement; 25 | 26 | $: moreButtons = [ 27 | { 28 | text: m.device_card_btn_more_sleep(), 29 | icon: faBed, 30 | onClick: () => sleep(), 31 | requires: 32 | ($pocketbase.authStore.isSuperuser || $permission.power?.includes(device.id)) && 33 | device.status === 'online' && 34 | device.sol_enabled 35 | }, 36 | { 37 | text: m.device_card_btn_more_reboot(), 38 | icon: faRotateLeft, 39 | onClick: () => askRebootConfirmation(), 40 | requires: 41 | ($pocketbase.authStore.isSuperuser || $permission.power?.includes(device.id)) && 42 | device.status === 'online' && 43 | device.shutdown_cmd !== '' 44 | }, 45 | { 46 | text: m.device_card_btn_more_edit(), 47 | icon: faPen, 48 | onClick: () => goto(`/device/${device.id}`), 49 | requires: $pocketbase.authStore.isSuperuser || $permission.update?.includes(device.id) 50 | } 51 | ]; 52 | 53 | // update device status change 54 | let now = Date.now(); 55 | let interval: number; 56 | $: { 57 | clearInterval(interval); 58 | interval = setInterval(() => { 59 | now = Date.now(); 60 | }, 1000); 61 | } 62 | 63 | function sleep() { 64 | fetch(`${backendUrl}api/upsnap/sleep/${device.id}`, { 65 | headers: { 66 | Authorization: $pocketbase.authStore.token 67 | } 68 | }).catch((err) => { 69 | toast.error(err.message); 70 | }); 71 | } 72 | 73 | function reboot() { 74 | fetch(`${backendUrl}api/upsnap/reboot/${device.id}`, { 75 | headers: { 76 | Authorization: $pocketbase.authStore.token 77 | } 78 | }).catch((err) => { 79 | toast.error(err.message); 80 | }); 81 | } 82 | 83 | function askRebootConfirmation() { 84 | if (device.shutdown_confirm) { 85 | modalReboot.showModal(); 86 | } else { 87 | reboot(); 88 | } 89 | } 90 | </script> 91 | 92 | <div class="card bg-base-200 shadow-sm" transition:scale={{ delay: 0, duration: 200 }}> 93 | <div class="card-body p-6"> 94 | {#if device.link.toString() !== ''} 95 | <a href={device.link.toString()} target="_blank"> 96 | <h1 class="link card-title">{device.name}</h1> 97 | </a> 98 | {:else} 99 | <h1 class="card-title">{device.name}</h1> 100 | {/if} 101 | {#if device.description} 102 | <p class="grow-0">{device.description}</p> 103 | {/if} 104 | <div class="card rounded-box w-full"> 105 | <DeviceCardNic {device} /> 106 | </div> 107 | {#if device.wake_cron_enabled || device.shutdown_cron_enabled || device.password} 108 | <div class="mt-1 flex flex-row flex-wrap gap-2"> 109 | {#if device.wake_cron_enabled} 110 | <div class="tooltip" data-tip={m.device_card_tooltip_wake_cron()}> 111 | <span class="badge badge-success gap-1 p-3" 112 | ><Fa icon={faCircleArrowUp} /> 113 | {nextCronDate(device.wake_cron)} 114 | </span> 115 | </div> 116 | {/if} 117 | {#if device.shutdown_cron_enabled} 118 | <div class="tooltip" data-tip={m.device_card_tooltip_shutdown_cron()}> 119 | <span class="badge badge-error gap-1 p-3" 120 | ><Fa icon={faCircleArrowDown} /> 121 | {nextCronDate(device.shutdown_cron)} 122 | </span> 123 | </div> 124 | {/if} 125 | {#if device.password} 126 | <div class="tooltip" data-tip={m.device_card_tooltip_wake_password()}> 127 | <span class="badge gap-1 p-3"><Fa icon={faLock} />{m.device_card_password()}</span> 128 | </div> 129 | {/if} 130 | </div> 131 | {/if} 132 | <div class="card-actions mt-auto items-center"> 133 | <span 134 | class="tooltip" 135 | data-tip="{m.device_card_tooltip_last_status_change()}: {device.updated}" 136 | > 137 | {formatDistance(parseISO(device.updated), now, { 138 | includeSeconds: true, 139 | addSuffix: true, 140 | locale: $dateFnsLocale 141 | })} 142 | </span> 143 | {#if moreButtons.filter((btn) => btn.requires).length > 0} 144 | <div class="ms-auto flex flex-row flex-wrap gap-1"> 145 | {#each moreButtons as btn} 146 | {#if btn.requires} 147 | <div class="tooltip" data-tip={btn.text}> 148 | <button class="btn btn-sm btn-circle" on:click={btn.onClick}> 149 | <Fa icon={btn.icon} /> 150 | </button> 151 | </div> 152 | {/if} 153 | {/each} 154 | </div> 155 | {/if} 156 | </div> 157 | </div> 158 | </div> 159 | 160 | <dialog class="modal" bind:this={modalReboot}> 161 | <div class="modal-box"> 162 | <h3 class="text-lg font-bold"> 163 | {m.device_modal_confirm_shutdown_title({ device: device.name })} 164 | </h3> 165 | <p class="py-4">{m.device_modal_confirm_shutdown_desc({ device: device.name })}</p> 166 | <div class="modal-action"> 167 | <form method="dialog"> 168 | <button class="btn">{m.buttons_cancel()}</button> 169 | <button class="btn btn-success" on:click={reboot}>{m.buttons_confirm()}</button> 170 | </form> 171 | </div> 172 | </div> 173 | </dialog> 174 | -------------------------------------------------------------------------------- /frontend/src/lib/components/DeviceCardNic.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { m } from '$lib/paraglide/messages'; 3 | import { backendUrl, permission, pocketbase } from '$lib/stores/pocketbase'; 4 | import type { Device } from '$lib/types/device'; 5 | import { faPowerOff } from '@fortawesome/free-solid-svg-icons'; 6 | import Fa from 'svelte-fa'; 7 | import toast from 'svelte-french-toast'; 8 | 9 | export let device: Device; 10 | 11 | let hoverText = ''; 12 | let disabled = false; 13 | let timeout = 120; 14 | let interval: number; 15 | let modalWake: HTMLDialogElement; 16 | let modalShutdown: HTMLDialogElement; 17 | 18 | $: if (device.status === 'pending' && !interval) { 19 | countdown(Date.parse(device.updated), 'wake'); 20 | } 21 | $: minutes = Math.floor(timeout / 60); 22 | $: seconds = timeout % 60; 23 | $: if (device.status === 'pending' || device.status === '') { 24 | disabled = true; 25 | hoverText = m.device_card_nic_tooltip_pending(); 26 | } else if (device.status === 'online') { 27 | if (device.shutdown_cmd === '') { 28 | disabled = true; 29 | hoverText = m.device_card_nic_tooltip_shutdown_no_cmd(); 30 | } else if (!$pocketbase.authStore.isSuperuser && !$permission.power?.includes(device.id)) { 31 | disabled = true; 32 | hoverText = m.device_card_nic_tooltip_shutdown_no_permission(); 33 | } else { 34 | disabled = false; 35 | hoverText = m.device_card_nic_tooltip_shutdown(); 36 | } 37 | } else if (device.status === 'offline') { 38 | if (!$pocketbase.authStore.isSuperuser && !$permission.power?.includes(device.id)) { 39 | disabled = true; 40 | hoverText = m.device_card_nic_tooltip_power_no_permission(); 41 | } else { 42 | disabled = false; 43 | hoverText = m.device_card_nic_tooltip_power(); 44 | } 45 | } 46 | 47 | function wake() { 48 | countdown(Date.now(), 'wake'); 49 | device.status = 'pending'; 50 | 51 | fetch(`${backendUrl}api/upsnap/wake/${device.id}`, { 52 | headers: { 53 | Authorization: $pocketbase.authStore.token 54 | } 55 | }) 56 | .then((resp) => resp.json()) 57 | .then(async (data) => { 58 | if (data.status !== 200) { 59 | device.status = 'offline'; 60 | return; 61 | } 62 | device = data as Device; 63 | if (device.status === 'online' && device.link && device.link_open !== '') { 64 | if (device.link_open === 'new_tab') { 65 | window.open(device.link, '_blank'); 66 | } else { 67 | window.open(device.link, '_self'); 68 | } 69 | } 70 | }) 71 | .catch((err) => { 72 | toast.error(err.message); 73 | }); 74 | } 75 | 76 | function shutdown() { 77 | countdown(Date.now(), 'shutdown'); 78 | device.status = 'pending'; 79 | 80 | fetch(`${backendUrl}api/upsnap/shutdown/${device.id}`, { 81 | headers: { 82 | Authorization: $pocketbase.authStore.token 83 | } 84 | }) 85 | .then((resp) => resp.json()) 86 | .then(async (data) => { 87 | if (data.status !== 200) { 88 | device.status = 'online'; 89 | return; 90 | } 91 | device = data as Device; 92 | }) 93 | .catch((err) => { 94 | toast.error(err.message); 95 | }); 96 | } 97 | 98 | function countdown(updated: number, action: 'wake' | 'shutdown') { 99 | timeout = action === 'wake' ? device.wake_timeout : device.shutdown_timeout; 100 | if (timeout <= 0) { 101 | timeout = 120; 102 | } 103 | 104 | const end = updated + timeout * 1000; 105 | 106 | if (interval) { 107 | clearInterval(interval); 108 | interval = 0; 109 | } 110 | 111 | interval = setInterval(() => { 112 | timeout = Math.round((end - Date.now()) / 1000); 113 | 114 | if (timeout <= 0 || device.status !== 'pending') { 115 | clearInterval(interval); 116 | interval = 0; 117 | } 118 | }, 1000); 119 | } 120 | 121 | function handleClick() { 122 | if (device.status === 'offline') { 123 | if (device.wake_confirm) { 124 | askConfirmation('wake'); 125 | } else { 126 | wake(); 127 | } 128 | } else if (device.status === 'online') { 129 | if (device.shutdown_confirm) { 130 | askConfirmation('shutdown'); 131 | } else { 132 | shutdown(); 133 | } 134 | } 135 | } 136 | 137 | function askConfirmation(action: string) { 138 | if (action === 'wake') { 139 | modalWake.showModal(); 140 | } else { 141 | modalShutdown.showModal(); 142 | } 143 | } 144 | </script> 145 | 146 | <div 147 | class={`tooltip ${disabled ? 'cursor-not-allowed' : 'hover:bg-base-300 cursor-pointer'} bg-base-100 rounded-box flex items-start gap-4 p-2`} 148 | data-tip={hoverText} 149 | on:click={disabled ? null : handleClick} 150 | on:keydown={disabled ? null : handleClick} 151 | role="none" 152 | > 153 | {#if device.status === 'offline'} 154 | <button class="btn btn-error btn-circle size-12"><Fa icon={faPowerOff} /></button> 155 | {:else if device.status === 'online'} 156 | <button 157 | class="btn btn-success btn-circle size-12" 158 | class:cursor-not-allowed={device.shutdown_cmd === ''}><Fa icon={faPowerOff} /></button 159 | > 160 | {:else if device.status === 'pending'} 161 | <button class="btn btn-warning"> 162 | <span class="countdown font-mono"> 163 | <span style="--value:{minutes};"></span>: 164 | <span style="--value:{seconds};"></span> 165 | </span> 166 | </button> 167 | {:else} 168 | <div class="btn btn-warning btn-circle size-12"> 169 | <span class="loading loading-ring loading-sm"></span> 170 | </div> 171 | {/if} 172 | <div class="grow"> 173 | <div class="text-lg leading-4 font-bold">{device.ip}</div> 174 | <div>{device.mac}</div> 175 | <div class="flex flex-wrap gap-x-4"> 176 | {#if device?.expand?.ports} 177 | {#each device?.expand?.ports.sort((a, b) => a.number - b.number) as port} 178 | <span class="flex items-center gap-1 break-all"> 179 | {#if port.status} 180 | <div class="inline-grid *:[grid-area:1/1]"> 181 | <div class="status status-success h-3 w-3 animate-ping"></div> 182 | <div class="status status-success h-3 w-3"></div> 183 | </div> 184 | {:else} 185 | <div class="inline-grid *:[grid-area:1/1]"> 186 | <div class="status status-error h-3 w-3 animate-ping"></div> 187 | <div class="status status-error h-3 w-3"></div> 188 | </div> 189 | {/if} 190 | {#if port.link} 191 | <a 192 | href={port.link} 193 | target="_blank" 194 | class="underline" 195 | on:click={(e) => e.stopPropagation()}>{port.name} ({port.number})</a 196 | > 197 | {:else} 198 | {port.name} ({port.number}) 199 | {/if} 200 | </span> 201 | {/each} 202 | {/if} 203 | </div> 204 | </div> 205 | </div> 206 | 207 | <dialog class="modal" bind:this={modalWake}> 208 | <div class="modal-box"> 209 | <h3 class="text-lg font-bold"> 210 | {m.device_modal_confirm_wake_title({ device: device.name })} 211 | </h3> 212 | <p class="py-4">{m.device_modal_confirm_wake_desc({ device: device.name })}</p> 213 | <div class="modal-action"> 214 | <form method="dialog"> 215 | <button class="btn">{m.buttons_cancel()}</button> 216 | <button class="btn btn-success" on:click={wake}>{m.buttons_confirm()}</button> 217 | </form> 218 | </div> 219 | </div> 220 | </dialog> 221 | 222 | <dialog class="modal" bind:this={modalShutdown}> 223 | <div class="modal-box"> 224 | <h3 class="text-lg font-bold"> 225 | {m.device_modal_confirm_shutdown_title({ device: device.name })} 226 | </h3> 227 | <p class="py-4">{m.device_modal_confirm_shutdown_desc({ device: device.name })}</p> 228 | <div class="modal-action"> 229 | <form method="dialog"> 230 | <button class="btn">{m.buttons_cancel()}</button> 231 | <button class="btn btn-success" on:click={shutdown}>{m.buttons_confirm()}</button> 232 | </form> 233 | </div> 234 | </div> 235 | </dialog> 236 | -------------------------------------------------------------------------------- /frontend/src/lib/components/DeviceFormPort.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { m } from '$lib/paraglide/messages'; 3 | import { pocketbase } from '$lib/stores/pocketbase'; 4 | import type { Device, Port } from '$lib/types/device'; 5 | import { faTrash } from '@fortawesome/free-solid-svg-icons'; 6 | import type { RecordModel } from 'pocketbase'; 7 | import Fa from 'svelte-fa'; 8 | import toast from 'svelte-french-toast'; 9 | 10 | export let device: Device; 11 | export let index: number; 12 | 13 | function deletePort(port: Port | RecordModel) { 14 | if (port.id !== undefined) { 15 | $pocketbase 16 | .collection('ports') 17 | .delete(port.id) 18 | .then(() => { 19 | device.ports = device.ports.filter((id) => id !== device.ports[index]); 20 | }) 21 | .catch((err) => { 22 | toast.error(err.message); 23 | }); 24 | } 25 | device.expand.ports = device.expand.ports.filter((p) => p !== device.expand.ports[index]); 26 | } 27 | </script> 28 | 29 | {#if device.expand.ports[index]} 30 | <fieldset class="fieldset bg-base-100 border-base-300 rounded-box w-xs max-w-full border p-4"> 31 | <legend class="fieldset-legend"># {device.expand.ports.length}</legend> 32 | <div class="flex flex-row gap-2"> 33 | <fieldset class="fieldset"> 34 | <label class="floating-label"> 35 | <span>{m.device_ports_name()} <span class="text-error">*</span></span> 36 | <input 37 | type="text" 38 | placeholder={m.device_ports_name()} 39 | class="input" 40 | required 41 | bind:value={device.expand.ports[index].name} 42 | /> 43 | </label> 44 | </fieldset> 45 | <fieldset class="fieldset"> 46 | <label class="floating-label"> 47 | <span>{m.device_ports_number()} <span class="text-error">*</span></span> 48 | <input 49 | type="number" 50 | placeholder={m.device_ports_number()} 51 | class="input" 52 | min="1" 53 | max="65535" 54 | required 55 | bind:value={device.expand.ports[index].number} 56 | /> 57 | </label> 58 | </fieldset> 59 | </div> 60 | <fieldset class="fieldset"> 61 | <label class="floating-label"> 62 | <span>{m.device_link()}</span> 63 | <input 64 | type="text" 65 | placeholder={m.device_link()} 66 | class="input" 67 | bind:value={device.expand.ports[index].link} 68 | /> 69 | </label> 70 | </fieldset> 71 | <button 72 | class="btn btn-error btn-xs btn-soft ms-auto" 73 | on:click={() => deletePort(device.expand.ports[index])} 74 | type="button"><Fa icon={faTrash} />{m.buttons_delete()}</button 75 | > 76 | </fieldset> 77 | {/if} 78 | -------------------------------------------------------------------------------- /frontend/src/lib/components/Navbar.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { goto } from '$app/navigation'; 3 | import { page } from '$app/stores'; 4 | import { m } from '$lib/paraglide/messages'; 5 | import { backendUrl, permission, pocketbase } from '$lib/stores/pocketbase'; 6 | import { settingsPub } from '$lib/stores/settings'; 7 | import { 8 | faCheck, 9 | faChevronDown, 10 | faCog, 11 | faDoorOpen, 12 | faHome, 13 | faPlus, 14 | faSwatchbook, 15 | faUserGear, 16 | faUsersGear 17 | } from '@fortawesome/free-solid-svg-icons'; 18 | import { onMount } from 'svelte'; 19 | import Fa from 'svelte-fa'; 20 | import { themeChange } from 'theme-change'; 21 | 22 | let availableThemes = [ 23 | 'light', 24 | 'dark', 25 | 'cupcake', 26 | 'bumblebee', 27 | 'emerald', 28 | 'corporate', 29 | 'synthwave', 30 | 'retro', 31 | 'cyberpunk', 32 | 'valentine', 33 | 'halloween', 34 | 'garden', 35 | 'forest', 36 | 'aqua', 37 | 'lofi', 38 | 'pastel', 39 | 'fantasy', 40 | 'wireframe', 41 | 'black', 42 | 'luxury', 43 | 'dracula', 44 | 'cmyk', 45 | 'autumn', 46 | 'business', 47 | 'acid', 48 | 'lemonade', 49 | 'night', 50 | 'coffee', 51 | 'winter', 52 | 'dim', 53 | 'nord', 54 | 'sunset', 55 | 'caramellatte', 56 | 'abyss', 57 | 'silk' 58 | ]; 59 | let activeTheme: string | null = ''; 60 | $: avatar = $pocketbase.authStore.record?.avatar; 61 | 62 | onMount(() => { 63 | themeChange(false); 64 | activeTheme = document.documentElement.getAttribute('data-theme'); 65 | 66 | $pocketbase.authStore.onChange(() => { 67 | avatar = $pocketbase.authStore.record?.avatar; 68 | }); 69 | }); 70 | 71 | async function logout() { 72 | await $pocketbase.collection('devices').unsubscribe('*'); 73 | await $pocketbase.collection('ports').unsubscribe('*'); 74 | await $pocketbase.collection('permissions').unsubscribe('*'); 75 | $pocketbase.authStore.clear(); 76 | goto('/login', { invalidateAll: true }); 77 | } 78 | </script> 79 | 80 | <div class="navbar bg-base-100"> 81 | <div class="justify-start"> 82 | <div class="dropdown"> 83 | <label 84 | tabindex="-1" 85 | class="btn btn-ghost {$settingsPub?.website_title ? 'lg:hidden' : 'md:hidden'}" 86 | for="mobile-menu" 87 | > 88 | <svg 89 | xmlns="http://www.w3.org/2000/svg" 90 | class="h-5 w-5" 91 | fill="none" 92 | viewBox="0 0 24 24" 93 | stroke="currentColor" 94 | ><path 95 | stroke-linecap="round" 96 | stroke-linejoin="round" 97 | stroke-width="2" 98 | d="M4 6h16M4 12h8m-8 6h16" 99 | /></svg 100 | > 101 | </label> 102 | <ul 103 | id="mobile-menu" 104 | tabindex="-1" 105 | class="menu dropdown-content rounded-box bg-base-200 z-[1] mt-3 w-max gap-1 p-2 shadow-sm" 106 | > 107 | {#if $settingsPub?.website_title} 108 | <div class="menu-title"> 109 | {$settingsPub?.website_title} 110 | </div> 111 | {/if} 112 | <li> 113 | <a href="/" class="px-4 py-2" class:active={$page.url.pathname === '/'} 114 | ><Fa icon={faHome} />{m.home_page_title()}</a 115 | > 116 | </li> 117 | {#if $pocketbase.authStore.isSuperuser} 118 | <li> 119 | <a 120 | href="/users" 121 | class="px-4 py-2" 122 | class:active={$page.url.pathname.startsWith('/users')} 123 | ><Fa icon={faUsersGear} />{m.users_page_title()}</a 124 | > 125 | </li> 126 | <li> 127 | <a 128 | href="/settings/" 129 | class="px-4 py-2" 130 | class:active={$page.url.pathname.startsWith('/settings')} 131 | ><Fa icon={faCog} />{m.settings_page_title()}</a 132 | > 133 | </li> 134 | {/if} 135 | <li> 136 | <details> 137 | <summary> 138 | <Fa icon={faSwatchbook} /> 139 | Themes</summary 140 | > 141 | <ul> 142 | <div class="h-fit max-h-72 overflow-scroll"> 143 | {#each availableThemes as theme} 144 | <li class="w-full"> 145 | <button 146 | class="gap-3 px-2" 147 | data-set-theme={theme} 148 | on:click={() => (activeTheme = theme)} 149 | on:keydown={() => (activeTheme = theme)} 150 | > 151 | <div 152 | data-theme={theme} 153 | class="bg-base-100 grid shrink-0 grid-cols-2 gap-0.5 rounded-md p-1 shadow-sm" 154 | > 155 | <div class="bg-base-content size-1 rounded-full"></div> 156 | <div class="bg-primary size-1 rounded-full"></div> 157 | <div class="bg-secondary size-1 rounded-full"></div> 158 | <div class="bg-accent size-1 rounded-full"></div> 159 | </div> 160 | <div class="truncate">{theme}</div> 161 | <Fa icon={faCheck} class={activeTheme === theme ? 'visible' : 'invisible'} /> 162 | </button> 163 | </li> 164 | {/each} 165 | </div> 166 | </ul> 167 | </details> 168 | </li> 169 | </ul> 170 | </div> 171 | <a class="btn btn-ghost h-full border-0 px-2 text-xl" href="/"> 172 | <img 173 | src={$settingsPub?.id && $settingsPub?.favicon 174 | ? `${backendUrl}api/files/settings_public/${$settingsPub?.id}/${$settingsPub?.favicon}` 175 | : '/gopher.svg'} 176 | alt={$settingsPub?.website_title ? $settingsPub?.website_title : 'UpSnap'} 177 | width="45" 178 | height="45" 179 | /> 180 | </a> 181 | </div> 182 | <div class="hidden {$settingsPub?.website_title ? 'lg:flex' : 'md:flex'}"> 183 | {#if $settingsPub?.website_title} 184 | <span class="px-2">{$settingsPub?.website_title}</span> 185 | {/if} 186 | <ul class="menu menu-horizontal h-full gap-1 px-1"> 187 | <li class="h-full"> 188 | <a href="/" class="p-2" class:menu-active={$page.url.pathname === '/'} 189 | ><Fa icon={faHome} />{m.home_page_title()}</a 190 | > 191 | </li> 192 | {#if $pocketbase.authStore.isSuperuser} 193 | <li class="h-full"> 194 | <a href="/users" class="p-2" class:menu-active={$page.url.pathname.startsWith('/users')} 195 | ><Fa icon={faUsersGear} />{m.users_page_title()}</a 196 | > 197 | </li> 198 | <li class="h-full"> 199 | <a 200 | href="/settings/" 201 | class="p-2" 202 | class:menu-active={$page.url.pathname.startsWith('/settings')} 203 | ><Fa icon={faCog} />{m.settings_page_title()}</a 204 | > 205 | </li> 206 | {/if} 207 | <div class="dropdown dropdown-end"> 208 | <button class="btn btn-ghost hover:bg-base-content/10 h-full border-0 p-2" tabindex="0"> 209 | <Fa icon={faSwatchbook} /> 210 | <span class="font-normal">{m.navbar_theme()}</span> 211 | <Fa icon={faChevronDown} /> 212 | </button> 213 | <div class="dropdown-content bg-base-200 rounded-box z-1 mt-3 w-52 shadow-sm"> 214 | <ul class="menu menu-horizontal h-fit max-h-96 overflow-y-auto"> 215 | {#each availableThemes as theme} 216 | <li class="w-full"> 217 | <button 218 | class="gap-3 px-2" 219 | data-set-theme={theme} 220 | on:click={() => (activeTheme = theme)} 221 | on:keydown={() => (activeTheme = theme)} 222 | > 223 | <div 224 | data-theme={theme} 225 | class="bg-base-100 grid shrink-0 grid-cols-2 gap-0.5 rounded-md p-1 shadow-sm" 226 | > 227 | <div class="bg-base-content size-1 rounded-full"></div> 228 | <div class="bg-primary size-1 rounded-full"></div> 229 | <div class="bg-secondary size-1 rounded-full"></div> 230 | <div class="bg-accent size-1 rounded-full"></div> 231 | </div> 232 | <div class="truncate">{theme}</div> 233 | <Fa icon={faCheck} class={activeTheme === theme ? 'visible' : 'invisible'} /> 234 | </button> 235 | </li> 236 | {/each} 237 | </ul> 238 | </div> 239 | </div> 240 | </ul> 241 | </div> 242 | <div class="ms-auto justify-end"> 243 | {#if $pocketbase.authStore?.record !== null} 244 | {#if $pocketbase.authStore.isSuperuser || $permission.create} 245 | <a class="btn btn-success me-4" href="/device/new"> 246 | <Fa icon={faPlus} /> 247 | {m.navbar_new()} 248 | </a> 249 | {/if} 250 | <div class="dropdown dropdown-end"> 251 | <label tabindex="-1" class="avatar btn btn-circle btn-ghost" for="avatar"> 252 | <div class="w-10 rounded-full" id="avatar"> 253 | <img src="/avatars/avatar{avatar}.svg" alt="Avatar {avatar}" /> 254 | </div> 255 | </label> 256 | <ul 257 | tabindex="-1" 258 | class="menu dropdown-content rounded-box bg-base-200 z-[1] mt-3 w-52 p-2 shadow" 259 | > 260 | <li class="menu-title"> 261 | {$pocketbase.authStore.isSuperuser 262 | ? $pocketbase.authStore.record?.email 263 | : $pocketbase.authStore.record?.username} 264 | </li> 265 | <li> 266 | <a href="/account"><Fa icon={faUserGear} />{m.navbar_edit_account()}</a> 267 | </li> 268 | <li> 269 | <div on:click={async () => logout()} on:keydown={async () => logout()} role="none"> 270 | <Fa icon={faDoorOpen} />{m.navbar_logout()} 271 | </div> 272 | </li> 273 | </ul> 274 | </div> 275 | {/if} 276 | </div> 277 | </div> 278 | -------------------------------------------------------------------------------- /frontend/src/lib/components/NetworkScan.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { goto } from '$app/navigation'; 3 | import PageLoading from '$lib/components/PageLoading.svelte'; 4 | import { m } from '$lib/paraglide/messages'; 5 | import { localeStore } from '$lib/stores/locale'; 6 | import { backendUrl, pocketbase } from '$lib/stores/pocketbase'; 7 | import { settingsPriv } from '$lib/stores/settings'; 8 | import type { Device } from '$lib/types/device'; 9 | import type { ScanResponse, ScannedDevice } from '$lib/types/scan'; 10 | import type { SettingsPrivate } from '$lib/types/settings'; 11 | import { faMagnifyingGlass, faPlus, faX } from '@fortawesome/free-solid-svg-icons'; 12 | import { onMount } from 'svelte'; 13 | import Fa from 'svelte-fa'; 14 | import toast from 'svelte-french-toast'; 15 | 16 | let scanRange = ''; 17 | let scanRunning = false; 18 | let scanResponse: ScanResponse = { 19 | netmask: '', 20 | devices: [] 21 | }; 22 | let addAllCheckbox = true; 23 | let replaceNetmaskCheckbox = false; 24 | let replaceNetmask = ''; 25 | 26 | onMount(() => { 27 | if (!$settingsPriv) { 28 | $pocketbase 29 | .collection('settings_private') 30 | .getFirstListItem('') 31 | .then((res) => { 32 | const settings = res as SettingsPrivate; 33 | settingsPriv.set(settings); 34 | scanRange = settings.scan_range; 35 | }) 36 | .catch((err) => { 37 | toast.error(err.message); 38 | }); 39 | } else { 40 | scanRange = $settingsPriv.scan_range; 41 | } 42 | }); 43 | 44 | function saveSettings() { 45 | $pocketbase 46 | .collection('settings_private') 47 | .update($settingsPriv.id, { 48 | scan_range: scanRange 49 | }) 50 | .then((res) => { 51 | settingsPriv.set(res as SettingsPrivate); 52 | toast.success(m.device_network_scan_range_saved()); 53 | }) 54 | .catch((err) => { 55 | toast.error(err.message); 56 | }); 57 | } 58 | 59 | function scan() { 60 | scanRunning = true; 61 | fetch(`${backendUrl}api/upsnap/scan`, { 62 | headers: { 63 | Authorization: $pocketbase.authStore.token 64 | } 65 | }) 66 | .then(async (resp) => { 67 | if (resp.ok) { 68 | return resp.json(); 69 | } else { 70 | return Promise.reject(await resp.json()); 71 | } 72 | }) 73 | .then((data) => { 74 | scanResponse = data as ScanResponse; 75 | }) 76 | .catch((err) => { 77 | toast.error(err); 78 | }) 79 | .finally(() => (scanRunning = false)); 80 | } 81 | 82 | async function createDevice(device: ScannedDevice): Promise<Device> { 83 | if (replaceNetmaskCheckbox) { 84 | device.netmask = replaceNetmask; 85 | } else { 86 | device.netmask = scanResponse.netmask; 87 | } 88 | return $pocketbase.collection('devices').create(device); 89 | } 90 | 91 | async function addSingle(device: ScannedDevice) { 92 | await createDevice(device) 93 | .then(() => { 94 | toast.success(m.toasts_device_created({ device: device.name })); 95 | }) 96 | .catch((err) => { 97 | toast.error(err.message); 98 | }); 99 | } 100 | 101 | async function addAll() { 102 | let count = 0; 103 | await Promise.all( 104 | scanResponse.devices.map(async (dev) => { 105 | if (!addAllCheckbox && dev.name === 'Unknown') return; 106 | await createDevice(dev) 107 | .catch((err) => { 108 | toast.error(err.message); 109 | }) 110 | .then(() => { 111 | count += 1; 112 | }); 113 | }) 114 | ); 115 | toast.success(m.toasts_devices_created_multiple({ count: count })); 116 | goto('/'); 117 | } 118 | </script> 119 | 120 | {#if $settingsPriv} 121 | <div class="card bg-base-200 mt-6 w-full shadow-sm"> 122 | <div class="card-body"> 123 | <h2 class="card-title">{m['device_tabs.1']()}</h2> 124 | <p class="my-2"> 125 | {m.device_network_scan_desc()} 126 | </p> 127 | <div class="flex flex-row flex-wrap items-end gap-4"> 128 | <form on:submit|preventDefault={saveSettings}> 129 | <fieldset class="fieldset p-0"> 130 | <label class="floating-label mt-2"> 131 | <span>{m.device_network_scan_ip_range()}</span> 132 | <div class="join max-w-xs"> 133 | <input 134 | id="scan-range" 135 | class="input join-item w-full" 136 | type="text" 137 | placeholder="192.168.1.0/24" 138 | bind:value={scanRange} 139 | /> 140 | <button class="btn btn-neutral join-item" type="submit">{m.buttons_save()}</button> 141 | </div> 142 | </label> 143 | </fieldset> 144 | </form> 145 | <div> 146 | <div> 147 | {#if !$settingsPriv.scan_range} 148 | <button class="btn btn-error" disabled> 149 | <Fa icon={faX} /> 150 | {m.device_network_scan_no_range()} 151 | </button> 152 | {:else if scanRange !== $settingsPriv.scan_range} 153 | <button class="btn btn-error" disabled> 154 | <Fa icon={faX} /> 155 | {m.device_network_scan_unsaved_changes()} 156 | </button> 157 | {:else if scanRunning} 158 | <button class="btn no-animation"> 159 | <span class="loading loading-spinner"></span> 160 | {m.device_network_scan_running()} 161 | </button> 162 | {:else} 163 | <button class="btn btn-success" on:click={() => scan()}> 164 | <Fa icon={faMagnifyingGlass} /> 165 | {m.device_network_scan()} 166 | </button> 167 | {/if} 168 | </div> 169 | </div> 170 | </div> 171 | {#if scanResponse.devices?.length > 0} 172 | {#each scanResponse.devices.sort( (a, b) => a.ip.localeCompare( b.ip, $localeStore, { numeric: true } ) ) as device, index} 173 | <div class="collapse-arrow bg-base-100 collapse"> 174 | <input type="radio" name="scanned-devices" checked={index === 0} /> 175 | <div class="collapse-title font-bold"> 176 | {device.name} <span class="badge">{device.ip}</span> 177 | </div> 178 | <div class="collapse-content"> 179 | <div class="flex flex-row flex-wrap gap-4"> 180 | <div> 181 | <strong>{m.device_network_scan_ip()}</strong><br /> 182 | {device.ip} 183 | </div> 184 | <div> 185 | <strong>{m.device_network_scan_mac()}</strong><br /> 186 | {device.mac} 187 | </div> 188 | <div> 189 | <strong>{m.device_network_scan_mac_vendor()}</strong><br /> 190 | {device.mac_vendor} 191 | </div> 192 | 193 | <div> 194 | <strong>{m.device_network_scan_netmask()}</strong><br /> 195 | {scanResponse.netmask} 196 | </div> 197 | <div class="ms-auto"> 198 | <button 199 | class="btn btn-success btn-sm" 200 | on:click={(e) => { 201 | addSingle(device); 202 | e.currentTarget.disabled = true; 203 | }}><Fa icon={faPlus} />{m.buttons_add()}</button 204 | > 205 | </div> 206 | </div> 207 | </div> 208 | </div> 209 | {/each} 210 | <h2 class="card-title mt-4">{m.device_network_scan_add_all()}</h2> 211 | <div class="max-w-fit"> 212 | <label class="label cursor-pointer"> 213 | <input type="checkbox" class="checkbox" bind:checked={replaceNetmaskCheckbox} /> 214 | <span class="ms-2 text-wrap break-words">{m.device_network_scan_replace_netmask()}</span 215 | > 216 | </label> 217 | </div> 218 | {#if replaceNetmaskCheckbox} 219 | <div class="max-w-fit"> 220 | <label class="label cursor-pointer" for="replaceNetmaskInput"> 221 | <span class="ms-2">{m.device_network_scan_new_netmask()}</span> 222 | </label> 223 | <input 224 | id="replaceNetmaskInput" 225 | class="input" 226 | type="text" 227 | placeholder="255.255.255.0" 228 | bind:value={replaceNetmask} 229 | /> 230 | </div> 231 | {/if} 232 | {#if scanResponse.devices.find((dev) => dev.name === 'Unknown')} 233 | <div class="max-w-fit"> 234 | <label class="label cursor-pointer"> 235 | <input type="checkbox" class="checkbox" bind:checked={addAllCheckbox} /> 236 | <span class="ms-2 text-wrap break-words" 237 | >{m.device_network_scan_include_unknown()}</span 238 | > 239 | </label> 240 | {#if addAllCheckbox} 241 | <button 242 | class="btn btn-success" 243 | on:click={() => addAll()} 244 | disabled={scanResponse.devices.length === 0} 245 | > 246 | <Fa icon={faPlus} /> 247 | {m.device_network_scan_add_all()} ({scanResponse.devices.length}) 248 | </button> 249 | {:else} 250 | <button 251 | class="btn btn-success" 252 | on:click={() => addAll()} 253 | disabled={scanResponse.devices.filter((dev) => dev.name !== 'Unknown').length === 0} 254 | > 255 | <Fa icon={faPlus} /> 256 | {m.device_network_scan_add_all()} ({scanResponse.devices.filter( 257 | (dev) => dev.name !== 'Unknown' 258 | ).length}) 259 | </button> 260 | {/if} 261 | </div> 262 | {:else} 263 | <div class="max-w-fit"> 264 | <button 265 | class="btn btn-success" 266 | on:click={() => addAll()} 267 | disabled={scanResponse.devices.length === 0} 268 | > 269 | <Fa icon={faPlus} /> 270 | {m.device_network_scan_add_all()} ({scanResponse.devices.length}) 271 | </button> 272 | </div> 273 | {/if} 274 | {/if} 275 | </div> 276 | </div> 277 | {:else} 278 | <PageLoading /> 279 | {/if} 280 | -------------------------------------------------------------------------------- /frontend/src/lib/components/PageLoading.svelte: -------------------------------------------------------------------------------- 1 | <div class="container mx-auto max-w-lg text-center"> 2 | <span class="loading loading-dots loading-lg"></span> 3 | </div> 4 | -------------------------------------------------------------------------------- /frontend/src/lib/components/Transition.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { fly } from 'svelte/transition'; 3 | export let url: URL; 4 | </script> 5 | 6 | {#key url} 7 | <div in:fly={{ y: 50, duration: 300 }}> 8 | <slot /> 9 | </div> 10 | {/key} 11 | -------------------------------------------------------------------------------- /frontend/src/lib/helpers/cron.ts: -------------------------------------------------------------------------------- 1 | import { m } from '$lib/paraglide/messages'; 2 | import { dateFnsLocale } from '$lib/stores/locale'; 3 | import { backendUrl } from '$lib/stores/pocketbase'; 4 | import cronParser from 'cron-parser'; 5 | import { formatDate, type Locale } from 'date-fns'; 6 | import { get } from 'svelte/store'; 7 | 8 | export function nextCronDate(expression: string) { 9 | try { 10 | const cron = cronParser.parseExpression(expression, {}); 11 | return formatDate(cron.next().toISOString(), 'PPpp', { 12 | locale: get(dateFnsLocale) as unknown as Locale 13 | }); 14 | } catch { 15 | return m.settings_invalid_cron(); 16 | } 17 | } 18 | 19 | export function parseCron(expression: string): Promise<boolean> { 20 | return fetch(backendUrl + 'api/upsnap/validate-cron', { 21 | method: 'POST', 22 | headers: { 23 | 'Content-Type': 'application/json' 24 | }, 25 | body: JSON.stringify({ 26 | cron: expression 27 | }) 28 | }) 29 | .then((response) => { 30 | return response.ok; 31 | }) 32 | .catch(() => { 33 | return false; 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/lib/helpers/forms.ts: -------------------------------------------------------------------------------- 1 | export function toggleVisibility(el: HTMLInputElement) { 2 | if (el.type === 'password') { 3 | el.type = 'text'; 4 | } else { 5 | el.type = 'password'; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/lib/stores/locale.ts: -------------------------------------------------------------------------------- 1 | import { getLocale } from '$lib/paraglide/runtime'; 2 | import type { Locale } from 'date-fns'; 3 | import { id } from 'date-fns/locale'; 4 | import { de } from 'date-fns/locale/de'; 5 | import { enUS } from 'date-fns/locale/en-US'; 6 | import { es } from 'date-fns/locale/es'; 7 | import { fr } from 'date-fns/locale/fr'; 8 | import { it } from 'date-fns/locale/it'; 9 | import { ja } from 'date-fns/locale/ja'; 10 | import { ko } from 'date-fns/locale/ko'; 11 | import { nl } from 'date-fns/locale/nl'; 12 | import { pl } from 'date-fns/locale/pl'; 13 | import { pt } from 'date-fns/locale/pt'; 14 | import { zhCN } from 'date-fns/locale/zh-CN'; 15 | import { zhTW } from 'date-fns/locale/zh-TW'; 16 | 17 | import { writable, type Writable } from 'svelte/store'; 18 | 19 | export const localeStore = writable(getLocale()); 20 | export const dateFnsLocale: Writable<Locale> = writable(enUS); 21 | 22 | localeStore.subscribe((l: string) => { 23 | switch (l) { 24 | case 'de-DE': 25 | dateFnsLocale.set(de); 26 | break; 27 | case 'en-US': 28 | dateFnsLocale.set(enUS); 29 | break; 30 | case 'es-ES': 31 | dateFnsLocale.set(es); 32 | break; 33 | case 'fr-FR': 34 | dateFnsLocale.set(fr); 35 | break; 36 | case 'id-ID': 37 | dateFnsLocale.set(id); 38 | break; 39 | case 'it-IT': 40 | dateFnsLocale.set(it); 41 | break; 42 | case 'ja-JP': 43 | dateFnsLocale.set(ja); 44 | break; 45 | case 'ko-KR': 46 | dateFnsLocale.set(ko); 47 | break; 48 | case 'nl-NL': 49 | dateFnsLocale.set(nl); 50 | break; 51 | case 'pl-PL': 52 | dateFnsLocale.set(pl); 53 | break; 54 | case 'pt': 55 | dateFnsLocale.set(pt); 56 | break; 57 | case 'zh-CN': 58 | dateFnsLocale.set(zhCN); 59 | break; 60 | case 'zh-TW': 61 | dateFnsLocale.set(zhTW); 62 | break; 63 | default: 64 | dateFnsLocale.set(enUS); 65 | break; 66 | } 67 | }); 68 | -------------------------------------------------------------------------------- /frontend/src/lib/stores/pocketbase.ts: -------------------------------------------------------------------------------- 1 | import type { Permission } from '$lib/types/permission'; 2 | import PocketBase from 'pocketbase'; 3 | import { writable } from 'svelte/store'; 4 | 5 | // set backend url based on environment 6 | export const backendUrl = import.meta.env.DEV ? 'http://127.0.0.1:8090/' : '/'; 7 | 8 | // connect to backend 9 | const pb = new PocketBase(backendUrl); 10 | pb.autoCancellation(false); 11 | 12 | // export stores 13 | export const pocketbase = writable(pb); 14 | export const permission = writable({} as Permission); 15 | -------------------------------------------------------------------------------- /frontend/src/lib/stores/settings.ts: -------------------------------------------------------------------------------- 1 | import type { SettingsPrivate, SettingsPublic } from '$lib/types/settings'; 2 | import { writable } from 'svelte/store'; 3 | 4 | export const settingsPub = writable<SettingsPublic>(); 5 | export const settingsPriv = writable<SettingsPrivate>(); 6 | -------------------------------------------------------------------------------- /frontend/src/lib/types/device.ts: -------------------------------------------------------------------------------- 1 | import type { RecordModel } from 'pocketbase'; 2 | 3 | export type Device = RecordModel & { 4 | name: string; 5 | ip: string; 6 | mac: string; 7 | netmask: string; 8 | description: string; 9 | status: 'pending' | 'online' | 'offline' | ''; 10 | ports: string[]; 11 | link: URL; 12 | link_open: '' | 'same_tab' | 'new_tab'; 13 | ping_cmd: string; 14 | wake_cron: string; 15 | wake_cron_enabled: boolean; 16 | wake_cmd: string; 17 | wake_confirm: boolean; 18 | wake_timeout: number; 19 | shutdown_cron: string; 20 | shutdown_cron_enabled: boolean; 21 | shutdown_cmd: string; 22 | shutdown_confirm: boolean; 23 | shutdown_timeout: number; 24 | password: string; 25 | groups: string[]; 26 | expand: { 27 | ports: Port[]; 28 | groups: Group[]; 29 | }; 30 | created_by: string; 31 | sol_enabled: boolean; 32 | sol_auth: boolean; 33 | sol_user: string; 34 | sol_password: string; 35 | sol_port: number; 36 | }; 37 | 38 | export type Port = RecordModel & { 39 | name: string; 40 | number: number; 41 | link: string; 42 | }; 43 | 44 | export type Group = RecordModel & { 45 | name: string; 46 | }; 47 | -------------------------------------------------------------------------------- /frontend/src/lib/types/permission.ts: -------------------------------------------------------------------------------- 1 | import type { RecordModel } from 'pocketbase'; 2 | 3 | export type Permission = RecordModel & { 4 | user: string; 5 | create: boolean; 6 | read: string[]; 7 | update: string[]; 8 | delete: string[]; 9 | power: string[]; 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/lib/types/scan.ts: -------------------------------------------------------------------------------- 1 | export type ScannedDevice = { 2 | name: string; 3 | ip: string; 4 | mac: string; 5 | mac_vendor: string; 6 | netmask: string; 7 | status: 'pending' | 'online' | 'offline' | ''; 8 | }; 9 | 10 | export type ScanResponse = { 11 | netmask: string; 12 | devices: ScannedDevice[]; 13 | }; 14 | -------------------------------------------------------------------------------- /frontend/src/lib/types/settings.ts: -------------------------------------------------------------------------------- 1 | import type { RecordModel } from 'pocketbase'; 2 | 3 | export type SettingsPublic = RecordModel & { 4 | collectionId: string; 5 | favicon: string; 6 | setup_completed: boolean; 7 | website_title: string; 8 | }; 9 | 10 | export type SettingsPrivate = RecordModel & { 11 | interval: string; 12 | lazy_ping: boolean; 13 | scan_range: string; 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/src/lib/types/user.ts: -------------------------------------------------------------------------------- 1 | import type { RecordModel } from 'pocketbase'; 2 | 3 | export type User = RecordModel & { 4 | username: string; 5 | email: string; 6 | avatar: number; 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | <script lang="ts"> 2 | import { goto } from '$app/navigation'; 3 | import { page } from '$app/state'; 4 | import Navbar from '$lib/components/Navbar.svelte'; 5 | import Transition from '$lib/components/Transition.svelte'; 6 | import { m } from '$lib/paraglide/messages.js'; 7 | import { locales, localizeHref } from '$lib/paraglide/runtime'; 8 | import { backendUrl, permission, pocketbase } from '$lib/stores/pocketbase'; 9 | import { settingsPub } from '$lib/stores/settings'; 10 | import type { Permission } from '$lib/types/permission'; 11 | import type { SettingsPublic } from '$lib/types/settings'; 12 | import { onMount } from 'svelte'; 13 | import toast, { Toaster, type ToastOptions } from 'svelte-french-toast'; 14 | import '../app.css'; 15 | const toastOptions: ToastOptions = { 16 | duration: 5000 17 | }; 18 | 19 | let authIsValid = false; 20 | 21 | onMount(async () => { 22 | $pocketbase.authStore.onChange(() => { 23 | authIsValid = $pocketbase.authStore.isValid; 24 | 25 | // load user permissions 26 | if ($pocketbase.authStore.record?.collectionName === 'users') { 27 | $pocketbase 28 | .collection('permissions') 29 | .getFirstListItem(`user.id = '${$pocketbase.authStore.record.id}'`) 30 | .then((data) => { 31 | permission.set(data as Permission); 32 | }) 33 | .catch(() => { 34 | return; 35 | }); 36 | 37 | $pocketbase.collection('permissions').subscribe('*', (event) => { 38 | permission.set(event.record as Permission); 39 | toast.success(m.toasts_permissions_updated_personal()); 40 | }); 41 | } 42 | }); 43 | 44 | // set settingsPub store on load 45 | if (!$settingsPub) { 46 | const res = await $pocketbase.collection('settings_public').getFirstListItem(''); 47 | settingsPub.set(res as SettingsPublic); 48 | } 49 | 50 | // redirect to welcome page if setup is not completed 51 | if ($settingsPub.setup_completed === false && page.url.pathname !== '/welcome') { 52 | $pocketbase.authStore.clear(); 53 | goto('/welcome'); 54 | return; 55 | } 56 | 57 | // refresh auth token 58 | if ($pocketbase.authStore.isSuperuser) { 59 | await $pocketbase 60 | .collection('_superusers') 61 | .authRefresh() 62 | .catch((err) => { 63 | // clear the store only on invalidated/expired token 64 | const status = err?.status << 0; 65 | if (status == 401 || status == 403) { 66 | $pocketbase.authStore.clear(); 67 | goto('/login'); 68 | } else { 69 | console.log('NOT CLEARED'); 70 | } 71 | }); 72 | } else { 73 | await $pocketbase 74 | .collection('users') 75 | .authRefresh() 76 | .catch((err) => { 77 | // clear the store only on invalidated/expired token 78 | const status = err?.status << 0; 79 | if (status == 401 || status == 403) { 80 | $pocketbase.authStore.clear(); 81 | goto('/login'); 82 | } 83 | }); 84 | } 85 | }); 86 | </script> 87 | 88 | <svelte:head> 89 | <link 90 | rel="shortcut icon" 91 | href={$settingsPub?.id && $settingsPub?.favicon 92 | ? `${backendUrl}api/files/settings_public/${$settingsPub?.id}/${$settingsPub?.favicon}` 93 | : '/gopher.svg'} 94 | /> 95 | {#if $settingsPub === undefined} 96 | <title>UpSnap 97 | {:else} 98 | {$settingsPub.website_title === '' ? 'UpSnap' : $settingsPub.website_title} 99 | {/if} 100 | 101 | 102 | {#if authIsValid && !page.url.pathname.startsWith('/welcome')} 103 | 104 | {/if} 105 | 106 | 107 | 108 | 109 |
110 | 111 |
112 |
113 | 114 | 119 | -------------------------------------------------------------------------------- /frontend/src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true; 2 | export const trailingSlash = 'always'; 3 | -------------------------------------------------------------------------------- /frontend/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 103 | 104 | 105 | 106 | {#if loading} 107 | 108 | {:else if devices.length > 0} 109 |
110 | 131 |
132 | {#if orderExpanded} 133 | 137 | 145 | 153 | {/if} 154 | 161 |
162 |
163 | 164 | {#if orderByGroups} 165 |
166 | {#if devicesWithoutGroups().length > 0} 167 |
168 | {#each devicesWithoutGroups().sort( (a, b) => a[orderBy].localeCompare( b[orderBy], $localeStore, { numeric: true } ) ) as device} 169 | 170 | {/each} 171 |
172 | {/if} 173 | {#each Object.entries(devicesWithGroup()).sort( ([a], [b]) => a.localeCompare( b, $localeStore, { numeric: true } ) ) as [group, groupDevices]} 174 |
175 |

176 | {groupDevices[0].expand.groups.find((grp) => grp.id === group)?.name || 177 | 'Unknown group name'} 178 | 181 |

182 |
183 | {#each groupDevices.sort( (a, b) => a[orderBy].localeCompare( b[orderBy], $localeStore, { numeric: true } ) ) as device} 184 | 185 | {/each} 186 |
187 |
188 | {/each} 189 |
190 | {:else} 191 |
192 | {#each filteredDevices().sort( (a, b) => a[orderBy].localeCompare( b[orderBy], $localeStore, { numeric: true } ) ) as device} 193 | 194 | {/each} 195 |
196 | {/if} 197 | {:else} 198 |
199 | 213 |
214 | {/if} 215 | -------------------------------------------------------------------------------- /frontend/src/routes/account/+page.svelte: -------------------------------------------------------------------------------- 1 | 128 | 129 |

{m.account_page_title()}

130 |
131 |
132 |
133 |
134 |
135 | {#if $pocketbase.authStore.record?.id} 136 | Avatar {newAvatar ?? $pocketbase.authStore.record?.avatar} 140 | {/if} 141 |
142 |
143 |
144 |

145 | {$pocketbase.authStore.isSuperuser 146 | ? $pocketbase.authStore.record?.email 147 | : $pocketbase.authStore.record?.username} 148 |

149 |

150 | {$pocketbase.authStore.isSuperuser 151 | ? m.account_account_type_admin() 152 | : m.account_account_type_user()} 153 |

154 |
155 |
156 |
157 |

{m.account_avatar_title()}

158 |
159 | {#each [...Array(10).keys()] as i} 160 |
161 |
(newAvatar = i)} 176 | role="none" 177 | > 178 | {#if $pocketbase.authStore.record?.id} 179 | {m.account_avatar_title()} {i} 180 | {/if} 181 |
182 |
183 | {/each} 184 |
185 |

{m.account_language_title()}

186 | 194 |
195 | 198 |
199 |
200 |
201 |
202 |
203 |
204 |

{m.account_change_password_title()}

205 |

{m.account_change_password_body()}

206 |
207 |
208 | {#if !$pocketbase.authStore.isSuperuser} 209 |
210 | 222 |
223 | {/if} 224 |
225 | 237 |
238 |
239 |
240 |
241 | 253 |
254 |
255 |
256 | 259 |
260 |
261 |
262 |
263 | -------------------------------------------------------------------------------- /frontend/src/routes/device/[id]/+page.svelte: -------------------------------------------------------------------------------- 1 | 40 | 41 | {#await getDevice()} 42 | 43 | {:then device} 44 |

{device.name}

45 | 46 | {:catch err} 47 |
48 | {err} 49 |
50 | {/await} 51 | -------------------------------------------------------------------------------- /frontend/src/routes/device/new/+page.svelte: -------------------------------------------------------------------------------- 1 | 72 | 73 |

{m.device_page_title()}

74 |
75 | 86 |
87 | 88 | {#if activeTab === 'manual'} 89 | 90 | {:else} 91 | 92 | {/if} 93 | -------------------------------------------------------------------------------- /frontend/src/routes/login/+page.svelte: -------------------------------------------------------------------------------- 1 | 58 | 59 |
60 |
61 |
62 | {#if $settingsPub?.website_title} 63 |

{$settingsPub?.website_title}

64 | {/if} 65 |
66 |
67 | {$settingsPub?.website_title 73 |
74 |

{m.login_welcome()}

75 |
76 |
77 | 80 | 81 | 84 | 103 |
104 | 130 | 133 |
134 |
135 |
136 |
137 |
138 | -------------------------------------------------------------------------------- /frontend/src/routes/settings/+page.svelte: -------------------------------------------------------------------------------- 1 | 100 | 101 | {#if settingsPubClone === undefined || settingsPrivClone === undefined} 102 | 103 | {:else} 104 |

{m.settings_page_title()}

105 |
106 |
107 |
108 |

{m.settings_ping_interval_title()}

109 |

110 | 111 | {@html m.settings_ping_interval_desc1()} 112 |

113 |

114 | 117 | 118 | {@html m.settings_ping_interval_desc2()} 119 |

120 |
121 | 127 |
128 |

129 | {#await parseCron(settingsPrivClone.interval)} 130 | 131 | {:then valid} 132 | {valid ? '✅ ' + nextCronDate(settingsPrivClone.interval) : m.settings_invalid_cron()} 133 | {/await} 134 |

135 |
* * * * * *
137 | | | | | | |
138 | | | | | | day of the week (0–6)
139 | | | | | month (1–12)
140 | | | | day of the month (1–31)
141 | | | hour (0–23)
142 | | minute (0–59)
143 | second (0–59, optional)
144 | 
145 |

{m.settings_lazy_ping_title()}

146 |

147 | {m.settings_lazy_ping_desc()} 148 |

149 |
150 | 154 |
155 |
156 |
157 |
158 |
159 |

{m.settings_website_title_title()}

160 |

{m.settings_website_title_desc()}

161 |
162 | 168 |
169 |
170 |
171 |
172 |
173 |

{m.settings_icon_title()}

174 |

175 | {m.settings_icon_desc()} 176 | .ico .png 177 | .svg .gif 178 | .jpg/.jpeg 179 |

180 |
181 | Favicon preview 189 |
190 |
191 | 197 | 202 |
203 |
204 |
205 |
206 | 207 |
208 |
209 |
210 | {#if PUBLIC_VERSION === ''} 211 | {m.settings_upsnap_version()}: (untracked) 212 | {:else} 213 | {m.settings_upsnap_version()}: 214 | {PUBLIC_VERSION} 217 | {/if} 218 |
219 | {/if} 220 | -------------------------------------------------------------------------------- /frontend/src/routes/welcome/+page.svelte: -------------------------------------------------------------------------------- 1 | 69 | 70 |
71 |
72 |
73 | {#if stepsCompleted === 0 && $settingsPub?.setup_completed} 74 |
Gopher
75 |
76 |

{m.welcome_not_expected_title()}

77 |

{m.welcome_not_expected_desc()}

78 |
79 | 82 |
83 |
84 | {:else if stepsCompleted === 0} 85 |
Gopher
86 |
87 |

{m.welcome_step1_page_title()}

88 |

{m.welcome_step1_setup_desc()}

89 |
90 | 93 |
94 |
95 | {:else if stepsCompleted === 1} 96 |
97 |
98 |
Gopher
99 |

{m.welcome_step2_page_title()}

100 |
101 |
102 | 105 | 106 | 110 | 130 | 133 | 153 |
154 | 157 |
158 |
159 |
160 | {:else if stepsCompleted === 2} 161 |
Gopher
162 |
163 |

{m.welcome_step3_page_title()}

164 |

{m.welcome_step3_page_desc()}

165 |
166 | 169 |
170 |
171 | {/if} 172 |
173 | {#if $settingsPub && !$settingsPub.setup_completed} 174 |
    175 |
  • {m.welcome_progress_step1()}
  • 176 |
  • 0}>{m.welcome_progress_step2()}
  • 177 |
  • 1}>{m.welcome_progress_step3()}
  • 178 |
179 | {/if} 180 |
181 |
182 | -------------------------------------------------------------------------------- /frontend/static/avatars/avatar0.svg: -------------------------------------------------------------------------------- 1 | Mary Roebling 2 | -------------------------------------------------------------------------------- /frontend/static/avatars/avatar1.svg: -------------------------------------------------------------------------------- 1 | Nellie Bly 2 | -------------------------------------------------------------------------------- /frontend/static/avatars/avatar2.svg: -------------------------------------------------------------------------------- 1 | Elizabeth Peratrovich 2 | -------------------------------------------------------------------------------- /frontend/static/avatars/avatar3.svg: -------------------------------------------------------------------------------- 1 | Amelia Boynton 2 | -------------------------------------------------------------------------------- /frontend/static/avatars/avatar4.svg: -------------------------------------------------------------------------------- 1 | Victoria Woodhull 2 | -------------------------------------------------------------------------------- /frontend/static/avatars/avatar5.svg: -------------------------------------------------------------------------------- 1 | Chien-Shiung 2 | -------------------------------------------------------------------------------- /frontend/static/avatars/avatar6.svg: -------------------------------------------------------------------------------- 1 | Hetty Green 2 | -------------------------------------------------------------------------------- /frontend/static/avatars/avatar7.svg: -------------------------------------------------------------------------------- 1 | Elizabeth Peratrovich 2 | -------------------------------------------------------------------------------- /frontend/static/avatars/avatar8.svg: -------------------------------------------------------------------------------- 1 | Jane Johnston 2 | -------------------------------------------------------------------------------- /frontend/static/avatars/avatar9.svg: -------------------------------------------------------------------------------- 1 | Virginia Apgar 2 | -------------------------------------------------------------------------------- /frontend/static/icon_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/frontend/static/icon_192.png -------------------------------------------------------------------------------- /frontend/static/icon_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/frontend/static/icon_512.png -------------------------------------------------------------------------------- /frontend/static/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "UpSnap", 3 | "short_name": "UpSnap", 4 | "description": "A simple wake on lan web app written with SvelteKit, Go and PocketBase.", 5 | "theme_color": "#55BCD9", 6 | "background_color": "#55BCD9", 7 | "display": "standalone", 8 | "orientation": "any", 9 | "scope": "/", 10 | "start_url": "/", 11 | "icons": [ 12 | { 13 | "src": "/icon_192.png", 14 | "sizes": "192x192", 15 | "type": "image/png", 16 | "purpose": "any" 17 | }, 18 | { 19 | "src": "/icon_512.png", 20 | "sizes": "512x512", 21 | "type": "image/png", 22 | "purpose": "any" 23 | }, 24 | { 25 | "src": "/maskable_192.png", 26 | "sizes": "192x192", 27 | "type": "image/png", 28 | "purpose": "maskable" 29 | }, 30 | { 31 | "src": "/maskable_512.png", 32 | "sizes": "512x512", 33 | "type": "image/png", 34 | "purpose": "maskable" 35 | }, 36 | { 37 | "src": "/gopher.svg", 38 | "type": "image/svg+xml", 39 | "sizes": "any" 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /frontend/static/maskable_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/frontend/static/maskable_192.png -------------------------------------------------------------------------------- /frontend/static/maskable_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seriousm4x/UpSnap/a4650a3350115c1577f77e443be69544c60d90af/frontend/static/maskable_512.png -------------------------------------------------------------------------------- /frontend/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-static'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter(), 15 | prerender: { 16 | entries: [ 17 | '/', 18 | '/login', 19 | '/account', 20 | '/welcome', 21 | '/device/[id]', 22 | '/device/new', 23 | '/users', 24 | '/settings' 25 | ] 26 | } 27 | } 28 | }; 29 | 30 | export default config; 31 | -------------------------------------------------------------------------------- /frontend/translations/ko-KR.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://inlang.com/schema/inlang-message-format", 3 | "account_account_type_admin": "관리자", 4 | "account_account_type_user": "사용자", 5 | "account_avatar_title": "아바타", 6 | "account_change_password_body": "암호가 변경된 이후에, 다시 로그인해야 합니다.", 7 | "account_change_password_confirm": "암호 확인", 8 | "account_change_password_label": "이전 암호", 9 | "account_change_password_new": "새 암호", 10 | "account_change_password_title": "암호 변경", 11 | "account_language_title": "언어", 12 | "account_page_title": "계정", 13 | "buttons_add": "추가", 14 | "buttons_cancel": "취소", 15 | "buttons_change": "변경", 16 | "buttons_confirm": "확인", 17 | "buttons_delete": "삭제", 18 | "buttons_reset": "초기화", 19 | "buttons_save": "저장", 20 | "device_card_btn_more_edit": "편집", 21 | "device_card_btn_more_reboot": "시스템 다시 시작", 22 | "device_card_btn_more_sleep": "절전", 23 | "device_card_btn_more": "더 보기", 24 | "device_card_nic_tooltip_pending": "대기 중", 25 | "device_card_nic_tooltip_power_no_permission": "이 기기를 시작하기에 암호가 충분하지 않음", 26 | "device_card_nic_tooltip_power": "전원 켜기", 27 | "device_card_nic_tooltip_shutdown_no_cmd": "시스템 종료 명령어가 설정되지 않음", 28 | "device_card_nic_tooltip_shutdown_no_permission": "이 기기를 종료하기에 암호가 충분하지 않음", 29 | "device_card_nic_tooltip_shutdown": "시스템 종료", 30 | "device_card_password": "암호", 31 | "device_card_tooltip_last_status_change": "마지막 상태 변경", 32 | "device_card_tooltip_shutdown_cron": "시스템 종료 일정", 33 | "device_card_tooltip_wake_cron": "깨우기 일정", 34 | "device_card_tooltip_wake_password": "깨우기 암호", 35 | "device_general_description_placeholder": "기기에 관한 설명", 36 | "device_general_description": "설명", 37 | "device_general_ip": "IP 주소", 38 | "device_general_mac": "Mac 주소", 39 | "device_general_name": "이름", 40 | "device_general_netmask": "넷 마스크", 41 | "device_general_required_field": "필수 입력 항목", 42 | "device_general": "일반", 43 | "device_groups_desc": "제어판에서 그룹 별로 정렬할 수 있도록 기기를 그룹에 추가할 수 있습니다.", 44 | "device_groups_placeholder": "예시. '지하실' 또는 '사무실'", 45 | "device_groups": "그룹", 46 | "device_link_desc": "장치 이름을 클릭 가능한 링크로 만들어, 예를 들어 대시보드를 연결하는 데 완벽하게 사용할 수 있습니다.", 47 | "device_link_open_new_tab": "새 탭", 48 | "device_link_open_no": "아니요", 49 | "device_link_open_same_tab": "같은 탭", 50 | "device_link_open": "자동으로 링크 열기", 51 | "device_link": "링크", 52 | "device_modal_confirm_shutdown_desc": "정말 {device}를 종료할까요?", 53 | "device_modal_confirm_shutdown_title": "{device}를 종료할까요?", 54 | "device_modal_confirm_wake_desc": "{device}를 시작하시려면 예를 눌러주세요.", 55 | "device_modal_confirm_wake_title": "{device}를 시작할까요?", 56 | "device_network_scan_add_all": "모든 기기 추가하기", 57 | "device_network_scan_desc": "네트워크에서 장치를 자동으로 스캔합니다. 이 기능을 사용하려면 UpSnap을 루트/관리자 권한으로 실행해야 하며, nmap이 설치되어 있고 $PATH에 등록되어 있어야 합니다. (Docker 사용자라면 이미 설정되어 있으므로 따로 조치할 필요는 없습니다.) 스캔에는 몇 초 정도 소요될 수 있습니다.", 58 | "device_network_scan_include_unknown": "이름이 \"Unknown\"인 장치도 포함됩니다.", 59 | "device_network_scan_ip_range": "IP 범위", 60 | "device_network_scan_ip": "IP:", 61 | "device_network_scan_mac_vendor": "Mac 벤더:", 62 | "device_network_scan_mac": "Mac:", 63 | "device_network_scan_netmask": "넷마스크:", 64 | "device_network_scan_new_netmask": "새 넷마스크", 65 | "device_network_scan_no_range": "스캔할 범위 없음", 66 | "device_network_scan_range_saved": "스캔할 범위 저장됨", 67 | "device_network_scan_replace_netmask": "모든 기기에 넷마스크를 덮어씌울까요?", 68 | "device_network_scan_running": "스캔 실행중", 69 | "device_network_scan_unsaved_changes": "저장되지 않은 변경사항", 70 | "device_network_scan": "스캔", 71 | "device_page_title": "새 기기", 72 | "device_password_desc": "일부 네트워크 카드에는 매직 패킷에 비밀번호를 설정할 수 있는 옵션이 있으며, 이를 SecureON이라고도 합니다. 비밀번호는 0자, 4자 또는 6자 길이만 허용됩니다.", 73 | "device_password": "암호", 74 | "device_ping_cmd": "사용자 지정 핑 명령어", 75 | "device_ping_desc": "장치가 켜져 있는지 확인하기 위해 사용자 지정 셸 명령을 사용할 수 있습니다. 명령이 0의 종료 코드를 반환하면 장치가 켜져 있는 것으로 간주되며, 그 외의 종료 코드는 장치가 꺼져 있는 것으로 표시됩니다.", 76 | "device_ping": "Ping", 77 | "device_ports_add_new": "새 포트 추가", 78 | "device_ports_desc": "UpSnap은 제공된 포트가 열려있는지도 확인할 수 있습니다.", 79 | "device_ports_name": "이름", 80 | "device_ports_number": "번호", 81 | "device_ports": "포트", 82 | "device_require_confirmation": "확인 필요", 83 | "device_shutdown_cmd": "시스템 종료 명령어", 84 | "device_shutdown_cron_desc": "장치를 깨우기 위해 크론을 설정할 수 있는 것처럼, 이 장치를 종료하도록 크론 작업을 예약할 수도 있습니다.", 85 | "device_shutdown_cron_enable": "시스템 종료 크론 활성화", 86 | "device_shutdown_cron": "시스템 종료 크론", 87 | "device_shutdown_desc": "이 셸 명령어는 Docker를 사용할 경우 컨테이너 내부에서, 바이너리를 사용할 경우 호스트에서 실행됩니다. 명령어가 제대로 작동하는지 확인하려면 먼저 컨테이너 내부 또는 호스트 셸에서 직접 실행해 볼 수 있습니다. 일반적으로 Windows에서는 net rpc, Linux에서는 sshpass, 웹 요청을 보낼 때는 curl 명령어가 자주 사용됩니다.", 88 | "device_shutdown_examples_linux": "원격 리눅스 머신 종료:", 89 | "device_shutdown_examples_windows": "원격 윈도우 머신 종료:", 90 | "device_shutdown_examples": "예시:", 91 | "device_shutdown_timeout": "시스템 종료 타임아웃 (초)", 92 | "device_shutdown": "시스템 종료", 93 | "device_sol_authorization": "인증", 94 | "device_sol_desc1": "Sleep-On-LAN 도구를 사용하여 컴퓨터를 절전 모드로 전환할 수 있습니다. Sleep-On-LAN(SOL)은 절전 모드로 전환하려는 PC에서 동작하는 외부 도구/데몬으로, REST 엔드포인트를 제공합니다. Sleep-On-LAN 설정 방법은 사용법 섹션을 참고해 주세요.", 95 | "device_sol_desc2": "SOL은 인증 기능을 제공하고 요청의 신뢰성을 높이기 위해 UDP 대신 HTTP를 통해 요청을 전송하도록 설정되어 있습니다.", 96 | "device_sol_desc3": "따라서 SOL 구성Listeners 섹션에 HTTP:<YOURPORT>를 반드시 포함해 주세요.", 97 | "device_sol_enable": "Sleep-On-LAN 활성화", 98 | "device_sol_password": "SOL 암호", 99 | "device_sol_port": "SOL 포트", 100 | "device_sol_user": "SOL 사용자", 101 | "device_sol": "Sleep-On-LAN", 102 | "device_tabs.0": "수동", 103 | "device_tabs.1": "네트워크 스캔", 104 | "device_wake_cmd": "", 105 | "device_wake_cron_enable": "꺠우기 크론 활성화", 106 | "device_wake_cron": "깨우기 크론", 107 | "device_wake_desc": "이 장치는 예약된 크론 작업을 통해 전원을 켤 수 있습니다.", 108 | "device_wake_timeout": "꺠우기 타임아웃 (초)", 109 | "device_wake": "깨우기", 110 | "home_add_first_device": "첫 기기 추가하기", 111 | "home_grant_permissions": "관리자에게 기존 장치에 대한 권한을 부여하거나 새 장치를 생성할 수 있도록 요청해 주세요.", 112 | "home_no_devices": "아무 기기도 존재하지 않네요.", 113 | "home_order_groups": "모든 그룹", 114 | "home_order_ip": "IP 주소", 115 | "home_order_name": "이름", 116 | "home_order_tooltip": "정렬", 117 | "home_page_title": "홈", 118 | "home_search_placeholder": "기기 검색", 119 | "home_wake_group": "깨우기 그룹", 120 | "login_btn_login": "로그인", 121 | "login_btn_more": "더보기", 122 | "login_email_label": "이메일 또는 사용자명", 123 | "login_menu_title_auth_providers": "다른 인증 제공자", 124 | "login_password_label": "암호:", 125 | "login_welcome": "환영합니다", 126 | "navbar_edit_account": "계정 수정", 127 | "navbar_logout": "로그아웃", 128 | "navbar_new": "신규", 129 | "navbar_theme": "테마", 130 | "settings_icon_desc": "사용자 지정 favicon을 설정하세요. 지원되는 파일 형식:", 131 | "settings_icon_title": "아이콘", 132 | "settings_invalid_cron": "❌ 잘못된 크론 구문", 133 | "settings_lazy_ping_desc": "Lazy Ping이 활성화되면, UpSnap은 웹사이트를 방문하는 활성 사용자가 있을 때만 장치를 핑합니다. 비활성화되면, UpSnap은 항상 장치를 핑합니다.", 134 | "settings_lazy_ping_enable": "활성화", 135 | "settings_lazy_ping_title": "Lazy Ping", 136 | "settings_page_title": "설정", 137 | "settings_ping_interval_desc1": "장치가 핑되는 간격을 설정합니다. 비워 두면 기본값인 */3 * * * * *가 사용됩니다.", 138 | "settings_ping_interval_desc2": "Cron의 올바른 구문에 대해 더 알아보려면 위키백과를 참조하거나, 패키지 문서를 참조하세요.", 139 | "settings_ping_interval_title": "핑 간격", 140 | "settings_upsnap_version": "UpSnap 버전", 141 | "settings_website_title_desc": "웹사이트와 브라우저 탭의 제목을 설정합니다.", 142 | "settings_website_title_title": "웹사이트 제목", 143 | "toasts_admin_saved": "관리자 저장됨", 144 | "toasts_device_created": "{device} 추가됨", 145 | "toasts_device_deleted": "{device} 삭제됨", 146 | "toasts_device_updated": "{device} 업데이트됨", 147 | "toasts_devices_created_multiple": "새 {count} 기기 생성됨", 148 | "toasts_group_created": "새 그룹 {group} 생성됨", 149 | "toasts_group_deleted": "{group} 삭제됨", 150 | "toasts_no_permission": "{url}에 방문할 수 있는 권한이 없습니다.", 151 | "toasts_password_changed": "암호가 변경되었습니다. 다시 로그인하세요.", 152 | "toasts_passwords_missmatch": "암호가 일치하지 않습니다.", 153 | "toasts_permissions_created": "{username}의 권한이 생성되었습니다.", 154 | "toasts_permissions_deleted": "{username}의 권한이 삭제되었습니다.", 155 | "toasts_permissions_updated_personal": "당신의 권한이 업데이트 되었습니다.", 156 | "toasts_permissions_updated": "{username}의 권한이 업데이트 되었습니다.", 157 | "toasts_settings_saved": "저장된 설정", 158 | "toasts_user_created": "유저 {username}이(가) 생성되었습니다.", 159 | "toasts_user_deleted": "유저 {username}이(가) 삭제되었습니다.", 160 | "toasts_user_saved": "사용자 저장됨", 161 | "users_allow_create_devices": "{username}에게 새 장치를 생성하고 장치 그룹을 편집할 수 있는 권한을 부여합니다.", 162 | "users_confirm_delete_desc": "정말로 {username}을(를) 삭제하시겠습니까?", 163 | "users_confirm_delete_title": "삭제 확인", 164 | "users_create_new_device": "새 기기 추가", 165 | "users_create_new_user": "새 사용자 추가", 166 | "users_delete": "삭제", 167 | "users_device_permissions": "기기 권한", 168 | "users_page_title": "사용자", 169 | "users_password_confirm": "암호 확인", 170 | "users_password": "암호", 171 | "users_power": "전원", 172 | "users_read": "읽기", 173 | "users_required_field": "필수 항목", 174 | "users_toggle": "켬/끔", 175 | "users_update": "업데이트", 176 | "users_username": "사용자 이름", 177 | "welcome_not_expected_back": "돌아가기", 178 | "welcome_not_expected_desc": "설정이 이미 완료되었습니다! 할 일이 없습니다.", 179 | "welcome_not_expected_title": "여기서 만날줄은 몰랐네요! 🧐", 180 | "welcome_progress_step1": "환영합니다", 181 | "welcome_progress_step2": "계정 생성", 182 | "welcome_progress_step3": "완료", 183 | "welcome_step1_page_title": "UpSnap에 오신걸 환영합니다! 🥳", 184 | "welcome_step1_setup_btn_next": "다음", 185 | "welcome_step1_setup_desc": "초기 설정을 마무리하기 위해 다음 절차들을 완료해주세요.", 186 | "welcome_step2_btn_create": "생성", 187 | "welcome_step2_label_email": "이메일:", 188 | "welcome_step2_label_min_chars": "최소. 10글자", 189 | "welcome_step2_label_password_confirm": "암호 확인:", 190 | "welcome_step2_label_password": "암호:", 191 | "welcome_step2_page_title": "관리자 계정 생성", 192 | "welcome_step3_btn_done": "해봅시다!", 193 | "welcome_step3_page_desc": "대시보드에 가서 새 기기를 추가해 보세요.", 194 | "welcome_step3_page_title": "준비가 완료되었습니다! 🎉" 195 | } 196 | -------------------------------------------------------------------------------- /frontend/translations/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://inlang.com/schema/inlang-message-format", 3 | "account_account_type_admin": "管理员", 4 | "account_account_type_user": "普通用户", 5 | "account_avatar_title": "头像", 6 | "account_change_password_body": "更改密码后, 您需要重新登录.", 7 | "account_change_password_confirm": "确认密码", 8 | "account_change_password_label": "旧密码", 9 | "account_change_password_new": "新密码", 10 | "account_change_password_title": "更改密码", 11 | "account_language_title": "语言", 12 | "account_page_title": "账户信息", 13 | "buttons_add": "添加", 14 | "buttons_cancel": "取消", 15 | "buttons_change": "更改", 16 | "buttons_confirm": "确认", 17 | "buttons_delete": "删除", 18 | "buttons_reset": "重置", 19 | "buttons_save": "保存", 20 | "device_card_btn_more_edit": "编辑", 21 | "device_card_btn_more_reboot": "重启", 22 | "device_card_btn_more_sleep": "Sleep", 23 | "device_card_btn_more": "更多", 24 | "device_card_nic_tooltip_pending": "等待中...", 25 | "device_card_nic_tooltip_power_no_permission": "没有启动此设备的权限", 26 | "device_card_nic_tooltip_power": "启动", 27 | "device_card_nic_tooltip_shutdown_no_cmd": "未设置关机命令", 28 | "device_card_nic_tooltip_shutdown_no_permission": "没有关闭此设备的权限", 29 | "device_card_nic_tooltip_shutdown": "关机", 30 | "device_card_password": "密码", 31 | "device_card_tooltip_last_status_change": "最近状态更新", 32 | "device_card_tooltip_shutdown_cron": "关机计划", 33 | "device_card_tooltip_wake_cron": "唤醒计划", 34 | "device_card_tooltip_wake_password": "唤醒密码", 35 | "device_general_description_placeholder": "设备的一些说明", 36 | "device_general_description": "说明", 37 | "device_general_ip": "IP", 38 | "device_general_mac": "Mac", 39 | "device_general_name": "名称", 40 | "device_general_netmask": "子网掩码", 41 | "device_general_required_field": "必填字段", 42 | "device_general": "通用", 43 | "device_groups_desc": "您可以将设备添加到组, 以便在控制面板上按组对其进行排序.", 44 | "device_groups_placeholder": "例如: 'Basement' 或 'Office'", 45 | "device_groups": "分组", 46 | "device_link_desc": "为您的设备名设置一个可点击的链接.", 47 | "device_link_open_new_tab": "新标签", 48 | "device_link_open_no": "没有", 49 | "device_link_open_same_tab": "同一标签", 50 | "device_link_open": "自动打开链接", 51 | "device_link": "链接", 52 | "device_modal_confirm_shutdown_desc": "您确定要关闭 {device} 吗?", 53 | "device_modal_confirm_shutdown_title": "关闭 {device}?", 54 | "device_modal_confirm_wake_desc": "您确定要唤醒 {device} 吗?", 55 | "device_modal_confirm_wake_title": "唤醒 {device}?", 56 | "device_network_scan_add_all": "添加全部设备", 57 | "device_network_scan_desc": "自动扫描网络中的设备. 要实现这一点, 您需要以超级用户/管理员身份运行 UpSnap, 并在您的 $PATH 中安装和使用nmap(对于docker用户, 您不需要执行任何操作). 扫描可能需要一些时间.", 58 | "device_network_scan_include_unknown": "包括名称为 “Unknown” 的设备", 59 | "device_network_scan_ip_range": "IP 范围", 60 | "device_network_scan_ip": "IP:", 61 | "device_network_scan_mac_vendor": "Mac 供应商:", 62 | "device_network_scan_mac": "Mac:", 63 | "device_network_scan_netmask": "子网掩码:", 64 | "device_network_scan_new_netmask": "新子网掩码", 65 | "device_network_scan_no_range": "无扫描范围", 66 | "device_network_scan_range_saved": "扫描范围已保存", 67 | "device_network_scan_replace_netmask": "更换所有设备的子网掩码?", 68 | "device_network_scan_running": "扫描中", 69 | "device_network_scan_unsaved_changes": "未保存的更改", 70 | "device_network_scan": "扫描", 71 | "device_page_title": "新设备", 72 | "device_password_desc": "一些网卡允许为 magic packets 设置密码, 也可以称之为 SecureON. 密码长度只能为0、4或6个字符.", 73 | "device_password": "密码", 74 | "device_ping_cmd": "自定义 ping 命令", 75 | "device_ping_desc": "您可以使用自定义 shell 命令来查看设备是否已接通电源。该命令应返回 0 的退出代码,表示设备电源已打开,任何其他退出代码都将标记设备电源已关闭。", 76 | "device_ping": "Ping", 77 | "device_ports_add_new": "添加端口", 78 | "device_ports_desc": "UpSnap 可以检查设备端口是否正常.", 79 | "device_ports_name": "名称", 80 | "device_ports_number": "端口号", 81 | "device_ports": "端口", 82 | "device_require_confirmation": "二次确认", 83 | "device_shutdown_cmd": "关机命令", 84 | "device_shutdown_cron_desc": "就像设置计划任务来唤醒设备一样, 您也可以设置计划任务来关闭该设备.", 85 | "device_shutdown_cron_enable": "启用定时关机", 86 | "device_shutdown_cron": "关机计划任务", 87 | "device_shutdown_desc": "此 shell 命令 将在您的容器内运行 (如果使用的是Docker) 或在您的主机上运行. 要验证该命令是否有效, 您可以在容器内或主机上的shell中运行该命令. 通常在 Windows 上使用 net rpc, Linux上使用 sshpass 或者针对Web请求使用 curl.", 88 | "device_shutdown_examples_linux": "关闭远程 Linux 主机:", 89 | "device_shutdown_examples_windows": "关闭远程 Windows 主机:", 90 | "device_shutdown_examples": "示例:", 91 | "device_shutdown_timeout": "关机超时(秒)", 92 | "device_shutdown": "关机", 93 | "device_sol_authorization": "认证", 94 | "device_sol_desc1": "您可以使用 Sleep-On-LAN 工具让计算机进入睡眠状态. Sleep-On-LAN (SOL) 是一个外部的工具/守护程序, 它在您想要进入休眠状态的PC上运行并提供RestApi接口. 有关 Sleep-On-LAN 设置的说明, 请参阅文档 Usage 部分.", 95 | "device_sol_desc2": "SOL 配置为通过HTTP而不是UDP发送请求, 以启用认证功能并使请求更可靠", 96 | "device_sol_desc3": "因此, 请确保在 SOL 配置Listeners 部分中包含HTTP:<YOURPORT>", 97 | "device_sol_enable": "启用 Sleep-On-LAN", 98 | "device_sol_password": "SOL 密码", 99 | "device_sol_port": "SOL 端口", 100 | "device_sol_user": "SOL 账号", 101 | "device_sol": "Sleep-On-LAN", 102 | "device_tabs.0": "手动配置", 103 | "device_tabs.1": "网络扫描", 104 | "device_wake_cmd": "自定义唤醒命令", 105 | "device_wake_cron_enable": "启用定时唤醒", 106 | "device_wake_cron": "唤醒计划任务", 107 | "device_wake_desc": "您可以通过计划任务来唤醒设备.", 108 | "device_wake_timeout": "唤醒超时(秒)", 109 | "device_wake": "唤醒", 110 | "home_add_first_device": "添加您的第一台设备", 111 | "home_grant_permissions": "请联系管理员授予您相应权限.", 112 | "home_no_devices": "还没有任何设备", 113 | "home_order_groups": "分组", 114 | "home_order_ip": "IP地址", 115 | "home_order_name": "名称", 116 | "home_order_tooltip": "排序", 117 | "home_page_title": "首页", 118 | "home_search_placeholder": "搜索设备", 119 | "home_wake_group": "唤醒小组", 120 | "login_btn_login": "登录", 121 | "login_btn_more": "更多", 122 | "login_email_label": "邮箱/用户名:", 123 | "login_menu_title_auth_providers": "其他身份验证提供程序", 124 | "login_password_label": "密码:", 125 | "login_welcome": "欢迎使用", 126 | "navbar_edit_account": "编辑账户", 127 | "navbar_logout": "退出", 128 | "navbar_new": "新增", 129 | "navbar_theme": "主题", 130 | "settings_icon_desc": "设置一个自定义图标. 支持的文件类型:", 131 | "settings_icon_title": "图标", 132 | "settings_invalid_cron": "❌ 无效的 cron 语法", 133 | "settings_lazy_ping_desc": "当开关打开时, UpSnap 只会在用户访问网站期间进行 ping 操作.", 134 | "settings_lazy_ping_enable": "启用", 135 | "settings_lazy_ping_title": "Lazy ping", 136 | "settings_page_title": "设置", 137 | "settings_ping_interval_desc1": "设置 ping 的间隔。为空时使用默认值 */3 * * * * *.", 138 | "settings_ping_interval_desc2": "有关 cron 的更多信息, 请访问Wikipedia 或参阅 cron 文档.", 139 | "settings_ping_interval_title": "Ping 间隔", 140 | "settings_upsnap_version": "UpSnap 版本", 141 | "settings_website_title_desc": "设置在浏览器选项卡和网站中显示标题", 142 | "settings_website_title_title": "网站标题", 143 | "toasts_admin_saved": "账户信息已保存", 144 | "toasts_device_created": "已创建 {device}", 145 | "toasts_device_deleted": "已删除 {device}", 146 | "toasts_device_updated": "已更新 {device}", 147 | "toasts_devices_created_multiple": "已创建 {count} 个设备", 148 | "toasts_group_created": "已创建组 {group}", 149 | "toasts_group_deleted": "已删除组 {group}", 150 | "toasts_no_permission": "您没有权限访问 {url}", 151 | "toasts_password_changed": "密码已修改, 请重新登录.", 152 | "toasts_passwords_missmatch": "两次输入的密码不匹配", 153 | "toasts_permissions_created": "用户 {username} 的权限已创建", 154 | "toasts_permissions_deleted": "用户 {username} 的权限已删除", 155 | "toasts_permissions_updated_personal": "您的权限已更新", 156 | "toasts_permissions_updated": "用户 {username} 的权限已更新", 157 | "toasts_settings_saved": "设置已保存", 158 | "toasts_user_created": "用户 {username} 已创建", 159 | "toasts_user_deleted": "用户 {username} 已删除", 160 | "toasts_user_saved": "账户信息已保存", 161 | "users_allow_create_devices": "允许 {username} 创建新设备和编辑设备组", 162 | "users_confirm_delete_desc": "您确定要删除 {username} 吗?", 163 | "users_confirm_delete_title": "确认删除", 164 | "users_create_new_device": "创建新设备", 165 | "users_create_new_user": "新增用户", 166 | "users_delete": "删除", 167 | "users_device_permissions": "设备权限", 168 | "users_page_title": "用户", 169 | "users_password_confirm": "确认密码", 170 | "users_password": "密码", 171 | "users_power": "电源", 172 | "users_read": "查看", 173 | "users_required_field": "必填字段", 174 | "users_toggle": "全选", 175 | "users_update": "更新", 176 | "users_username": "用户名", 177 | "welcome_not_expected_back": "返回", 178 | "welcome_not_expected_desc": "您已经完成了设置, 无需再次设置", 179 | "welcome_not_expected_title": "意料之外! 🧐", 180 | "welcome_progress_step1": "欢迎", 181 | "welcome_progress_step2": "创建账号", 182 | "welcome_progress_step3": "完成", 183 | "welcome_step1_page_title": "欢迎使用 UpSnap 🥳", 184 | "welcome_step1_setup_btn_next": "下一步", 185 | "welcome_step1_setup_desc": "初次使用, 请依照指示完成下列步骤.", 186 | "welcome_step2_btn_create": "创建", 187 | "welcome_step2_label_email": "邮箱:", 188 | "welcome_step2_label_min_chars": "请输入至少10个字符", 189 | "welcome_step2_label_password_confirm": "确认密码:", 190 | "welcome_step2_label_password": "密码:", 191 | "welcome_step2_page_title": "创建管理员账号", 192 | "welcome_step3_btn_done": "开始使用!", 193 | "welcome_step3_page_desc": "继续向您的仪表板添加一些设备.", 194 | "welcome_step3_page_title": "一切就绪! 🎉" 195 | } 196 | -------------------------------------------------------------------------------- /frontend/translations/zh-TW.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://inlang.com/schema/inlang-message-format", 3 | "account_account_type_admin": "管理員", 4 | "account_account_type_user": "普通使用者", 5 | "account_avatar_title": "頭像", 6 | "account_change_password_body": "更改密碼後,將需重新登入", 7 | "account_change_password_confirm": "確認密碼", 8 | "account_change_password_label": "舊密碼", 9 | "account_change_password_new": "新密碼", 10 | "account_change_password_title": "更改密碼", 11 | "account_language_title": "語言", 12 | "account_page_title": "帳號資訊", 13 | "buttons_add": "新增", 14 | "buttons_cancel": "取消", 15 | "buttons_change": "更改", 16 | "buttons_confirm": "確認", 17 | "buttons_delete": "刪除", 18 | "buttons_reset": "重設", 19 | "buttons_save": "保存", 20 | "device_card_btn_more_edit": "編輯", 21 | "device_card_btn_more_reboot": "重新啟動", 22 | "device_card_btn_more_sleep": "睡眠", 23 | "device_card_btn_more": "更多", 24 | "device_card_nic_tooltip_pending": "等待中...", 25 | "device_card_nic_tooltip_power_no_permission": "無開啟此裝置之權限", 26 | "device_card_nic_tooltip_power": "開機", 27 | "device_card_nic_tooltip_shutdown_no_cmd": "未設定關機指令", 28 | "device_card_nic_tooltip_shutdown_no_permission": "無關閉此裝置之權限", 29 | "device_card_nic_tooltip_shutdown": "關機", 30 | "device_card_password": "密碼", 31 | "device_card_tooltip_last_status_change": "最後狀態更新", 32 | "device_card_tooltip_shutdown_cron": "關機排程", 33 | "device_card_tooltip_wake_cron": "喚醒排程", 34 | "device_card_tooltip_wake_password": "喚醒密碼", 35 | "device_general_description_placeholder": "關於這个裝置的描述", 36 | "device_general_description": "描述", 37 | "device_general_ip": "IP", 38 | "device_general_mac": "Mac", 39 | "device_general_name": "名稱", 40 | "device_general_netmask": "子網域遮罩", 41 | "device_general_required_field": "必填", 42 | "device_general": "通用", 43 | "device_groups_desc": "你可以將裝置加到群組, 以便在控制面板上依群組排序。", 44 | "device_groups_placeholder": "例如: 'Basement' 或 'Office'", 45 | "device_groups": "群組", 46 | "device_link_desc": "讓您的裝置名稱成為可點擊的連結,非常適合用來連結儀表板等。", 47 | "device_link_open_new_tab": "新標籤", 48 | "device_link_open_no": "毋", 49 | "device_link_open_same_tab": "同一個標籤", 50 | "device_link_open": "自動開啟連結", 51 | "device_link": "連結", 52 | "device_modal_confirm_shutdown_desc": "你確定要關閉 {device} 嗎?", 53 | "device_modal_confirm_shutdown_title": "關閉 {device}?", 54 | "device_modal_confirm_wake_desc": "你確定要喚醒 {device} 嗎?", 55 | "device_modal_confirm_wake_title": "喚醒 {device}?", 56 | "device_network_scan_add_all": "新增全部裝置", 57 | "device_network_scan_desc": "自動掃描網路上的設備,要使用這個功能,你需要以root/系統管理員權限執行 UpSnap, 並在你的 $PATH 中安裝nmap(使用docker安裝者不需執行任何操作),掃描可能需一段時間。", 58 | "device_network_scan_include_unknown": "包括名稱為 “Unknown” 的裝置", 59 | "device_network_scan_ip_range": "IP 範圍", 60 | "device_network_scan_ip": "IP:", 61 | "device_network_scan_mac_vendor": "Mac 供應商:", 62 | "device_network_scan_mac": "Mac:", 63 | "device_network_scan_netmask": "子網域遮罩:", 64 | "device_network_scan_new_netmask": "新子網域遮罩", 65 | "device_network_scan_no_range": "無掃瞄範圍", 66 | "device_network_scan_range_saved": "掃瞄範圍已儲存", 67 | "device_network_scan_replace_netmask": "替換所有設備的子網域遮罩?", 68 | "device_network_scan_running": "掃描中", 69 | "device_network_scan_unsaved_changes": "未儲存的更改", 70 | "device_network_scan": "掃描", 71 | "device_page_title": "新裝置", 72 | "device_password_desc": "一些網路介面卡能給 magic packets 設密碼, 也可稱作 SecureON. 密碼長度只能為0、4或6個字。", 73 | "device_password": "密碼", 74 | "device_ping_cmd": "自定義 ping 指令", 75 | "device_ping_desc": "你可以使用自定義的指令來檢查裝置是否開機。該指令應返回退出碼 0 以表示裝置已開機,任何其他退出碼將標記裝置為關機。", 76 | "device_ping": "Ping", 77 | "device_ports_add_new": "新增埠", 78 | "device_ports_desc": "UpSnap 可以檢查指定的埠是否開啟", 79 | "device_ports_name": "名稱", 80 | "device_ports_number": "通訊埠編號", 81 | "device_ports": "埠", 82 | "device_require_confirmation": "再次確認", 83 | "device_shutdown_cmd": "關機指令", 84 | "device_shutdown_cron_desc": "就像設定排程來喚醒裝置一樣,您也可以設定一個排程來關閉這個裝置。", 85 | "device_shutdown_cron_enable": "啟用關機排程", 86 | "device_shutdown_cron": "關機排程", 87 | "device_shutdown_desc": "這個shell指令將在您的容器內運行(如果您使用docker),或者在您的主機上運行(如果您使用二進制)。為了驗證其正常工作,您可以先在容器內或主機的shell中運行這個指令。常見的指令有net rpc用於Windows,sshpass用於Linux,或者一般用於進行網絡請求的curl。", 88 | "device_shutdown_examples_linux": "關閉遠端 Linux 主機:", 89 | "device_shutdown_examples_windows": "關閉遠端 Windows 主機:", 90 | "device_shutdown_examples": "範例:", 91 | "device_shutdown_timeout": "關機超時(秒)", 92 | "device_shutdown": "關機", 93 | "device_sol_authorization": "認證", 94 | "device_sol_desc1": "你可以使用 Sleep-On-LAN 工具讓電腦睡眠。 Sleep-On-LAN (SOL) 是一个外部的工具,它在你想要進入睡眠的PC上執行並有RestAPI端口. 有關 Sleep-On-LAN 設定的說明, 請見文檔 Usage", 95 | "device_sol_desc2": "SOL 已配置為使用 HTTP 發送請求,而不是 UDP以啟用授權並提高請求的可靠性。", 96 | "device_sol_desc3": "因此, 請確保在 SOL 配置Listeners 部分中包含HTTP:<YOURPORT>", 97 | "device_sol_enable": "啟用 Sleep-On-LAN", 98 | "device_sol_password": "SOL 密碼", 99 | "device_sol_port": "SOL 通訊埠", 100 | "device_sol_user": "SOL 帳號", 101 | "device_sol": "Sleep-On-LAN", 102 | "device_tabs.0": "手動設定", 103 | "device_tabs.1": "網路掃描", 104 | "device_wake_cmd": "自定義唤醒指令", 105 | "device_wake_cron_enable": "啟用定時喚醒", 106 | "device_wake_cron": "唤醒排程", 107 | "device_wake_desc": "你可以透過排程来唤醒裝置。", 108 | "device_wake_timeout": "喚醒超時(秒)", 109 | "device_wake": "喚醒", 110 | "home_add_first_device": "添加你的第一台設備", 111 | "home_grant_permissions": "請聯絡系統管理員授權現有設備或創建新設備的權限", 112 | "home_no_devices": "尚無設備", 113 | "home_order_groups": "群組", 114 | "home_order_ip": "IP位址", 115 | "home_order_name": "名稱", 116 | "home_order_tooltip": "排序", 117 | "home_page_title": "首頁", 118 | "home_search_placeholder": "搜尋設備", 119 | "home_wake_group": "群組喚醒", 120 | "login_btn_login": "登入", 121 | "login_btn_more": "更多", 122 | "login_email_label": "電子郵件/使用者名稱:", 123 | "login_menu_title_auth_providers": "其他身份驗證提供者", 124 | "login_password_label": "密碼:", 125 | "login_welcome": "歡迎使用", 126 | "navbar_edit_account": "編輯帳號", 127 | "navbar_logout": "退出", 128 | "navbar_new": "新增", 129 | "navbar_theme": "主題", 130 | "settings_icon_desc": "設定一個自訂義圖標。支援的檔案類型:", 131 | "settings_icon_title": "圖標", 132 | "settings_invalid_cron": "❌ 「無效的 Cron 語法」", 133 | "settings_lazy_ping_desc": "當開關打開時, UpSnap 只會在有訪問者訪問網站時才對設備進行 ping 測試", 134 | "settings_lazy_ping_enable": "啟用", 135 | "settings_lazy_ping_title": "Lazy ping", 136 | "settings_page_title": "設定", 137 | "settings_ping_interval_desc1": "設定 ping 的間隔。留空使用預設值 */3 * * * * *.", 138 | "settings_ping_interval_desc2": "有關 cron 的更多資訊,請見Wikipedia 或參閱 cron 文檔.", 139 | "settings_ping_interval_title": "Ping 間隔", 140 | "settings_upsnap_version": "UpSnap 版本", 141 | "settings_website_title_desc": "設置網站的標題,同時顯示在瀏覽器的標籤中。", 142 | "settings_website_title_title": "網站標題", 143 | "toasts_admin_saved": "帳號資訊已儲存", 144 | "toasts_device_created": "已創建 {device}", 145 | "toasts_device_deleted": "已刪除 {device}", 146 | "toasts_device_updated": "已更新 {device}", 147 | "toasts_devices_created_multiple": "已創建 {count} 個設備", 148 | "toasts_group_created": "已創建群組 {group}", 149 | "toasts_group_deleted": "已創建群組 {group}", 150 | "toasts_no_permission": "你無權訪問 {url}", 151 | "toasts_password_changed": "密碼已修改,請重新登入。", 152 | "toasts_passwords_missmatch": "兩次輸入的密碼不相同", 153 | "toasts_permissions_created": "使用者 {username} 的權限已創建", 154 | "toasts_permissions_deleted": "使用者 {username} 的權限已刪除", 155 | "toasts_permissions_updated_personal": "你的權限已更新", 156 | "toasts_permissions_updated": "使用者 {username} 的權限已更新", 157 | "toasts_settings_saved": "設定已儲存", 158 | "toasts_user_created": "使用者 {username} 已創建", 159 | "toasts_user_deleted": "使用者 {username} 已刪除", 160 | "toasts_user_saved": "帳號資訊已儲存", 161 | "users_allow_create_devices": "允許 {username} 創建新裝置和修改群組", 162 | "users_confirm_delete_desc": "你確定要刪除 {username} 嗎?", 163 | "users_confirm_delete_title": "確認刪除", 164 | "users_create_new_device": "創建新裝置", 165 | "users_create_new_user": "新增使用者", 166 | "users_delete": "刪除", 167 | "users_device_permissions": "裝置權限", 168 | "users_page_title": "使用者", 169 | "users_password_confirm": "確認密碼", 170 | "users_password": "密碼", 171 | "users_power": "電源", 172 | "users_read": "查看", 173 | "users_required_field": "必填", 174 | "users_toggle": "全選", 175 | "users_update": "更新", 176 | "users_username": "使用者名稱", 177 | "welcome_not_expected_back": "返回", 178 | "welcome_not_expected_desc": "你已經完成設定,無需再次設定", 179 | "welcome_not_expected_title": "意料之外! 🧐", 180 | "welcome_progress_step1": "歡迎", 181 | "welcome_progress_step2": "創建帳號", 182 | "welcome_progress_step3": "完成", 183 | "welcome_step1_page_title": "歡迎使用 UpSnap 🥳", 184 | "welcome_step1_setup_btn_next": "下一步", 185 | "welcome_step1_setup_desc": "初次使用,請依下列指示完成步驟", 186 | "welcome_step2_btn_create": "創建", 187 | "welcome_step2_label_email": "電子郵件:", 188 | "welcome_step2_label_min_chars": "請輸入至少10个字元", 189 | "welcome_step2_label_password_confirm": "確認密碼:", 190 | "welcome_step2_label_password": "密碼:", 191 | "welcome_step2_page_title": "創建管理者帳號", 192 | "welcome_step3_btn_done": "開始使用!", 193 | "welcome_step3_page_desc": "繼續向您的儀表板新增裝置。", 194 | "welcome_step3_page_title": "一切就緒! 🎉" 195 | } 196 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true 12 | } 13 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 14 | // 15 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 16 | // from the referenced tsconfig.json - TypeScript does not merge them in 17 | } 18 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { paraglideVitePlugin } from '@inlang/paraglide-js'; 2 | import { sveltekit } from '@sveltejs/kit/vite'; 3 | import { defineConfig } from 'vite'; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' }), 8 | sveltekit() 9 | ] 10 | }); 11 | --------------------------------------------------------------------------------