├── .dockerignore ├── .env.example ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── config.yml │ └── feature.yml ├── dependabot.yml └── workflows │ ├── backend-linter.yml │ ├── build-and-push-docker-image.yml │ ├── e2e-tests.yml │ └── release.yml ├── .gitignore ├── .version ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── TODO.md ├── backend ├── .golangci.yml ├── cmd │ └── server │ │ └── main.go ├── go.mod ├── go.sum └── internal │ ├── api │ ├── handlers │ │ ├── app_config.go │ │ ├── images.go │ │ ├── repositories.go │ │ ├── sync.go │ │ └── tags.go │ └── routes │ │ └── routes.go │ ├── bootstrap │ ├── bootstrap.go │ ├── config.go │ ├── database.go │ ├── repositories.go │ ├── router.go │ └── sync.go │ ├── config │ ├── appconfig.go │ └── database.go │ ├── models │ ├── app_config.go │ ├── image.go │ ├── models.go │ ├── repository.go │ └── tag.go │ ├── repository │ ├── config_repository.go │ ├── docker_repository.go │ ├── gorm │ │ ├── config_repository.go │ │ ├── docker_repository.go │ │ ├── image_repository.go │ │ └── tag_repository.go │ ├── image_repository.go │ ├── repository.go │ └── tag_repository.go │ ├── services │ ├── registry_client.go │ └── sync_service.go │ └── utils │ ├── oci_utils.go │ └── registry_utils.go ├── docker-compose.yml ├── frontend ├── .prettierignore ├── .prettierrc ├── components.json ├── eslint.config.js ├── package-lock.json ├── package.json ├── playwright.config.ts ├── src │ ├── app.css │ ├── app.d.ts │ ├── app.html │ ├── hooks.server.ts │ ├── lib │ │ ├── components │ │ │ ├── badges │ │ │ │ ├── count-badge.svelte │ │ │ │ └── text-badge.svelte │ │ │ ├── buttons │ │ │ │ ├── SupportLinkButton.svelte │ │ │ │ └── SyncButton.svelte │ │ │ ├── cards │ │ │ │ ├── RepositoryCard.svelte │ │ │ │ └── dropdown-card.svelte │ │ │ ├── docker-metadata │ │ │ │ ├── DockerFileViewer.svelte │ │ │ │ ├── LayerVisualization.svelte │ │ │ │ ├── MetadataItem.svelte │ │ │ │ └── RepoImage.svelte │ │ │ ├── header │ │ │ │ └── header.svelte │ │ │ ├── image-table │ │ │ │ └── tag-dropdown-actions.svelte │ │ │ └── ui │ │ │ │ ├── alert-dialog │ │ │ │ ├── alert-dialog-action.svelte │ │ │ │ ├── alert-dialog-cancel.svelte │ │ │ │ ├── alert-dialog-content.svelte │ │ │ │ ├── alert-dialog-description.svelte │ │ │ │ ├── alert-dialog-footer.svelte │ │ │ │ ├── alert-dialog-header.svelte │ │ │ │ ├── alert-dialog-overlay.svelte │ │ │ │ ├── alert-dialog-title.svelte │ │ │ │ └── index.ts │ │ │ │ ├── badge │ │ │ │ ├── badge.svelte │ │ │ │ └── index.ts │ │ │ │ ├── breadcrumb │ │ │ │ ├── breadcrumb-ellipsis.svelte │ │ │ │ ├── breadcrumb-item.svelte │ │ │ │ ├── breadcrumb-link.svelte │ │ │ │ ├── breadcrumb-list.svelte │ │ │ │ ├── breadcrumb-page.svelte │ │ │ │ ├── breadcrumb-separator.svelte │ │ │ │ ├── breadcrumb.svelte │ │ │ │ └── index.ts │ │ │ │ ├── button │ │ │ │ ├── button.svelte │ │ │ │ └── index.ts │ │ │ │ ├── card │ │ │ │ ├── card-content.svelte │ │ │ │ ├── card-description.svelte │ │ │ │ ├── card-footer.svelte │ │ │ │ ├── card-header.svelte │ │ │ │ ├── card-title.svelte │ │ │ │ ├── card.svelte │ │ │ │ └── index.ts │ │ │ │ ├── data-table │ │ │ │ ├── data-table.svelte.ts │ │ │ │ ├── flex-render.svelte │ │ │ │ ├── index.ts │ │ │ │ └── render-helpers.ts │ │ │ │ ├── dialog │ │ │ │ ├── dialog-content.svelte │ │ │ │ ├── dialog-description.svelte │ │ │ │ ├── dialog-footer.svelte │ │ │ │ ├── dialog-header.svelte │ │ │ │ ├── dialog-overlay.svelte │ │ │ │ ├── dialog-title.svelte │ │ │ │ └── index.ts │ │ │ │ ├── dropdown-menu │ │ │ │ ├── dropdown-menu-checkbox-item.svelte │ │ │ │ ├── dropdown-menu-content.svelte │ │ │ │ ├── dropdown-menu-group-heading.svelte │ │ │ │ ├── dropdown-menu-item.svelte │ │ │ │ ├── dropdown-menu-label.svelte │ │ │ │ ├── dropdown-menu-radio-item.svelte │ │ │ │ ├── dropdown-menu-separator.svelte │ │ │ │ ├── dropdown-menu-shortcut.svelte │ │ │ │ ├── dropdown-menu-sub-content.svelte │ │ │ │ ├── dropdown-menu-sub-trigger.svelte │ │ │ │ └── index.ts │ │ │ │ ├── form │ │ │ │ ├── form-button.svelte │ │ │ │ ├── form-description.svelte │ │ │ │ ├── form-element-field.svelte │ │ │ │ ├── form-field-errors.svelte │ │ │ │ ├── form-field.svelte │ │ │ │ ├── form-fieldset.svelte │ │ │ │ ├── form-label.svelte │ │ │ │ ├── form-legend.svelte │ │ │ │ └── index.ts │ │ │ │ ├── input │ │ │ │ ├── index.ts │ │ │ │ └── input.svelte │ │ │ │ ├── label │ │ │ │ ├── index.ts │ │ │ │ └── label.svelte │ │ │ │ ├── pagination │ │ │ │ ├── index.ts │ │ │ │ ├── pagination-content.svelte │ │ │ │ ├── pagination-ellipsis.svelte │ │ │ │ ├── pagination-item.svelte │ │ │ │ ├── pagination-link.svelte │ │ │ │ ├── pagination-next-button.svelte │ │ │ │ ├── pagination-prev-button.svelte │ │ │ │ └── pagination.svelte │ │ │ │ ├── progress │ │ │ │ ├── index.ts │ │ │ │ └── progress.svelte │ │ │ │ ├── scroll-area │ │ │ │ ├── index.ts │ │ │ │ ├── scroll-area-scrollbar.svelte │ │ │ │ └── scroll-area.svelte │ │ │ │ ├── select │ │ │ │ ├── index.ts │ │ │ │ ├── select-content.svelte │ │ │ │ ├── select-group-heading.svelte │ │ │ │ ├── select-item.svelte │ │ │ │ ├── select-scroll-down-button.svelte │ │ │ │ ├── select-scroll-up-button.svelte │ │ │ │ ├── select-separator.svelte │ │ │ │ └── select-trigger.svelte │ │ │ │ ├── separator │ │ │ │ ├── index.ts │ │ │ │ └── separator.svelte │ │ │ │ ├── sonner │ │ │ │ ├── index.ts │ │ │ │ └── sonner.svelte │ │ │ │ ├── switch │ │ │ │ ├── index.ts │ │ │ │ └── switch.svelte │ │ │ │ └── table │ │ │ │ ├── index.ts │ │ │ │ ├── table-body.svelte │ │ │ │ ├── table-caption.svelte │ │ │ │ ├── table-cell.svelte │ │ │ │ ├── table-footer.svelte │ │ │ │ ├── table-head.svelte │ │ │ │ ├── table-header.svelte │ │ │ │ ├── table-row.svelte │ │ │ │ └── table.svelte │ │ ├── index.ts │ │ ├── services │ │ │ ├── app-config-service.ts │ │ │ ├── image-service.ts │ │ │ ├── logger.ts │ │ │ ├── repository-service.ts │ │ │ └── tag-service.ts │ │ ├── stores │ │ │ └── sync-store.ts │ │ ├── types │ │ │ ├── app-config-type.ts │ │ │ ├── components │ │ │ │ ├── configuration.ts │ │ │ │ └── image-table.ts │ │ │ ├── image-type.ts │ │ │ ├── index.ts │ │ │ ├── repository-type.ts │ │ │ └── tag-type.ts │ │ ├── utils.ts │ │ └── utils │ │ │ ├── api │ │ │ ├── auth.ts │ │ │ ├── delete.ts │ │ │ ├── health.ts │ │ │ ├── index.ts │ │ │ ├── manifest.ts │ │ │ └── registry.ts │ │ │ ├── crypto │ │ │ ├── index.ts │ │ │ ├── polyfill.ts │ │ │ └── universal-crypto.ts │ │ │ ├── formatting │ │ │ ├── index.ts │ │ │ ├── oci.ts │ │ │ ├── size.ts │ │ │ └── time.ts │ │ │ ├── repos.ts │ │ │ ├── style.ts │ │ │ └── ui │ │ │ ├── clipboard.ts │ │ │ └── index.ts │ └── routes │ │ ├── +layout.server.ts │ │ ├── +layout.svelte │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ ├── details │ │ └── [repo] │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ └── [image] │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ └── [tag] │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ ├── health │ │ └── +server.ts │ │ └── settings │ │ ├── +page.server.ts │ │ └── +page.svelte ├── static │ ├── favicon.png │ └── img │ │ ├── docker-mark-blue.png │ │ ├── svelocker-old.png │ │ └── svelocker.png ├── svelte.config.js ├── tailwind.config.ts ├── tests │ └── e2e │ │ ├── docker-compose.test.yml │ │ ├── global-setup.ts │ │ ├── global-teardown.ts │ │ ├── registry.spec.ts │ │ └── setup.ts ├── tsconfig.json ├── vite-env.d.ts └── vite.config.ts └── scripts ├── development ├── create-dev-image.sh └── create-release.sh └── docker ├── entrypoint.sh └── setup-container.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | # JetBrains 2 | **/.idea 3 | 4 | node_modules 5 | 6 | # Output 7 | .output 8 | .vercel 9 | .svelte-kit 10 | build 11 | 12 | # OS 13 | .DS_Store 14 | Thumbs.db 15 | 16 | # Env 17 | .env 18 | .env.* 19 | !.env.example 20 | !.env.test 21 | 22 | # Vite 23 | vite.config.js.timestamp-* 24 | vite.config.ts.timestamp-* 25 | 26 | # Binaries for programs and plugins 27 | *.exe 28 | *.exe~ 29 | *.dll 30 | *.so 31 | *.dylib 32 | 33 | 34 | # Misc 35 | .DS_Store 36 | .env.local 37 | .env.development.local 38 | .env.test.local 39 | .env.production.local 40 | 41 | npm-debug.log* 42 | yarn-debug.log* 43 | yarn-error.log* -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Server Configuration 2 | SERVER_HOST=0.0.0.0 3 | SERVER_PORT=8080 4 | PUBLIC_BACKEND_URL=http://localhost:8080 5 | 6 | # Registry Configuration 7 | PUBLIC_REGISTRY_URL=https://registry.example.com 8 | PUBLIC_REGISTRY_NAME=My Docker Registry 9 | REGISTRY_USERNAME= 10 | REGISTRY_PASSWORD= 11 | 12 | # Database Configuration 13 | DB_PATH=data/svelockerui.db 14 | 15 | # Application Configuration 16 | APP_ENV=production 17 | PUBLIC_APP_URL=http://localhost:3000 18 | PUBLIC_LOG_LEVEL=INFO # Available levels: DEBUG, INFO, WARN, ERROR -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: kmendell 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: "Submit a Bug Report" 3 | title: "Bug: " 4 | labels: [bug] 5 | body: 6 | - type: textarea 7 | id: bug-description 8 | validations: 9 | required: true 10 | attributes: 11 | label: "Bug description" 12 | description: "Description of the bug at hand. Include and logs as well." 13 | placeholder: "..." -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Support 4 | url: https://github.com/kmendell/svelocker-ui/discussions 5 | about: Please ask and answer questions here. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: "Submit a proposal for a new feature" 3 | title: "Feature: " 4 | labels: [feature] 5 | body: 6 | - type: textarea 7 | id: feature-description 8 | validations: 9 | required: true 10 | attributes: 11 | label: "Feature description" 12 | description: "What should be added?" 13 | placeholder: "You should add ..." 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | # Frontend dependencies 9 | - package-ecosystem: "npm" 10 | directory: "/frontend" 11 | schedule: 12 | interval: "weekly" 13 | groups: 14 | frontend-dependencies: 15 | patterns: 16 | - "*" 17 | exclude-patterns: 18 | - "@types/*" 19 | - "typescript" 20 | - "eslint*" 21 | - "prettier*" 22 | - "vite" 23 | - "*-plugin" 24 | - "*-loader" 25 | - "jest*" 26 | - "vitest*" 27 | - "@testing-library/*" 28 | - "@playwright/*" 29 | update-types: 30 | - "minor" 31 | - "patch" 32 | frontend-dev-dependencies: 33 | patterns: 34 | - "@types/*" 35 | - "typescript" 36 | - "eslint*" 37 | - "prettier*" 38 | - "vite" 39 | - "*-plugin" 40 | - "*-loader" 41 | - "jest*" 42 | - "vitest*" 43 | - "@testing-library/*" 44 | - "@playwright/*" 45 | update-types: 46 | - "minor" 47 | - "patch" 48 | frontend-major-updates: 49 | patterns: 50 | - "*" 51 | update-types: 52 | - "major" 53 | 54 | # Backend dependencies 55 | - package-ecosystem: "gomod" 56 | directory: "/backend" 57 | schedule: 58 | interval: "daily" 59 | groups: 60 | backend-dependencies: 61 | patterns: 62 | - "*" 63 | update-types: 64 | - "minor" 65 | - "patch" 66 | backend-major-updates: 67 | patterns: 68 | - "*" 69 | update-types: 70 | - "major" 71 | 72 | # GitHub Actions 73 | - package-ecosystem: "github-actions" 74 | directory: "/" 75 | schedule: 76 | interval: "weekly" 77 | groups: 78 | github-actions: 79 | patterns: 80 | - "*" 81 | -------------------------------------------------------------------------------- /.github/workflows/backend-linter.yml: -------------------------------------------------------------------------------- 1 | name: GoLang Backend Linter 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - "backend/**" 8 | pull_request: 9 | branches: [main] 10 | paths: 11 | - "backend/**" 12 | 13 | permissions: 14 | # Required: allow read access to the content for analysis. 15 | contents: read 16 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 17 | pull-requests: read 18 | # Optional: allow write access to checks to allow the action to annotate code in the PR. 19 | checks: write 20 | 21 | jobs: 22 | golangci-lint: 23 | name: Run Golangci-lint 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v4 28 | 29 | - name: Set up Go 30 | uses: actions/setup-go@v5 31 | with: 32 | go-version-file: backend/go.mod 33 | 34 | - name: Run Golangci-lint 35 | uses: golangci/golangci-lint-action@4f58623b88e35c8172c05b70ed3b93ee8a1bfdd1 # v7.0.0 36 | with: 37 | version: v2.0.2 38 | working-directory: backend 39 | only-new-issues: ${{ github.event_name == 'pull_request' }} 40 | -------------------------------------------------------------------------------- /.github/workflows/build-and-push-docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-22.04 11 | permissions: 12 | contents: read 13 | packages: write 14 | steps: 15 | - name: checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Docker metadata 19 | id: meta 20 | uses: docker/metadata-action@v5 21 | with: 22 | images: | 23 | ghcr.io/${{ github.repository }} 24 | tags: | 25 | type=semver,pattern={{version}},prefix=v 26 | type=semver,pattern={{major}}.{{minor}},prefix=v 27 | 28 | - name: Set up QEMU 29 | uses: docker/setup-qemu-action@v3 30 | 31 | - name: Set up Docker Buildx 32 | uses: docker/setup-buildx-action@v3 33 | 34 | - name: Login to Docker Hub 35 | uses: docker/login-action@v3 36 | with: 37 | username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} 38 | password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }} 39 | 40 | - name: "Login to GitHub Container Registry" 41 | uses: docker/login-action@v3 42 | with: 43 | registry: ghcr.io 44 | username: ${{github.repository_owner}} 45 | password: ${{secrets.GITHUB_TOKEN}} 46 | 47 | - name: Build and push 48 | uses: docker/build-push-action@v6 49 | with: 50 | context: . 51 | platforms: linux/amd64,linux/arm64 52 | push: true 53 | tags: ${{ steps.meta.outputs.tags }} 54 | labels: ${{ steps.meta.outputs.labels }} 55 | cache-from: type=gha 56 | cache-to: type=gha,mode=max 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # JetBrains 2 | **/.idea 3 | 4 | src/routes/*.old 5 | 6 | test-results/* 7 | test-results 8 | tests/.* 9 | 10 | node_modules 11 | 12 | # Output 13 | .output 14 | .vercel 15 | .svelte-kit 16 | build 17 | 18 | # OS 19 | .DS_Store 20 | Thumbs.db 21 | 22 | # Env 23 | .env 24 | .env.* 25 | !.env.example 26 | !.env.test 27 | 28 | # Vite 29 | vite.config.js.timestamp-* 30 | vite.config.ts.timestamp-* 31 | 32 | # Binaries for programs and plugins 33 | *.exe 34 | *.exe~ 35 | *.dll 36 | *.so 37 | *.dylib 38 | 39 | test.db.* 40 | 41 | 42 | # Misc 43 | .DS_Store 44 | .env.local 45 | .env.development.local 46 | .env.test.local 47 | .env.production.local 48 | 49 | npm-debug.log* 50 | yarn-debug.log* 51 | yarn-error.log* 52 | 53 | notes.txt 54 | 55 | svelockerui.db 56 | svelockerui.db-journal 57 | *.db 58 | *.db-shm 59 | *.db-wal 60 | .vscode/settings.json 61 | -------------------------------------------------------------------------------- /.version: -------------------------------------------------------------------------------- 1 | 0.27.1 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Getting started 4 | 5 | ## Submit a Pull Request 6 | 7 | Before you submit the pull request for review please ensure that 8 | 9 | - The pull request naming follows the [Conventional Commits specification](https://www.conventionalcommits.org): 10 | 11 | `[optional scope]: ` 12 | 13 | example: 14 | 15 | ``` 16 | feat(share): add password protection 17 | ``` 18 | 19 | Where `TYPE` can be: 20 | 21 | - **feat** - is a new feature 22 | - **doc** - documentation only changes 23 | - **fix** - a bug fix 24 | - **refactor** - code change that neither fixes a bug nor adds a feature 25 | 26 | - Your pull request has a detailed description 27 | - You run `npm run format` to format the code 28 | 29 | ## Setup project 30 | 31 | The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript. 32 | The backend is written in Go and built with GIN and GORM for the database. 33 | 34 | #### Setup 35 | 36 | 1. Open the project folder 37 | 2. Copy the .env.example to the backend and frontend folders, and rename it .env, and make the correct modifications. 38 | 3. Install the frontend dependencies with `cd frontend && npm install` 39 | 4. Start the backend `cd backend && go run cmd/server/main.go` 40 | 5. Start the frontend with `cd frontend && npm run dev` 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Tags passed to "go build" 2 | ARG BUILD_TAGS="" 3 | 4 | # Stage 1: Build Frontend 5 | FROM node:22-alpine AS frontend-builder 6 | WORKDIR /app/frontend 7 | COPY ./frontend/package*.json ./ 8 | RUN npm ci 9 | COPY ./frontend ./ 10 | RUN npm run build 11 | RUN npm prune --production 12 | 13 | # Stage 2: Build Backend 14 | FROM golang:1.24-alpine AS backend-builder 15 | ARG BUILD_TAGS 16 | WORKDIR /app/backend 17 | COPY ./backend/go.mod ./backend/go.sum ./ 18 | RUN go mod download 19 | 20 | RUN apk add --no-cache gcc musl-dev git 21 | 22 | COPY ./backend ./ 23 | WORKDIR /app/backend/cmd/server 24 | RUN CGO_ENABLED=1 GOOS=linux go build -tags "${BUILD_TAGS}" -o /app/backend/svelocker-backend . 25 | 26 | # Stage 3: Production Image 27 | FROM node:22-alpine 28 | 29 | # Delete default node user 30 | RUN deluser --remove-home node 31 | 32 | RUN apk add --no-cache su-exec curl 33 | 34 | WORKDIR /app 35 | # Create data directory 36 | RUN mkdir -p /app/data 37 | 38 | # Copy frontend files 39 | COPY --from=frontend-builder /app/frontend/build ./frontend/build 40 | COPY --from=frontend-builder /app/frontend/node_modules ./frontend/node_modules 41 | COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json 42 | COPY --from=frontend-builder /app/frontend/static ./frontend/static 43 | 44 | # Copy backend binary 45 | COPY --from=backend-builder /app/backend/svelocker-backend ./backend/svelocker-backend 46 | RUN chmod +x ./backend/svelocker-backend 47 | 48 | # Copy scripts 49 | COPY ./scripts ./scripts 50 | RUN chmod +x ./scripts/docker/*.sh 51 | 52 | EXPOSE 3000 53 | EXPOSE 8080 54 | 55 | LABEL org.opencontainers.image.authors="kmendell" 56 | LABEL org.opencontainers.image.description="A Simple and Modern Docker Registry UI for use with Distribution/Registry" 57 | 58 | # Add volume for persistent data 59 | VOLUME ["/app/data"] 60 | 61 | ENTRYPOINT ["sh", "./scripts/docker/setup-container.sh"] 62 | CMD ["sh", "./scripts/docker/entrypoint.sh"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2025, Kyle Mendell 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > This project is currently in development. Features may not be available currently but may come in the future. Feel free to request or help contribute :) 3 | 4 | #

Svelocker
5 | 6 |

A Simple and Modern Docker Registry UI built with Typescript and SvelteKit.

7 | 8 | ### Home Page: 9 | 10 | image 11 | 12 | ### Home Page Card: 13 | 14 | image 15 | 16 | ### Repo Details: 17 | 18 | image 19 | 20 | ### Image Tag Details: 21 | 22 | image 23 | 24 | 25 |
26 | 27 | 28 | ## Features: 29 | 30 | - Simple and Modern Design 31 | - Easy Setup 32 | - Connects to Local Registries using the official [distribution/registry](https://hub.docker.com/_/registry) container image. 33 | - Delete Image Tags from the UI 34 | - SQLite Cache Layer for registry data 35 | - View and Copy the Dockerfile from the UI 36 | - View all Images for a specific Repo/Namespace 37 | 38 | ## Get Started 39 | 40 | ### Requirements 41 | 42 | - Docker and Docker Compose 43 | - Private Container Registry using the [distribution/registry](https://hub.docker.com/_/registry) container. 44 | 45 | # Install 46 | 47 | Follow the Install guide [here](https://github.com/kmendell/svelocker-ui/wiki/Installation) 48 | 49 | # Additional Information 50 | 51 | - All the API Calls to the Registry happen Server-Side, the only exceptions to this are the `Copy Dockerfile` button. If you find something that is not happening Server side please open up a issue or a PR to help fix it. 52 | 53 | # Shoutouts 54 | 55 | - Shoutout to [joxi/docker-registry-ui](https://github.com/Joxit/docker-registry-ui) for the inspiration for this project. 56 | - Shoutout to [pocket-id/pocket-id](https://github.com/pocket-id/pocket-id) for the SvelteKit inspiration and the Dropdown Card Component. 57 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please email kmendell@outlook.com to report a vulnerability, please dont create a issue as the issue could be exploited. 6 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | Ideas i have to maybe implement. 2 | 3 | 1. **Authentication & Authorization** 4 | 5 | - User login/logout functionality 6 | - Role-based access control 7 | - Token-based authentication for registry operations 8 | 9 | 2. **Advanced Search & Filtering** 10 | 11 | - Search by image name/tag 12 | - Filter by creation date 13 | - Filter by image size 14 | 15 | 3. **Image Analysis** 16 | 17 | - Security vulnerability scanning 18 | - Base image analysis 19 | - Dependencies overview 20 | 21 | 4. **Advanced Operations** 22 | 23 | - Batch delete functionality 24 | - Tag promotion between registries 25 | - Image mirroring capabilities 26 | - Registry garbage collection trigger 27 | 28 | 5. **Monitoring & Metrics** 29 | 30 | - Registry disk usage stats 31 | - Pull/push metrics 32 | - Most used images 33 | 34 | 6. **UI Enhancements** 35 | 36 | - Customizable items per page 37 | - Image version comparison 38 | - Tag sorting options 39 | - Configurable columns 40 | 41 | 7. **Export & Import** 42 | 43 | - Export image metadata 44 | - Batch import capabilities 45 | - Backup/restore functionality 46 | -------------------------------------------------------------------------------- /backend/.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | tests: true 4 | timeout: 5m 5 | linters: 6 | default: none 7 | enable: 8 | - asasalint 9 | - asciicheck 10 | - bidichk 11 | - bodyclose 12 | - contextcheck 13 | - copyloopvar 14 | - durationcheck 15 | - errcheck 16 | - errchkjson 17 | - errorlint 18 | - exhaustive 19 | - gocheckcompilerdirectives 20 | - gochecksumtype 21 | - gocognit 22 | - gocritic 23 | - gosec 24 | - gosmopolitan 25 | - govet 26 | - ineffassign 27 | - loggercheck 28 | - makezero 29 | - musttag 30 | - nilerr 31 | - nilnesserr 32 | - noctx 33 | - protogetter 34 | - reassign 35 | - recvcheck 36 | - rowserrcheck 37 | - spancheck 38 | - sqlclosecheck 39 | - staticcheck 40 | - testifylint 41 | - unused 42 | - usestdlibvars 43 | - zerologlint 44 | exclusions: 45 | generated: lax 46 | presets: 47 | - comments 48 | - common-false-positives 49 | - legacy 50 | - std-error-handling 51 | paths: 52 | - third_party$ 53 | - builtin$ 54 | - examples$ 55 | - internal/service/test_service.go 56 | formatters: 57 | enable: 58 | - goimports 59 | exclusions: 60 | generated: lax 61 | paths: 62 | - third_party$ 63 | - builtin$ 64 | - examples$ 65 | -------------------------------------------------------------------------------- /backend/cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/ofkm/svelocker-ui/backend/internal/bootstrap" 14 | ) 15 | 16 | //nolint:gocritic 17 | func main() { 18 | // Create context that can be cancelled on shutdown 19 | ctx, cancel := context.WithCancel(context.Background()) 20 | defer cancel() 21 | 22 | // Bootstrap the application 23 | app, err := bootstrap.Bootstrap(ctx) 24 | if err != nil { 25 | log.Printf("Failed to bootstrap application: %v", err) // Log the error 26 | return // Return from main instead of exiting 27 | } 28 | defer app.Close() 29 | 30 | // Create HTTP server 31 | server := &http.Server{ 32 | Addr: fmt.Sprintf("%s:%d", app.Config.Server.Host, app.Config.Server.Port), 33 | Handler: app.Router, 34 | ReadHeaderTimeout: 20 * time.Second, 35 | } 36 | 37 | // Channel to listen for errors coming from the server 38 | serverErrors := make(chan error, 1) 39 | 40 | // Start server 41 | go func() { 42 | log.Printf("Server listening on %s", server.Addr) 43 | serverErrors <- server.ListenAndServe() 44 | }() 45 | 46 | // Channel to listen for interrupt signals 47 | shutdown := make(chan os.Signal, 1) 48 | signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM) 49 | 50 | // Block until we receive a shutdown signal or server error 51 | select { 52 | case err := <-serverErrors: 53 | log.Printf("Server error: %v", err) 54 | 55 | // Create shutdown context with timeout 56 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) 57 | defer shutdownCancel() 58 | 59 | // Trigger application cleanup 60 | if err := app.Close(); err != nil { 61 | log.Printf("Error during cleanup: %v", err) 62 | } 63 | 64 | // Shutdown server 65 | if err := server.Shutdown(shutdownCtx); err != nil { 66 | log.Printf("Error during server shutdown: %v", err) 67 | server.Close() 68 | } 69 | os.Exit(1) // Exit after cleanup 70 | 71 | case <-shutdown: 72 | log.Println("Starting graceful shutdown...") 73 | 74 | // Create shutdown context with timeout 75 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) 76 | defer shutdownCancel() 77 | 78 | // Trigger application cleanup 79 | if err := app.Close(); err != nil { 80 | log.Printf("Error during cleanup: %v", err) 81 | } 82 | 83 | // Shutdown server 84 | if err := server.Shutdown(shutdownCtx); err != nil { 85 | log.Printf("Error during server shutdown: %v", err) 86 | server.Close() 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ofkm/svelocker-ui/backend 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/gin-gonic/gin v1.10.0 7 | github.com/joho/godotenv v1.5.1 8 | gorm.io/driver/sqlite v1.5.7 9 | gorm.io/gorm v1.26.0 10 | ) 11 | 12 | require ( 13 | github.com/bytedance/sonic v1.13.2 // indirect 14 | github.com/bytedance/sonic/loader v0.2.4 // indirect 15 | github.com/cloudwego/base64x v0.1.5 // indirect 16 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 17 | github.com/gin-contrib/sse v1.0.0 // indirect 18 | github.com/go-playground/locales v0.14.1 // indirect 19 | github.com/go-playground/universal-translator v0.18.1 // indirect 20 | github.com/go-playground/validator/v10 v10.26.0 // indirect 21 | github.com/goccy/go-json v0.10.5 // indirect 22 | github.com/jinzhu/inflection v1.0.0 // indirect 23 | github.com/jinzhu/now v1.1.5 // indirect 24 | github.com/json-iterator/go v1.1.12 // indirect 25 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 26 | github.com/leodido/go-urn v1.4.0 // indirect 27 | github.com/mattn/go-isatty v0.0.20 // indirect 28 | github.com/mattn/go-sqlite3 v1.14.27 // indirect 29 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 30 | github.com/modern-go/reflect2 v1.0.2 // indirect 31 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 32 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 33 | github.com/ugorji/go/codec v1.2.12 // indirect 34 | golang.org/x/arch v0.16.0 // indirect 35 | golang.org/x/crypto v0.37.0 // indirect 36 | golang.org/x/net v0.38.0 // indirect 37 | golang.org/x/sys v0.32.0 // indirect 38 | golang.org/x/text v0.24.0 // indirect 39 | google.golang.org/protobuf v1.36.6 // indirect 40 | gopkg.in/yaml.v3 v3.0.1 // indirect 41 | ) 42 | -------------------------------------------------------------------------------- /backend/internal/api/handlers/app_config.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/ofkm/svelocker-ui/backend/internal/repository" 8 | ) 9 | 10 | type AppConfigHandler struct { 11 | repo repository.ConfigRepository 12 | } 13 | 14 | func NewAppConfigHandler(repo repository.ConfigRepository) *AppConfigHandler { 15 | return &AppConfigHandler{repo: repo} 16 | } 17 | 18 | // ListConfigs handles GET /api/config 19 | func (h *AppConfigHandler) ListConfigs(c *gin.Context) { 20 | configs, err := h.repo.List(c.Request.Context()) 21 | if err != nil { 22 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 23 | return 24 | } 25 | c.JSON(http.StatusOK, configs) 26 | } 27 | 28 | // GetConfig handles GET /api/config/:key 29 | func (h *AppConfigHandler) GetConfig(c *gin.Context) { 30 | key := c.Param("key") 31 | config, err := h.repo.Get(c.Request.Context(), key) 32 | if err != nil { 33 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 34 | return 35 | } 36 | if config == nil { 37 | c.JSON(http.StatusNotFound, gin.H{"error": "Configuration not found"}) 38 | return 39 | } 40 | c.JSON(http.StatusOK, config) 41 | } 42 | 43 | // UpdateConfig handles PUT /api/config/:key 44 | func (h *AppConfigHandler) UpdateConfig(c *gin.Context) { 45 | key := c.Param("key") 46 | var input struct { 47 | Value string `json:"value" binding:"required"` 48 | } 49 | if err := c.ShouldBindJSON(&input); err != nil { 50 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 51 | return 52 | } 53 | 54 | err := h.repo.Update(c.Request.Context(), key, input.Value) 55 | if err != nil { 56 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 57 | return 58 | } 59 | 60 | c.Status(http.StatusOK) 61 | } 62 | -------------------------------------------------------------------------------- /backend/internal/api/handlers/images.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/ofkm/svelocker-ui/backend/internal/repository" 8 | ) 9 | 10 | type ImageHandler struct { 11 | repo repository.ImageRepository 12 | } 13 | 14 | func NewImageHandler(repo repository.ImageRepository) *ImageHandler { 15 | return &ImageHandler{repo: repo} 16 | } 17 | 18 | // ListImages handles GET /api/repositories/:name/images 19 | func (h *ImageHandler) ListImages(c *gin.Context) { 20 | repoName := c.Param("name") 21 | images, err := h.repo.ListImages(c.Request.Context(), repoName) 22 | if err != nil { 23 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 24 | return 25 | } 26 | c.JSON(http.StatusOK, images) 27 | } 28 | 29 | // GetImage handles GET /api/repositories/:name/images/:image 30 | func (h *ImageHandler) GetImage(c *gin.Context) { 31 | repoName := c.Param("name") 32 | imageName := c.Param("image") 33 | 34 | image, err := h.repo.GetImage(c.Request.Context(), repoName, imageName) 35 | if err != nil { 36 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 37 | return 38 | } 39 | if image == nil { 40 | c.JSON(http.StatusNotFound, gin.H{"error": "Image not found"}) 41 | return 42 | } 43 | 44 | c.JSON(http.StatusOK, image) 45 | } 46 | -------------------------------------------------------------------------------- /backend/internal/api/handlers/repositories.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/ofkm/svelocker-ui/backend/internal/repository" 9 | ) 10 | 11 | type RepositoryHandler struct { 12 | repo repository.DockerRepository 13 | } 14 | 15 | func NewRepositoryHandler(repo repository.DockerRepository) *RepositoryHandler { 16 | return &RepositoryHandler{repo: repo} 17 | } 18 | 19 | // ListRepositories handles GET /api/repositories 20 | func (h *RepositoryHandler) ListRepositories(c *gin.Context) { 21 | page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) 22 | limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) 23 | search := c.DefaultQuery("search", "") 24 | 25 | repositories, total, err := h.repo.ListRepositories(c.Request.Context(), page, limit, search) 26 | if err != nil { 27 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 28 | return 29 | } 30 | 31 | c.JSON(http.StatusOK, gin.H{ 32 | "repositories": repositories, 33 | "totalCount": total, 34 | "page": page, 35 | "limit": limit, 36 | }) 37 | } 38 | 39 | // GetRepository handles GET /api/repositories/:name 40 | func (h *RepositoryHandler) GetRepository(c *gin.Context) { 41 | name := c.Param("name") 42 | repository, err := h.repo.GetRepository(c.Request.Context(), name) 43 | if err != nil { 44 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 45 | return 46 | } 47 | if repository == nil { 48 | c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"}) 49 | return 50 | } 51 | 52 | c.JSON(http.StatusOK, repository) 53 | } 54 | -------------------------------------------------------------------------------- /backend/internal/api/handlers/sync.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/ofkm/svelocker-ui/backend/internal/services" 8 | ) 9 | 10 | type SyncHandler struct { 11 | syncSvc *services.SyncService 12 | } 13 | 14 | func NewSyncHandler(syncSvc *services.SyncService) *SyncHandler { 15 | return &SyncHandler{syncSvc: syncSvc} 16 | } 17 | 18 | // TriggerSync handles POST /api/sync 19 | func (h *SyncHandler) TriggerSync(c *gin.Context) { 20 | if err := h.syncSvc.PerformSync(c.Request.Context()); err != nil { 21 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 22 | return 23 | } 24 | c.Status(http.StatusOK) 25 | } 26 | 27 | // GetLastSync handles GET /api/sync/last 28 | func (h *SyncHandler) GetLastSync(c *gin.Context) { 29 | lastSync, err := h.syncSvc.GetLastSyncTime(c.Request.Context()) 30 | if err != nil { 31 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 32 | return 33 | } 34 | 35 | if lastSync == nil { 36 | c.JSON(http.StatusNotFound, gin.H{"error": "No sync has been performed yet"}) 37 | return 38 | } 39 | 40 | c.JSON(http.StatusOK, gin.H{"lastSync": lastSync}) 41 | } 42 | -------------------------------------------------------------------------------- /backend/internal/api/handlers/tags.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/ofkm/svelocker-ui/backend/internal/repository" 10 | ) 11 | 12 | type TagHandler struct { 13 | repo repository.TagRepository 14 | } 15 | 16 | func NewTagHandler(repo repository.TagRepository) *TagHandler { 17 | return &TagHandler{repo: repo} 18 | } 19 | 20 | // ListTags handles GET /api/repositories/:name/images/:image/tags 21 | func (h *TagHandler) ListTags(c *gin.Context) { 22 | repoName := c.Param("name") 23 | imageName := c.Param("image") 24 | 25 | tags, err := h.repo.ListTags(c.Request.Context(), repoName, imageName) 26 | if err != nil { 27 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 28 | return 29 | } 30 | 31 | c.JSON(http.StatusOK, tags) 32 | } 33 | 34 | // GetTag handles GET /api/repositories/:name/images/:image/tags/:tag 35 | func (h *TagHandler) GetTag(c *gin.Context) { 36 | repoName := c.Param("name") 37 | imageName := c.Param("image") 38 | tagName := c.Param("tag") 39 | 40 | tag, err := h.repo.GetTag(c.Request.Context(), repoName, imageName, tagName) 41 | if err != nil { 42 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 43 | return 44 | } 45 | if tag == nil { 46 | c.JSON(http.StatusNotFound, gin.H{"error": "Tag not found"}) 47 | return 48 | } 49 | 50 | c.JSON(http.StatusOK, tag) 51 | } 52 | 53 | // DeleteTag handles requests to delete a specific tag 54 | func (h *TagHandler) DeleteTag(c *gin.Context) { 55 | repoName := c.Param("name") 56 | imageName := c.Param("image") 57 | tagName := c.Param("tag") 58 | 59 | // Call repository method to delete the tag 60 | err := h.repo.DeleteTag(c, repoName, imageName, tagName) 61 | if err != nil { 62 | status := http.StatusInternalServerError 63 | if strings.Contains(err.Error(), "record not found") { 64 | status = http.StatusNotFound 65 | } 66 | 67 | c.JSON(status, gin.H{ 68 | "error": fmt.Sprintf("Failed to delete tag: %v", err), 69 | }) 70 | return 71 | } 72 | 73 | // Return success response 74 | c.JSON(http.StatusOK, gin.H{ 75 | "message": fmt.Sprintf("Tag %s deleted successfully", tagName), 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /backend/internal/api/routes/routes.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/ofkm/svelocker-ui/backend/internal/api/handlers" 6 | "github.com/ofkm/svelocker-ui/backend/internal/repository" 7 | "github.com/ofkm/svelocker-ui/backend/internal/services" 8 | ) 9 | 10 | func SetupRoutes( 11 | r *gin.Engine, 12 | configRepo repository.ConfigRepository, 13 | dockerRepo repository.DockerRepository, 14 | imageRepo repository.ImageRepository, 15 | tagRepo repository.TagRepository, 16 | syncSvc *services.SyncService, 17 | ) { 18 | // Create handlers with their specific repositories 19 | repoHandler := handlers.NewRepositoryHandler(dockerRepo) 20 | imageHandler := handlers.NewImageHandler(imageRepo) 21 | tagHandler := handlers.NewTagHandler(tagRepo) 22 | configHandler := handlers.NewAppConfigHandler(configRepo) 23 | syncHandler := handlers.NewSyncHandler(syncSvc) 24 | 25 | // API v1 group 26 | v1 := r.Group("/api/v1") 27 | { 28 | // App Config routes 29 | config := v1.Group("/config") 30 | { 31 | config.GET("", configHandler.ListConfigs) 32 | config.GET("/:key", configHandler.GetConfig) 33 | config.PUT("/:key", configHandler.UpdateConfig) 34 | } 35 | 36 | // Sync routes 37 | sync := v1.Group("/sync") 38 | { 39 | sync.POST("", syncHandler.TriggerSync) 40 | sync.GET("/last", syncHandler.GetLastSync) 41 | } 42 | 43 | // Repository routes 44 | repos := v1.Group("/repositories") 45 | { 46 | repos.GET("", repoHandler.ListRepositories) 47 | repos.GET("/:name", repoHandler.GetRepository) 48 | 49 | // Image routes 50 | repos.GET("/:name/images", imageHandler.ListImages) 51 | repos.GET("/:name/images/:image", imageHandler.GetImage) 52 | 53 | // Tag routes 54 | repos.GET("/:name/images/:image/tags", tagHandler.ListTags) 55 | repos.GET("/:name/images/:image/tags/:tag", tagHandler.GetTag) 56 | repos.DELETE("/:name/images/:image/tags/:tag", tagHandler.DeleteTag) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /backend/internal/bootstrap/bootstrap.go: -------------------------------------------------------------------------------- 1 | // Package bootstrap handles application initialization and setup 2 | package bootstrap 3 | 4 | import ( 5 | "context" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/ofkm/svelocker-ui/backend/internal/config" 9 | "github.com/ofkm/svelocker-ui/backend/internal/repository" 10 | "github.com/ofkm/svelocker-ui/backend/internal/services" 11 | "gorm.io/gorm" 12 | ) 13 | 14 | // Application represents the bootstrapped application 15 | type Application struct { 16 | Config *config.AppConfig 17 | DB *gorm.DB 18 | Router *gin.Engine 19 | ConfigRepo repository.ConfigRepository 20 | DockerRepo repository.DockerRepository 21 | ImageRepo repository.ImageRepository 22 | TagRepo repository.TagRepository 23 | SyncSvc *services.SyncService 24 | } 25 | 26 | // Bootstrap initializes the application 27 | func Bootstrap(ctx context.Context) (*Application, error) { 28 | app := &Application{} 29 | 30 | // Initialize configuration 31 | if err := app.initConfig(); err != nil { 32 | return nil, err 33 | } 34 | 35 | // Initialize database 36 | if err := app.initDatabase(); err != nil { 37 | return nil, err 38 | } 39 | 40 | // Initialize repositories 41 | if err := app.initRepositories(ctx); err != nil { 42 | return nil, err 43 | } 44 | 45 | // Initialize sync service 46 | if err := app.initSyncService(ctx); err != nil { 47 | return nil, err 48 | } 49 | 50 | // Initialize router and middleware 51 | if err := app.initRouter(); err != nil { 52 | return nil, err 53 | } 54 | 55 | return app, nil 56 | } 57 | 58 | func (app *Application) Close() error { 59 | if app.SyncSvc != nil { 60 | app.SyncSvc.Stop() 61 | } 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /backend/internal/bootstrap/config.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "log" 5 | "path/filepath" 6 | 7 | "github.com/joho/godotenv" 8 | "github.com/ofkm/svelocker-ui/backend/internal/config" 9 | ) 10 | 11 | func (app *Application) initConfig() error { 12 | // Load .env file if it exists 13 | if err := godotenv.Load(filepath.Join(".", ".env")); err != nil { 14 | // Try loading from backend directory 15 | if err := godotenv.Load(filepath.Join("backend", ".env")); err != nil { 16 | // Log the error or handle it as needed 17 | log.Println("No .env file found, using environment variables") 18 | } 19 | } 20 | 21 | // Initialize application configuration 22 | appConfig, err := config.NewAppConfig() 23 | if err != nil { 24 | return err 25 | } 26 | 27 | // Validate configuration 28 | if err := appConfig.Validate(); err != nil { 29 | return err 30 | } 31 | 32 | app.Config = appConfig 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /backend/internal/bootstrap/repositories.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ofkm/svelocker-ui/backend/internal/repository/gorm" 7 | ) 8 | 9 | func (app *Application) initRepositories(ctx context.Context) error { 10 | // Initialize repositories 11 | app.ConfigRepo = gorm.NewConfigRepository(app.DB) 12 | app.DockerRepo = gorm.NewDockerRepository(app.DB) 13 | app.ImageRepo = gorm.NewImageRepository(app.DB) 14 | app.TagRepo = gorm.NewTagRepository(app.DB) 15 | 16 | if err := app.ConfigRepo.Update(ctx, "registry_url", app.Config.Registry.URL); err != nil { 17 | return err 18 | } 19 | if err := app.ConfigRepo.Update(ctx, "registry_name", app.Config.Registry.Name); err != nil { 20 | return err 21 | } 22 | 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /backend/internal/bootstrap/router.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/ofkm/svelocker-ui/backend/internal/api/routes" 8 | ) 9 | 10 | func (app *Application) initRouter() error { 11 | // Set Gin mode based on log level 12 | if app.Config.Logging.Level == "DEBUG" { 13 | gin.SetMode(gin.DebugMode) 14 | } else { 15 | gin.SetMode(gin.ReleaseMode) 16 | } 17 | 18 | // Create Gin router 19 | r := gin.Default() 20 | 21 | // Set up CORS middleware 22 | r.Use(func(c *gin.Context) { 23 | 24 | c.Writer.Header().Set("Access-Control-Allow-Origin", "*") 25 | // Allow credentials to be sent with the request 26 | c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") 27 | c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 28 | c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") 29 | 30 | if c.Request.Method == http.MethodOptions { 31 | c.AbortWithStatus(204) 32 | return 33 | } 34 | 35 | c.Next() 36 | }) 37 | 38 | // Set up routes with the repositories and sync service 39 | routes.SetupRoutes(r, app.ConfigRepo, app.DockerRepo, app.ImageRepo, app.TagRepo, app.SyncSvc) 40 | 41 | app.Router = r 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /backend/internal/bootstrap/sync.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ofkm/svelocker-ui/backend/internal/services" 7 | ) 8 | 9 | func (app *Application) initSyncService(ctx context.Context) error { 10 | // Create sync service instance 11 | app.SyncSvc = services.NewSyncService( 12 | app.DockerRepo, 13 | app.ImageRepo, 14 | app.TagRepo, 15 | app.ConfigRepo, 16 | app.Config.Registry.URL, 17 | app.Config.Registry.Username, 18 | app.Config.Registry.Password, 19 | ) 20 | 21 | // Start the sync service 22 | if err := app.SyncSvc.Start(ctx); err != nil { 23 | return err 24 | } 25 | 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /backend/internal/config/appconfig.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strconv" 8 | ) 9 | 10 | // AppConfig holds all configuration for the application 11 | type AppConfig struct { 12 | Server ServerConfig 13 | Database DatabaseConfig 14 | Registry RegistryConfig 15 | Logging LoggingConfig 16 | Sync SyncConfig 17 | } 18 | 19 | type ServerConfig struct { 20 | Host string 21 | Port int 22 | BackendUrl string 23 | } 24 | 25 | type RegistryConfig struct { 26 | URL string 27 | Name string 28 | Username string 29 | Password string 30 | } 31 | 32 | type LoggingConfig struct { 33 | Level string 34 | } 35 | 36 | type SyncConfig struct { 37 | Interval int // Interval in minutes 38 | } 39 | 40 | // NewAppConfig creates a new application configuration 41 | func NewAppConfig() (*AppConfig, error) { 42 | return &AppConfig{ 43 | Server: ServerConfig{ 44 | Host: getEnv("SERVER_HOST", "0.0.0.0"), 45 | Port: getEnvAsInt("SERVER_PORT", 8080), 46 | BackendUrl: getEnv("PUBLIC_BACKEND_URL", "http://localhost:8080"), 47 | }, 48 | Database: DatabaseConfig{ 49 | Path: getEnv("DB_PATH", "data/svelockerui.db"), 50 | }, 51 | Registry: RegistryConfig{ 52 | URL: getEnv("PUBLIC_REGISTRY_URL", "http://localhost:5000"), 53 | Name: getEnv("PUBLIC_REGISTRY_NAME", "Local Registry"), 54 | Username: getEnv("REGISTRY_USERNAME", ""), 55 | Password: getEnv("REGISTRY_PASSWORD", ""), 56 | }, 57 | Logging: LoggingConfig{ 58 | Level: getEnv("PUBLIC_LOG_LEVEL", "INFO"), 59 | }, 60 | Sync: SyncConfig{ 61 | Interval: 5, // Default to 5 minutes, will be overridden by database value 62 | }, 63 | }, nil 64 | } 65 | 66 | // Helper functions for environment variables 67 | func getEnv(key, fallback string) string { 68 | if value, ok := os.LookupEnv(key); ok { 69 | return value 70 | } 71 | return fallback 72 | } 73 | 74 | func getEnvAsInt(key string, fallback int) int { 75 | if value, ok := os.LookupEnv(key); ok { 76 | if intVal, err := strconv.Atoi(value); err == nil { 77 | return intVal 78 | } 79 | } 80 | return fallback 81 | } 82 | 83 | // Validate checks if the configuration is valid 84 | func (c *AppConfig) Validate() error { 85 | if c.Registry.URL == "" { 86 | return fmt.Errorf("registry URL is required") 87 | } 88 | 89 | dbDir := filepath.Dir(c.Database.Path) 90 | if err := os.MkdirAll(dbDir, 0755); err != nil { 91 | return fmt.Errorf("failed to create database directory: %w", err) 92 | } 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /backend/internal/config/database.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "gorm.io/driver/sqlite" 9 | "gorm.io/gorm" 10 | "gorm.io/gorm/logger" 11 | ) 12 | 13 | // DatabaseConfig holds the configuration for the database 14 | type DatabaseConfig struct { 15 | Path string 16 | ENV string // Add environment field 17 | } 18 | 19 | // NewDatabaseConfig creates a new database configuration 20 | func NewDatabaseConfig() *DatabaseConfig { 21 | dbPath := os.Getenv("DB_PATH") 22 | if dbPath == "" { 23 | dbPath = "data/svelockerui.db" 24 | } 25 | 26 | // Get environment 27 | env := os.Getenv("APP_ENV") 28 | if env == "" { 29 | env = "production" // Default to production 30 | } 31 | 32 | // Ensure the directory exists 33 | dbDir := filepath.Dir(dbPath) 34 | if err := os.MkdirAll(dbDir, 0755); err != nil { 35 | fmt.Printf("Failed to create database directory: %v\n", err) 36 | } 37 | 38 | return &DatabaseConfig{ 39 | Path: dbPath, 40 | ENV: env, 41 | } 42 | } 43 | 44 | // Connect establishes a connection to the database 45 | func (c *DatabaseConfig) Connect(logLevel string) (*gorm.DB, error) { 46 | config := &gorm.Config{} 47 | 48 | // Only enable logging for development and testing environments 49 | if c.ENV == "development" || c.ENV == "testing" { 50 | config.Logger = logger.Default.LogMode(parseLogLevel(logLevel)) 51 | } else { 52 | config.Logger = logger.Default.LogMode(logger.Silent) // Disable logging in production 53 | } 54 | 55 | db, err := gorm.Open(sqlite.Open(c.Path), config) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to connect to database: %w", err) 58 | } 59 | 60 | // Set connection pool settings 61 | sqlDB, err := db.DB() 62 | if err != nil { 63 | return nil, fmt.Errorf("failed to get database instance: %w", err) 64 | } 65 | 66 | // Configure connection pool 67 | sqlDB.SetMaxIdleConns(10) 68 | sqlDB.SetMaxOpenConns(100) 69 | 70 | return db, nil 71 | } 72 | 73 | // parseLogLevel converts a string log level to gorm logger.LogLevel 74 | func parseLogLevel(level string) logger.LogLevel { 75 | switch level { 76 | case "DEBUG": 77 | return logger.Info 78 | case "INFO": 79 | return logger.Info 80 | case "WARN": 81 | return logger.Warn 82 | case "ERROR": 83 | return logger.Error 84 | default: 85 | return logger.Info 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /backend/internal/models/app_config.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "gorm.io/gorm" 4 | 5 | // AppConfig represents application configuration stored in the database 6 | type AppConfig struct { 7 | gorm.Model 8 | Key string `json:"key" gorm:"uniqueIndex"` 9 | Value string `json:"value"` 10 | } 11 | -------------------------------------------------------------------------------- /backend/internal/models/image.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "gorm.io/gorm" 4 | 5 | // Image represents a Docker image in a repository 6 | type Image struct { 7 | gorm.Model 8 | RepositoryID uint `json:"repositoryId"` 9 | Name string `json:"name"` 10 | FullName string `json:"fullName"` 11 | PullCount int `json:"pullCount"` 12 | Tags []Tag `json:"tags,omitempty" gorm:"foreignKey:ImageID"` 13 | } 14 | 15 | // ImageLayer represents a layer in a Docker image 16 | type ImageLayer struct { 17 | gorm.Model 18 | TagMetadataID uint `json:"tagMetadataId"` 19 | Size int64 `json:"size"` 20 | Digest string `json:"digest"` 21 | } 22 | -------------------------------------------------------------------------------- /backend/internal/models/models.go: -------------------------------------------------------------------------------- 1 | // Package models contains the data models for the application 2 | package models 3 | -------------------------------------------------------------------------------- /backend/internal/models/repository.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | // Repository represents a Docker repository 10 | type Repository struct { 11 | gorm.Model 12 | Name string `json:"name" gorm:"uniqueIndex"` 13 | LastSynced time.Time `json:"lastSynced"` 14 | Images []Image `json:"images,omitempty" gorm:"foreignKey:RepositoryID"` 15 | } 16 | -------------------------------------------------------------------------------- /backend/internal/models/tag.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | // Tag represents an image tag 10 | type Tag struct { 11 | gorm.Model 12 | ImageID uint `json:"imageId"` 13 | Name string `json:"name"` 14 | Digest string `json:"digest"` 15 | CreatedAt time.Time `json:"createdAt"` 16 | Metadata TagMetadata `json:"metadata,omitempty" gorm:"foreignKey:TagID"` 17 | } 18 | 19 | // TagMetadata represents metadata for a tag 20 | type TagMetadata struct { 21 | gorm.Model 22 | TagID uint `json:"tagId"` 23 | Created string `json:"created"` 24 | OS string `json:"os"` 25 | Architecture string `json:"architecture"` 26 | Author string `json:"author"` 27 | DockerFile string `json:"dockerFile" gorm:"type:text"` 28 | ConfigDigest string `json:"configDigest"` 29 | ExposedPorts string `json:"exposedPorts" gorm:"type:text"` // JSON string array 30 | TotalSize int64 `json:"totalSize"` 31 | WorkDir string `json:"workDir"` 32 | Command string `json:"command"` 33 | Description string `json:"description"` 34 | ContentDigest string `json:"contentDigest"` 35 | Entrypoint string `json:"entrypoint"` 36 | IndexDigest string `json:"indexDigest"` 37 | IsOCI bool `json:"isOCI"` 38 | Layers []ImageLayer `json:"layers,omitempty" gorm:"foreignKey:TagMetadataID"` 39 | } 40 | -------------------------------------------------------------------------------- /backend/internal/repository/config_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ofkm/svelocker-ui/backend/internal/models" 7 | ) 8 | 9 | // ConfigRepository handles database operations for application configuration 10 | type ConfigRepository interface { 11 | Get(ctx context.Context, key string) (*models.AppConfig, error) 12 | Update(ctx context.Context, key, value string) error 13 | List(ctx context.Context) ([]models.AppConfig, error) 14 | } 15 | -------------------------------------------------------------------------------- /backend/internal/repository/docker_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ofkm/svelocker-ui/backend/internal/models" 7 | ) 8 | 9 | // DockerRepository handles database operations for Docker repositories 10 | type DockerRepository interface { 11 | // Repository operations 12 | ListRepositories(ctx context.Context, page, limit int, search string) ([]models.Repository, int64, error) 13 | GetRepository(ctx context.Context, name string) (*models.Repository, error) 14 | CreateRepository(ctx context.Context, repo *models.Repository) error 15 | UpdateRepository(ctx context.Context, repo *models.Repository) error 16 | DeleteRepository(ctx context.Context, name string) error 17 | } 18 | -------------------------------------------------------------------------------- /backend/internal/repository/gorm/config_repository.go: -------------------------------------------------------------------------------- 1 | package gorm 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/ofkm/svelocker-ui/backend/internal/models" 8 | "github.com/ofkm/svelocker-ui/backend/internal/repository" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | type configRepository struct { 13 | db *gorm.DB 14 | } 15 | 16 | func NewConfigRepository(db *gorm.DB) repository.ConfigRepository { 17 | return &configRepository{db: db} 18 | } 19 | 20 | func (r *configRepository) Get(ctx context.Context, key string) (*models.AppConfig, error) { 21 | var config models.AppConfig 22 | err := r.db.Where("key = ?", key).First(&config).Error 23 | if err != nil { 24 | if errors.Is(err, gorm.ErrRecordNotFound) { 25 | return nil, nil 26 | } 27 | return nil, err 28 | } 29 | return &config, nil 30 | } 31 | 32 | func (r *configRepository) Update(ctx context.Context, key, value string) error { 33 | result := r.db.Where("key = ?", key). 34 | Assign(models.AppConfig{Value: value}). 35 | FirstOrCreate(&models.AppConfig{Key: key, Value: value}) 36 | return result.Error 37 | } 38 | 39 | func (r *configRepository) List(ctx context.Context) ([]models.AppConfig, error) { 40 | var configs []models.AppConfig 41 | err := r.db.Find(&configs).Error 42 | return configs, err 43 | } 44 | -------------------------------------------------------------------------------- /backend/internal/repository/gorm/docker_repository.go: -------------------------------------------------------------------------------- 1 | package gorm 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/ofkm/svelocker-ui/backend/internal/models" 8 | "github.com/ofkm/svelocker-ui/backend/internal/repository" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | type dockerRepository struct { 13 | db *gorm.DB 14 | } 15 | 16 | func NewDockerRepository(db *gorm.DB) repository.DockerRepository { 17 | return &dockerRepository{db: db} 18 | } 19 | 20 | func (r *dockerRepository) ListRepositories(ctx context.Context, page, limit int, search string) ([]models.Repository, int64, error) { 21 | var repositories []models.Repository 22 | var total int64 23 | 24 | query := r.db.Model(&models.Repository{}) 25 | if search != "" { 26 | query = query.Where("name LIKE ?", "%"+search+"%") 27 | } 28 | 29 | if err := query.Count(&total).Error; err != nil { 30 | return nil, 0, err 31 | } 32 | 33 | offset := (page - 1) * limit 34 | err := query.Offset(offset).Limit(limit). 35 | Preload("Images", func(db *gorm.DB) *gorm.DB { 36 | return db.Order("name ASC") 37 | }). 38 | Preload("Images.Tags", func(db *gorm.DB) *gorm.DB { 39 | return db.Order("name ASC") 40 | }). 41 | Find(&repositories).Error 42 | 43 | return repositories, total, err 44 | } 45 | 46 | func (r *dockerRepository) GetRepository(ctx context.Context, name string) (*models.Repository, error) { 47 | var repository models.Repository 48 | err := r.db.Where("name = ?", name). 49 | Preload("Images.Tags.Metadata.Layers"). 50 | First(&repository).Error 51 | if err != nil { 52 | if errors.Is(err, gorm.ErrRecordNotFound) { 53 | return nil, nil 54 | } 55 | return nil, err 56 | } 57 | return &repository, nil 58 | } 59 | 60 | func (r *dockerRepository) CreateRepository(ctx context.Context, repo *models.Repository) error { 61 | return r.db.Create(repo).Error 62 | } 63 | 64 | func (r *dockerRepository) UpdateRepository(ctx context.Context, repo *models.Repository) error { 65 | return r.db.Save(repo).Error 66 | } 67 | 68 | func (r *dockerRepository) DeleteRepository(ctx context.Context, name string) error { 69 | return r.db.Where("name = ?", name).Delete(&models.Repository{}).Error 70 | } 71 | -------------------------------------------------------------------------------- /backend/internal/repository/gorm/image_repository.go: -------------------------------------------------------------------------------- 1 | package gorm 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/ofkm/svelocker-ui/backend/internal/models" 8 | "github.com/ofkm/svelocker-ui/backend/internal/repository" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | type imageRepository struct { 13 | db *gorm.DB 14 | } 15 | 16 | func NewImageRepository(db *gorm.DB) repository.ImageRepository { 17 | return &imageRepository{db: db} 18 | } 19 | 20 | func (r *imageRepository) ListImages(ctx context.Context, repoName string) ([]models.Image, error) { 21 | var images []models.Image 22 | err := r.db.Joins("JOIN repositories ON repositories.id = images.repository_id"). 23 | Where("repositories.name = ?", repoName). 24 | Preload("Tags.Metadata.Layers"). 25 | Find(&images).Error 26 | return images, err 27 | } 28 | 29 | func (r *imageRepository) GetImage(ctx context.Context, repoName, imageName string) (*models.Image, error) { 30 | var image models.Image 31 | err := r.db.Joins("JOIN repositories ON repositories.id = images.repository_id"). 32 | Where("repositories.name = ? AND images.name = ?", repoName, imageName). 33 | Preload("Tags.Metadata.Layers"). 34 | First(&image).Error 35 | if err != nil { 36 | if errors.Is(err, gorm.ErrRecordNotFound) { 37 | return nil, nil 38 | } 39 | return nil, err 40 | } 41 | return &image, nil 42 | } 43 | 44 | func (r *imageRepository) CreateImage(ctx context.Context, image *models.Image) error { 45 | return r.db.Create(image).Error 46 | } 47 | 48 | func (r *imageRepository) UpdateImage(ctx context.Context, image *models.Image) error { 49 | return r.db.Save(image).Error 50 | } 51 | 52 | func (r *imageRepository) DeleteImage(ctx context.Context, repoName, imageName string) error { 53 | return r.db.Joins("JOIN repositories ON repositories.id = images.repository_id"). 54 | Where("repositories.name = ? AND images.name = ?", repoName, imageName). 55 | Delete(&models.Image{}).Error 56 | } 57 | -------------------------------------------------------------------------------- /backend/internal/repository/image_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ofkm/svelocker-ui/backend/internal/models" 7 | ) 8 | 9 | // ImageRepository handles database operations for Docker images 10 | type ImageRepository interface { 11 | ListImages(ctx context.Context, repoName string) ([]models.Image, error) 12 | GetImage(ctx context.Context, repoName, imageName string) (*models.Image, error) 13 | CreateImage(ctx context.Context, image *models.Image) error 14 | UpdateImage(ctx context.Context, image *models.Image) error 15 | DeleteImage(ctx context.Context, repoName, imageName string) error 16 | } 17 | -------------------------------------------------------------------------------- /backend/internal/repository/repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ofkm/svelocker-ui/backend/internal/models" 7 | ) 8 | 9 | // RepositoryStore handles database operations for repositories 10 | type RepositoryStore interface { 11 | // AppConfig operations 12 | GetAppConfig(ctx context.Context, key string) (*models.AppConfig, error) 13 | UpdateAppConfig(ctx context.Context, key, value string) error 14 | ListAppConfigs(ctx context.Context) ([]models.AppConfig, error) 15 | 16 | // Repository operations 17 | ListRepositories(ctx context.Context, page, limit int, search string) ([]models.Repository, int64, error) 18 | GetRepository(ctx context.Context, name string) (*models.Repository, error) 19 | CreateRepository(ctx context.Context, repo *models.Repository) error 20 | UpdateRepository(ctx context.Context, repo *models.Repository) error 21 | DeleteRepository(ctx context.Context, name string) error 22 | 23 | // Image operations 24 | ListImages(ctx context.Context, repoName string) ([]models.Image, error) 25 | GetImage(ctx context.Context, repoName, imageName string) (*models.Image, error) 26 | CreateImage(ctx context.Context, image *models.Image) error 27 | UpdateImage(ctx context.Context, image *models.Image) error 28 | DeleteImage(ctx context.Context, repoName, imageName string) error 29 | 30 | // Tag operations 31 | ListTags(ctx context.Context, repoName, imageName string) ([]models.Tag, error) 32 | GetTag(ctx context.Context, repoName, imageName, tagName string) (*models.Tag, error) 33 | CreateTag(ctx context.Context, tag *models.Tag) error 34 | UpdateTag(ctx context.Context, tag *models.Tag) error 35 | DeleteTag(ctx context.Context, repoName, imageName, tagName string) error 36 | } 37 | -------------------------------------------------------------------------------- /backend/internal/repository/tag_repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/ofkm/svelocker-ui/backend/internal/models" 7 | ) 8 | 9 | // TagRepository handles database operations for Docker image tags 10 | type TagRepository interface { 11 | ListTags(ctx context.Context, repoName, imageName string) ([]models.Tag, error) 12 | GetTag(ctx context.Context, repoName, imageName, tagName string) (*models.Tag, error) 13 | CreateTag(ctx context.Context, tag *models.Tag) error 14 | UpdateTag(ctx context.Context, tag *models.Tag) error 15 | DeleteTag(ctx context.Context, repoName, imageName, tagName string) error 16 | } 17 | -------------------------------------------------------------------------------- /backend/internal/utils/registry_utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // ExtractDockerfileFromHistory generates a Dockerfile-like content from image history 8 | // Similar to the TypeScript version: config.history?.map((entry: any) => entry.created_by).join('\n') 9 | func ExtractDockerfileFromHistory(history []struct { 10 | Created string `json:"created"` 11 | CreatedBy string `json:"created_by"` 12 | Author string `json:"author"` 13 | Comment string `json:"comment"` 14 | EmptyLayer bool `json:"empty_layer"` 15 | }) string { 16 | var dockerCommands []string 17 | 18 | if len(history) == 0 { 19 | return "No Dockerfile found" 20 | } 21 | 22 | for _, historyEntry := range history { 23 | if historyEntry.CreatedBy != "" { 24 | // Clean up the command 25 | cmd := strings.TrimPrefix(historyEntry.CreatedBy, "/bin/sh -c ") 26 | cmd = strings.TrimPrefix(cmd, "#(nop) ") 27 | cmd = strings.TrimSpace(cmd) 28 | 29 | if cmd != "" { 30 | dockerCommands = append(dockerCommands, cmd) 31 | } 32 | } 33 | } 34 | 35 | if len(dockerCommands) == 0 { 36 | return "No Dockerfile found" 37 | } 38 | 39 | return strings.Join(dockerCommands, "\n") 40 | } 41 | 42 | // ExtractAuthorFromLabels extracts the author from image labels, similar to TypeScript implementation 43 | func ExtractAuthorFromLabels(labels map[string]string, defaultAuthor string) string { 44 | 45 | if labels == nil { 46 | return defaultAuthor 47 | } 48 | 49 | // Check all possible label variations 50 | labelKeys := []string{ 51 | "org.opencontainers.image.authors", 52 | "org.opencontainers.image.vendor", 53 | "maintainer", 54 | "MAINTAINER", 55 | "Author", 56 | "author", 57 | } 58 | 59 | for _, key := range labelKeys { 60 | if value, exists := labels[key]; exists && value != "" { 61 | return value 62 | } 63 | } 64 | 65 | // If no labels match and we have a default author, use it 66 | if defaultAuthor != "" { 67 | return defaultAuthor 68 | } 69 | 70 | return "Unknown" 71 | } 72 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | svelocker-ui: 3 | image: ghcr.io/ofkm/svelocker-ui:latest 4 | container_name: svelocker-ui 5 | ports: 6 | - "3000:3000" # Frontend 7 | - "8080:8080" # Backend 8 | environment: 9 | APP_ENV: production 10 | PUBLIC_REGISTRY_URL: http://localhost:5000 11 | PUBLIC_REGISTRY_NAME: Local Registry 12 | REGISTRY_USERNAME: your-username 13 | REGISTRY_PASSWORD: your-password 14 | PUID: 1000 15 | PGID: 1000 16 | volumes: 17 | - ./data:/app/data 18 | restart: unless-stopped -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 600, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "overrides": [ 8 | { 9 | "files": "*.svelte", 10 | "options": { 11 | "parser": "svelte" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://next.shadcn-svelte.com/schema.json", 3 | "style": "default", 4 | "tailwind": { 5 | "config": "tailwind.config.ts", 6 | "css": "src\\app.css", 7 | "baseColor": "slate" 8 | }, 9 | "aliases": { 10 | "components": "$lib/components", 11 | "utils": "$lib/utils", 12 | "ui": "$lib/components/ui", 13 | "hooks": "$lib/hooks" 14 | }, 15 | "typescript": true, 16 | "registry": "https://next.shadcn-svelte.com/registry" 17 | } 18 | -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import prettier from 'eslint-config-prettier'; 2 | import js from '@eslint/js'; 3 | import { includeIgnoreFile } from '@eslint/compat'; 4 | import svelte from 'eslint-plugin-svelte'; 5 | import globals from 'globals'; 6 | import { fileURLToPath } from 'node:url'; 7 | import ts from 'typescript-eslint'; 8 | const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); 9 | 10 | export default ts.config( 11 | includeIgnoreFile(gitignorePath), 12 | js.configs.recommended, 13 | ...ts.configs.recommended, 14 | ...svelte.configs['flat/recommended'], 15 | prettier, 16 | ...svelte.configs['flat/prettier'], 17 | { 18 | languageOptions: { 19 | globals: { 20 | ...globals.browser, 21 | ...globals.node 22 | } 23 | } 24 | }, 25 | { 26 | files: ['**/*.svelte'], 27 | 28 | languageOptions: { 29 | parserOptions: { 30 | parser: ts.parser 31 | } 32 | } 33 | } 34 | ); 35 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelocker-ui", 3 | "private": true, 4 | "version": "0.27.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite dev --host", 8 | "build": "vite build", 9 | "preview": "vite preview --port 3000", 10 | "prepare": "svelte-kit sync || echo ''", 11 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 | "lint": "eslint . && prettier --check .", 14 | "format": "prettier --write .", 15 | "test:e2e": "PLAYWRIGHT=true DB_PATH=test.db PUBLIC_REGISTRY_URL=http://localhost:5000 playwright test", 16 | "test:e2e:ui": "PLAYWRIGHT=true DB_PATH=test.db playwright test --ui" 17 | }, 18 | "devDependencies": { 19 | "@eslint/compat": "^1.2.9", 20 | "@eslint/js": "^9.28.0", 21 | "@lucide/svelte": "^0.511.0", 22 | "@playwright/test": "^1.53.1", 23 | "@sveltejs/adapter-auto": "^6.0.1", 24 | "@sveltejs/kit": "^2.21.1", 25 | "@sveltejs/vite-plugin-svelte": "^5.0.0", 26 | "@types/node": "^22.15.17", 27 | "bits-ui": "^1.4.6", 28 | "clsx": "^2.1.1", 29 | "eslint": "^9.30.0", 30 | "eslint-config-prettier": "^10.1.5", 31 | "formsnap": "^2.0.1", 32 | "eslint-plugin-svelte": "^3.10.1", 33 | "globals": "^16.2.0", 34 | "lucide-svelte": "^0.511.0", 35 | "mode-watcher": "^0.5.1", 36 | "prettier": "^3.6.2", 37 | "prettier-plugin-svelte": "^3.4.0", 38 | "svelte": "^5.33.13", 39 | "svelte-check": "^4.2.1", 40 | "svelte-sonner": "^0.3.28", 41 | "tailwind-merge": "^3.3.0", 42 | "tailwind-variants": "^1.0.0", 43 | "tailwindcss-animate": "^1.0.7", 44 | "typescript": "^5.8.3", 45 | "typescript-eslint": "^8.33.0", 46 | "vaul-svelte": "^1.0.0-next.7", 47 | "vite": "^6.3.5" 48 | }, 49 | "dependencies": { 50 | "@sveltejs/adapter-node": "^5.2.12", 51 | "@tailwindcss/vite": "^4.1.8", 52 | "@tanstack/table-core": "^8.21.3", 53 | "@types/axios": "^0.14.4", 54 | "@types/better-sqlite3": "^7.6.13", 55 | "@types/node-cron": "^3.0.11", 56 | "axios": "^1.9.0", 57 | "better-sqlite3": "^11.10.0", 58 | "buffer": "^6.0.3", 59 | "date-fns": "^4.1.0", 60 | "dotenv": "^16.5.0", 61 | "json": "^11.0.0", 62 | "node-cron": "^3.0.3", 63 | "stream-browserify": "^3.0.0", 64 | "tailwindcss": "^4.0.12" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /frontend/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | export default defineConfig({ 4 | outputDir: './tests/.output', 5 | testDir: './tests/e2e', 6 | timeout: 30000, 7 | expect: { 8 | timeout: 5000 9 | }, 10 | fullyParallel: false, 11 | globalSetup: './tests/e2e/global-setup', 12 | globalTeardown: './tests/e2e/global-teardown', 13 | reporter: process.env.CI ? [['html', { outputFolder: 'tests-results/.report' }], ['github']] : [['line'], ['html', { open: 'never', outputFolder: 'tests-results/.report' }]], 14 | use: { 15 | baseURL: 'http://localhost:3000', 16 | trace: 'on-first-retry' 17 | }, 18 | projects: [ 19 | { 20 | name: 'chromium', 21 | use: { browserName: 'chromium' } 22 | } 23 | ] 24 | }); 25 | -------------------------------------------------------------------------------- /frontend/src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://svelte.dev/docs/kit/types#app.d.ts 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /frontend/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | %sveltekit.head% 11 | 12 | 13 |
%sveltekit.body%
14 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '$lib/services/logger'; 2 | import type { Handle } from '@sveltejs/kit'; 3 | 4 | const logger = Logger.getInstance('ServerHooks'); 5 | 6 | // Initialize database and sync service when server starts 7 | async function initialize() {} 8 | 9 | // Run initialization 10 | initialize(); 11 | 12 | export const handle: Handle = async ({ event, resolve }) => { 13 | return resolve(event); 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/src/lib/components/badges/count-badge.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 |
20 | {count} 21 |  {label} 22 |
23 | -------------------------------------------------------------------------------- /frontend/src/lib/components/badges/text-badge.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 |
21 | {#if icon} 22 | 23 | 24 | 25 | {/if} 26 | {text} 27 |
28 | 29 | 35 | -------------------------------------------------------------------------------- /frontend/src/lib/components/buttons/SupportLinkButton.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | {#if children}{@render children()}{:else} 13 | {label} 14 | {/if} 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/lib/components/buttons/SyncButton.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 | 39 | -------------------------------------------------------------------------------- /frontend/src/lib/components/cards/dropdown-card.svelte: -------------------------------------------------------------------------------- 1 | 51 | 52 | 53 | 54 |
55 |
56 |
57 | 58 | {title} 59 | 60 | {#if title === 'library'} 61 | Default Namespace 62 | {/if} 63 |
64 | {#if description || lastSynced} 65 | 66 | {description || ''} 67 | {#if lastSynced} 68 |
69 | 70 | {lastSynced} 71 |
72 | {/if} 73 |
74 | {/if} 75 |
76 | 79 |
80 |
81 | {#if expanded} 82 |
1 - Math.pow(1 - t, 3) }}> 83 | 84 | {@render children()} 85 | 86 |
87 | {/if} 88 |
89 | -------------------------------------------------------------------------------- /frontend/src/lib/components/docker-metadata/LayerVisualization.svelte: -------------------------------------------------------------------------------- 1 | 2 | 27 | 28 |
29 |

Layer Composition

30 | 31 | {#if Array.isArray(layers) && layers.length > 0 && totalSize > 0} 32 | 33 |
34 | {#each layersWithPercentage as layer, i} 35 | 36 |
37 | {/each} 38 |
39 | 40 |
41 | Total size: {formatSize(totalSize)} 42 |
43 | 44 | 45 |
46 | {#each layersWithPercentage as layer, i} 47 |
48 |
49 |
50 | {layer.digest?.substring(7, 19) || 'unknown'} 51 |
52 |
53 | {formatSize(layer.size)} ({layer.percentage.toFixed(1)}%) 54 |
55 |
56 |
57 |
58 |
59 | {/each} 60 |
61 | {:else} 62 |
No layer information available
63 | {/if} 64 |
65 | -------------------------------------------------------------------------------- /frontend/src/lib/components/docker-metadata/MetadataItem.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 |
23 | 28 |

29 | {displayValue} 30 |

31 |
32 | -------------------------------------------------------------------------------- /frontend/src/lib/components/header/header.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
15 | 53 |
54 | 55 | 56 |
57 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 18 | 26 | 27 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
19 | {@render children?.()} 20 |
21 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
19 | {@render children?.()} 20 |
21 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/alert-dialog/index.ts: -------------------------------------------------------------------------------- 1 | import { AlertDialog as AlertDialogPrimitive } from "bits-ui"; 2 | import Title from "./alert-dialog-title.svelte"; 3 | import Action from "./alert-dialog-action.svelte"; 4 | import Cancel from "./alert-dialog-cancel.svelte"; 5 | import Footer from "./alert-dialog-footer.svelte"; 6 | import Header from "./alert-dialog-header.svelte"; 7 | import Overlay from "./alert-dialog-overlay.svelte"; 8 | import Content from "./alert-dialog-content.svelte"; 9 | import Description from "./alert-dialog-description.svelte"; 10 | 11 | const Root = AlertDialogPrimitive.Root; 12 | const Trigger = AlertDialogPrimitive.Trigger; 13 | const Portal = AlertDialogPrimitive.Portal; 14 | 15 | export { 16 | Root, 17 | Title, 18 | Action, 19 | Cancel, 20 | Portal, 21 | Footer, 22 | Header, 23 | Trigger, 24 | Overlay, 25 | Content, 26 | Description, 27 | // 28 | Root as AlertDialog, 29 | Title as AlertDialogTitle, 30 | Action as AlertDialogAction, 31 | Cancel as AlertDialogCancel, 32 | Portal as AlertDialogPortal, 33 | Footer as AlertDialogFooter, 34 | Header as AlertDialogHeader, 35 | Trigger as AlertDialogTrigger, 36 | Overlay as AlertDialogOverlay, 37 | Content as AlertDialogContent, 38 | Description as AlertDialogDescription, 39 | }; 40 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/badge/badge.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/badge/index.ts: -------------------------------------------------------------------------------- 1 | import { type VariantProps, tv } from 'tailwind-variants'; 2 | export { default as Badge } from './badge.svelte'; 3 | 4 | export const badgeVariants = tv({ 5 | base: 'inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 break-keep whitespace-nowrap', 6 | variants: { 7 | variant: { 8 | default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', 9 | secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 10 | destructive: 11 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', 12 | outline: 'text-foreground' 13 | } 14 | }, 15 | defaultVariants: { 16 | variant: 'default' 17 | } 18 | }); 19 | 20 | export type Variant = VariantProps['variant']; 21 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 24 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
  • 15 | {@render children?.()} 16 |
  • 17 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | {#if child} 26 | {@render child({ props: attrs })} 27 | {:else} 28 | 29 | {@render children?.()} 30 | 31 | {/if} 32 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
      22 | {@render children?.()} 23 |
    24 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | {@render children?.()} 23 | 24 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 28 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/breadcrumb/breadcrumb.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 16 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/breadcrumb/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./breadcrumb.svelte"; 2 | import Ellipsis from "./breadcrumb-ellipsis.svelte"; 3 | import Item from "./breadcrumb-item.svelte"; 4 | import Separator from "./breadcrumb-separator.svelte"; 5 | import Link from "./breadcrumb-link.svelte"; 6 | import List from "./breadcrumb-list.svelte"; 7 | import Page from "./breadcrumb-page.svelte"; 8 | 9 | export { 10 | Root, 11 | Ellipsis, 12 | Item, 13 | Separator, 14 | Link, 15 | List, 16 | Page, 17 | // 18 | Root as Breadcrumb, 19 | Ellipsis as BreadcrumbEllipsis, 20 | Item as BreadcrumbItem, 21 | Separator as BreadcrumbSeparator, 22 | Link as BreadcrumbLink, 23 | List as BreadcrumbList, 24 | Page as BreadcrumbPage, 25 | }; 26 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/button/button.svelte: -------------------------------------------------------------------------------- 1 | 40 | 41 | 55 | 56 | {#if href} 57 | 63 | {@render children?.()} 64 | 65 | {:else} 66 | 74 | {/if} 75 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/button/index.ts: -------------------------------------------------------------------------------- 1 | import Root, { 2 | type ButtonProps, 3 | type ButtonSize, 4 | type ButtonVariant, 5 | buttonVariants, 6 | } from "./button.svelte"; 7 | 8 | export { 9 | Root, 10 | type ButtonProps as Props, 11 | // 12 | Root as Button, 13 | buttonVariants, 14 | type ButtonProps, 15 | type ButtonSize, 16 | type ButtonVariant, 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/card/card-content.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    12 | 13 |
    14 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/card/card-description.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |

    12 | 13 |

    14 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/card/card-footer.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    12 | 13 |
    14 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/card/card-header.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    12 | 13 |
    14 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/card/card-title.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/card/card.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
    15 | 16 |
    17 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/card/index.ts: -------------------------------------------------------------------------------- 1 | import Root from './card.svelte'; 2 | import Content from './card-content.svelte'; 3 | import Description from './card-description.svelte'; 4 | import Footer from './card-footer.svelte'; 5 | import Header from './card-header.svelte'; 6 | import Title from './card-title.svelte'; 7 | 8 | export { 9 | Root, 10 | Content, 11 | Description, 12 | Footer, 13 | Header, 14 | Title, 15 | // 16 | Root as Card, 17 | Content as CardContent, 18 | Description as CardDescription, 19 | Footer as CardFooter, 20 | Header as CardHeader, 21 | Title as CardTitle 22 | }; 23 | 24 | export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; 25 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/data-table/flex-render.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 27 | 28 | {#if typeof content === "string"} 29 | {content} 30 | {:else if content instanceof Function} 31 | 32 | 33 | {@const result = content(context as any)} 34 | {#if result instanceof RenderComponentConfig} 35 | {@const { component: Component, props } = result} 36 | 37 | {:else if result instanceof RenderSnippetConfig} 38 | {@const { snippet, params } = result} 39 | {@render snippet(params)} 40 | {:else} 41 | {result} 42 | {/if} 43 | {/if} 44 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/data-table/index.ts: -------------------------------------------------------------------------------- 1 | export { default as FlexRender } from "./flex-render.svelte"; 2 | export { renderComponent, renderSnippet } from "./render-helpers.js"; 3 | export { createSvelteTable } from "./data-table.svelte.js"; 4 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dialog/dialog-content.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | 22 | 30 | {@render children?.()} 31 | 34 | 35 | Close 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dialog/dialog-description.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dialog/dialog-footer.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
    19 | {@render children?.()} 20 |
    21 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dialog/dialog-header.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
    19 | {@render children?.()} 20 |
    21 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dialog/dialog-overlay.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dialog/dialog-title.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dialog/index.ts: -------------------------------------------------------------------------------- 1 | import { Dialog as DialogPrimitive } from "bits-ui"; 2 | 3 | import Title from "./dialog-title.svelte"; 4 | import Footer from "./dialog-footer.svelte"; 5 | import Header from "./dialog-header.svelte"; 6 | import Overlay from "./dialog-overlay.svelte"; 7 | import Content from "./dialog-content.svelte"; 8 | import Description from "./dialog-description.svelte"; 9 | 10 | const Root = DialogPrimitive.Root; 11 | const Trigger = DialogPrimitive.Trigger; 12 | const Close = DialogPrimitive.Close; 13 | const Portal = DialogPrimitive.Portal; 14 | 15 | export { 16 | Root, 17 | Title, 18 | Portal, 19 | Footer, 20 | Header, 21 | Trigger, 22 | Overlay, 23 | Content, 24 | Description, 25 | Close, 26 | // 27 | Root as Dialog, 28 | Title as DialogTitle, 29 | Portal as DialogPortal, 30 | Footer as DialogFooter, 31 | Header as DialogHeader, 32 | Trigger as DialogTrigger, 33 | Overlay as DialogOverlay, 34 | Content as DialogContent, 35 | Description as DialogDescription, 36 | Close as DialogClose, 37 | }; 38 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 30 | {#snippet children({ checked, indeterminate })} 31 | 32 | {#if indeterminate} 33 | 34 | {:else} 35 | 36 | {/if} 37 | 38 | {@render childrenProp?.()} 39 | {/snippet} 40 | 41 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 26 | 27 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 20 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 24 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 |
    22 | {@render children?.()} 23 |
    24 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | {#snippet children({ checked })} 23 | 24 | {#if checked} 25 | 26 | {/if} 27 | 28 | {@render childrenProp?.({ checked })} 29 | {/snippet} 30 | 31 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | {@render children?.()} 20 | 21 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 26 | {@render children?.()} 27 | 28 | 29 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/dropdown-menu/index.ts: -------------------------------------------------------------------------------- 1 | import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; 2 | import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; 3 | import Content from "./dropdown-menu-content.svelte"; 4 | import GroupHeading from "./dropdown-menu-group-heading.svelte"; 5 | import Item from "./dropdown-menu-item.svelte"; 6 | import Label from "./dropdown-menu-label.svelte"; 7 | import RadioItem from "./dropdown-menu-radio-item.svelte"; 8 | import Separator from "./dropdown-menu-separator.svelte"; 9 | import Shortcut from "./dropdown-menu-shortcut.svelte"; 10 | import SubContent from "./dropdown-menu-sub-content.svelte"; 11 | import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; 12 | 13 | const Sub = DropdownMenuPrimitive.Sub; 14 | const Root = DropdownMenuPrimitive.Root; 15 | const Trigger = DropdownMenuPrimitive.Trigger; 16 | const Group = DropdownMenuPrimitive.Group; 17 | const RadioGroup = DropdownMenuPrimitive.RadioGroup; 18 | 19 | export { 20 | CheckboxItem, 21 | Content, 22 | Root as DropdownMenu, 23 | CheckboxItem as DropdownMenuCheckboxItem, 24 | Content as DropdownMenuContent, 25 | Group as DropdownMenuGroup, 26 | GroupHeading as DropdownMenuGroupHeading, 27 | Item as DropdownMenuItem, 28 | Label as DropdownMenuLabel, 29 | RadioGroup as DropdownMenuRadioGroup, 30 | RadioItem as DropdownMenuRadioItem, 31 | Separator as DropdownMenuSeparator, 32 | Shortcut as DropdownMenuShortcut, 33 | Sub as DropdownMenuSub, 34 | SubContent as DropdownMenuSubContent, 35 | SubTrigger as DropdownMenuSubTrigger, 36 | Trigger as DropdownMenuTrigger, 37 | Group, 38 | GroupHeading, 39 | Item, 40 | Label, 41 | RadioGroup, 42 | RadioItem, 43 | Root, 44 | Separator, 45 | Shortcut, 46 | Sub, 47 | SubContent, 48 | SubTrigger, 49 | Trigger, 50 | }; 51 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/form/form-button.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/form/form-description.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/form/form-element-field.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 23 | 24 | 25 | {#snippet children({ constraints, errors, tainted, value })} 26 |
    27 | {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })} 28 |
    29 | {/snippet} 30 |
    31 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/form/form-field-errors.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | {#snippet children({ errors, errorProps })} 23 | {#if childrenProp} 24 | {@render childrenProp({ errors, errorProps })} 25 | {:else} 26 | {#each errors as error (error)} 27 |
    {error}
    28 | {/each} 29 | {/if} 30 | {/snippet} 31 |
    32 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/form/form-field.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 23 | 24 | 25 | {#snippet children({ constraints, errors, tainted, value })} 26 |
    27 | {@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })} 28 |
    29 | {/snippet} 30 |
    31 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/form/form-fieldset.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/form/form-label.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | {#snippet child({ props })} 17 | 20 | {/snippet} 21 | 22 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/form/form-legend.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/form/index.ts: -------------------------------------------------------------------------------- 1 | import * as FormPrimitive from "formsnap"; 2 | import Description from "./form-description.svelte"; 3 | import Label from "./form-label.svelte"; 4 | import FieldErrors from "./form-field-errors.svelte"; 5 | import Field from "./form-field.svelte"; 6 | import Fieldset from "./form-fieldset.svelte"; 7 | import Legend from "./form-legend.svelte"; 8 | import ElementField from "./form-element-field.svelte"; 9 | import Button from "./form-button.svelte"; 10 | 11 | const Control = FormPrimitive.Control; 12 | 13 | export { 14 | Field, 15 | Control, 16 | Label, 17 | Button, 18 | FieldErrors, 19 | Description, 20 | Fieldset, 21 | Legend, 22 | ElementField, 23 | // 24 | Field as FormField, 25 | Control as FormControl, 26 | Description as FormDescription, 27 | Label as FormLabel, 28 | FieldErrors as FormFieldErrors, 29 | Fieldset as FormFieldset, 30 | Legend as FormLegend, 31 | ElementField as FormElementField, 32 | Button as FormButton, 33 | }; 34 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/input/index.ts: -------------------------------------------------------------------------------- 1 | import Root from './input.svelte'; 2 | 3 | export { 4 | Root, 5 | // 6 | Root as Input 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/input/input.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 23 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/label/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./label.svelte"; 2 | 3 | export { 4 | Root, 5 | // 6 | Root as Label, 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/label/label.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 20 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/pagination/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./pagination.svelte"; 2 | import Content from "./pagination-content.svelte"; 3 | import Item from "./pagination-item.svelte"; 4 | import Link from "./pagination-link.svelte"; 5 | import PrevButton from "./pagination-prev-button.svelte"; 6 | import NextButton from "./pagination-next-button.svelte"; 7 | import Ellipsis from "./pagination-ellipsis.svelte"; 8 | 9 | export { 10 | Root, 11 | Content, 12 | Item, 13 | Link, 14 | PrevButton, 15 | NextButton, 16 | Ellipsis, 17 | // 18 | Root as Pagination, 19 | Content as PaginationContent, 20 | Item as PaginationItem, 21 | Link as PaginationLink, 22 | PrevButton as PaginationPrevButton, 23 | NextButton as PaginationNextButton, 24 | Ellipsis as PaginationEllipsis, 25 | }; 26 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/pagination/pagination-content.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
      15 | {@render children?.()} 16 |
    17 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/pagination/pagination-ellipsis.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 23 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/pagination/pagination-item.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
  • 13 | {@render children?.()} 14 |
  • 15 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/pagination/pagination-link.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | {#snippet Fallback()} 21 | {page.value} 22 | {/snippet} 23 | 24 | 37 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/pagination/pagination-next-button.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | {#snippet Fallback()} 16 | Next 17 | 18 | {/snippet} 19 | 20 | 32 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/pagination/pagination-prev-button.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | {#snippet Fallback()} 16 | 17 | Previous 18 | {/snippet} 19 | 20 | 32 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/pagination/pagination.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 26 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/progress/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./progress.svelte"; 2 | 3 | export { 4 | Root, 5 | // 6 | Root as Progress, 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/progress/progress.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 21 |
    25 |
    26 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/scroll-area/index.ts: -------------------------------------------------------------------------------- 1 | import Scrollbar from "./scroll-area-scrollbar.svelte"; 2 | import Root from "./scroll-area.svelte"; 3 | 4 | export { 5 | Root, 6 | Scrollbar, 7 | //, 8 | Root as ScrollArea, 9 | Scrollbar as ScrollAreaScrollbar, 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/scroll-area/scroll-area-scrollbar.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 25 | {@render children?.()} 26 | 29 | 30 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/scroll-area/scroll-area.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | 23 | {@render children?.()} 24 | 25 | {#if orientation === "vertical" || orientation === "both"} 26 | 27 | {/if} 28 | {#if orientation === "horizontal" || orientation === "both"} 29 | 30 | {/if} 31 | 32 | 33 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/index.ts: -------------------------------------------------------------------------------- 1 | import { Select as SelectPrimitive } from "bits-ui"; 2 | 3 | import GroupHeading from "./select-group-heading.svelte"; 4 | import Item from "./select-item.svelte"; 5 | import Content from "./select-content.svelte"; 6 | import Trigger from "./select-trigger.svelte"; 7 | import Separator from "./select-separator.svelte"; 8 | import ScrollDownButton from "./select-scroll-down-button.svelte"; 9 | import ScrollUpButton from "./select-scroll-up-button.svelte"; 10 | 11 | const Root = SelectPrimitive.Root; 12 | const Group = SelectPrimitive.Group; 13 | 14 | export { 15 | Root, 16 | Group, 17 | GroupHeading, 18 | Item, 19 | Content, 20 | Trigger, 21 | Separator, 22 | ScrollDownButton, 23 | ScrollUpButton, 24 | // 25 | Root as Select, 26 | Group as SelectGroup, 27 | GroupHeading as SelectGroupHeading, 28 | Item as SelectItem, 29 | Content as SelectContent, 30 | Trigger as SelectTrigger, 31 | Separator as SelectSeparator, 32 | ScrollDownButton as SelectScrollDownButton, 33 | ScrollUpButton as SelectScrollUpButton, 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/select-content.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 29 | 30 | 35 | {@render children?.()} 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/select-group-heading.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/select-item.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 25 | {#snippet children({ selected, highlighted })} 26 | 27 | {#if selected} 28 | 29 | {/if} 30 | 31 | {#if childrenProp} 32 | {@render childrenProp({ selected, highlighted })} 33 | {:else} 34 | {label || value} 35 | {/if} 36 | {/snippet} 37 | 38 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/select-scroll-down-button.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/select-scroll-up-button.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/select-separator.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/select/select-trigger.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | span]:line-clamp-1", 18 | className 19 | )} 20 | {...restProps} 21 | > 22 | {@render children?.()} 23 | 24 | 25 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/separator/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./separator.svelte"; 2 | 3 | export { 4 | Root, 5 | // 6 | Root as Separator, 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/separator/separator.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 23 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/sonner/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Toaster } from "./sonner.svelte"; 2 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/sonner/sonner.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 21 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/switch/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./switch.svelte"; 2 | 3 | export { 4 | Root, 5 | // 6 | Root as Switch, 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/switch/switch.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | 27 | 28 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/table/index.ts: -------------------------------------------------------------------------------- 1 | import Root from "./table.svelte"; 2 | import Body from "./table-body.svelte"; 3 | import Caption from "./table-caption.svelte"; 4 | import Cell from "./table-cell.svelte"; 5 | import Footer from "./table-footer.svelte"; 6 | import Head from "./table-head.svelte"; 7 | import Header from "./table-header.svelte"; 8 | import Row from "./table-row.svelte"; 9 | 10 | export { 11 | Root, 12 | Body, 13 | Caption, 14 | Cell, 15 | Footer, 16 | Head, 17 | Header, 18 | Row, 19 | // 20 | Root as Table, 21 | Body as TableBody, 22 | Caption as TableCaption, 23 | Cell as TableCell, 24 | Footer as TableFooter, 25 | Head as TableHead, 26 | Header as TableHeader, 27 | Row as TableRow, 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/table/table-body.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | {@render children?.()} 16 | 17 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/table/table-caption.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | {@render children?.()} 16 | 17 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/table/table-cell.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 19 | {@render children?.()} 20 | 21 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/table/table-footer.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | {@render children?.()} 16 | 17 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/table/table-head.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | {@render children?.()} 23 | 24 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/table/table-header.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | {@render children?.()} 16 | 17 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/table/table-row.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | {@render children?.()} 23 | 24 | -------------------------------------------------------------------------------- /frontend/src/lib/components/ui/table/table.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
    15 | 16 | {@render children?.()} 17 |
    18 |
    19 | -------------------------------------------------------------------------------- /frontend/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // place files you want to import through the `$lib` alias in this folder. 2 | -------------------------------------------------------------------------------- /frontend/src/lib/services/image-service.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import type { Image } from '$lib/types'; 3 | import { Logger } from './logger'; 4 | import { env } from '$env/dynamic/public'; 5 | 6 | export class ImageService { 7 | private static instance: ImageService; 8 | private logger = Logger.getInstance('ImageService'); 9 | private imageCache: Map = new Map(); 10 | private singleImageCache: Map = new Map(); 11 | private baseUrl = env.PUBLIC_BACKEND_URL || 'http://localhost:8080'; 12 | 13 | private constructor() {} 14 | 15 | public static getInstance(): ImageService { 16 | if (!ImageService.instance) { 17 | ImageService.instance = new ImageService(); 18 | } 19 | return ImageService.instance; 20 | } 21 | 22 | /** 23 | * List all images for a repository 24 | */ 25 | async listImages(repoName: string): Promise { 26 | try { 27 | // Check cache first 28 | const cacheKey = repoName; 29 | const cachedImages = this.imageCache.get(cacheKey); 30 | if (cachedImages) { 31 | return cachedImages; 32 | } 33 | 34 | const response = await axios.get(`${this.baseUrl}/api/v1/repositories/${encodeURIComponent(repoName)}/images`); 35 | 36 | // Update cache 37 | this.imageCache.set(cacheKey, response.data); 38 | 39 | return response.data; 40 | } catch (error) { 41 | this.logger.error(`Failed to list images for repository ${repoName}:`, error); 42 | throw error; 43 | } 44 | } 45 | 46 | /** 47 | * Get a single image by repository name and image name 48 | */ 49 | async getImage(repoName: string, imageName: string): Promise { 50 | try { 51 | // Check cache first 52 | const cacheKey = `${repoName}/${imageName}`; 53 | const cachedImage = this.singleImageCache.get(cacheKey); 54 | if (cachedImage) { 55 | return cachedImage; 56 | } 57 | 58 | const response = await axios.get(`${this.baseUrl}/api/v1/repositories/${encodeURIComponent(repoName)}/images/${encodeURIComponent(imageName)}`); 59 | 60 | // Update cache 61 | this.singleImageCache.set(cacheKey, response.data); 62 | 63 | return response.data; 64 | } catch (error) { 65 | this.logger.error(`Failed to get image ${imageName} in repository ${repoName}:`, error); 66 | throw error; 67 | } 68 | } 69 | 70 | /** 71 | * Clear all cached images 72 | */ 73 | clearCache(): void { 74 | this.imageCache.clear(); 75 | this.singleImageCache.clear(); 76 | } 77 | 78 | /** 79 | * Clear cached images for a specific repository 80 | */ 81 | clearRepositoryCache(repoName: string): void { 82 | this.imageCache.delete(repoName); 83 | 84 | // Clear single image cache entries for this repository 85 | for (const key of this.singleImageCache.keys()) { 86 | if (key.startsWith(repoName + '/')) { 87 | this.singleImageCache.delete(key); 88 | } 89 | } 90 | } 91 | } 92 | 93 | // Example usage: 94 | // const imageService = ImageService.getInstance(); 95 | // const images = await imageService.listImages('library/ubuntu'); 96 | // const singleImage = await imageService.getImage('library/ubuntu', 'ubuntu'); 97 | -------------------------------------------------------------------------------- /frontend/src/lib/services/logger.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/public'; 2 | 3 | export enum LogLevel { 4 | DEBUG = 0, 5 | INFO = 1, 6 | WARN = 2, 7 | ERROR = 3 8 | } 9 | 10 | type LogLevelKey = keyof typeof LogLevel; 11 | 12 | export class Logger { 13 | private static instances: Map = new Map(); 14 | private serviceName: string; 15 | private minimumLevel: LogLevel; 16 | 17 | private constructor(serviceName: string) { 18 | this.serviceName = serviceName; 19 | // Use PUBLIC_LOG_LEVEL or default to 'INFO' 20 | const configLevel = (env.PUBLIC_LOG_LEVEL as LogLevelKey) || 'INFO'; 21 | this.minimumLevel = LogLevel[configLevel] ?? LogLevel.INFO; 22 | } 23 | 24 | public static getInstance(serviceName: string): Logger { 25 | if (!this.instances.has(serviceName)) { 26 | this.instances.set(serviceName, new Logger(serviceName)); 27 | } 28 | return this.instances.get(serviceName)!; 29 | } 30 | 31 | private formatTimestamp(): string { 32 | const now = new Date(); 33 | return now.toLocaleString('en-US', { 34 | timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, 35 | year: 'numeric', 36 | month: '2-digit', 37 | day: '2-digit', 38 | hour: '2-digit', 39 | minute: '2-digit', 40 | second: '2-digit', 41 | hour12: false 42 | }); 43 | } 44 | 45 | private formatMessage(level: LogLevel, message: string): string { 46 | const timestamp = this.formatTimestamp(); 47 | return `[${timestamp}] [${LogLevel[level]}] [${this.serviceName}] ${message}`; 48 | } 49 | 50 | // Logging methods check against minimumLevel 51 | public debug(message: string, ...args: any[]): void { 52 | if (LogLevel.DEBUG >= this.minimumLevel) { 53 | console.debug(this.formatMessage(LogLevel.DEBUG, message), ...args); 54 | } 55 | } 56 | 57 | public info(message: string, ...args: any[]): void { 58 | if (LogLevel.INFO >= this.minimumLevel) { 59 | console.info(this.formatMessage(LogLevel.INFO, message), ...args); 60 | } 61 | } 62 | 63 | public warn(message: string, ...args: any[]): void { 64 | if (LogLevel.WARN >= this.minimumLevel) { 65 | console.warn(this.formatMessage(LogLevel.WARN, message), ...args); 66 | } 67 | } 68 | 69 | public error(message: string, error?: any): void { 70 | if (LogLevel.ERROR >= this.minimumLevel) { 71 | console.error(this.formatMessage(LogLevel.ERROR, message), error || ''); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /frontend/src/lib/services/repository-service.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import type { Repository, RepositoryResponse } from '$lib/types'; 3 | import { Logger } from './logger'; 4 | import { env } from '$env/dynamic/public'; 5 | 6 | export class RepositoryService { 7 | private static instance: RepositoryService; 8 | private logger = Logger.getInstance('RepositoryService'); 9 | private repositoryCache: Map = new Map(); 10 | private baseUrl = env.PUBLIC_BACKEND_URL || 'http://localhost:8080'; 11 | 12 | private constructor() {} 13 | 14 | public static getInstance(): RepositoryService { 15 | if (!RepositoryService.instance) { 16 | RepositoryService.instance = new RepositoryService(); 17 | } 18 | return RepositoryService.instance; 19 | } 20 | 21 | /** 22 | * Get a list of repositories with pagination and search support 23 | */ 24 | async listRepositories(page = 1, limit = 10, search = ''): Promise { 25 | try { 26 | const url = new URL('/api/v1/repositories', this.baseUrl); 27 | url.searchParams.append('page', page.toString()); 28 | url.searchParams.append('limit', limit.toString()); 29 | if (search) { 30 | url.searchParams.append('search', search); 31 | } 32 | 33 | const response = await axios.get(url.toString()); 34 | 35 | // Update cache with fetched repositories 36 | response.data.repositories.forEach((repo) => { 37 | this.repositoryCache.set(repo.name, repo); 38 | }); 39 | 40 | return response.data; 41 | } catch (error) { 42 | this.logger.error('Failed to list repositories:', error); 43 | throw error; 44 | } 45 | } 46 | 47 | /** 48 | * Get a single repository by name 49 | */ 50 | async getRepository(name: string): Promise { 51 | try { 52 | // Check cache first 53 | const cachedRepo = this.repositoryCache.get(name); 54 | if (cachedRepo) { 55 | return cachedRepo; 56 | } 57 | 58 | const response = await axios.get(`${this.baseUrl}/api/v1/repositories/${encodeURIComponent(name)}`); 59 | 60 | // Update cache with fetched repository 61 | this.repositoryCache.set(name, response.data); 62 | 63 | return response.data; 64 | } catch (error) { 65 | this.logger.error(`Failed to get repository ${name}:`, error); 66 | throw error; 67 | } 68 | } 69 | 70 | /** 71 | * Clear cached repositories 72 | */ 73 | clearCache(): void { 74 | this.repositoryCache.clear(); 75 | } 76 | } 77 | 78 | // Example usage: 79 | // const repoService = RepositoryService.getInstance(); 80 | // const repositories = await repoService.listRepositories(1, 10, 'ubuntu'); 81 | // const singleRepo = await repoService.getRepository('library/ubuntu'); 82 | -------------------------------------------------------------------------------- /frontend/src/lib/services/tag-service.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import type { Tag } from '$lib/types'; 3 | import { Logger } from './logger'; 4 | import { env } from '$env/dynamic/public'; 5 | 6 | export class TagService { 7 | private static instance: TagService; 8 | private logger = Logger.getInstance('TagService'); 9 | private baseUrl = env.PUBLIC_BACKEND_URL || 'http://localhost:8080'; 10 | 11 | private constructor() {} 12 | 13 | public static getInstance(): TagService { 14 | if (!TagService.instance) { 15 | TagService.instance = new TagService(); 16 | } 17 | return TagService.instance; 18 | } 19 | 20 | /** 21 | * List all tags for an image in a repository 22 | */ 23 | async listTags(repoName: string, imageName: string): Promise { 24 | try { 25 | const response = await axios.get(`${this.baseUrl}/api/v1/repositories/${encodeURIComponent(repoName)}/images/${encodeURIComponent(imageName)}/tags`); 26 | return response.data; 27 | } catch (error) { 28 | this.logger.error(`Failed to list tags for image ${imageName} in repository ${repoName}:`, error); 29 | throw error; 30 | } 31 | } 32 | 33 | /** 34 | * Get a single tag by repository name, image name, and tag name 35 | */ 36 | async getTag(repoName: string, imageName: string, tagName: string): Promise { 37 | try { 38 | const response = await axios.get(`${this.baseUrl}/api/v1/repositories/${encodeURIComponent(repoName)}/images/${encodeURIComponent(imageName)}/tags/${encodeURIComponent(tagName)}`); 39 | this.logger.info('Tag data received:', response.data); 40 | return response.data; 41 | } catch (error) { 42 | this.logger.error(`Failed to get tag ${tagName}:`, error); 43 | throw error; 44 | } 45 | } 46 | 47 | /** 48 | * Delete a tag from a repository 49 | */ 50 | async deleteTag(repoName: string, imageName: string, tagName: string): Promise { 51 | try { 52 | await axios.delete(`${this.baseUrl}/api/v1/repositories/${encodeURIComponent(repoName)}/images/${encodeURIComponent(imageName)}/tags/${encodeURIComponent(tagName)}`); 53 | this.logger.info(`Successfully deleted tag ${tagName} from image ${imageName} in repository ${repoName}`); 54 | } catch (error) { 55 | this.logger.error(`Failed to delete tag ${tagName}:`, error); 56 | throw error; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /frontend/src/lib/stores/sync-store.ts: -------------------------------------------------------------------------------- 1 | // src/lib/stores/syncStore.ts 2 | import { writable } from 'svelte/store'; 3 | 4 | export const lastSyncTimestamp = writable(Date.now()); 5 | export const isSyncing = writable(false); 6 | 7 | export function notifySyncComplete() { 8 | lastSyncTimestamp.set(Date.now()); 9 | isSyncing.set(false); 10 | } 11 | 12 | export function startSync() { 13 | isSyncing.set(true); 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/lib/types/app-config-type.ts: -------------------------------------------------------------------------------- 1 | export interface AppConfigItem { 2 | ID?: number; 3 | CreatedAt?: string; 4 | UpdatedAt?: string; 5 | DeletedAt?: null | string; 6 | key: string; 7 | value: string; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/lib/types/components/configuration.ts: -------------------------------------------------------------------------------- 1 | export type VersionInfo = { 2 | currentVersion: string; 3 | }; 4 | -------------------------------------------------------------------------------- /frontend/src/lib/types/components/image-table.ts: -------------------------------------------------------------------------------- 1 | import type { RepoImage } from '$lib/models/image.ts'; 2 | import type { ColumnDef } from '@tanstack/table-core'; 3 | import { renderComponent } from '$lib/components/ui/data-table/index.js'; 4 | import TagDropdownActions from '$lib/components/image-table/tag-dropdown-actions.svelte'; 5 | 6 | // Extend RepoImage type to include the new properties 7 | export type ExtendedRepoImage = RepoImage & { 8 | repo: string; 9 | repoIndex: number; 10 | }; 11 | 12 | export const columns: ColumnDef[] = [ 13 | { 14 | accessorKey: 'name', 15 | header: 'Image Name', 16 | accessorFn: (row) => { 17 | // Extract just the image name part 18 | if (row.name && row.name.includes('/')) { 19 | return row.name.split('/').pop(); 20 | } 21 | return row.name; 22 | } 23 | }, 24 | { 25 | accessorKey: 'fullName', 26 | header: 'Full Name' 27 | }, 28 | { 29 | id: 'tags', 30 | header: 'Tags', 31 | cell: ({ row }) => { 32 | return renderComponent(TagDropdownActions, { 33 | tags: row.original.tags || [], 34 | data: row.original, 35 | imageFullName: row.original.fullName, 36 | imageName: row.original.name.replace(/[^\w]/g, '-') 37 | }); 38 | } 39 | } 40 | ]; 41 | -------------------------------------------------------------------------------- /frontend/src/lib/types/image-type.ts: -------------------------------------------------------------------------------- 1 | import type { Tag } from '$lib/types'; 2 | 3 | export interface Image { 4 | ID?: number; 5 | CreatedAt?: string; 6 | UpdatedAt?: string; 7 | DeletedAt?: null | string; 8 | repositoryId: number; 9 | name: string; 10 | fullName: string; 11 | pullCount: number; 12 | tags?: Tag[]; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/lib/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app-config-type'; 2 | export * from './repository-type'; 3 | export * from './image-type'; 4 | export * from './tag-type'; 5 | -------------------------------------------------------------------------------- /frontend/src/lib/types/repository-type.ts: -------------------------------------------------------------------------------- 1 | import type { Image } from '$lib/types'; 2 | 3 | export interface Repository { 4 | ID?: number; 5 | CreatedAt?: string; 6 | UpdatedAt?: string; 7 | DeletedAt?: null | string; 8 | name: string; 9 | lastSynced: string | null; 10 | images?: Image[]; 11 | } 12 | 13 | export interface RepositoryResponse { 14 | repositories: Repository[]; 15 | totalCount: number; 16 | page: number; 17 | limit: number; 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/lib/types/tag-type.ts: -------------------------------------------------------------------------------- 1 | export interface Tag { 2 | ID?: number; 3 | CreatedAt?: string; 4 | UpdatedAt?: string; 5 | DeletedAt?: null | string; 6 | imageId: number; 7 | name: string; 8 | digest: string; 9 | createdAt: string; 10 | metadata?: TagMetadata; 11 | } 12 | 13 | export interface TagMetadata { 14 | ID?: number; 15 | tagId: number; 16 | created: string; 17 | os: string; 18 | architecture: string; 19 | author: string; 20 | dockerFile?: string; 21 | configDigest?: string; 22 | exposedPorts?: string; 23 | totalSize: number; 24 | workDir?: string; 25 | command?: string; 26 | description?: string; 27 | contentDigest?: string; 28 | entrypoint?: string; 29 | indexDigest?: string; 30 | isOCI: boolean; 31 | layers?: ImageLayer[]; 32 | } 33 | 34 | export interface ImageLayer { 35 | ID?: number; 36 | tagMetadataId: number; 37 | size: number; 38 | digest: string; 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/api/auth.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/public'; 2 | 3 | /** 4 | * Creates Basic authentication for registry requests 5 | * @returns Basic Authentication for Docker Registry API 6 | */ 7 | export function getBasicAuth(): string { 8 | const auth = Buffer.from(`${env.PUBLIC_REGISTRY_USERNAME}:${env.PUBLIC_REGISTRY_PASSWORD}`).toString('base64'); 9 | return `Basic ${auth}`; 10 | } 11 | 12 | /** 13 | * Creates authorization headers for registry requests 14 | * @returns Authorization headers object 15 | */ 16 | export function getAuthHeaders() { 17 | const auth = Buffer.from(`${env.PUBLIC_REGISTRY_USERNAME}:${env.PUBLIC_REGISTRY_PASSWORD}`).toString('base64'); 18 | return { 19 | Authorization: `Basic ${auth}`, 20 | Accept: 'application/json' 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/api/delete.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Logger } from '$lib/services/logger'; 3 | import { getAuthHeaders } from '$lib/utils/api/auth'; 4 | 5 | const logger = Logger.getInstance('DeleteTags'); 6 | 7 | /** 8 | * Delete a Docker manifest from the registry 9 | */ 10 | export async function deleteDockerManifestAxios(registryUrl: string, repo: string, digest: string): Promise { 11 | logger.info(`Deleting manifest: ${registryUrl}/v2/${repo}/manifests/${digest}`); 12 | 13 | try { 14 | await axios.delete(`${registryUrl}/v2/${repo}/manifests/${digest}`, { 15 | headers: { 16 | ...getAuthHeaders(), 17 | Accept: 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json' 18 | } 19 | }); 20 | 21 | logger.info(`Successfully deleted manifest: ${registryUrl}/v2/${repo}/manifests/${digest}`); 22 | } catch (error) { 23 | logger.error('Error in delete operation:', error); 24 | throw error; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/api/health.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Logger } from '$lib/services/logger'; 3 | import { getBasicAuth } from './auth'; 4 | 5 | export type HealthStatus = { 6 | isHealthy: boolean; 7 | supportsV2: boolean; 8 | needsAuth: boolean; 9 | message: string; 10 | }; 11 | 12 | export async function checkRegistryHealth(registryUrl: string): Promise { 13 | const logger = Logger.getInstance('RegistryHealth'); 14 | 15 | try { 16 | const auth = getBasicAuth(); 17 | 18 | const response = await axios.get(`${registryUrl}/v2/`, { 19 | headers: { 20 | Authorization: auth, 21 | Accept: 'application/json' 22 | }, 23 | validateStatus: (status) => [200, 401, 404].includes(status) 24 | }); 25 | 26 | const apiVersion = response.headers['docker-distribution-api-version']; 27 | 28 | // Handle different response scenarios 29 | if (response.status === 200 && apiVersion === 'registry/2.0') { 30 | logger.info('Registry is healthy and supports V2 API'); 31 | return { 32 | isHealthy: true, 33 | supportsV2: true, 34 | needsAuth: false, 35 | message: 'Registry is healthy and supports V2 API' 36 | }; 37 | } 38 | 39 | if (response.status === 401) { 40 | const authHeader = response.headers['www-authenticate']; 41 | logger.warn('Registry requires authentication', { authHeader }); 42 | return { 43 | isHealthy: true, 44 | supportsV2: apiVersion === 'registry/2.0', 45 | needsAuth: true, 46 | message: 'Registry requires authentication' 47 | }; 48 | } 49 | 50 | if (response.status === 404) { 51 | logger.error('Registry does not support V2 API'); 52 | return { 53 | isHealthy: false, 54 | supportsV2: false, 55 | needsAuth: false, 56 | message: 'Registry does not support V2 API' 57 | }; 58 | } 59 | 60 | logger.warn('Unexpected registry response', { status: response.status }); 61 | return { 62 | isHealthy: false, 63 | supportsV2: false, 64 | needsAuth: false, 65 | message: 'Unexpected registry response' 66 | }; 67 | } catch (error) { 68 | logger.error('Failed to connect to registry', error); 69 | return { 70 | isHealthy: false, 71 | supportsV2: false, 72 | needsAuth: false, 73 | message: 'Failed to connect to registry' 74 | }; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth'; 2 | export * from './delete'; 3 | export * from './health'; 4 | export * from './registry'; 5 | export * from './manifest'; 6 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/api/registry.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from 'axios'; 2 | import type { RepoImage, ImageTag } from '$lib/types/api.old/registry'; 3 | import { fetchDockerMetadata } from '$lib/utils/api'; 4 | import { Logger } from '$lib/services/logger'; 5 | import { getAuthHeaders } from '$lib/utils/api/auth'; 6 | import { extractRepoName } from '$lib/utils/formatting'; 7 | 8 | /** 9 | * Fetches tags for a Docker repository with a limit on the number of tags returned 10 | * @param registryUrl Base URL of the registry 11 | * @param repo Repository name (or path) 12 | * @param limit Maximum number of tags to return (default: 50) 13 | * @returns Promise resolving to repository image data with tags 14 | */ 15 | export async function getDockerTags(registryUrl: string, repo: string, limit: number = 50): Promise { 16 | const logger = Logger.getInstance('TagUtils'); 17 | logger.debug(`Fetching tags for repository: ${repo} (limit: ${limit})`); 18 | 19 | try { 20 | // Construct URL with tag limit parameter 21 | const tagsUrl = `${registryUrl}/v2/${repo}/tags/list?n=${limit}`; 22 | logger.debug(`Requesting tags from ${tagsUrl}`); 23 | 24 | const response = await axios.get(tagsUrl, { 25 | headers: getAuthHeaders() 26 | }); 27 | 28 | const data = response.data; 29 | 30 | // Extract and validate repository name 31 | const name = data.name || extractRepoName(repo); 32 | if (!name) { 33 | logger.error(`Invalid repository name for ${repo}`); 34 | throw new Error('Invalid repository name'); 35 | } 36 | 37 | // Process tags if available 38 | let tags: ImageTag[] = []; 39 | if (Array.isArray(data.tags) && data.tags.length > 0) { 40 | logger.debug(`Found ${data.tags.length} tags for ${repo} (limited to ${limit})`); 41 | 42 | // Fetch metadata for each tag in parallel 43 | tags = await Promise.all( 44 | data.tags.map(async (tag: string): Promise => { 45 | try { 46 | const metadata = await fetchDockerMetadata(registryUrl, repo, tag); 47 | return { name: tag, metadata }; 48 | } catch (error: unknown) { 49 | logger.error(`Error fetching metadata for ${repo}:${tag}:`, error instanceof Error ? error.message : String(error)); 50 | return { name: tag, metadata: undefined }; 51 | } 52 | }) 53 | ); 54 | } else { 55 | logger.info(`No tags found for repository ${repo}`); 56 | } 57 | 58 | // Return complete repository data 59 | return { 60 | name, 61 | fullName: repo, 62 | tags 63 | }; 64 | } catch (error) { 65 | // Handle errors gracefully 66 | if (error instanceof AxiosError) { 67 | logger.error(`Network error fetching tags for ${repo}: ${error.message}`, { 68 | status: error.response?.status, 69 | data: error.response?.data 70 | }); 71 | } else { 72 | logger.error(`Error fetching repo images for ${repo}:`, error instanceof Error ? error.message : String(error)); 73 | } 74 | 75 | // Return fallback structure with empty tags 76 | const fallbackName = extractRepoName(repo, 'unknown'); 77 | return { 78 | name: fallbackName, 79 | fullName: repo, 80 | tags: [] 81 | }; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/crypto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './polyfill'; 2 | export * from './universal-crypto'; 3 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/crypto/polyfill.ts: -------------------------------------------------------------------------------- 1 | // src/lib/utils/node-polyfills.js 2 | // Simple polyfill for some Node.js built-ins in browser environments 3 | 4 | // Add type declarations at the top of the file 5 | declare global { 6 | interface Window { 7 | process: any; 8 | Buffer: { 9 | from: (data: string) => Uint8Array; 10 | }; 11 | } 12 | } 13 | 14 | // Only run in browser environment 15 | if (typeof window !== 'undefined') { 16 | window.process = window.process || { 17 | env: {}, 18 | versions: { node: false }, 19 | nextTick: (cb: () => void) => setTimeout(cb, 0) 20 | }; 21 | 22 | if (!window.Buffer) { 23 | window.Buffer = { 24 | from: (data: string): Uint8Array => new TextEncoder().encode(data) 25 | }; 26 | } 27 | } 28 | 29 | export default {}; 30 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/crypto/universal-crypto.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '$lib/services/logger'; 2 | 3 | const logger = Logger.getInstance('UniversalCrypto'); 4 | 5 | /** 6 | * Platform-agnostic SHA-256 implementation 7 | * Works in both Node.js and browser environments 8 | */ 9 | export async function sha256(input: string): Promise { 10 | try { 11 | // Browser environment 12 | if (typeof window !== 'undefined') { 13 | const msgBuffer = new TextEncoder().encode(input); 14 | const hashBuffer = await window.crypto.subtle.digest('SHA-256', msgBuffer); 15 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 16 | return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); 17 | } 18 | // Node.js environment 19 | else { 20 | // Use dynamic import to avoid static analysis issues 21 | const nodeCrypto = await dynamicImportCrypto(); 22 | return nodeCrypto.createHash('sha256').update(input).digest('hex'); 23 | } 24 | } catch (error) { 25 | logger.error('SHA-256 hash error:', error); 26 | // Return fallback hash in case of error (for resilience) 27 | return `error-hash-${Date.now()}`; 28 | } 29 | } 30 | 31 | /** 32 | * Dynamic import of the crypto module to prevent bundler issues 33 | */ 34 | async function dynamicImportCrypto() { 35 | try { 36 | // First try with node: prefix 37 | return await import('node:crypto'); 38 | } catch (error) { 39 | try { 40 | // Fallback to regular import 41 | return await import('crypto'); 42 | } catch (fallbackError) { 43 | logger.error('Failed to load Node.js crypto module:', fallbackError); 44 | throw new Error('Crypto module not available'); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/formatting/index.ts: -------------------------------------------------------------------------------- 1 | export * from './time'; 2 | export * from './oci'; 3 | export * from './size'; 4 | 5 | /** 6 | * Extract repository name from full path 7 | */ 8 | export function extractRepoName(fullRepoPath: string, defaultName: string = ''): string { 9 | return fullRepoPath.split('/').pop() || defaultName; 10 | } 11 | 12 | /** 13 | * Extracts the namespace from a full repository name 14 | * @param fullName Full repository name (e.g., 'namespace/image' or 'image') 15 | * @returns Namespace string or 'library' for root-level images 16 | */ 17 | export function getNamespace(fullName: string): string { 18 | if (!fullName?.includes('/')) { 19 | return 'library'; // Use 'library' as default namespace like Docker Hub 20 | } 21 | return fullName.split('/')[0]; 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/formatting/oci.ts: -------------------------------------------------------------------------------- 1 | import { sha256 } from '$lib/utils/crypto'; 2 | import { Logger } from '$lib/services/logger'; 3 | 4 | const logger = Logger.getInstance('OCIManifest'); 5 | 6 | export async function filterAttestationManifests(manifestJson: string): Promise { 7 | try { 8 | const manifest = JSON.parse(manifestJson); 9 | 10 | if (manifest.manifests) { 11 | // Filter out attestation manifests 12 | manifest.manifests = manifest.manifests.filter((m: any) => !m.annotations?.['vnd.docker.reference.type']); 13 | 14 | // Ensure deterministic ordering 15 | manifest.manifests.sort((a: any, b: any) => a.digest.localeCompare(b.digest)); 16 | } 17 | 18 | // Ensure deterministic JSON stringification 19 | return JSON.stringify(manifest, null, 2); 20 | } catch (error) { 21 | logger.error('Error filtering attestation manifests:', error); 22 | // Return original JSON if there's an error 23 | return manifestJson; 24 | } 25 | } 26 | 27 | export async function calculateSha256(content: string): Promise { 28 | try { 29 | // Remove all whitespace and newlines for consistent hashing 30 | const normalized = content.replace(/\s/g, ''); 31 | const hash = await sha256(normalized); 32 | return `sha256:${hash}`; 33 | } catch (error) { 34 | logger.error('Error calculating SHA256:', error); 35 | // Return a fallback string in case of error to prevent cascading failures 36 | return `sha256:error-calculating-hash-${Date.now()}`; 37 | } 38 | } 39 | 40 | export async function generateManifestDigest(manifest: any): Promise { 41 | try { 42 | const manifestJson = JSON.stringify(manifest); 43 | // Replace direct crypto usage with our utility 44 | const hash = await sha256(manifestJson); 45 | return `sha256:${hash}`; 46 | } catch (error) { 47 | logger.error('Error generating manifest digest:', error); 48 | // Return a fallback string in case of error 49 | return `sha256:error-generating-digest-${Date.now()}`; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/formatting/size.ts: -------------------------------------------------------------------------------- 1 | import type { ImageLayer } from '$lib/types'; 2 | 3 | export function formatSize(bytes: number): string { 4 | const units = ['B', 'KB', 'MB', 'GB', 'TB']; 5 | let i = 0; 6 | while (bytes >= 1024 && i < units.length - 1) { 7 | bytes /= 1024; 8 | i++; 9 | } 10 | return `${bytes.toFixed(2)} ${units[i]}`; 11 | } 12 | 13 | export function getTotalLayerSize(layers: ImageLayer[]): number { 14 | return Array.isArray(layers) && layers.length > 0 ? layers.reduce((sum, layer) => sum + (typeof layer.size === 'number' ? layer.size : 0), 0) : 0; 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/formatting/time.ts: -------------------------------------------------------------------------------- 1 | export function convertTimeString(timeString: string): string | null { 2 | const date = new Date(timeString); 3 | 4 | if (isNaN(date.getTime())) { 5 | return null; // Invalid date 6 | } 7 | 8 | const formattedDate = date.toLocaleDateString(); 9 | const formattedTime = date.toLocaleTimeString(); 10 | 11 | return `${formattedDate} ${formattedTime}`; 12 | } 13 | 14 | // Format time difference for logging 15 | export function formatTimeDiff(diffMs: number): string { 16 | if (diffMs < 1000) return `${diffMs}ms`; 17 | if (diffMs < 60000) return `${Math.round(diffMs / 1000)}s`; 18 | if (diffMs < 3600000) return `${Math.round(diffMs / 60000)}m`; 19 | return `${Math.round(diffMs / 3600000)}h`; 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/repos.ts: -------------------------------------------------------------------------------- 1 | import type { RegistryRepo } from '$lib/types/api.old/registry'; 2 | import { getDockerTags } from '$lib/utils/api'; 3 | import axios, { AxiosError } from 'axios'; 4 | import { Logger } from '$lib/services/logger'; 5 | import { getAuthHeaders } from '$lib/utils/api/auth'; 6 | import { getNamespace } from '$lib/utils/formatting'; 7 | 8 | interface RegistryRepos { 9 | repositories: RegistryRepo[]; 10 | } 11 | 12 | /** 13 | * Fetches all repositories from the registry and organizes them by namespace 14 | * @param url Registry URL 15 | * @returns Promise resolving to organized repositories 16 | */ 17 | export async function getRegistryReposAxios(url: string): Promise { 18 | const logger = Logger.getInstance('Registry-GetRepos'); 19 | 20 | try { 21 | // Fix: Make sure we're using the base registry URL, not the catalog URL 22 | const baseRegistryUrl = url.endsWith('/v2/_catalog') ? url.replace('/v2/_catalog', '') : url; 23 | const catalogUrl = `${baseRegistryUrl}/v2/_catalog`; 24 | 25 | logger.info(`Fetching repositories from ${catalogUrl}`); 26 | 27 | const response = await axios.get(catalogUrl, { 28 | headers: getAuthHeaders() 29 | }); 30 | 31 | const { repositories = [] } = response.data as { repositories: string[] }; 32 | logger.info(`Found ${repositories.length} repositories`); 33 | 34 | // Group repositories by namespace 35 | const reposByNamespace: Record = {}; 36 | 37 | repositories.forEach((repo) => { 38 | const namespace = getNamespace(repo); 39 | 40 | if (!reposByNamespace[namespace]) { 41 | reposByNamespace[namespace] = []; 42 | } 43 | 44 | reposByNamespace[namespace].push(repo); 45 | }); 46 | 47 | // Process each namespace 48 | const namespacePromises = Object.entries(reposByNamespace).map(async ([namespace, repos]) => { 49 | // Create a namespace object 50 | const namespaceObj: RegistryRepo = { 51 | id: namespace, // Using namespace as id 52 | name: namespace, 53 | images: [] 54 | }; 55 | 56 | // Fetch tags for each repository in parallel 57 | const imagePromises = repos.map(async (repo) => { 58 | try { 59 | // Pass the base registry URL, not the catalog URL 60 | const repoData = await getDockerTags(baseRegistryUrl, repo); 61 | namespaceObj.images.push(repoData); 62 | } catch (error) { 63 | logger.error(`Error fetching tags for ${repo}:`, error instanceof Error ? error.message : String(error)); 64 | } 65 | }); 66 | 67 | await Promise.all(imagePromises); 68 | return namespaceObj; 69 | }); 70 | 71 | const namespaces = await Promise.all(namespacePromises); 72 | 73 | // Filter out empty namespaces 74 | const filteredNamespaces = namespaces.filter((ns) => ns.images.length > 0); 75 | 76 | return { repositories: filteredNamespaces }; 77 | } catch (error) { 78 | // Improved error logging with type safety and details 79 | if (axios.isAxiosError(error)) { 80 | logger.error(`Failed to fetch repositories from ${url}:`, { 81 | status: error.response?.status, 82 | url: error.config?.url, 83 | message: error.message, 84 | data: error.response?.data 85 | }); 86 | } else { 87 | logger.error(`Failed to fetch repositories from ${url}:`, error instanceof Error ? error.message : String(error)); 88 | } 89 | return { repositories: [] }; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/style.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | import { cubicOut } from 'svelte/easing'; 4 | import type { TransitionConfig } from 'svelte/transition'; 5 | 6 | export function cn(...inputs: ClassValue[]) { 7 | return twMerge(clsx(inputs)); 8 | } 9 | 10 | type FlyAndScaleParams = { 11 | y?: number; 12 | x?: number; 13 | start?: number; 14 | duration?: number; 15 | }; 16 | 17 | export const flyAndScale = ( 18 | node: Element, 19 | params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 } 20 | ): TransitionConfig => { 21 | const style = getComputedStyle(node); 22 | const transform = style.transform === 'none' ? '' : style.transform; 23 | 24 | const scaleConversion = (valueA: number, scaleA: [number, number], scaleB: [number, number]) => { 25 | const [minA, maxA] = scaleA; 26 | const [minB, maxB] = scaleB; 27 | 28 | const percentage = (valueA - minA) / (maxA - minA); 29 | const valueB = percentage * (maxB - minB) + minB; 30 | 31 | return valueB; 32 | }; 33 | 34 | const styleToString = (style: Record): string => { 35 | return Object.keys(style).reduce((str, key) => { 36 | if (style[key] === undefined) return str; 37 | return str + `${key}:${style[key]};`; 38 | }, ''); 39 | }; 40 | 41 | return { 42 | duration: params.duration ?? 200, 43 | delay: 0, 44 | css: (t) => { 45 | const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]); 46 | const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]); 47 | const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]); 48 | 49 | return styleToString({ 50 | transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`, 51 | opacity: t 52 | }); 53 | }, 54 | easing: cubicOut 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/ui/clipboard.ts: -------------------------------------------------------------------------------- 1 | import { toast } from 'svelte-sonner'; 2 | 3 | export async function copyTextToClipboard(text: string): Promise { 4 | try { 5 | await navigator.clipboard.writeText(text); 6 | return true; 7 | } catch (error) { 8 | console.error('Failed to copy text: ', error); 9 | return false; 10 | } 11 | } 12 | 13 | export async function copyDockerRunCommand(imageFullName: string, tagName: string, registryURL: string): Promise { 14 | let registryHost = ''; 15 | try { 16 | const url = new URL(registryURL); 17 | registryHost = url.host; 18 | } catch (e) { 19 | // Fallback if URL parsing fails 20 | registryHost = registryURL.replace(/^https?:\/\//, ''); 21 | } 22 | 23 | const dockerRunCmd = `docker run ${registryHost}/${imageFullName}:${tagName}`; 24 | 25 | copyTextToClipboard(dockerRunCmd).then((success) => { 26 | if (success) { 27 | toast.success('Docker Run command copied to clipboard'); 28 | } else { 29 | toast.error('Failed to copy Docker Run command'); 30 | } 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/lib/utils/ui/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * UI Utilities Index 3 | * 4 | * This file re-exports all UI-related utility functions to provide a centralized 5 | * import location. Import from '$lib/utils/ui' to access all UI utilities. 6 | */ 7 | 8 | // Re-export clipboard utilities 9 | export * from './clipboard'; 10 | 11 | // Add any standalone UI utility functions here 12 | export function truncateText(text: string, maxLength: number): string { 13 | if (!text) return ''; 14 | return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '$lib/services/logger'; 2 | import { env } from '$env/dynamic/public'; 3 | import { checkRegistryHealth } from '$lib/utils/api/health'; 4 | import { AppConfigService } from '$lib/services/app-config-service'; 5 | 6 | const logger = Logger.getInstance('LayoutServer'); 7 | 8 | export async function load() { 9 | try { 10 | // Check registry health 11 | const healthStatus = await checkRegistryHealth(env.PUBLIC_REGISTRY_URL); 12 | const configService = AppConfigService.getInstance(); 13 | 14 | await configService.loadAllConfigs(); 15 | const registryName = (await configService.getConfig('registry_name')) || 'Docker Registry'; 16 | const registryUrl = await configService.getConfig('registry_url'); 17 | 18 | return { 19 | healthStatus, 20 | appConfig: { 21 | registryName, 22 | registryUrl 23 | } 24 | }; 25 | } catch (error) { 26 | logger.error('Failed to check registry health on layout load:', error); 27 | return { 28 | error: true, // Indicate an error occurred 29 | healthStatus: { 30 | isHealthy: false 31 | }, 32 | appConfig: { 33 | registryName: 'Docker Registry', 34 | registryUrl: null 35 | } 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /frontend/src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
    10 |
    11 | 12 | 13 | {@render children()} 14 |
    15 | 16 | -------------------------------------------------------------------------------- /frontend/src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { RepositoryService } from '$lib/services/repository-service'; 2 | import type { PageServerLoad } from './$types'; 3 | 4 | export const load: PageServerLoad = async () => { 5 | try { 6 | // Get instances of our services 7 | const repoService = RepositoryService.getInstance(); 8 | 9 | // Load repositories directly from backend without URL parameters 10 | // Backend will handle pagination and defaults 11 | const repoResponse = await repoService.listRepositories(); 12 | 13 | // Check registry health if needed 14 | const healthStatus = { isHealthy: true }; // Replace with actual health check if available 15 | 16 | return { 17 | repositories: repoResponse.repositories, 18 | totalCount: repoResponse.totalCount, 19 | page: repoResponse.page, 20 | limit: repoResponse.limit, 21 | healthStatus 22 | }; 23 | } catch (error) { 24 | console.error('Error loading page data:', error); 25 | return { 26 | repositories: [], 27 | totalCount: 0, 28 | page: 1, 29 | limit: 5, 30 | search: '', 31 | error: 'Failed to load repositories' 32 | }; 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/src/routes/details/[repo]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { RepositoryService } from '$lib/services/repository-service'; 2 | 3 | export async function load({ params }) { 4 | const repoService = RepositoryService.getInstance(); 5 | 6 | const repoName = params.repo; 7 | 8 | const repoResponse = await repoService.getRepository(repoName); 9 | return { 10 | repository: repoResponse, 11 | repoName 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/routes/details/[repo]/[image]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { ImageService } from '$lib/services/image-service'; 2 | 3 | export async function load({ params }) { 4 | const imageService = ImageService.getInstance(); 5 | const repoName = params.repo; 6 | const imageName = params.image; 7 | 8 | const imageResponse = await imageService.getImage(repoName, imageName); 9 | 10 | return { 11 | image: imageResponse, 12 | repoName, 13 | imageName 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/routes/details/[repo]/[image]/[tag]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { PageServerLoad, Actions } from './$types'; 2 | import { TagService } from '$lib/services/tag-service'; 3 | import { error, redirect } from '@sveltejs/kit'; 4 | 5 | export const load: PageServerLoad = async ({ params }) => { 6 | const tagService = TagService.getInstance(); 7 | const tagResponse = await tagService.getTag(params.repo, params.image, params.tag); 8 | 9 | return { 10 | tag: tagResponse, 11 | repoName: params.repo, 12 | imageName: params.image, 13 | tagName: params.tag 14 | }; 15 | }; 16 | 17 | export const actions: Actions = { 18 | deleteTag: async ({ params, request }) => { 19 | const tagService = TagService.getInstance(); 20 | 21 | // Extract form data 22 | const formData = await request.formData(); 23 | const confirmation = formData.get('confirm'); 24 | 25 | // Optional: Check for confirmation 26 | if (confirmation !== 'true') { 27 | return { success: false, error: 'Please confirm deletion' }; 28 | } 29 | 30 | // Call delete method in service 31 | await tagService.deleteTag(params.repo, params.image, params.tag); 32 | 33 | // Throw a redirect instead of returning an object 34 | throw redirect(303, `/details/${params.repo}/${params.image}`); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /frontend/src/routes/health/+server.ts: -------------------------------------------------------------------------------- 1 | import { json } from '@sveltejs/kit'; 2 | 3 | export async function GET() { 4 | return json({ status: 'HEALTHY' }, { status: 200 }); 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/routes/settings/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { PageServerLoad, Actions } from './$types'; 2 | import { AppConfigService } from '$lib/services/app-config-service'; 3 | import { env } from '$env/dynamic/public'; 4 | import { json, fail } from '@sveltejs/kit'; 5 | 6 | // TODO - Implement Server Side callback for sync button 7 | 8 | export const load: PageServerLoad = async () => { 9 | const configService = AppConfigService.getInstance(); 10 | const syncInterval = await configService.getSyncInterval(); 11 | 12 | return { 13 | syncInterval 14 | }; 15 | }; 16 | 17 | export const actions: Actions = { 18 | async updateSyncInterval({ request }) { 19 | const formData = await request.formData(); // Use formData to read the body 20 | const syncInterval = formData.get('value'); // Get the value from form data 21 | 22 | // Validate syncInterval if necessary 23 | const validIntervals = [5, 15, 30, 60]; 24 | if (!validIntervals.includes(Number(syncInterval))) { 25 | return fail(400, { message: 'Invalid sync interval. Must be 5, 15, 30, or 60.' }); 26 | } 27 | 28 | try { 29 | const response = await fetch(`${env.PUBLIC_BACKEND_URL}/api/v1/config/sync_interval`, { 30 | method: 'PUT', 31 | headers: { 32 | 'Content-Type': 'application/json' 33 | }, 34 | body: JSON.stringify({ value: syncInterval }) 35 | }); 36 | 37 | if (!response.ok) { 38 | throw new Error('Failed to update sync interval'); 39 | } 40 | 41 | return { success: true, message: 'Sync interval updated successfully' }; // Return a plain object 42 | } catch (error) { 43 | console.error('Error updating sync interval:', error); 44 | return fail(500, { message: 'Failed to update sync interval' }); // Use fail for errors 45 | } 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /frontend/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ofkm/svelocker-ui/f98c550e4739c11f02bb611d162809ef9828e3c9/frontend/static/favicon.png -------------------------------------------------------------------------------- /frontend/static/img/docker-mark-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ofkm/svelocker-ui/f98c550e4739c11f02bb611d162809ef9828e3c9/frontend/static/img/docker-mark-blue.png -------------------------------------------------------------------------------- /frontend/static/img/svelocker-old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ofkm/svelocker-ui/f98c550e4739c11f02bb611d162809ef9828e3c9/frontend/static/img/svelocker-old.png -------------------------------------------------------------------------------- /frontend/static/img/svelocker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ofkm/svelocker-ui/f98c550e4739c11f02bb611d162809ef9828e3c9/frontend/static/img/svelocker.png -------------------------------------------------------------------------------- /frontend/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-node'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | import packageJson from './package.json' with { type: 'json' }; 4 | 5 | /** @type {import('@sveltejs/kit').Config} */ 6 | const config = { 7 | // Consult https://svelte.dev/docs/kit/integrations 8 | // for more information about preprocessors 9 | preprocess: vitePreprocess(), 10 | 11 | kit: { 12 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 13 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 14 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 15 | adapter: adapter({ 16 | out: 'build', 17 | precompress: false, 18 | polyfill: true 19 | }), 20 | version: { 21 | name: packageJson.version 22 | } 23 | } 24 | }; 25 | 26 | export default config; 27 | -------------------------------------------------------------------------------- /frontend/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { fontFamily } from 'tailwindcss/defaultTheme'; 2 | import type { Config } from 'tailwindcss'; 3 | import tailwindcssAnimate from 'tailwindcss-animate'; 4 | 5 | const config: Config = { 6 | darkMode: ['class'], 7 | content: ['./src/**/*.{html,js,svelte,ts}'], 8 | safelist: ['dark'], 9 | theme: { 10 | container: { 11 | center: true, 12 | padding: '2rem', 13 | screens: { 14 | '2xl': '1400px' 15 | } 16 | }, 17 | extend: { 18 | colors: { 19 | border: 'hsl(var(--border) / )', 20 | input: 'hsl(var(--input) / )', 21 | ring: 'hsl(var(--ring) / )', 22 | background: 'hsl(var(--background) / )', 23 | foreground: 'hsl(var(--foreground) / )', 24 | primary: { 25 | DEFAULT: 'hsl(var(--primary) / )', 26 | foreground: 'hsl(var(--primary-foreground) / )' 27 | }, 28 | secondary: { 29 | DEFAULT: 'hsl(var(--secondary) / )', 30 | foreground: 'hsl(var(--secondary-foreground) / )' 31 | }, 32 | destructive: { 33 | DEFAULT: 'hsl(var(--destructive) / )', 34 | foreground: 'hsl(var(--destructive-foreground) / )' 35 | }, 36 | muted: { 37 | DEFAULT: 'hsl(var(--muted) / )', 38 | foreground: 'hsl(var(--muted-foreground) / )' 39 | }, 40 | accent: { 41 | DEFAULT: 'hsl(var(--accent) / )', 42 | foreground: 'hsl(var(--accent-foreground) / )' 43 | }, 44 | popover: { 45 | DEFAULT: 'hsl(var(--popover) / )', 46 | foreground: 'hsl(var(--popover-foreground) / )' 47 | }, 48 | card: { 49 | DEFAULT: 'hsl(var(--card) / )', 50 | foreground: 'hsl(var(--card-foreground) / )' 51 | }, 52 | sidebar: { 53 | DEFAULT: 'hsl(var(--sidebar-background))', 54 | foreground: 'hsl(var(--sidebar-foreground))', 55 | primary: 'hsl(var(--sidebar-primary))', 56 | 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', 57 | accent: 'hsl(var(--sidebar-accent))', 58 | 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', 59 | border: 'hsl(var(--sidebar-border))', 60 | ring: 'hsl(var(--sidebar-ring))' 61 | } 62 | }, 63 | borderRadius: { 64 | xl: 'calc(var(--radius) + 4px)', 65 | lg: 'var(--radius)', 66 | md: 'calc(var(--radius) - 2px)', 67 | sm: 'calc(var(--radius) - 4px)' 68 | }, 69 | fontFamily: { 70 | sans: [...fontFamily.sans] 71 | }, 72 | keyframes: { 73 | 'accordion-down': { 74 | from: { height: '0' }, 75 | to: { height: 'var(--bits-accordion-content-height)' } 76 | }, 77 | 'accordion-up': { 78 | from: { height: 'var(--bits-accordion-content-height)' }, 79 | to: { height: '0' } 80 | }, 81 | 'caret-blink': { 82 | '0%,70%,100%': { opacity: '1' }, 83 | '20%,50%': { opacity: '0' } 84 | } 85 | }, 86 | animation: { 87 | 'accordion-down': 'accordion-down 0.2s ease-out', 88 | 'accordion-up': 'accordion-up 0.2s ease-out', 89 | 'caret-blink': 'caret-blink 1.25s ease-out infinite' 90 | } 91 | } 92 | }, 93 | plugins: [tailwindcssAnimate] 94 | }; 95 | 96 | export default config; 97 | -------------------------------------------------------------------------------- /frontend/tests/e2e/docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | registry: 5 | image: registry:3 6 | ports: 7 | - "5001:5000" 8 | environment: 9 | REGISTRY_STORAGE_DELETE_ENABLED: true 10 | REGISTRY_VALIDATION_DISABLED: true 11 | REGISTRY_HTTP_HEADERS_Access-Control-Allow-Origin: '[http://localhost:3000]' 12 | REGISTRY_HTTP_HEADERS_Access-Control-Allow-Methods: '[HEAD,GET,OPTIONS,DELETE]' 13 | REGISTRY_HTTP_HEADERS_Access-Control-Allow-Credentials: '[true]' 14 | REGISTRY_HTTP_HEADERS_Access-Control-Allow-Headers: '[Authorization,Accept,Cache-Control]' 15 | REGISTRY_HTTP_HEADERS_Access-Control-Expose-Headers: '[Docker-Content-Digest]' 16 | volumes: 17 | - registry-data:/var/lib/registry 18 | 19 | volumes: 20 | registry-data: -------------------------------------------------------------------------------- /frontend/tests/e2e/global-setup.ts: -------------------------------------------------------------------------------- 1 | import { setupTestEnvironment } from './setup'; 2 | import { type FullConfig } from '@playwright/test'; 3 | 4 | async function globalSetup(config: FullConfig) { 5 | await setupTestEnvironment(); 6 | } 7 | 8 | export default globalSetup; 9 | -------------------------------------------------------------------------------- /frontend/tests/e2e/global-teardown.ts: -------------------------------------------------------------------------------- 1 | import { teardownTestEnvironment } from './setup'; 2 | 3 | async function globalTeardown() { 4 | await teardownTestEnvironment(); 5 | } 6 | 7 | export default globalTeardown; 8 | -------------------------------------------------------------------------------- /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 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true 14 | } 15 | // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias 16 | // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files 17 | // 18 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 19 | // from the referenced tsconfig.json - TypeScript does not merge them in 20 | } 21 | -------------------------------------------------------------------------------- /frontend/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // Ensure TypeScript knows about the browser's crypto API 4 | interface Window { 5 | crypto: { 6 | subtle: { 7 | digest(algorithm: string, data: BufferSource): Promise; 8 | }; 9 | getRandomValues(buffer: T): T; 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vite'; 3 | import tailwindcss from '@tailwindcss/vite'; 4 | 5 | export default defineConfig({ 6 | plugins: [tailwindcss(), sveltekit()], 7 | resolve: { 8 | alias: {} 9 | }, 10 | // Optimize dependencies 11 | optimizeDeps: { 12 | esbuildOptions: { 13 | // Node.js global to browser globalThis 14 | define: { 15 | global: 'globalThis' 16 | } 17 | } 18 | }, 19 | 20 | // Allow importing specific Node.js modules 21 | server: { 22 | fs: { 23 | // Allow serving files from one level up to the project root 24 | allow: ['..'] 25 | } 26 | }, 27 | // Important: Tell Vite how to handle these Node.js built-in modules 28 | ssr: {} 29 | }); 30 | -------------------------------------------------------------------------------- /scripts/development/create-dev-image.sh: -------------------------------------------------------------------------------- 1 | podman buildx build --tag kmcr.cc/ofkm/svelocker-ui:0.10.0 --platform linux/amd64 --format docker . -------------------------------------------------------------------------------- /scripts/docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | echo "Starting frontend..." 5 | node frontend/build & 6 | FRONTEND_PID=$! 7 | 8 | echo "Starting backend..." 9 | cd backend && ./svelocker-backend & 10 | BACKEND_PID=$! 11 | 12 | # Handle shutdown signals 13 | trap 'kill $FRONTEND_PID $BACKEND_PID; exit' SIGINT SIGTERM 14 | 15 | # Wait for both processes to finish 16 | wait $FRONTEND_PID $BACKEND_PID -------------------------------------------------------------------------------- /scripts/docker/setup-container.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Flag file to indicate that user/group creation has already run 5 | setup_complete_file="/tmp/svelockerui_setup_complete" 6 | 7 | # Check if setup has already been completed 8 | if [ -f "${setup_complete_file}" ]; then 9 | echo "User and group setup already completed." 10 | exec su-exec "$PUID:$PGID" "$@" 11 | exit 0 12 | fi 13 | 14 | echo "Creating user and group..." 15 | 16 | PUID=${PUID:-1000} 17 | PGID=${PGID:-1000} 18 | 19 | # Check if the group with PGID exists; if not, create it 20 | if ! getent group svelockerui-group > /dev/null 2>&1; then 21 | echo "Creating group svelockerui-group with GID $PGID" 22 | addgroup -g "$PGID" svelockerui-group 23 | else 24 | echo "Group svelockerui-group already exists" 25 | fi 26 | 27 | # Check if a user with PUID exists; if not, create it 28 | if ! id -u svelockerui > /dev/null 2>&1; then 29 | if ! getent passwd "$PUID" > /dev/null 2>&1; then 30 | echo "Creating user svelockerui with UID $PUID and GID $PGID" 31 | adduser -u "$PUID" -G svelockerui-group -s /bin/sh -D svelockerui 32 | else 33 | # If a user with the PUID already exists, use that user 34 | existing_user=$(getent passwd "$PUID" | cut -d: -f1) 35 | echo "Using existing user: $existing_user" 36 | fi 37 | else 38 | echo "User svelockerui already exists" 39 | fi 40 | 41 | # Change ownership of the /app directory 42 | mkdir -p /app/data 43 | find /app/data \( ! -group "${PGID}" -o ! -user "${PUID}" \) -exec chown "${PUID}:${PGID}" {} + 44 | 45 | # Change ownership of /app and /app/data to the mapped user 46 | chown -R "${PUID}:${PGID}" /app 47 | chown -R "${PUID}:${PGID}" /app/data 48 | 49 | # Mark setup as complete 50 | touch "${setup_complete_file}" 51 | 52 | # Switch to the non-root user 53 | exec su-exec "$PUID:$PGID" "$@" --------------------------------------------------------------------------------