Compare commits

...

266 Commits
v0.0.1 ... main

Author SHA1 Message Date
Nicolas Meienberger
890bdb8319 docs: update readme version 2025-12-09 07:14:29 +01:00
Nicolas Meienberger
7091f1f296 chore: remove retry-lock 2025-12-06 11:17:31 +01:00
Nicolas Meienberger
b394aca7ca Merge branch 'tvarohohlavy-missing-icons-2' 2025-12-06 11:13:51 +01:00
Nicolas Meienberger
fdb84374a0 refactor: remove icons where it was making the context worse 2025-12-06 11:13:26 +01:00
Nicolas Meienberger
c42380b26b refactor: remove trim on password
The password should be taken as-is. It could potentially contain a space
2025-12-06 10:36:39 +01:00
Nicolas Meienberger
51ed47c30f refactor: no need to print safe args as it's already sanitized 2025-12-06 10:36:39 +01:00
Nicolas Meienberger
b8ae10b316 fix: volume data not refreshing when changing selection 2025-12-06 10:36:39 +01:00
Nico
f232fc07c1 feat: custom include patterns (#104)
* feat: add custom include patterns

* feat: add exclude-if-present option
2025-12-06 10:36:39 +01:00
Nico
d542318e2c feat: naming backup schedules (#103)
* docs: add agents instructions

* feat: naming backup schedules

* fix: wrong table for filtering
2025-12-06 10:36:39 +01:00
Nicolas Meienberger
2660d91002 fix: broken migration 2025-12-06 10:36:25 +01:00
Nicolas Meienberger
b155f82575 chore: update dependencies 2025-12-06 10:36:25 +01:00
Nico
9120153375 feat: mirror repositories (#95)
* feat: mirror repositories

feat: mirror backup repositories

* chore: pr feedbacks
2025-12-06 10:36:17 +01:00
Jakub Trávník
780fdae63e cryptoUtils.decrypt alligned with encrypt in regards of handling extra space in password file 2025-12-06 10:35:55 +01:00
Jakub Trávník
fc482e9729 crypto.Utils comments updated to reflect behaviour 2025-12-06 10:35:55 +01:00
Jakub Trávník
1fe026a76f avoid logging secrets in smb backend 2025-12-06 10:35:55 +01:00
Jakub Trávník
2df1fa53a0 feat: implement encryption for sensitive fields in volume backends 2025-12-06 10:35:55 +01:00
Nicolas Meienberger
da8e9c4ada ci: fix docker cache args on wrong step 2025-12-06 10:35:55 +01:00
Nicolas Meienberger
da489fab24 Merge branch 'tvarohohlavy-volumes-secrets-encryption' 2025-12-06 10:08:20 +01:00
Nicolas Meienberger
e4b8076351 refactor: remove trim on password
The password should be taken as-is. It could potentially contain a space
2025-12-06 10:08:01 +01:00
Nicolas Meienberger
70c72f0f9a refactor: no need to print safe args as it's already sanitized 2025-12-06 10:06:03 +01:00
Nicolas Meienberger
c45b760abc ci: fix docker cache args on wrong step 2025-12-06 09:49:59 +01:00
Nicolas Meienberger
9ba26b7599 feat: add DOCKER_HOST support 2025-12-06 09:49:59 +01:00
Nicolas Meienberger
01127ee9d6 fix: volume data not refreshing when changing selection 2025-12-06 09:49:59 +01:00
Nicolas Meienberger
77f5886110 fix: remove debug logs in production 2025-12-06 09:49:59 +01:00
Nico
6b6338291b feat: custom include patterns (#104)
* feat: add custom include patterns

* feat: add exclude-if-present option
2025-12-06 09:49:59 +01:00
Nico
2c11b7c7de feat: naming backup schedules (#103)
* docs: add agents instructions

* feat: naming backup schedules

* fix: wrong table for filtering
2025-12-06 09:49:59 +01:00
Nicolas Meienberger
a0fa043207 fix: broken migration 2025-12-06 09:49:59 +01:00
Nicolas Meienberger
143701820a chore: update dependencies 2025-12-06 09:49:59 +01:00
Nico
aff875c62f feat: mirror repositories (#95)
* feat: mirror repositories

feat: mirror backup repositories

* chore: pr feedbacks
2025-12-06 09:49:59 +01:00
Nicolas Meienberger
e52c25d87b ci: fix docker cache args on wrong step 2025-12-06 09:39:10 +01:00
Jakub Trávník
4403e3cf19 Merge branch 'main' into missing-icons 2025-12-05 00:22:39 +01:00
Nicolas Meienberger
e85cc35b1a feat: add DOCKER_HOST support 2025-12-04 19:09:23 +01:00
Nicolas Meienberger
321dc4cdf7 fix: volume data not refreshing when changing selection 2025-12-04 18:54:02 +01:00
Nicolas Meienberger
0f7bd1e042 fix: remove debug logs in production 2025-12-04 18:46:12 +01:00
Nico
08d8a47352 feat: custom include patterns (#104)
* feat: add custom include patterns

* feat: add exclude-if-present option
2025-12-04 18:44:34 +01:00
Nico
1e20fb225e feat: naming backup schedules (#103)
* docs: add agents instructions

* feat: naming backup schedules

* fix: wrong table for filtering
2025-12-04 18:31:00 +01:00
Jakub Trávník
9fec6883f6 cryptoUtils.decrypt alligned with encrypt in regards of handling extra space in password file 2025-12-04 08:43:17 +01:00
Jakub Trávník
f4df9e935d crypto.Utils comments updated to reflect behaviour 2025-12-04 08:39:51 +01:00
Jakub Trávník
f326f41599 avoid logging secrets in smb backend 2025-12-04 08:33:55 +01:00
Jakub Trávník
f6b8e7e5a2 feat: implement encryption for sensitive fields in volume backends 2025-12-04 00:04:26 +01:00
Nicolas Meienberger
b8e30e298c fix: broken migration 2025-12-03 21:56:41 +01:00
Nicolas Meienberger
ce07c588ad chore: update dependencies 2025-12-03 20:55:38 +01:00
Nico
e7f0a2828d feat: mirror repositories (#95)
* feat: mirror repositories

feat: mirror backup repositories

* chore: pr feedbacks
2025-12-03 20:52:47 +01:00
Jakub Trávník
ff4c1404a6 feat: add icons to buttons and alerts for improved UI clarity 2025-12-03 17:31:44 +01:00
Nicolas Meienberger
7ff38f0128 refactor: run forget commands in the background 2025-12-01 20:22:43 +01:00
Nicolas Meienberger
33e6f3773b fix: only lock shared tasks if an exclusive is running 2025-12-01 20:07:30 +01:00
Nicolas Meienberger
a91dede086 docs: bump version in readme 2025-12-01 19:48:55 +01:00
Nico
9b46737852 refactor(repositories): add a locking mechanism for restic operations (#94)
* refactor(repositories): add a locking mechanism for restic operations

* fix: add missing lock in list repositories
2025-12-01 19:47:21 +01:00
Nicolas Meienberger
999850dab8 Merge branch 'tvarohohlavy-telegram-notification' 2025-11-30 17:06:07 +01:00
Nicolas Meienberger
dbd9ae2241 chore: generate types 2025-11-30 17:05:40 +01:00
Nico
0287bca4bb restore as a page (#87)
* feat: add custom restore target directory

Adds the ability to restore snapshots to a custom directory instead of
only the original path. Closes #12.

Changes:
- Add target parameter to restore API endpoint
- Add directory picker UI in file browser restore dialog
- Add target input field in snapshot restore form
- Create reusable PathSelector component

Note: Run `bun run gen:api-client` after merging to regenerate types.

* refactor: path selector design

* refactor: unify restore snapshot dialogs

* refactor: restore snapshot as a page

* chore: fix liniting issues

* chore(create-notification): remove un-used prop

---------

Co-authored-by: Deepseek1 <Deepseek1@users.noreply.github.com>
2025-11-30 16:47:14 +01:00
Nico
9a9991eb9b restore as a page (#87)
* feat: add custom restore target directory

Adds the ability to restore snapshots to a custom directory instead of
only the original path. Closes #12.

Changes:
- Add target parameter to restore API endpoint
- Add directory picker UI in file browser restore dialog
- Add target input field in snapshot restore form
- Create reusable PathSelector component

Note: Run `bun run gen:api-client` after merging to regenerate types.

* refactor: path selector design

* refactor: unify restore snapshot dialogs

* refactor: restore snapshot as a page

* chore: fix liniting issues

* chore(create-notification): remove un-used prop

---------

Co-authored-by: Deepseek1 <Deepseek1@users.noreply.github.com>
2025-11-30 16:43:34 +01:00
Jakub Trávník
03b898f84c Update app/client/modules/notifications/components/create-notification-form.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-30 15:38:45 +01:00
Jakub Trávník
6fbb11fefe telegram notification 2025-11-30 15:15:26 +01:00
Nico
3bf3b22b96 feat: restore to custom location (#78)
* feat: restore to custom location

* refactor: define overwrite mode in shared schema
2025-11-29 16:53:44 +01:00
Nicolas Meienberger
58708cf35d refactor: repo healthcheck once per day 2025-11-29 12:25:46 +01:00
Nicolas Meienberger
1d4e7100ab fix: healtcheck, to not read full data 2025-11-29 12:24:07 +01:00
Nicolas Meienberger
0dfe000148 feat: rename volumes & repositories 2025-11-28 20:47:27 +01:00
Nicolas Meienberger
7d9d3d5d3d fix: wrong compression modes used 2025-11-28 20:28:47 +01:00
Nicolas Meienberger
8e90c4ace1 refactor: native repository healthcheck 2025-11-28 08:20:06 +01:00
Nicolas Meienberger
803eb1cd76 chore: update favicon 2025-11-28 08:18:34 +01:00
Nico
673827f9f3 refactor: all timestamps to ms (#77)
* refactor: change all timestamps to be in miliseconds

* chore: format files

* chore: fix syntax error
2025-11-26 23:20:22 +01:00
Nicolas Meienberger
4328607cc1 fix: skip renaming imported repository 2025-11-26 22:20:42 +01:00
Nicolas Meienberger
bedd325a60 fix(db): set pragma after migrations 2025-11-26 20:12:12 +01:00
Nico
b26a062648 refactor: use short ids to allow changing the name of volumes & repos (#67)
* refactor: use short ids to allow changing the name of volumes & repos

* refactor: address PR feedbacks

* fix: make short_id non null after initial population
2025-11-26 19:47:09 +01:00
Nico
d190d9c8cd feat: partial success warning status (#74)
* feat: report partial backups with warnings

* chore: rebase

* chore: remove un-used size prop
2025-11-26 19:02:29 +01:00
Nicolas Meienberger
f8363a6c71 docs: update readme with TZ environment 2025-11-25 18:25:22 +01:00
Nico
59b2b53837 docs: update README 2025-11-23 21:21:52 +01:00
Nicolas Meienberger
e99487eed9 fix(notifications): multiple providers using the wrong params 2025-11-23 21:09:23 +01:00
Nicolas Meienberger
8d4e5d2d4e fix(ntfy): wrong usage of token 2025-11-23 20:49:44 +01:00
Nicolas Meienberger
daea3e64e4 fix(smtp-notification): always use smtp:// 2025-11-23 20:37:21 +01:00
Nicolas Meienberger
70df79079f fix(backups): correctly apply repository compression mode 2025-11-23 17:53:13 +01:00
Nicolas Meienberger
f1096220dd chore: update readme first screenshot 2025-11-23 11:28:27 +01:00
Nicolas Meienberger
2418870284 docs: update readme 2025-11-22 17:51:58 +01:00
Nicolas Meienberger
43dfe6b190 chore: fix typing issue 2025-11-22 17:50:58 +01:00
Nicolas Meienberger
8c4939af4e feat(notifications): native support for pushover 2025-11-22 17:48:19 +01:00
Nicolas Meienberger
a622b5e689 feat: exclude specific xattr during restore 2025-11-22 17:39:08 +01:00
Nico
6c30e7e357 Feat/notifications alerts (#52)
* feat: notifications backend & creation

* feat: assign notification to backup schedule

* refactor: status dot one component

* chore(notification-details): remove refetchInterval
2025-11-22 14:58:21 +01:00
Nicolas Meienberger
043f73ea87 fix: properly decode path to support all special unicode characters 2025-11-21 18:25:27 +01:00
Nicolas Meienberger
518700eef6 chore: update readme version 2025-11-20 22:02:05 +01:00
Nico
a250c442f8 feat: add sftp repositories support (#36) 2025-11-20 20:31:40 +01:00
Nicolas Meienberger
6981600ad7 docs: update readme version 2025-11-20 19:15:05 +01:00
Nico
cb0d23fd52 refactor: rebrand to zerobyte (#45) 2025-11-20 18:59:57 +01:00
Nicolas Meienberger
0e4c302620 refactor: make healthchecks less expensive 2025-11-20 18:50:40 +01:00
Nico
ef87ca816d docs: add contributing.md and clarify CLA requirements (#41) 2025-11-18 21:43:51 +01:00
Nicolas Meienberger
70e4c782ff fix: undefined path in local repo 2025-11-17 21:09:46 +01:00
Nicolas Meienberger
c726c6fc72 feat: custom local repository path 2025-11-17 18:17:51 +01:00
Nicolas Meienberger
4d48d7be58 feat: add support for REST server 2025-11-16 18:24:09 +01:00
Nicolas Meienberger
df6b70c96f fix(create-volume): all port fields as number 2025-11-16 17:27:49 +01:00
Nicolas Meienberger
94423bd0a5 chore: remove unnecessary deps 2025-11-16 17:20:46 +01:00
Nicolas Meienberger
ed2a625fa7 ci: fix app version build arg 2025-11-16 17:11:30 +01:00
Nicolas Meienberger
a3e027694a ci: fix version injection to be a docker build arg 2025-11-16 16:53:29 +01:00
Copilot
0d36484c04 Add "Ironmount" prefix to page titles and display version in sidebar (#28)
* Initial plan

* Initial exploration - understanding the codebase

Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>

* Add "Ironmount - " prefix to all route titles and version in sidebar

Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>
2025-11-16 16:49:35 +01:00
Nicolas Meienberger
67b1accbd0 docs: add warning for location of /var/lib/ironmount 2025-11-16 12:53:13 +01:00
Nicolas Meienberger
98924ea59d fix: timezone parsing cron 2025-11-16 11:51:00 +01:00
Nico
e5435969be feat: remove individual snapshot (#26) 2025-11-16 11:14:18 +01:00
Nicolas Meienberger
c0bef7f65e chore: update versions in readme 2025-11-15 12:41:53 +01:00
Nicolas Meienberger
29c96c9fc6 ci: don't create gh release for alpha and beta versions 2025-11-15 12:28:34 +01:00
Nicolas Meienberger
2c0f22af59 fix(create-repo): don't try to load rclone remotes if the capability is disabled 2025-11-15 12:22:56 +01:00
Nicolas Meienberger
3ff6a04f8e feat(repositories): allow importing existing repos 2025-11-15 11:58:52 +01:00
Nicolas Meienberger
54ee02deb9 feat(backups): manual repository cleanup 2025-11-15 11:24:17 +01:00
Nicolas Meienberger
b83881c189 fix(backups): re-calculate next backup date before starting the backup 2025-11-15 11:13:23 +01:00
Nicolas Meienberger
d78b4adfd9 chore: update readme 2025-11-15 10:37:35 +01:00
Nicolas Meienberger
4d3ec524e2 chore: add all caps for dev container 2025-11-15 10:23:15 +01:00
Nicolas Meienberger
681cf5dff1 fix: hide test-connection button for directories 2025-11-15 10:15:25 +01:00
Nicolas Meienberger
31da747c2d fix: mount and unmount command not properly throwing errors 2025-11-15 10:08:16 +01:00
Nicolas Meienberger
b86081b2e8 Merge branch 'altendorfme-backup-file-path' 2025-11-15 09:51:05 +01:00
Nicolas Meienberger
3622fd57ef refactor(repository): keep the error if repo is already init 2025-11-15 09:45:04 +01:00
Nicolas Meienberger
5b1d7eff17 chore: update .gitignore 2025-11-15 09:45:04 +01:00
Nicolas Meienberger
2b3d8dffc5 Merge branch 'altendorfme-main' 2025-11-15 09:42:50 +01:00
Nicolas Meienberger
f517438a8e refactor(repository): keep the error if repo is already init 2025-11-15 09:42:29 +01:00
Nicolas Meienberger
1ddd4d701b chore: update .gitignore 2025-11-15 09:39:49 +01:00
Renan Bernordi
9a1797b8b2 backup file and folders 2025-11-14 23:21:13 -03:00
Renan Bernordi
52046c88cc support cloudflare r2 2025-11-14 22:37:27 -03:00
Nicolas Meienberger
951d9d970c chore: update readme 2025-11-14 22:44:58 +01:00
Nicolas Meienberger
ffc821af2b chore: update readme 2025-11-14 21:36:33 +01:00
Nicolas Meienberger
cfeff643c4 refactor(create-volume): from dialog to page 2025-11-14 21:23:52 +01:00
Nicolas Meienberger
c898e1ce07 refactor(create-repository): from dialog to page 2025-11-14 21:10:40 +01:00
Nicolas Meienberger
c179a16d15 refactor: small code style 2025-11-14 20:59:13 +01:00
Nicolas Meienberger
00916a1fd2 refactor(browsers): create hook for common operations 2025-11-14 20:56:06 +01:00
Nicolas Meienberger
18f863cbac chore: remove node_modules folder 2025-11-14 20:03:11 +01:00
Nicolas Meienberger
1b8595c17e fix: cookie not secure 2025-11-14 19:13:14 +01:00
Nicolas Meienberger
6e6becec3b refactor(breadcrumbs): use handler & match pattern 2025-11-13 22:28:53 +01:00
Nicolas Meienberger
6d3d3c38f9 fix: docker build 2025-11-13 20:15:56 +01:00
Nico
95a0d44b45 refactor: unify backend and frontend servers (#3)
* refactor: unify backend and frontend servers

* refactor: correct paths for openapi & drizzle

* refactor: move api-client to client

* fix: drizzle paths

* chore: fix linting issues

* fix: form reset issue
2025-11-13 20:11:46 +01:00
Nicolas Meienberger
8d7e50508d refactor: system-info hook 2025-11-11 21:48:42 +01:00
Nicolas Meienberger
52e38a6242 refactor: rclone system capability 2025-11-11 21:44:04 +01:00
Nicolas Meienberger
36b0282d18 feat(frontend): rclone repositories config 2025-11-11 21:31:10 +01:00
Nicolas Meienberger
8f9873148a feat(repositories): rclone backends 2025-11-11 20:42:44 +01:00
Nicolas Meienberger
a1cc89c66e fix: ensure caching in file explorers 2025-11-11 18:01:54 +01:00
Nicolas Meienberger
ff7f6ffad9 feat(repositories): azure blob storage 2025-11-10 21:07:12 +01:00
Nicolas Meienberger
e98c0af8ca feat(repositories): add google cloud storage support 2025-11-10 21:04:08 +01:00
Nicolas Meienberger
d31fa8d464 chore: small improvements 2025-11-10 21:03:37 +01:00
Nicolas Meienberger
bf33b15b3e fix: cleanup volumes on shutdown 2025-11-10 07:08:51 +01:00
Nicolas Meienberger
2b0fea9645 fix(mounts): use bun shell instead of execFile 2025-11-10 06:52:14 +01:00
Nicolas Meienberger
e9eeda304b chore: update readme with new version 2025-11-09 15:37:16 +01:00
Nicolas Meienberger
4ddc45a74f style(create-schedule): fix explorer width on mobile 2025-11-09 14:19:34 +01:00
Nicolas Meienberger
2aa90ec44d feat: exclude patterns 2025-11-09 14:09:49 +01:00
Nicolas Meienberger
dd36397346 Merge branch 'steveiliop56-main' 2025-11-09 12:34:44 +01:00
Nicolas Meienberger
2ec8d4c1dd chore: small fixes 2025-11-09 12:34:12 +01:00
Nicolas Meienberger
4b981bdcac style: use card in backup progress waiting 2025-11-09 12:25:41 +01:00
Nicolas Meienberger
5e908dc945 feat: backup progress 2025-11-09 12:25:41 +01:00
Nicolas Meienberger
5f35cfd4c2 feat: throttle logs during backup 2025-11-09 12:25:41 +01:00
Nicolas Meienberger
1152939373 feat: repository doctor always visible 2025-11-09 12:25:41 +01:00
Nicolas Meienberger
94398f81bf style: use card in backup progress waiting 2025-11-09 12:25:15 +01:00
Stavros
db0d153610 chore: format 2025-11-09 13:16:14 +02:00
Nicolas Meienberger
5ff48f4d5d feat: backup progress 2025-11-09 12:11:00 +01:00
Stavros
ffca433a43 fix: accessibility and responsiveness fixes 2025-11-09 13:04:14 +02:00
Nicolas Meienberger
4389029ba5 feat: throttle logs during backup 2025-11-09 11:33:14 +01:00
Nicolas Meienberger
927db77f60 feat: repository doctor always visible 2025-11-09 11:31:44 +01:00
Nicolas Meienberger
3e80850396 fix(backup): only keep last line of stdout in memory 2025-11-08 23:41:29 +01:00
Nicolas Meienberger
5f620b4c45 feat: allow stopping an ongoing backup 2025-11-08 23:26:53 +01:00
Nicolas Meienberger
3abf8ab12d fix: skip backups if in_progress 2025-11-08 18:19:49 +01:00
Nicolas Meienberger
b5ba03da3d feat: download recovery file restic password 2025-11-08 17:52:43 +01:00
Nicolas Meienberger
b289920720 fix: timeout statfs 2025-11-08 16:52:58 +01:00
Nicolas Meienberger
ef5d95d347 refactor: consolidate page titles 2025-11-08 14:04:06 +01:00
Nicolas Meienberger
a1ef34118c chore: update README 2025-11-08 13:23:09 +01:00
Nicolas Meienberger
59433f3686 chore: update README with docker & host propagation instructions 2025-11-08 13:21:59 +01:00
Nicolas Meienberger
3debd80e15 chore: update readme with detailed instructions 2025-11-08 12:55:39 +01:00
Nicolas Meienberger
0461ed63d8 chore: update README 2025-11-08 11:27:47 +01:00
Nicolas Meienberger
1c4b1f34dd fix: get correct volume path for directories 2025-11-08 11:19:22 +01:00
Nicolas Meienberger
11dd6f46c8 feat: sse for volume status change 2025-11-08 11:07:09 +01:00
Nicolas Meienberger
fd3a527164 feat: sse events 2025-11-08 11:06:26 +01:00
Nicolas Meienberger
5b4b571581 feat: local volume explore file system 2025-11-08 11:00:18 +01:00
Nicolas Meienberger
4aeebea5b2 refactor: consolidate all data in /var/lib/ironmount 2025-11-08 10:40:25 +01:00
Nicolas Meienberger
ba08c97681 feat: backup in progress status 2025-11-08 10:33:08 +01:00
Nicolas Meienberger
42497be4b5 feat: host/proc optional capability 2025-11-08 10:26:50 +01:00
Nicolas Meienberger
9d27bffc21 feat: docker optional capability 2025-11-08 10:16:18 +01:00
Nicolas Meienberger
9ec765bd90 feat: system optional capabilities 2025-11-08 10:14:42 +01:00
Nicolas Meienberger
4dc239139f feat(repositories): healthchecks and doctor command 2025-11-08 10:11:07 +01:00
Nicolas Meienberger
f5339d3708 feat(volumes): read only mount mode 2025-11-08 10:01:54 +01:00
Nicolas Meienberger
418369c4ad feat(settings): change password 2025-11-08 09:47:10 +01:00
Nicolas Meienberger
195aea052e refactor(backup-details): load snapshots after page load 2025-11-08 09:24:30 +01:00
Nicolas Meienberger
c32eb0831f fix: favicon and background image serving 2025-11-07 19:26:37 +01:00
Nicolas Meienberger
885ae02b18 fix: error details visible in backup page 2025-11-07 19:19:02 +01:00
Nicolas Meienberger
8f1e24a034 chore: update readme 2025-11-05 22:17:11 +01:00
Nicolas Meienberger
1e68339dd9 style: fix snapshots table 2025-11-05 22:00:39 +01:00
Nicolas Meienberger
941be2f306 style(snapshots): fix margin in timeline 2025-11-05 21:31:49 +01:00
Nicolas Meienberger
99d4d46338 feat(restore): delete files not in snapshot option 2025-11-05 19:00:44 +01:00
Nicolas Meienberger
ab997ef450 feat: restore alert dialog 2025-11-05 18:36:48 +01:00
Nicolas Meienberger
9ee5871fbb feat: restore 2025-11-05 18:30:10 +01:00
Nicolas Meienberger
01c2a3669c refactor(backups): tag snapshots by backup id and run forget by grouping first by tags 2025-11-04 20:13:00 +01:00
Nicolas Meienberger
ecd517341c fix(backup-details): re-fetch when selected snapshot is changed 2025-11-04 18:41:30 +01:00
Nicolas Meienberger
ef27ab8ed8 fix(file-tree) select parent even if children are not loaded yet 2025-11-04 18:13:35 +01:00
Nicolas Meienberger
d1e46918ec refactor: simplify snapshot file explorer 2025-11-04 18:01:37 +01:00
Nicolas Meienberger
11ca80a929 feat: backup details snapshots timeline & file explorer 2025-11-03 22:03:55 +01:00
Nicolas Meienberger
f2643436b0 refactor: snapshots flat response 2025-11-03 21:54:31 +01:00
Nicolas Meienberger
acc5f44565 feat: select folders to backup when creating a job 2025-11-02 19:16:24 +01:00
Nicolas Meienberger
ccfa5e35e9 refactor: delete alert dialogs 2025-11-02 16:55:24 +01:00
Nicolas Meienberger
c2041932b5 feat: delete backup schedule 2025-11-02 16:52:25 +01:00
Nicolas Meienberger
44917f3513 fix(create-backup): missing submit button 2025-11-02 16:47:42 +01:00
Nicolas Meienberger
3e514e61db feat(backups): add backup card 2025-11-02 16:42:21 +01:00
Nicolas Meienberger
67e7d36fe7 refactor(ui): use dot for backup status 2025-11-02 16:36:02 +01:00
Nicolas Meienberger
8f447ac58d refactor: use correct namings 2025-11-02 16:05:22 +01:00
Nicolas Meienberger
3befa127d7 refactor: frontend components consolidation 2025-11-01 19:42:13 +01:00
Nicolas Meienberger
18115b374c feat(frontend): backup jobs page 2025-11-01 17:09:43 +01:00
Nicolas Meienberger
d81f3653ec feat: run backup now 2025-10-31 22:16:31 +01:00
Nicolas Meienberger
c64e50bdec chore: drizzle config root 2025-10-31 22:05:28 +01:00
Nicolas Meienberger
afeaf87bb0 refactor(utils): use nsenter only if /host/proc is mounted 2025-10-31 22:04:55 +01:00
Nicolas Meienberger
ee79fce2aa feat(frontend): restore whole snapshot 2025-10-31 21:52:54 +01:00
Nicolas Meienberger
c7db88fb56 feat(snapshot): backend restore api 2025-10-31 21:15:43 +01:00
Nicolas Meienberger
5846c1ff86 feat: snapshot files list frontend 2025-10-30 19:01:49 +01:00
Nicolas Meienberger
b80a187108 feat(snapshots): list files in snapshots api 2025-10-30 18:58:57 +01:00
Nicolas Meienberger
ed73ca73fb refactor(snapshots): unify tables in a single component 2025-10-30 18:38:05 +01:00
Nicolas Meienberger
bd168df352 style(layout): fix header sticky
style(layout): fix unnecessary scroll
2025-10-30 18:22:53 +01:00
Nicolas Meienberger
cce2d356fe feat: backup schedule frontend 2025-10-30 18:18:11 +01:00
Nicolas Meienberger
9628310d53 refactor(backups): use upsert instead of create/update split 2025-10-29 21:14:41 +01:00
Nicolas Meienberger
e335133237 fix(restic): unlock repo before trying to backup 2025-10-29 19:16:20 +01:00
Nicolas Meienberger
b188a84af3 refactor: simplify dtos and improve type saftey in json returns 2025-10-29 18:28:00 +01:00
Nicolas Meienberger
d1c1adaba7 feat: backup schedule creation form 2025-10-28 22:34:56 +01:00
Nicolas Meienberger
37a22b260f fix(login/onboarding): redirect to dashboard if already logged in 2025-10-25 22:46:37 +02:00
Nicolas Meienberger
a7bc1c2e7e refactor: bind /proc and mount volumes with nsenter 2025-10-25 22:30:33 +02:00
Nicolas Meienberger
43e31596f1 feat(backend): backup service with retention policy 2025-10-25 20:38:13 +02:00
Nicolas Meienberger
2202ad3247 refactor(backend): better job scheduling pattern 2025-10-25 17:46:01 +02:00
Nicolas Meienberger
47ff720adb style(auth): redesign login and onboarding pages 2025-10-25 17:12:43 +02:00
Nicolas Meienberger
d58c4f793d feat: enable restic cache 2025-10-23 21:10:36 +02:00
Nicolas Meienberger
f7718055eb feat: repositories snapshots frontend 2025-10-23 20:55:44 +02:00
Nicolas Meienberger
cae8538b2e feat(repositories): list snapshots api 2025-10-23 20:22:09 +02:00
Nicolas Meienberger
4ae738ce41 refactor: use schema constants 2025-10-23 19:25:12 +02:00
Nicolas Meienberger
8b1438ea62 feat: repositories frontend 2025-10-23 19:14:03 +02:00
Nicolas Meienberger
9d10e48da6 style: logo header 2025-10-22 22:16:29 +02:00
Nicolas Meienberger
a927411c0d style: full width design 2025-10-22 21:28:14 +02:00
Nicolas Meienberger
a64de8ec78 refactor: / -> /volumes 2025-10-21 21:03:38 +02:00
Nicolas Meienberger
6960b4d71e feat: sidebar 2025-10-21 20:45:20 +02:00
Nicolas Meienberger
06cb401cf7 fix(lifecycle): catch error on startup 2025-10-21 20:30:42 +02:00
Nicolas Meienberger
0090c3c43c refactor(schemas): move restic schemas to a subfolder 2025-10-21 19:57:04 +02:00
Nicolas Meienberger
e07c22a7d4 docs: add required licences 2025-10-21 19:56:59 +02:00
Nicolas Meienberger
100c24de13 feat: encrypt repository credentials at rest 2025-10-18 15:15:30 +02:00
Nicolas Meienberger
6e8aa4b465 feat: repositories controller and service for crd 2025-10-18 14:23:42 +02:00
Nicolas Meienberger
ad54948a69 refactor: use getVolumePath helper 2025-10-17 23:39:16 +02:00
Nicolas Meienberger
5b6a86331e fix(list): copy path to clipboard 2025-10-17 23:26:58 +02:00
Nicolas Meienberger
8fcc9ada74 feat: cleanup dangling volumes and folders on startup and on schedule 2025-10-17 23:01:47 +02:00
Nicolas Meienberger
8a9d5fc3c8 refactor(statfs): switch back to statfs but convert values for smb 2025-10-17 22:22:22 +02:00
Nicolas Meienberger
219dec1c9c fix(statfs): fix usage graph by using df command 2025-10-17 21:34:40 +02:00
Nicolas Meienberger
3bda6e81ae feat(details): keep tab in url to preserve active tab on reload 2025-10-17 21:23:33 +02:00
Nicolas Meienberger
269116c25e refactor: improve file explorer performance by pre-fetching on hover 2025-10-17 21:19:58 +02:00
Nicolas Meienberger
c8fc5a1273 chore: sanitize sensitive data from logs and error messages 2025-10-17 21:11:12 +02:00
Nicolas Meienberger
ae592481af feat: base restic repo schemas 2025-10-17 21:03:13 +02:00
Nicolas Meienberger
65a7f436fe fix: clean undefined values before posting form 2025-10-17 21:01:32 +02:00
Nicolas Meienberger
8af0bac63b feat: restic pass file generation 2025-10-17 13:15:24 +02:00
Nicolas Meienberger
41756e087a feat: report an issue button 2025-10-15 22:16:48 +02:00
Nicolas Meienberger
71ca5d3309 chore: bump bun to 1.3.0 2025-10-15 22:16:40 +02:00
Nico
e29908757f Revise warning message in README
Updated warning message to encourage feature requests.
2025-10-10 19:05:45 +02:00
Nicolas Meienberger
15f0dc637d chore: improve logging
Update README with version warning

Update Ironmount image version to v0.2.0
2025-10-09 22:41:56 +02:00
Nicolas Meienberger
d16be6cbca ci: create releases 2025-10-06 19:49:44 +02:00
Nico
1e3419c250 feat: file explorer (#1)
* feat: list volume files backend

* feat: file tree component

* feat: load sub folders

* fix: filetree wrong opening order

* temp: open / close icons

* chore: remove all hc files when cleaning

* chore: file-tree optimizations
2025-10-06 19:46:49 +02:00
Nicolas Meienberger
a5e0fb6aa2 docs: update README 2025-10-04 14:50:39 +02:00
Nicolas Meienberger
3d87814aee ui: empty state 2025-10-04 14:43:29 +02:00
Nicolas Meienberger
56a4afdc92 chore: remove prismjs 2025-10-04 14:20:36 +02:00
Nicolas Meienberger
728cfebeb7 refactor: extract grid background 2025-10-04 14:02:34 +02:00
Nicolas Meienberger
472f7799a4 ui: redesign tabs 2025-10-04 13:26:34 +02:00
Nicolas Meienberger
e134d0e1d1 ui: redesign 2025-10-04 00:16:47 +02:00
Nicolas Meienberger
689f14dff7 ui: card corner accent 2025-10-03 23:11:14 +02:00
Nicolas Meienberger
8d46074bb1 fix: issue with the test connection message 2025-10-03 21:50:57 +02:00
Nicolas Meienberger
5f003fe69d fix: build issues 2025-10-03 20:19:54 +02:00
Nicolas Meienberger
7784389b57 docs: update README 2025-10-02 21:56:41 +02:00
Nicolas Meienberger
1ad8f69355 refactor: use rhf for login and onboarding 2025-10-02 21:52:55 +02:00
Nicolas Meienberger
2be7e18ab5 feat: auth client middleware 2025-10-02 21:28:08 +02:00
Nicolas Meienberger
0120641e3a feat: onboarding flow 2025-10-02 20:30:02 +02:00
Nicolas Meienberger
689e92ffc1 chore: small ui improvements 2025-10-02 19:59:04 +02:00
Nicolas Meienberger
86adda848e fix: mobile viewport 2025-10-02 19:34:08 +02:00
Nicolas Meienberger
c013351026 fix: make the add form scrollable for smaller screens 2025-10-02 18:47:53 +02:00
Nicolas Meienberger
1e7530cc09 feat: authentication 2025-10-02 18:47:25 +02:00
Nico
7f79fd7628 Update docker-compose command to use 'docker compose' 2025-10-01 21:52:13 +02:00
Nico
c29f35fc34 Update README.md 2025-10-01 21:50:09 +02:00
Nicolas Meienberger
9872185b69 docs: update README again 2025-10-01 21:43:57 +02:00
331 changed files with 36289 additions and 6997 deletions

View File

@@ -1,6 +1,8 @@
*
!turbo.json
!bun.lock
!package.json
!**/package.json
!**/bun.lock
@@ -8,10 +10,14 @@
!**/vite.config.ts
!**/react-router.config.ts
!**/build.ts
!**/components.json
!apps/**/src/**
!apps/**/drizzle/**
!apps/**/app/**
!apps/**/public/**
!src/**
!app/**
!public/**
# License files and attributions
!LICENSE
!NOTICES.md
!LICENSES/**
!packages/**/src/**

1
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1 @@
- This project uses the AGENTS.md file to give detailed information about the repository structure and development commands. Make sure to read this file before starting development.

View File

@@ -54,7 +54,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/ironmount
images: ghcr.io/${{ github.repository_owner }}/zerobyte
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}},prefix=v,enable=${{ needs.determine-release-type.outputs.release_type == 'release' }}
@@ -62,8 +62,6 @@ jobs:
type=semver,pattern={{major}}.{{minor}}.{{patch}},prefix=v,enable=${{ needs.determine-release-type.outputs.release_type == 'release' }}
flavor: |
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
cache-from: type=registry,ref=ghcr.io/nicotsx/ironmount:buildcache
cache-to: type=registry,ref=ghcr.io/nicotsx/ironmount:buildcache,mode=max
- name: Build and push images
uses: docker/build-push-action@v6
@@ -74,3 +72,28 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
cache-from: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache
cache-to: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache,mode=max
publish-release:
runs-on: ubuntu-latest
needs: [build-images, determine-release-type]
if: needs.determine-release-type.outputs.release_type == 'release'
outputs:
id: ${{ steps.create_release.outputs.id }}
steps:
- name: Create GitHub release
id: create_release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
body: |
**${{ needs.determine-release-type.outputs.tagname }}**
tag_name: ${{ needs.determine-release-type.outputs.tagname }}
name: ${{ needs.determine-release-type.outputs.tagname }}
draft: false
prerelease: true
files: cli/runtipi-cli-*

45
.gitignore vendored
View File

@@ -1,43 +1,14 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
.DS_Store
/node_modules/
# Test binary, built with `go test -c`
*.test
# React Router
/.react-router/
/build/
/dist/
# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Editor/IDE
# .idea/
# .vscode/
ironmount
out/
*.db
tmp/
node_modules/
.env*
.turbo
CLAUDE.md
mutagen.yml.lock
notes.md

267
AGENTS.md Normal file
View File

@@ -0,0 +1,267 @@
# AGENTS.md
## Important instructions
- Never create migration files manually. Always use the provided command to generate migrations
- If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration
## Project Overview
Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage).
## Technology Stack
- **Runtime**: Bun 1.3.1
- **Server**: Hono (web framework) with Bun runtime
- **Client**: React Router v7 (SSR) with React 19
- **Database**: SQLite with Drizzle ORM
- **Validation**: ArkType for runtime schema validation
- **Styling**: Tailwind CSS v4 + Radix UI components
- **Architecture**: Unified application structure (not a monorepo)
- **Code Quality**: Biome (formatter & linter)
- **Containerization**: Docker with multi-stage builds
## Repository Structure
This is a unified application with the following structure:
- `app/server` - Bun-based API server with Hono
- `app/client` - React Router SSR frontend components and modules
- `app/schemas` - Shared ArkType schemas for validation
- `app/drizzle` - Database migrations
### Type Checking
```bash
# Run type checking and generate React Router types
bun run tsc
```
### Building
```bash
# Build for production
bun run build
```
### Database Migrations
```bash
# Generate new migration from schema changes
bun gen:migrations
# Generate a custom empty migration
bunx drizzle-kit generate --custom --name=fix-timestamps-to-ms
```
### API Client Generation
```bash
# Generate TypeScript API client from OpenAPI spec
# Note: Server is always running don't need to start it separately
bun run gen:api-client
```
### Code Quality
```bash
# Format and lint (Biome)
bunx biome check --write .
# Format only
bunx biome format --write .
# Lint only
bunx biome lint .
```
## Architecture
### Server Architecture
The server follows a modular service-oriented architecture:
**Entry Point**: `app/server/index.ts`
- Initializes servers using `react-router-hono-server`:
1. Main API server on port 4096 (REST API + serves static frontend)
2. Docker volume plugin server on Unix socket `/run/docker/plugins/zerobyte.sock` (optional, if Docker is available)
**Modules** (`app/server/modules/`):
Each module follows a controller <20> service <20> database pattern:
- `auth/` - User authentication and session management
- `volumes/` - Volume mounting/unmounting (NFS, SMB, WebDAV, directories)
- `repositories/` - Restic repository management (S3, Azure, GCS, local, rclone)
- `backups/` - Backup schedule management and execution
- `notifications/` - Notification system with multiple providers (Discord, email, Gotify, Ntfy, Slack, Pushover)
- `driver/` - Docker volume plugin implementation
- `events/` - Server-Sent Events for real-time updates
- `system/` - System information and capabilities
- `lifecycle/` - Application startup/shutdown hooks
**Backends** (`app/server/modules/backends/`):
Each volume backend (NFS, SMB, WebDAV, directory) implements mounting logic using system tools (mount.nfs, mount.cifs, davfs2).
**Jobs** (`app/server/jobs/`):
Cron-based background jobs managed by the Scheduler:
- `backup-execution.ts` - Runs scheduled backups (every minute)
- `cleanup-dangling.ts` - Removes stale mounts (hourly)
- `healthchecks.ts` - Checks volume health (every 5 minutes)
- `repository-healthchecks.ts` - Validates repositories (every 10 minutes)
- `cleanup-sessions.ts` - Expires old sessions (daily)
**Core** (`app/server/core/`):
- `scheduler.ts` - Job scheduling system using node-cron
- `capabilities.ts` - Detects available system features (Docker support, etc.)
- `constants.ts` - Application-wide constants
**Utils** (`app/server/utils/`):
- `restic.ts` - Restic CLI wrapper with type-safe output parsing
- `spawn.ts` - Safe subprocess execution helpers
- `logger.ts` - Winston-based logging
- `crypto.ts` - Encryption utilities
- `errors.ts` - Error handling middleware
**Database** (`app/server/db/`):
- Uses Drizzle ORM with SQLite
- Schema in `schema.ts` defines: volumes, repositories, backup schedules, notifications, users, sessions
- Migrations: `app/drizzle/`
### Client Architecture
**Framework**: React Router v7 with SSR
**Entry Point**: `app/root.tsx`
The client uses:
- TanStack Query for server state management
- Auto-generated API client from OpenAPI spec (in `app/client/api-client/`)
- Radix UI primitives with custom Tailwind styling
- Server-Sent Events hook (`use-server-events.ts`) for real-time updates
Routes are organized in feature modules at `app/client/modules/*/routes/`.
### Shared Schemas
`app/schemas/` contains ArkType schemas used by both client and server:
- Volume configurations (NFS, SMB, WebDAV, directory)
- Repository configurations (S3, Azure, GCS, local, rclone)
- Restic command output parsing types
- Backend status types
These schemas provide runtime validation and TypeScript types.
## Restic Integration
Zerobyte is a wrapper around Restic for backup operations. Key integration points:
**Repository Management**:
- Creates/initializes Restic repositories via `restic init`
- Supports multiple backends: local, S3, Azure Blob Storage, Google Cloud Storage, or any rclone-supported backend
- Stores single encryption password in `/var/lib/zerobyte/restic/password` (auto-generated on first run)
**Backup Operations**:
- Executes `restic backup` with user-defined schedules (cron expressions)
- Supports include/exclude patterns for selective backups
- Parses JSON output for progress tracking and statistics
- Implements retention policies via `restic forget --prune`
**Repository Utilities** (`utils/restic.ts`):
- `buildRepoUrl()` - Constructs repository URLs for different backends
- `buildEnv()` - Sets environment variables (credentials, cache dir)
- `ensurePassfile()` - Manages encryption password file
- Type-safe parsing of Restic JSON output using ArkType schemas
**Rclone Integration** (`app/server/modules/repositories/`):
- Allows using any rclone backend as a Restic repository
- Dynamically generates rclone config and passes via environment variables
- Supports backends like Dropbox, Google Drive, OneDrive, Backblaze B2, etc.
## Docker Volume Plugin
When Docker socket is available (`/var/run/docker.sock`), Zerobyte registers as a Docker volume plugin:
**Plugin Location**: `/run/docker/plugins/zerobyte.sock`
**Implementation**: `app/server/modules/driver/driver.controller.ts`
This allows other containers to mount Zerobyte volumes using Docker.
The plugin implements the Docker Volume Plugin API v1.
## Environment & Configuration
**Runtime Environment Variables**:
- Database path: `./data/zerobyte.db` (configurable via `drizzle.config.ts`)
- Restic cache: `/var/lib/zerobyte/restic/cache`
- Restic password: `/var/lib/zerobyte/restic/password`
- Volume mounts: `/var/lib/zerobyte/mounts/<volume-name>`
- Local repositories: `/var/lib/zerobyte/repositories/<repo-name>`
**Capabilities Detection**:
On startup, the server detects available capabilities (see `core/capabilities.ts`):
- **Docker**: Requires `/var/run/docker.sock` access
- System will gracefully degrade if capabilities are unavailable
## Common Workflows
### Adding a New Volume Backend
1. Create backend implementation in `app/server/modules/backends/<backend>/`
2. Implement `mount()` and `unmount()` methods
3. Add schema to `app/schemas/volumes.ts`
4. Update `volumeConfigSchema` discriminated union
5. Update backend factory in `app/server/modules/backends/backend.ts`
### Adding a New Repository Backend
1. Add backend type to `app/schemas/restic.ts`
2. Update `buildRepoUrl()` in `app/server/utils/restic.ts`
3. Update `buildEnv()` to handle credentials/configuration
4. Add DTO schemas in `app/server/modules/repositories/repositories.dto.ts`
5. Update repository service to handle new backend
### Adding a New Scheduled Job
1. Create job class in `app/server/jobs/<job-name>.ts` extending `Job`
2. Implement `run()` method
3. Register in `app/server/modules/lifecycle/startup.ts` with cron expression:
```typescript
Scheduler.build(YourJob).schedule("* * * * *");
```
## Important Notes
- **Code Style**: Uses Biome with tabs (not spaces), 120 char line width, double quotes
- **Imports**: Organize imports is disabled in Biome - do not auto-organize
- **TypeScript**: Uses `"type": "module"` - all imports must include extensions when targeting Node/Bun
- **Validation**: Prefer ArkType over Zod - it's used throughout the codebase
- **Database**: Timestamps are stored as Unix epoch integers, not ISO strings
- **Security**: Restic password file has 0600 permissions - never expose it
- **Mounting**: Requires privileged container or CAP_SYS_ADMIN for FUSE mounts
- **API Documentation**: OpenAPI spec auto-generated at `/api/v1/openapi.json`, docs at `/api/v1/docs`
## Docker Development Setup
The `docker-compose.yml` defines two services:
- `zerobyte-dev` - Development with hot reload (uses `development` stage)
- `zerobyte-prod` - Production build (uses `production` stage)
Both mount:
- `/var/lib/zerobyte` for persistent data
- `/dev/fuse` device for FUSE mounting
- Optionally `/var/run/docker.sock` for Docker plugin functionality

167
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,167 @@
# Contributing to Zerobyte
Thank you for your interest in contributing to Zerobyte! We welcome contributions from the community and are grateful for your support in making this project better.
## Table of Contents
- [Getting Started](#getting-started)
- [Contributor License Agreement (CLA)](#contributor-license-agreement-cla)
- [How to Contribute](#how-to-contribute)
- [Development Setup](#development-setup)
- [Submission Guidelines](#submission-guidelines)
- [Code Standards](#code-standards)
- [Community Guidelines](#community-guidelines)
## Getting Started
Before you begin:
1. Check the [issues](https://github.com/nicotsx/zerobyte/issues) to see if someone is already working on what you have in mind
2. For major changes, please open an issue first to discuss what you would like to change
3. Make sure you have read and agreed to our Contributor License Agreement (CLA)
## Contributor License Agreement (CLA)
### What is a CLA?
A Contributor License Agreement (CLA) is a legal document in which you state you are entitled to contribute the code/documentation/translation to the project you're contributing to and are willing to have it used in distributions and derivative works. This means you grant us permission to use your contributions under our project's license terms.
### Why do we need a CLA?
We require a CLA for several important reasons:
1. **License Flexibility**: It allows the project to evolve its licensing model if needed in the future without requiring re-approval from all past contributors, ensuring Zerobyte can continue to operate and adapt to changing needs of the community.
2. **Patent Protection**: The CLA includes a patent license grant, which protects the project and its users from potential patent claims related to your contributions.
3. **Protecting Your Rights**: While you grant us rights to use your contributions, you retain ownership of your work and can use it for any other purpose.
### How to Sign the CLA
When you submit your first pull request, our CLA Assistant will automatically prompt you to sign the agreement via GitHub. The process is simple:
1. Create your pull request
2. The CLA Assistant bot will comment on your PR
3. Follow the link provided to review and sign the CLA electronically
4. Once signed, the bot will update your PR status
You only need to sign the CLA once, and it will cover all your future contributions to Zerobyte.
### Key Points of Our CLA
- You grant us a non-exclusive, royalty-free license to use your contributions
- You retain ownership and all rights to your contributions
- You confirm that you have the right to make the contribution (it's your original work or you have permission)
- You're not required to provide support for your contributions
- The CLA does not guarantee that your contribution will be accepted or kept into the project
For the complete CLA text, please see the [CLA document](https://cla-assistant.io/nicotsx/zerobyte).
## How to Contribute
There are many ways to contribute to Zerobyte:
### Reporting Bugs
If you find a bug, please open an issue with:
- A clear, descriptive title
- Steps to reproduce the issue
- Expected vs. actual behavior
- Your environment (OS, Docker version, Zerobyte version)
- Any relevant logs or screenshots
### Suggesting Features
When suggesting a feature:
- Check if it's already been suggested
- Clearly describe the feature and its use case
- Explain why it would be valuable to other users
- Consider the scope and complexity
### Contributing Code
1. **Fork the repository** and create your branch from `main`
2. **Make your changes** following our code standards
3. **Test your changes** thoroughly
4. **Update documentation** if needed
5. **Commit your changes** with clear, descriptive commit messages
6. **Push to your fork** and submit a pull request
### Improving Documentation
Documentation improvements are always welcome! This includes:
- Fixing typos or clarifying existing docs
- Adding examples or use cases
- Writing guides or tutorials
- Improving README or other documentation files
### Translations
We welcome translations to make Zerobyte accessible to more users worldwide. Please open an issue to discuss translation efforts before starting.
## Development Setup
1. **Clone your fork**:
```bash
git clone https://github.com/your-username/zerobyte.git
cd zerobyte
```
2. **Set up your development environment**:
```bash
bun run start:dev
```
3. **Create a feature branch**:
```bash
git checkout -b feature/your-feature-name
```
4. **Make your changes and test them**
5. **Commit your changes**:
```bash
git add .
git commit -m "Add your descriptive commit message"
```
## Submission Guidelines
### Pull Request Process
1. **Update your branch** with the latest changes from main before submitting
2. **Ensure all tests pass** and your code builds successfully
3. **Write a clear PR description** that explains:
- What changes you made
- Why you made them
- Any breaking changes or migration notes
- Link to related issues
4. **Be responsive** to feedback and review comments
5. **Keep PRs focused** - one feature or fix per PR when possible
## Code Standards
- Follow the existing code style and conventions
- Write clear, self-documenting code. No unless comments are necessary
- Ensure your code is properly formatted
- Keep security in mind - never commit sensitive data like passwords or API keys
## Community Guidelines
- Be respectful and constructive in all interactions
- Welcome newcomers and help them get started
- Assume good intentions
- Focus on what is best for the community and the project
- Show empathy towards other community members
## Questions?
If you have questions about contributing, feel free to:
- Open an issue with your question
- Check existing issues and discussions
- Reach out to the maintainers
---
Thank you for contributing to Zerobyte!

View File

@@ -1,28 +1,61 @@
ARG BUN_VERSION="1.2.20"
ARG BUN_VERSION="1.3.3"
FROM oven/bun:${BUN_VERSION}-alpine AS runner_base
FROM oven/bun:${BUN_VERSION}-alpine AS base
RUN apk add --no-cache davfs2
RUN apk add --no-cache davfs2=1.6.1-r2 openssh-client
# ------------------------------
# DEPENDENCIES
# ------------------------------
FROM base AS deps
WORKDIR /deps
ARG TARGETARCH
ARG RESTIC_VERSION="0.18.1"
ARG SHOUTRRR_VERSION="0.12.1"
ENV TARGETARCH=${TARGETARCH}
RUN apk add --no-cache curl bzip2 unzip tar
RUN echo "Building for ${TARGETARCH}"
RUN if [ "${TARGETARCH}" = "arm64" ]; then \
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_arm64.bz2; \
curl -O https://downloads.rclone.org/rclone-current-linux-arm64.zip; \
unzip rclone-current-linux-arm64.zip; \
curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_arm64v8_${SHOUTRRR_VERSION}.tar.gz"; \
elif [ "${TARGETARCH}" = "amd64" ]; then \
curl -L -o restic.bz2 "https://github.com/restic/restic/releases/download/v$RESTIC_VERSION/restic_$RESTIC_VERSION"_linux_amd64.bz2; \
curl -O https://downloads.rclone.org/rclone-current-linux-amd64.zip; \
unzip rclone-current-linux-amd64.zip; \
curl -L -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v$SHOUTRRR_VERSION/shoutrrr_linux_amd64_${SHOUTRRR_VERSION}.tar.gz"; \
fi
RUN bzip2 -d restic.bz2 && chmod +x restic
RUN mv rclone-*-linux-*/rclone /deps/rclone && chmod +x /deps/rclone
RUN tar -xzf shoutrrr.tar.gz && chmod +x shoutrrr
# ------------------------------
# DEVELOPMENT
# ------------------------------
FROM runner_base AS development
FROM base AS development
ENV NODE_ENV="development"
WORKDIR /app
COPY --from=deps /deps/restic /usr/local/bin/restic
COPY --from=deps /deps/rclone /usr/local/bin/rclone
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
COPY ./package.json ./bun.lock ./
COPY ./packages/schemas/package.json ./packages/schemas/package.json
COPY ./apps/client/package.json ./apps/client/package.json
COPY ./apps/server/package.json ./apps/server/package.json
RUN bun install --frozen-lockfile
COPY . .
EXPOSE 3000
EXPOSE 4096
CMD ["bun", "run", "dev"]
@@ -31,32 +64,42 @@ CMD ["bun", "run", "dev"]
# ------------------------------
FROM oven/bun:${BUN_VERSION} AS builder
ARG APP_VERSION=dev
WORKDIR /app
COPY ./package.json ./bun.lock ./
COPY ./packages/schemas/package.json ./packages/schemas/package.json
COPY ./apps/client/package.json ./apps/client/package.json
COPY ./apps/server/package.json ./apps/server/package.json
RUN bun install --frozen-lockfile
COPY . .
RUN touch .env
RUN echo "VITE_APP_VERSION=${APP_VERSION}" >> .env
RUN bun run build
FROM runner_base AS production
FROM base AS production
ENV NODE_ENV="production"
RUN apk add --no-cache davfs2
WORKDIR /app
COPY --from=builder /app/apps/server/dist ./
COPY --from=builder /app/apps/server/drizzle ./assets/migrations
COPY --from=builder /app/apps/client/dist/client ./assets/frontend
COPY --from=builder /app/package.json ./
RUN bun install --production --frozen-lockfile --verbose
COPY --from=deps /deps/restic /usr/local/bin/restic
COPY --from=deps /deps/rclone /usr/local/bin/rclone
COPY --from=deps /deps/shoutrrr /usr/local/bin/shoutrrr
COPY --from=builder /app/dist/client ./dist/client
COPY --from=builder /app/dist/server ./dist/server
COPY --from=builder /app/app/drizzle ./assets/migrations
CMD ["bun", "./index.js"]
# Include third-party licenses and attribution
COPY ./LICENSES ./LICENSES
COPY ./NOTICES.md ./NOTICES.md
COPY ./LICENSE ./LICENSE.md
EXPOSE 4096
CMD ["bun", "run", "start"]

View File

@@ -0,0 +1,25 @@
BSD 2-Clause License
Copyright (c) 2014, Alexander Neumann <alexander@bumpern.de>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

29
NOTICES.md Normal file
View File

@@ -0,0 +1,29 @@
# Third-Party Software Licenses and Attributions
This software includes the following third-party components that are subject to separate license terms:
## Restic
- **Version**: [Specify the version you're shipping]
- **Copyright**: Copyright (c) 2014, Alexander Neumann <alexander@bumpern.de>
- **License**: BSD 2-Clause License
- **Status**: Included unchanged
- **Source**: https://github.com/restic/restic
- **Full License Text**: See [LICENSES/BSD-2-Clause-Restic.txt](LICENSES/BSD-2-Clause-Restic.txt)
Restic is a backup program that is fast, secure, and efficient. It's included in this distribution as-is without any modifications.
---
## License Information
The main source code of this project is licensed under the GNU Affero General Public License v3. See the [LICENSE](LICENSE) file for details.
Each third-party component retains its original copyright and license. When distributing this software:
1. The original license of each component must be preserved
2. Copyright notices must be retained
3. A notice of changes (if any) must be provided for modified components
4. The original license file should be included with distributions
For more information about a specific component's license, refer to the corresponding license file in the [LICENSES/](LICENSES/) directory.

306
README.md
View File

@@ -1,53 +1,47 @@
<div align="center">
<h1>Ironmount</h1>
<h3>Keep your volumes in check!<br />One interface to manage all your storage</h3>
<a href="https://github.com/nicotsx/ironmount/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/nicotsx/ironmount" />
<h1>Zerobyte</h1>
<h3>Powerful backup automation for your remote storage<br />Encrypt, compress, and protect your data with ease</h3>
<a href="https://github.com/nicotsx/zerobyte/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/nicotsx/zerobyte" />
</a>
<br />
<figure>
<img src="https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-details.png?raw=true" alt="Demo" />
<img src="https://github.com/nicotsx/zerobyte/blob/main/screenshots/backup-details.webp?raw=true" alt="Demo" />
<figcaption>
<p align="center">
Volume details view with usage statistics and health check status
Backup management with scheduling and monitoring
</p>
</figcaption>
</figure>
</div>
<br />
> [!WARNING]
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Expect bugs! Please open issues or feature requests
<p align="center">
<a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
</p>
## Intro
Ironmount is an easy to use web interface to manage your remote storage and mount them as local volumes on your server. Docker as a first class citizen, Ironmount allows you to easily mount your remote storage directly into your containers with few lines of code.
Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
### Features
https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-creation.png?raw=true
- &nbsp; Support for multiple protocols: NFS, SMB, FTP, Directory
- 📡&nbsp; Mount your remote storage as local folders
- 🐳&nbsp; Docker integration: mount your remote storage directly into your containers via a docker volume syntax
- 🔍&nbsp; Keep an eye on your mounts with health checks and automatic remounting on error
- 📊&nbsp; Monitor your mounts usage with detailed statistics and graphs
### Coming soon
- 🔐&nbsp; User authentication and role management
- 💾&nbsp; Automated backups and snapshots with encryption, strategies and retention policies
- 🔄&nbsp; Re-exporting your mounts to other protocols (e.g. mount an FTP server as an SMB share with fine-grained permissions)
- ☁️&nbsp; Integration with cloud storage providers (e.g. AWS S3, Google Drive, Dropbox)
- 🔀&nbsp; Storage sharding and replication for high availability and performance
- &nbsp; **Automated backups** with encryption, compression and retention policies powered by Restic
- &nbsp; **Flexible scheduling** For automated backup jobs with fine-grained retention policies
- &nbsp; **End-to-end encryption** ensuring your data is always protected
- &nbsp; **Multi-protocol support**: Backup from NFS, SMB, WebDAV, or local directories
## Installation
In order to run Ironmount, you need to have Docker and Docker Compose installed on your server. Then, you can use the provided `docker-compose.yml` file to start the application.
In order to run Zerobyte, you need to have Docker and Docker Compose installed on your server. Then, you can use the provided `docker-compose.yml` file to start the application.
```yaml
services:
ironmount:
image: nicotsx/ironmount:v0.0.1
container_name: ironmount
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.17
container_name: zerobyte
restart: unless-stopped
cap_add:
- SYS_ADMIN
@@ -55,29 +49,259 @@ services:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris # Set your timezone here
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /run/docker/plugins:/run/docker/plugins
- /var/lib/docker/volumes/:/var/lib/docker/volumes:rshared
- ironmount_data:/data
volumes:
ironmount_data:
driver: local
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
```
Then, run the following command to start Ironmount:
> [!WARNING]
> Do not try to point `/var/lib/zerobyte` on a network share. You will face permission issues and strong performance degradation.
Then, run the following command to start Zerobyte:
```bash
docker-compose up -d
docker compose up -d
```
Once the container is running, you can access the web interface at `http://<your-server-ip>:4096`.
## Docker volume usage
## Adding your first volume
![Preview](https://github.com/nicotsx/ironmount/blob/main/screenshots/docker-instructions.png?raw=true)
Zerobyte supports multiple volume backends including NFS, SMB, WebDAV, and local directories. A volume represents the source data you want to back up and monitor.
## Volume creation
To add your first volume, navigate to the "Volumes" section in the web interface and click on "Create volume". Fill in the required details such as volume name, type, and connection settings.
![Preview](https://github.com/nicotsx/ironmount/blob/main/screenshots/volume-creation.png?raw=true)
If you want to track a local directory on the same server where Zerobyte is running, you'll first need to mount that directory into the Zerobyte container. You can do this by adding a volume mapping in your `docker-compose.yml` file. For example, to mount `/path/to/your/directory` from the host to `/mydata` in the container, you would add the following line under the `volumes` section:
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.17
container_name: zerobyte
restart: unless-stopped
cap_add:
- SYS_ADMIN
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
+ - /path/to/your/directory:/mydata
```
After updating the `docker-compose.yml` file, restart the Zerobyte container to apply the changes:
```bash
docker compose down
docker compose up -d
```
Now, when adding a new volume in the Zerobyte web interface, you can select "Directory" as the volume type and search for your mounted path (e.g., `/mydata`) as the source path.
![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/add-volume.png?raw=true)
## Creating a repository
A repository is where your backups will be securely stored encrypted. Zerobyte supports multiple storage backends for your backup repositories:
- **Local directories** - Store backups on local disk at `/var/lib/zerobyte/repositories/<repository-name>`
- **S3-compatible storage** - Amazon S3, MinIO, Wasabi, DigitalOcean Spaces, etc.
- **Google Cloud Storage** - Google's cloud storage service
- **Azure Blob Storage** - Microsoft Azure storage
- **rclone remotes** - 40+ cloud storage providers via rclone (see below)
Repositories are optimized for storage efficiency and data integrity, leveraging Restic's deduplication and encryption features.
To create a repository, navigate to the "Repositories" section in the web interface and click on "Create repository". Fill in the required details such as repository name, type, and connection settings.
### Using rclone for cloud storage
Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage providers including Google Drive, Dropbox, OneDrive, Box, pCloud, Mega, and many more. This gives you the flexibility to store your backups on virtually any cloud storage service.
**Setup instructions:**
1. **Install rclone on your host system** (if not already installed):
```bash
curl https://rclone.org/install.sh | sudo bash
```
2. **Configure your cloud storage remote** using rclone's interactive config:
```bash
rclone config
```
Follow the prompts to set up your cloud storage provider. For OAuth providers (Google Drive, Dropbox, etc.), rclone will guide you through the authentication flow.
3. **Verify your remote is configured**:
```bash
rclone listremotes
```
4. **Mount the rclone config into the Zerobyte container** by updating your `docker-compose.yml`:
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.17
container_name: zerobyte
restart: unless-stopped
cap_add:
- SYS_ADMIN
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
+ - ~/.config/rclone:/root/.config/rclone
```
5. **Restart the Zerobyte container**:
```bash
docker compose down
docker compose up -d
```
6. **Create a repository** in Zerobyte:
- Select "rclone" as the repository type
- Choose your configured remote from the dropdown
- Specify the path within your remote (e.g., `backups/zerobyte`)
For a complete list of supported providers, see the [rclone documentation](https://rclone.org/).
## Your first backup job
Once you have added a volume and created a repository, you can create your first backup job. A backup job defines the schedule and parameters for backing up a specific volume to a designated repository.
When creating a backup job, you can specify the following settings:
- **Schedule**: Define how often the backup should run (e.g., daily, weekly)
- **Retention Policy**: Set rules for how long backups should be retained (e.g., keep daily backups for 7 days, weekly backups for 4 weeks)
- **Paths**: Specify which files or directories to include in the backup
After configuring the backup job, save it and Zerobyte will automatically execute the backup according to the defined schedule.
You can monitor the progress and status of your backup jobs in the "Backups" section of the web interface.
![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/backups-list.png?raw=true)
## Restoring data
Zerobyte allows you to easily restore your data from backups. To restore data, navigate to the "Backups" section and select the backup job from which you want to restore data. You can then choose a specific backup snapshot and select the files or directories you wish to restore. The data you select will be restored to their original location.
![Preview](https://github.com/nicotsx/zerobyte/blob/main/screenshots/restoring.png?raw=true)
## Propagating mounts to host
Zerobyte is capable of propagating mounted volumes from within the container to the host system. This is particularly useful when you want to access the mounted data directly from the host to use it with other applications or services.
In order to enable this feature, you need to change your bind mount `/var/lib/zerobyte` to use the `:rshared` flag. Here is an example of how to set this up in your `docker-compose.yml` file:
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.17
container_name: zerobyte
restart: unless-stopped
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/zerobyte:/var/lib/zerobyte
+ - /var/lib/zerobyte:/var/lib/zerobyte:rshared
```
Restart the Zerobyte container to apply the changes:
```bash
docker compose down
docker compose up -d
```
## Docker plugin
Zerobyte can also be used as a Docker volume plugin, allowing you to mount your volumes directly into other Docker containers. This enables seamless integration with your containerized applications.
In order to enable this feature, you need to run Zerobyte with several items shared from the host. Here is an example of how to set this up in your `docker-compose.yml` file:
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.17
container_name: zerobyte
restart: unless-stopped
cap_add:
- SYS_ADMIN
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/zerobyte:/var/lib/zerobyte
+ - /var/lib/zerobyte:/var/lib/zerobyte:rshared
+ - /run/docker/plugins:/run/docker/plugins
+ - /var/run/docker.sock:/var/run/docker.sock
```
Restart the Zerobyte container to apply the changes:
```bash
docker compose down
docker compose up -d
```
Your Zerobyte volumes will now be available as Docker volumes that you can mount into other containers using the `--volume` flag:
```bash
docker run -v zb-abc12:/path/in/container nginx:latest
```
Or using Docker Compose:
```yaml
services:
myservice:
image: nginx:latest
volumes:
- zb-abc12:/path/in/container
volumes:
zb-abc12:
external: true
```
The volume name format is `zb-<short-id>` where `<short-id>` is the unique identifier shown on the volume's Docker tab in Zerobyte. This short ID remains stable even if you rename the volume. You can verify that the volume is available by running:
```bash
docker volume ls
```
## Third-Party Software
This project includes the following third-party software components:
### Restic
Zerobyte includes [Restic](https://github.com/restic/restic) for backup functionality.
- **License**: BSD 2-Clause License
- **Copyright**: Copyright (c) 2014, Alexander Neumann <alexander@bumpern.de>
- **Status**: Included unchanged
- **License Text**: See [LICENSES/BSD-2-Clause-Restic.txt](LICENSES/BSD-2-Clause-Restic.txt)
For a complete list of third-party software licenses and attributions, please refer to the [NOTICES.md](NOTICES.md) file.
## Contributing
Contributions by anyone are welcome! If you find a bug or have a feature request, please open an issue on GitHub. If you want to contribute code, feel free to fork the repository and submit a pull request. We require that all contributors sign a Contributor License Agreement (CLA) before we can accept your contributions. This is to protect both you and the project. Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for more details.

View File

@@ -1,9 +1,11 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "dither-plugin";
@custom-variant dark (&:is(.dark *));
@theme {
--breakpoint-xs: 32rem;
--font-sans:
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
@@ -11,11 +13,21 @@
html,
body {
@apply bg-white dark:bg-[#0D0D0D];
overflow-x: hidden;
width: 100%;
position: relative;
overscroll-behavior: none;
scrollbar-width: thin;
}
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
body {
@apply bg-[#131313];
min-height: 100dvh;
}
.main-content {
scrollbar-width: thin;
scrollbar-gutter: stable;
}
@theme inline {
@@ -27,6 +39,7 @@ body {
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-card-header: var(--card-header);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
@@ -54,6 +67,7 @@ body {
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-strong-accent: var(--strong-accent);
}
:root {
@@ -62,6 +76,7 @@ body {
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-header: oklch(0.922 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
@@ -89,12 +104,16 @@ body {
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--strong-accent: #ff543a;
}
.dark {
--background: oklch(0.145 0 0);
color-scheme: dark;
--background: #131313;
--foreground: oklch(0.985 0 0);
--card: oklch(0.1448 0 0);
--card: #131313;
--card-header: #1b1b1b;
/* --card: oklch(0.205 0 0); ORIGINAL */
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
@@ -108,7 +127,7 @@ body {
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive: #ff543a;
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
@@ -125,6 +144,7 @@ body {
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--strong-accent: #ff543a;
}
@layer base {

View File

@@ -0,0 +1,946 @@
// This file is auto-generated by @hey-api/openapi-ts
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen';
import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
/**
* Register a new user
*/
export const registerMutation = (options?: Partial<Options<RegisterData>>): UseMutationOptions<RegisterResponse, DefaultError, Options<RegisterData>> => {
const mutationOptions: UseMutationOptions<RegisterResponse, DefaultError, Options<RegisterData>> = {
mutationFn: async (fnOptions) => {
const { data } = await register({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Login with username and password
*/
export const loginMutation = (options?: Partial<Options<LoginData>>): UseMutationOptions<LoginResponse, DefaultError, Options<LoginData>> => {
const mutationOptions: UseMutationOptions<LoginResponse, DefaultError, Options<LoginData>> = {
mutationFn: async (fnOptions) => {
const { data } = await login({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Logout current user
*/
export const logoutMutation = (options?: Partial<Options<LogoutData>>): UseMutationOptions<LogoutResponse, DefaultError, Options<LogoutData>> => {
const mutationOptions: UseMutationOptions<LogoutResponse, DefaultError, Options<LogoutData>> = {
mutationFn: async (fnOptions) => {
const { data } = await logout({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
_id: string;
_infinite?: boolean;
tags?: ReadonlyArray<string>;
}
];
const createQueryKey = <TOptions extends Options>(id: string, options?: TOptions, infinite?: boolean, tags?: ReadonlyArray<string>): [
QueryKey<TOptions>[0]
] => {
const params: QueryKey<TOptions>[0] = { _id: id, baseUrl: options?.baseUrl || (options?.client ?? client).getConfig().baseUrl } as QueryKey<TOptions>[0];
if (infinite) {
params._infinite = infinite;
}
if (tags) {
params.tags = tags;
}
if (options?.body) {
params.body = options.body;
}
if (options?.headers) {
params.headers = options.headers;
}
if (options?.path) {
params.path = options.path;
}
if (options?.query) {
params.query = options.query;
}
return [params];
};
export const getMeQueryKey = (options?: Options<GetMeData>) => createQueryKey('getMe', options);
/**
* Get current authenticated user
*/
export const getMeOptions = (options?: Options<GetMeData>) => queryOptions<GetMeResponse, DefaultError, GetMeResponse, ReturnType<typeof getMeQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getMe({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getMeQueryKey(options)
});
export const getStatusQueryKey = (options?: Options<GetStatusData>) => createQueryKey('getStatus', options);
/**
* Get authentication system status
*/
export const getStatusOptions = (options?: Options<GetStatusData>) => queryOptions<GetStatusResponse, DefaultError, GetStatusResponse, ReturnType<typeof getStatusQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getStatus({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getStatusQueryKey(options)
});
/**
* Change current user password
*/
export const changePasswordMutation = (options?: Partial<Options<ChangePasswordData>>): UseMutationOptions<ChangePasswordResponse, DefaultError, Options<ChangePasswordData>> => {
const mutationOptions: UseMutationOptions<ChangePasswordResponse, DefaultError, Options<ChangePasswordData>> = {
mutationFn: async (fnOptions) => {
const { data } = await changePassword({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey('listVolumes', options);
/**
* List all volumes
*/
export const listVolumesOptions = (options?: Options<ListVolumesData>) => queryOptions<ListVolumesResponse, DefaultError, ListVolumesResponse, ReturnType<typeof listVolumesQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listVolumes({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: listVolumesQueryKey(options)
});
/**
* Create a new volume
*/
export const createVolumeMutation = (options?: Partial<Options<CreateVolumeData>>): UseMutationOptions<CreateVolumeResponse, DefaultError, Options<CreateVolumeData>> => {
const mutationOptions: UseMutationOptions<CreateVolumeResponse, DefaultError, Options<CreateVolumeData>> = {
mutationFn: async (fnOptions) => {
const { data } = await createVolume({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Test connection to backend
*/
export const testConnectionMutation = (options?: Partial<Options<TestConnectionData>>): UseMutationOptions<TestConnectionResponse, DefaultError, Options<TestConnectionData>> => {
const mutationOptions: UseMutationOptions<TestConnectionResponse, DefaultError, Options<TestConnectionData>> = {
mutationFn: async (fnOptions) => {
const { data } = await testConnection({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Delete a volume
*/
export const deleteVolumeMutation = (options?: Partial<Options<DeleteVolumeData>>): UseMutationOptions<DeleteVolumeResponse, DefaultError, Options<DeleteVolumeData>> => {
const mutationOptions: UseMutationOptions<DeleteVolumeResponse, DefaultError, Options<DeleteVolumeData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteVolume({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getVolumeQueryKey = (options: Options<GetVolumeData>) => createQueryKey('getVolume', options);
/**
* Get a volume by name
*/
export const getVolumeOptions = (options: Options<GetVolumeData>) => queryOptions<GetVolumeResponse, DefaultError, GetVolumeResponse, ReturnType<typeof getVolumeQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getVolume({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getVolumeQueryKey(options)
});
/**
* Update a volume's configuration
*/
export const updateVolumeMutation = (options?: Partial<Options<UpdateVolumeData>>): UseMutationOptions<UpdateVolumeResponse, DefaultError, Options<UpdateVolumeData>> => {
const mutationOptions: UseMutationOptions<UpdateVolumeResponse, DefaultError, Options<UpdateVolumeData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateVolume({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getContainersUsingVolumeQueryKey = (options: Options<GetContainersUsingVolumeData>) => createQueryKey('getContainersUsingVolume', options);
/**
* Get containers using a volume by name
*/
export const getContainersUsingVolumeOptions = (options: Options<GetContainersUsingVolumeData>) => queryOptions<GetContainersUsingVolumeResponse, DefaultError, GetContainersUsingVolumeResponse, ReturnType<typeof getContainersUsingVolumeQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getContainersUsingVolume({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getContainersUsingVolumeQueryKey(options)
});
/**
* Mount a volume
*/
export const mountVolumeMutation = (options?: Partial<Options<MountVolumeData>>): UseMutationOptions<MountVolumeResponse, DefaultError, Options<MountVolumeData>> => {
const mutationOptions: UseMutationOptions<MountVolumeResponse, DefaultError, Options<MountVolumeData>> = {
mutationFn: async (fnOptions) => {
const { data } = await mountVolume({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Unmount a volume
*/
export const unmountVolumeMutation = (options?: Partial<Options<UnmountVolumeData>>): UseMutationOptions<UnmountVolumeResponse, DefaultError, Options<UnmountVolumeData>> => {
const mutationOptions: UseMutationOptions<UnmountVolumeResponse, DefaultError, Options<UnmountVolumeData>> = {
mutationFn: async (fnOptions) => {
const { data } = await unmountVolume({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Perform a health check on a volume
*/
export const healthCheckVolumeMutation = (options?: Partial<Options<HealthCheckVolumeData>>): UseMutationOptions<HealthCheckVolumeResponse, DefaultError, Options<HealthCheckVolumeData>> => {
const mutationOptions: UseMutationOptions<HealthCheckVolumeResponse, DefaultError, Options<HealthCheckVolumeData>> = {
mutationFn: async (fnOptions) => {
const { data } = await healthCheckVolume({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listFilesQueryKey = (options: Options<ListFilesData>) => createQueryKey('listFiles', options);
/**
* List files in a volume directory
*/
export const listFilesOptions = (options: Options<ListFilesData>) => queryOptions<ListFilesResponse, DefaultError, ListFilesResponse, ReturnType<typeof listFilesQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listFiles({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: listFilesQueryKey(options)
});
export const browseFilesystemQueryKey = (options?: Options<BrowseFilesystemData>) => createQueryKey('browseFilesystem', options);
/**
* Browse directories on the host filesystem
*/
export const browseFilesystemOptions = (options?: Options<BrowseFilesystemData>) => queryOptions<BrowseFilesystemResponse, DefaultError, BrowseFilesystemResponse, ReturnType<typeof browseFilesystemQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await browseFilesystem({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: browseFilesystemQueryKey(options)
});
export const listRepositoriesQueryKey = (options?: Options<ListRepositoriesData>) => createQueryKey('listRepositories', options);
/**
* List all repositories
*/
export const listRepositoriesOptions = (options?: Options<ListRepositoriesData>) => queryOptions<ListRepositoriesResponse, DefaultError, ListRepositoriesResponse, ReturnType<typeof listRepositoriesQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listRepositories({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: listRepositoriesQueryKey(options)
});
/**
* Create a new restic repository
*/
export const createRepositoryMutation = (options?: Partial<Options<CreateRepositoryData>>): UseMutationOptions<CreateRepositoryResponse, DefaultError, Options<CreateRepositoryData>> => {
const mutationOptions: UseMutationOptions<CreateRepositoryResponse, DefaultError, Options<CreateRepositoryData>> = {
mutationFn: async (fnOptions) => {
const { data } = await createRepository({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listRcloneRemotesQueryKey = (options?: Options<ListRcloneRemotesData>) => createQueryKey('listRcloneRemotes', options);
/**
* List all configured rclone remotes on the host system
*/
export const listRcloneRemotesOptions = (options?: Options<ListRcloneRemotesData>) => queryOptions<ListRcloneRemotesResponse, DefaultError, ListRcloneRemotesResponse, ReturnType<typeof listRcloneRemotesQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listRcloneRemotes({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: listRcloneRemotesQueryKey(options)
});
/**
* Delete a repository
*/
export const deleteRepositoryMutation = (options?: Partial<Options<DeleteRepositoryData>>): UseMutationOptions<DeleteRepositoryResponse, DefaultError, Options<DeleteRepositoryData>> => {
const mutationOptions: UseMutationOptions<DeleteRepositoryResponse, DefaultError, Options<DeleteRepositoryData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteRepository({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getRepositoryQueryKey = (options: Options<GetRepositoryData>) => createQueryKey('getRepository', options);
/**
* Get a single repository by name
*/
export const getRepositoryOptions = (options: Options<GetRepositoryData>) => queryOptions<GetRepositoryResponse, DefaultError, GetRepositoryResponse, ReturnType<typeof getRepositoryQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getRepository({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getRepositoryQueryKey(options)
});
/**
* Update a repository's name or settings
*/
export const updateRepositoryMutation = (options?: Partial<Options<UpdateRepositoryData>>): UseMutationOptions<UpdateRepositoryResponse, DefaultError, Options<UpdateRepositoryData>> => {
const mutationOptions: UseMutationOptions<UpdateRepositoryResponse, DefaultError, Options<UpdateRepositoryData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateRepository({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listSnapshotsQueryKey = (options: Options<ListSnapshotsData>) => createQueryKey('listSnapshots', options);
/**
* List all snapshots in a repository
*/
export const listSnapshotsOptions = (options: Options<ListSnapshotsData>) => queryOptions<ListSnapshotsResponse, DefaultError, ListSnapshotsResponse, ReturnType<typeof listSnapshotsQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listSnapshots({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: listSnapshotsQueryKey(options)
});
/**
* Delete a specific snapshot from a repository
*/
export const deleteSnapshotMutation = (options?: Partial<Options<DeleteSnapshotData>>): UseMutationOptions<DeleteSnapshotResponse, DefaultError, Options<DeleteSnapshotData>> => {
const mutationOptions: UseMutationOptions<DeleteSnapshotResponse, DefaultError, Options<DeleteSnapshotData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteSnapshot({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getSnapshotDetailsQueryKey = (options: Options<GetSnapshotDetailsData>) => createQueryKey('getSnapshotDetails', options);
/**
* Get details of a specific snapshot
*/
export const getSnapshotDetailsOptions = (options: Options<GetSnapshotDetailsData>) => queryOptions<GetSnapshotDetailsResponse, DefaultError, GetSnapshotDetailsResponse, ReturnType<typeof getSnapshotDetailsQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getSnapshotDetails({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getSnapshotDetailsQueryKey(options)
});
export const listSnapshotFilesQueryKey = (options: Options<ListSnapshotFilesData>) => createQueryKey('listSnapshotFiles', options);
/**
* List files and directories in a snapshot
*/
export const listSnapshotFilesOptions = (options: Options<ListSnapshotFilesData>) => queryOptions<ListSnapshotFilesResponse, DefaultError, ListSnapshotFilesResponse, ReturnType<typeof listSnapshotFilesQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listSnapshotFiles({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: listSnapshotFilesQueryKey(options)
});
/**
* Restore a snapshot to a target path on the filesystem
*/
export const restoreSnapshotMutation = (options?: Partial<Options<RestoreSnapshotData>>): UseMutationOptions<RestoreSnapshotResponse, DefaultError, Options<RestoreSnapshotData>> => {
const mutationOptions: UseMutationOptions<RestoreSnapshotResponse, DefaultError, Options<RestoreSnapshotData>> = {
mutationFn: async (fnOptions) => {
const { data } = await restoreSnapshot({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
*/
export const doctorRepositoryMutation = (options?: Partial<Options<DoctorRepositoryData>>): UseMutationOptions<DoctorRepositoryResponse, DefaultError, Options<DoctorRepositoryData>> => {
const mutationOptions: UseMutationOptions<DoctorRepositoryResponse, DefaultError, Options<DoctorRepositoryData>> = {
mutationFn: async (fnOptions) => {
const { data } = await doctorRepository({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey('listBackupSchedules', options);
/**
* List all backup schedules
*/
export const listBackupSchedulesOptions = (options?: Options<ListBackupSchedulesData>) => queryOptions<ListBackupSchedulesResponse, DefaultError, ListBackupSchedulesResponse, ReturnType<typeof listBackupSchedulesQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listBackupSchedules({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: listBackupSchedulesQueryKey(options)
});
/**
* Create a new backup schedule for a volume
*/
export const createBackupScheduleMutation = (options?: Partial<Options<CreateBackupScheduleData>>): UseMutationOptions<CreateBackupScheduleResponse, DefaultError, Options<CreateBackupScheduleData>> => {
const mutationOptions: UseMutationOptions<CreateBackupScheduleResponse, DefaultError, Options<CreateBackupScheduleData>> = {
mutationFn: async (fnOptions) => {
const { data } = await createBackupSchedule({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Delete a backup schedule
*/
export const deleteBackupScheduleMutation = (options?: Partial<Options<DeleteBackupScheduleData>>): UseMutationOptions<DeleteBackupScheduleResponse, DefaultError, Options<DeleteBackupScheduleData>> => {
const mutationOptions: UseMutationOptions<DeleteBackupScheduleResponse, DefaultError, Options<DeleteBackupScheduleData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteBackupSchedule({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getBackupScheduleQueryKey = (options: Options<GetBackupScheduleData>) => createQueryKey('getBackupSchedule', options);
/**
* Get a backup schedule by ID
*/
export const getBackupScheduleOptions = (options: Options<GetBackupScheduleData>) => queryOptions<GetBackupScheduleResponse, DefaultError, GetBackupScheduleResponse, ReturnType<typeof getBackupScheduleQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getBackupSchedule({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getBackupScheduleQueryKey(options)
});
/**
* Update a backup schedule
*/
export const updateBackupScheduleMutation = (options?: Partial<Options<UpdateBackupScheduleData>>): UseMutationOptions<UpdateBackupScheduleResponse, DefaultError, Options<UpdateBackupScheduleData>> => {
const mutationOptions: UseMutationOptions<UpdateBackupScheduleResponse, DefaultError, Options<UpdateBackupScheduleData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateBackupSchedule({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getBackupScheduleForVolumeQueryKey = (options: Options<GetBackupScheduleForVolumeData>) => createQueryKey('getBackupScheduleForVolume', options);
/**
* Get a backup schedule for a specific volume
*/
export const getBackupScheduleForVolumeOptions = (options: Options<GetBackupScheduleForVolumeData>) => queryOptions<GetBackupScheduleForVolumeResponse, DefaultError, GetBackupScheduleForVolumeResponse, ReturnType<typeof getBackupScheduleForVolumeQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getBackupScheduleForVolume({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getBackupScheduleForVolumeQueryKey(options)
});
/**
* Trigger a backup immediately for a schedule
*/
export const runBackupNowMutation = (options?: Partial<Options<RunBackupNowData>>): UseMutationOptions<RunBackupNowResponse, DefaultError, Options<RunBackupNowData>> => {
const mutationOptions: UseMutationOptions<RunBackupNowResponse, DefaultError, Options<RunBackupNowData>> = {
mutationFn: async (fnOptions) => {
const { data } = await runBackupNow({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Stop a backup that is currently in progress
*/
export const stopBackupMutation = (options?: Partial<Options<StopBackupData>>): UseMutationOptions<StopBackupResponse, DefaultError, Options<StopBackupData>> => {
const mutationOptions: UseMutationOptions<StopBackupResponse, DefaultError, Options<StopBackupData>> = {
mutationFn: async (fnOptions) => {
const { data } = await stopBackup({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Manually apply retention policy to clean up old snapshots
*/
export const runForgetMutation = (options?: Partial<Options<RunForgetData>>): UseMutationOptions<RunForgetResponse, DefaultError, Options<RunForgetData>> => {
const mutationOptions: UseMutationOptions<RunForgetResponse, DefaultError, Options<RunForgetData>> = {
mutationFn: async (fnOptions) => {
const { data } = await runForget({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getScheduleNotificationsQueryKey = (options: Options<GetScheduleNotificationsData>) => createQueryKey('getScheduleNotifications', options);
/**
* Get notification assignments for a backup schedule
*/
export const getScheduleNotificationsOptions = (options: Options<GetScheduleNotificationsData>) => queryOptions<GetScheduleNotificationsResponse, DefaultError, GetScheduleNotificationsResponse, ReturnType<typeof getScheduleNotificationsQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getScheduleNotifications({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getScheduleNotificationsQueryKey(options)
});
/**
* Update notification assignments for a backup schedule
*/
export const updateScheduleNotificationsMutation = (options?: Partial<Options<UpdateScheduleNotificationsData>>): UseMutationOptions<UpdateScheduleNotificationsResponse, DefaultError, Options<UpdateScheduleNotificationsData>> => {
const mutationOptions: UseMutationOptions<UpdateScheduleNotificationsResponse, DefaultError, Options<UpdateScheduleNotificationsData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateScheduleNotifications({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getScheduleMirrorsQueryKey = (options: Options<GetScheduleMirrorsData>) => createQueryKey('getScheduleMirrors', options);
/**
* Get mirror repository assignments for a backup schedule
*/
export const getScheduleMirrorsOptions = (options: Options<GetScheduleMirrorsData>) => queryOptions<GetScheduleMirrorsResponse, DefaultError, GetScheduleMirrorsResponse, ReturnType<typeof getScheduleMirrorsQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getScheduleMirrors({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getScheduleMirrorsQueryKey(options)
});
/**
* Update mirror repository assignments for a backup schedule
*/
export const updateScheduleMirrorsMutation = (options?: Partial<Options<UpdateScheduleMirrorsData>>): UseMutationOptions<UpdateScheduleMirrorsResponse, DefaultError, Options<UpdateScheduleMirrorsData>> => {
const mutationOptions: UseMutationOptions<UpdateScheduleMirrorsResponse, DefaultError, Options<UpdateScheduleMirrorsData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateScheduleMirrors({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getMirrorCompatibilityQueryKey = (options: Options<GetMirrorCompatibilityData>) => createQueryKey('getMirrorCompatibility', options);
/**
* Get mirror compatibility info for all repositories relative to a backup schedule's primary repository
*/
export const getMirrorCompatibilityOptions = (options: Options<GetMirrorCompatibilityData>) => queryOptions<GetMirrorCompatibilityResponse, DefaultError, GetMirrorCompatibilityResponse, ReturnType<typeof getMirrorCompatibilityQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getMirrorCompatibility({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getMirrorCompatibilityQueryKey(options)
});
export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) => createQueryKey('listNotificationDestinations', options);
/**
* List all notification destinations
*/
export const listNotificationDestinationsOptions = (options?: Options<ListNotificationDestinationsData>) => queryOptions<ListNotificationDestinationsResponse, DefaultError, ListNotificationDestinationsResponse, ReturnType<typeof listNotificationDestinationsQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await listNotificationDestinations({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: listNotificationDestinationsQueryKey(options)
});
/**
* Create a new notification destination
*/
export const createNotificationDestinationMutation = (options?: Partial<Options<CreateNotificationDestinationData>>): UseMutationOptions<CreateNotificationDestinationResponse, DefaultError, Options<CreateNotificationDestinationData>> => {
const mutationOptions: UseMutationOptions<CreateNotificationDestinationResponse, DefaultError, Options<CreateNotificationDestinationData>> = {
mutationFn: async (fnOptions) => {
const { data } = await createNotificationDestination({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Delete a notification destination
*/
export const deleteNotificationDestinationMutation = (options?: Partial<Options<DeleteNotificationDestinationData>>): UseMutationOptions<DeleteNotificationDestinationResponse, DefaultError, Options<DeleteNotificationDestinationData>> => {
const mutationOptions: UseMutationOptions<DeleteNotificationDestinationResponse, DefaultError, Options<DeleteNotificationDestinationData>> = {
mutationFn: async (fnOptions) => {
const { data } = await deleteNotificationDestination({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getNotificationDestinationQueryKey = (options: Options<GetNotificationDestinationData>) => createQueryKey('getNotificationDestination', options);
/**
* Get a notification destination by ID
*/
export const getNotificationDestinationOptions = (options: Options<GetNotificationDestinationData>) => queryOptions<GetNotificationDestinationResponse, DefaultError, GetNotificationDestinationResponse, ReturnType<typeof getNotificationDestinationQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getNotificationDestination({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getNotificationDestinationQueryKey(options)
});
/**
* Update a notification destination
*/
export const updateNotificationDestinationMutation = (options?: Partial<Options<UpdateNotificationDestinationData>>): UseMutationOptions<UpdateNotificationDestinationResponse, DefaultError, Options<UpdateNotificationDestinationData>> => {
const mutationOptions: UseMutationOptions<UpdateNotificationDestinationResponse, DefaultError, Options<UpdateNotificationDestinationData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateNotificationDestination({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/**
* Test a notification destination by sending a test message
*/
export const testNotificationDestinationMutation = (options?: Partial<Options<TestNotificationDestinationData>>): UseMutationOptions<TestNotificationDestinationResponse, DefaultError, Options<TestNotificationDestinationData>> => {
const mutationOptions: UseMutationOptions<TestNotificationDestinationResponse, DefaultError, Options<TestNotificationDestinationData>> = {
mutationFn: async (fnOptions) => {
const { data } = await testNotificationDestination({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getSystemInfoQueryKey = (options?: Options<GetSystemInfoData>) => createQueryKey('getSystemInfo', options);
/**
* Get system information including available capabilities
*/
export const getSystemInfoOptions = (options?: Options<GetSystemInfoData>) => queryOptions<GetSystemInfoResponse, DefaultError, GetSystemInfoResponse, ReturnType<typeof getSystemInfoQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getSystemInfo({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getSystemInfoQueryKey(options)
});
/**
* Download the Restic password file for backup recovery. Requires password re-authentication.
*/
export const downloadResticPasswordMutation = (options?: Partial<Options<DownloadResticPasswordData>>): UseMutationOptions<DownloadResticPasswordResponse, DefaultError, Options<DownloadResticPasswordData>> => {
const mutationOptions: UseMutationOptions<DownloadResticPasswordResponse, DefaultError, Options<DownloadResticPasswordData>> = {
mutationFn: async (fnOptions) => {
const { data } = await downloadResticPassword({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};

View File

@@ -0,0 +1,16 @@
// This file is auto-generated by @hey-api/openapi-ts
import { type ClientOptions, type Config, createClient, createConfig } from './client';
import type { ClientOptions as ClientOptions2 } from './types.gen';
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://192.168.2.42:4096' }));

View File

@@ -0,0 +1,301 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createSseClient } from '../core/serverSentEvents.gen';
import type { HttpMethod } from '../core/types.gen';
import { getValidRequestBody } from '../core/utils.gen';
import type {
Client,
Config,
RequestOptions,
ResolvedRequestOptions,
} from './types.gen';
import {
buildUrl,
createConfig,
createInterceptors,
getParseAs,
mergeConfigs,
mergeHeaders,
setAuthParams,
} from './utils.gen';
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
body?: any;
headers: ReturnType<typeof mergeHeaders>;
};
export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config);
const getConfig = (): Config => ({ ..._config });
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config);
return getConfig();
};
const interceptors = createInterceptors<
Request,
Response,
unknown,
ResolvedRequestOptions
>();
const beforeRequest = async (options: RequestOptions) => {
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined,
};
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
});
}
if (opts.requestValidator) {
await opts.requestValidator(opts);
}
if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body);
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.serializedBody === '') {
opts.headers.delete('Content-Type');
}
const url = buildUrl(opts);
return { opts, url };
};
const request: Client['request'] = async (options) => {
// @ts-expect-error
const { opts, url } = await beforeRequest(options);
const requestInit: ReqInit = {
redirect: 'follow',
...opts,
body: getValidRequestBody(opts),
};
let request = new Request(url, requestInit);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
let response: Response;
try {
response = await _fetch(request);
} catch (error) {
// Handle fetch exceptions (AbortError, network errors, etc.)
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(
error,
undefined as any,
request,
opts,
)) as unknown;
}
}
finalError = finalError || ({} as unknown);
if (opts.throwOnError) {
throw finalError;
}
// Return error response
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
request,
response: undefined as any,
};
}
for (const fn of interceptors.response.fns) {
if (fn) {
response = await fn(response, request, opts);
}
}
const result = {
request,
response,
};
if (response.ok) {
const parseAs =
(opts.parseAs === 'auto'
? getParseAs(response.headers.get('Content-Type'))
: opts.parseAs) ?? 'json';
if (
response.status === 204 ||
response.headers.get('Content-Length') === '0'
) {
let emptyData: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'text':
emptyData = await response[parseAs]();
break;
case 'formData':
emptyData = new FormData();
break;
case 'stream':
emptyData = response.body;
break;
case 'json':
default:
emptyData = {};
break;
}
return opts.responseStyle === 'data'
? emptyData
: {
data: emptyData,
...result,
};
}
let data: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'formData':
case 'json':
case 'text':
data = await response[parseAs]();
break;
case 'stream':
return opts.responseStyle === 'data'
? response.body
: {
data: response.body,
...result,
};
}
if (parseAs === 'json') {
if (opts.responseValidator) {
await opts.responseValidator(data);
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
}
}
return opts.responseStyle === 'data'
? data
: {
data,
...result,
};
}
const textError = await response.text();
let jsonError: unknown;
try {
jsonError = JSON.parse(textError);
} catch {
// noop
}
const error = jsonError ?? textError;
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, response, request, opts)) as string;
}
}
finalError = finalError || ({} as string);
if (opts.throwOnError) {
throw finalError;
}
// TODO: we probably want to return error and improve types
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
...result,
};
};
const makeMethodFn =
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
request({ ...options, method });
const makeSseFn =
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options);
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
return request;
},
url,
});
};
return {
buildUrl,
connect: makeMethodFn('CONNECT'),
delete: makeMethodFn('DELETE'),
get: makeMethodFn('GET'),
getConfig,
head: makeMethodFn('HEAD'),
interceptors,
options: makeMethodFn('OPTIONS'),
patch: makeMethodFn('PATCH'),
post: makeMethodFn('POST'),
put: makeMethodFn('PUT'),
request,
setConfig,
sse: {
connect: makeSseFn('CONNECT'),
delete: makeSseFn('DELETE'),
get: makeSseFn('GET'),
head: makeSseFn('HEAD'),
options: makeSseFn('OPTIONS'),
patch: makeSseFn('PATCH'),
post: makeSseFn('POST'),
put: makeSseFn('PUT'),
trace: makeSseFn('TRACE'),
},
trace: makeMethodFn('TRACE'),
} as Client;
};

View File

@@ -0,0 +1,25 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { Auth } from '../core/auth.gen';
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from '../core/bodySerializer.gen';
export { buildClientParams } from '../core/params.gen';
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
export { createClient } from './client.gen';
export type {
Client,
ClientOptions,
Config,
CreateClientConfig,
Options,
RequestOptions,
RequestResult,
ResolvedRequestOptions,
ResponseStyle,
TDataShape,
} from './types.gen';
export { createConfig, mergeHeaders } from './utils.gen';

View File

@@ -0,0 +1,241 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth } from '../core/auth.gen';
import type {
ServerSentEventsOptions,
ServerSentEventsResult,
} from '../core/serverSentEvents.gen';
import type {
Client as CoreClient,
Config as CoreConfig,
} from '../core/types.gen';
import type { Middleware } from './utils.gen';
export type ResponseStyle = 'data' | 'fields';
export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
CoreConfig {
/**
* Base URL for all requests made by this client.
*/
baseUrl?: T['baseUrl'];
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect.
*
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
*/
next?: never;
/**
* Return the response data parsed in a specified format. By default, `auto`
* will infer the appropriate method from the `Content-Type` response header.
* You can override this behavior with any of the {@link Body} methods.
* Select `stream` if you don't want to parse response data at all.
*
* @default 'auto'
*/
parseAs?:
| 'arrayBuffer'
| 'auto'
| 'blob'
| 'formData'
| 'json'
| 'stream'
| 'text';
/**
* Should we return only data or multiple fields (data, error, response, etc.)?
*
* @default 'fields'
*/
responseStyle?: ResponseStyle;
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T['throwOnError'];
}
export interface RequestOptions<
TData = unknown,
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends Config<{
responseStyle: TResponseStyle;
throwOnError: ThrowOnError;
}>,
Pick<
ServerSentEventsOptions<TData>,
| 'onSseError'
| 'onSseEvent'
| 'sseDefaultRetryDelay'
| 'sseMaxRetryAttempts'
| 'sseMaxRetryDelay'
> {
/**
* Any body that you want to add to your request.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>;
url: Url;
}
export interface ResolvedRequestOptions<
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
serializedBody?: string;
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = 'fields',
> = ThrowOnError extends true
? Promise<
TResponseStyle extends 'data'
? TData extends Record<string, unknown>
? TData[keyof TData]
: TData
: {
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
request: Request;
response: Response;
}
>
: Promise<
TResponseStyle extends 'data'
?
| (TData extends Record<string, unknown>
? TData[keyof TData]
: TData)
| undefined
: (
| {
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
error: undefined;
}
| {
data: undefined;
error: TError extends Record<string, unknown>
? TError[keyof TError]
: TError;
}
) & {
request: Request;
response: Response;
}
>;
export interface ClientOptions {
baseUrl?: string;
responseStyle?: ResponseStyle;
throwOnError?: boolean;
}
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type SseFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
Pick<
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
'method'
>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type BuildUrlFn = <
TData extends {
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
url: string;
},
>(
options: TData & Options<TData>,
) => string;
export type Client = CoreClient<
RequestFn,
Config,
MethodFn,
BuildUrlFn,
SseFn
> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
};
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>;
export interface TDataShape {
body?: unknown;
headers?: unknown;
path?: unknown;
query?: unknown;
url: string;
}
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
TResponse = unknown,
TResponseStyle extends ResponseStyle = 'fields',
> = OmitKeys<
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
'body' | 'path' | 'query' | 'url'
> &
([TData] extends [never] ? unknown : Omit<TData, 'url'>);

View File

@@ -0,0 +1,332 @@
// This file is auto-generated by @hey-api/openapi-ts
import { getAuthToken } from '../core/auth.gen';
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
import { jsonBodySerializer } from '../core/bodySerializer.gen';
import {
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from '../core/pathSerializer.gen';
import { getUrl } from '../core/utils.gen';
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
export const createQuerySerializer = <T = unknown>({
parameters = {},
...args
}: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = [];
if (queryParams && typeof queryParams === 'object') {
for (const name in queryParams) {
const value = queryParams[name];
if (value === undefined || value === null) {
continue;
}
const options = parameters[name] || args;
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'form',
value,
...options.array,
});
if (serializedArray) search.push(serializedArray);
} else if (typeof value === 'object') {
const serializedObject = serializeObjectParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'deepObject',
value: value as Record<string, unknown>,
...options.object,
});
if (serializedObject) search.push(serializedObject);
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved: options.allowReserved,
name,
value: value as string,
});
if (serializedPrimitive) search.push(serializedPrimitive);
}
}
}
return search.join('&');
};
return querySerializer;
};
/**
* Infers parseAs value from provided Content-Type header.
*/
export const getParseAs = (
contentType: string | null,
): Exclude<Config['parseAs'], 'auto'> => {
if (!contentType) {
// If no Content-Type header is provided, the best we can do is return the raw response body,
// which is effectively the same as the 'stream' option.
return 'stream';
}
const cleanContent = contentType.split(';')[0]?.trim();
if (!cleanContent) {
return;
}
if (
cleanContent.startsWith('application/json') ||
cleanContent.endsWith('+json')
) {
return 'json';
}
if (cleanContent === 'multipart/form-data') {
return 'formData';
}
if (
['application/', 'audio/', 'image/', 'video/'].some((type) =>
cleanContent.startsWith(type),
)
) {
return 'blob';
}
if (cleanContent.startsWith('text/')) {
return 'text';
}
return;
};
const checkForExistence = (
options: Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
},
name?: string,
): boolean => {
if (!name) {
return false;
}
if (
options.headers.has(name) ||
options.query?.[name] ||
options.headers.get('Cookie')?.includes(`${name}=`)
) {
return true;
}
return false;
};
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
}) => {
for (const auth of security) {
if (checkForExistence(options, auth.name)) {
continue;
}
const token = await getAuthToken(auth, options.auth);
if (!token) {
continue;
}
const name = auth.name ?? 'Authorization';
switch (auth.in) {
case 'query':
if (!options.query) {
options.query = {};
}
options.query[name] = token;
break;
case 'cookie':
options.headers.append('Cookie', `${name}=${token}`);
break;
case 'header':
default:
options.headers.set(name, token);
break;
}
}
};
export const buildUrl: Client['buildUrl'] = (options) =>
getUrl({
baseUrl: options.baseUrl as string,
path: options.path,
query: options.query,
querySerializer:
typeof options.querySerializer === 'function'
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url,
});
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b };
if (config.baseUrl?.endsWith('/')) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
}
config.headers = mergeHeaders(a.headers, b.headers);
return config;
};
const headersEntries = (headers: Headers): Array<[string, string]> => {
const entries: Array<[string, string]> = [];
headers.forEach((value, key) => {
entries.push([key, value]);
});
return entries;
};
export const mergeHeaders = (
...headers: Array<Required<Config>['headers'] | undefined>
): Headers => {
const mergedHeaders = new Headers();
for (const header of headers) {
if (!header) {
continue;
}
const iterator =
header instanceof Headers
? headersEntries(header)
: Object.entries(header);
for (const [key, value] of iterator) {
if (value === null) {
mergedHeaders.delete(key);
} else if (Array.isArray(value)) {
for (const v of value) {
mergedHeaders.append(key, v as string);
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e. their
// content value in OpenAPI specification is 'application/json'
mergedHeaders.set(
key,
typeof value === 'object' ? JSON.stringify(value) : (value as string),
);
}
}
}
return mergedHeaders;
};
type ErrInterceptor<Err, Res, Req, Options> = (
error: Err,
response: Res,
request: Req,
options: Options,
) => Err | Promise<Err>;
type ReqInterceptor<Req, Options> = (
request: Req,
options: Options,
) => Req | Promise<Req>;
type ResInterceptor<Res, Req, Options> = (
response: Res,
request: Req,
options: Options,
) => Res | Promise<Res>;
class Interceptors<Interceptor> {
fns: Array<Interceptor | null> = [];
clear(): void {
this.fns = [];
}
eject(id: number | Interceptor): void {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = null;
}
}
exists(id: number | Interceptor): boolean {
const index = this.getInterceptorIndex(id);
return Boolean(this.fns[index]);
}
getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === 'number') {
return this.fns[id] ? id : -1;
}
return this.fns.indexOf(id);
}
update(
id: number | Interceptor,
fn: Interceptor,
): number | Interceptor | false {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = fn;
return id;
}
return false;
}
use(fn: Interceptor): number {
this.fns.push(fn);
return this.fns.length - 1;
}
}
export interface Middleware<Req, Res, Err, Options> {
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
request: Interceptors<ReqInterceptor<Req, Options>>;
response: Interceptors<ResInterceptor<Res, Req, Options>>;
}
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
Req,
Res,
Err,
Options
> => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
});
const defaultQuerySerializer = createQuerySerializer({
allowReserved: false,
array: {
explode: true,
style: 'form',
},
object: {
explode: true,
style: 'deepObject',
},
});
const defaultHeaders = {
'Content-Type': 'application/json',
};
export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {},
): Config<Omit<ClientOptions, keyof T> & T> => ({
...jsonBodySerializer,
headers: defaultHeaders,
parseAs: 'auto',
querySerializer: defaultQuerySerializer,
...override,
});

View File

@@ -0,0 +1,42 @@
// This file is auto-generated by @hey-api/openapi-ts
export type AuthToken = string | undefined;
export interface Auth {
/**
* Which part of the request do we use to send the auth?
*
* @default 'header'
*/
in?: 'header' | 'query' | 'cookie';
/**
* Header or query parameter name.
*
* @default 'Authorization'
*/
name?: string;
scheme?: 'basic' | 'bearer';
type: 'apiKey' | 'http';
}
export const getAuthToken = async (
auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
): Promise<string | undefined> => {
const token =
typeof callback === 'function' ? await callback(auth) : callback;
if (!token) {
return;
}
if (auth.scheme === 'bearer') {
return `Bearer ${token}`;
}
if (auth.scheme === 'basic') {
return `Basic ${btoa(token)}`;
}
return token;
};

View File

@@ -0,0 +1,100 @@
// This file is auto-generated by @hey-api/openapi-ts
import type {
ArrayStyle,
ObjectStyle,
SerializerOptions,
} from './pathSerializer.gen';
export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: any) => any;
type QuerySerializerOptionsObject = {
allowReserved?: boolean;
array?: Partial<SerializerOptions<ArrayStyle>>;
object?: Partial<SerializerOptions<ObjectStyle>>;
};
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
/**
* Per-parameter serialization overrides. When provided, these settings
* override the global array/object settings for specific parameter names.
*/
parameters?: Record<string, QuerySerializerOptionsObject>;
};
const serializeFormDataPair = (
data: FormData,
key: string,
value: unknown,
): void => {
if (typeof value === 'string' || value instanceof Blob) {
data.append(key, value);
} else if (value instanceof Date) {
data.append(key, value.toISOString());
} else {
data.append(key, JSON.stringify(value));
}
};
const serializeUrlSearchParamsPair = (
data: URLSearchParams,
key: string,
value: unknown,
): void => {
if (typeof value === 'string') {
data.append(key, value);
} else {
data.append(key, JSON.stringify(value));
}
};
export const formDataBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): FormData => {
const data = new FormData();
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v));
} else {
serializeFormDataPair(data, key, value);
}
});
return data;
},
};
export const jsonBodySerializer = {
bodySerializer: <T>(body: T): string =>
JSON.stringify(body, (_key, value) =>
typeof value === 'bigint' ? value.toString() : value,
),
};
export const urlSearchParamsBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): string => {
const data = new URLSearchParams();
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
} else {
serializeUrlSearchParamsPair(data, key, value);
}
});
return data.toString();
},
};

View File

@@ -0,0 +1,176 @@
// This file is auto-generated by @hey-api/openapi-ts
type Slot = 'body' | 'headers' | 'path' | 'query';
export type Field =
| {
in: Exclude<Slot, 'body'>;
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If omitted, we use the same value as `key`.
*/
map?: string;
}
| {
in: Extract<Slot, 'body'>;
/**
* Key isn't required for bodies.
*/
key?: string;
map?: string;
}
| {
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If `in` is omitted, `map` aliases `key` to the transport layer.
*/
map: Slot;
};
export interface Fields {
allowExtra?: Partial<Record<Slot, boolean>>;
args?: ReadonlyArray<Field>;
}
export type FieldsConfig = ReadonlyArray<Field | Fields>;
const extraPrefixesMap: Record<string, Slot> = {
$body_: 'body',
$headers_: 'headers',
$path_: 'path',
$query_: 'query',
};
const extraPrefixes = Object.entries(extraPrefixesMap);
type KeyMap = Map<
string,
| {
in: Slot;
map?: string;
}
| {
in?: never;
map: Slot;
}
>;
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
if (!map) {
map = new Map();
}
for (const config of fields) {
if ('in' in config) {
if (config.key) {
map.set(config.key, {
in: config.in,
map: config.map,
});
}
} else if ('key' in config) {
map.set(config.key, {
map: config.map,
});
} else if (config.args) {
buildKeyMap(config.args, map);
}
}
return map;
};
interface Params {
body: unknown;
headers: Record<string, unknown>;
path: Record<string, unknown>;
query: Record<string, unknown>;
}
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === 'object' && !Object.keys(value).length) {
delete params[slot as Slot];
}
}
};
export const buildClientParams = (
args: ReadonlyArray<unknown>,
fields: FieldsConfig,
) => {
const params: Params = {
body: {},
headers: {},
path: {},
query: {},
};
const map = buildKeyMap(fields);
let config: FieldsConfig[number] | undefined;
for (const [index, arg] of args.entries()) {
if (fields[index]) {
config = fields[index];
}
if (!config) {
continue;
}
if ('in' in config) {
if (config.key) {
const field = map.get(config.key)!;
const name = field.map || config.key;
if (field.in) {
(params[field.in] as Record<string, unknown>)[name] = arg;
}
} else {
params.body = arg;
}
} else {
for (const [key, value] of Object.entries(arg ?? {})) {
const field = map.get(key);
if (field) {
if (field.in) {
const name = field.map || key;
(params[field.in] as Record<string, unknown>)[name] = value;
} else {
params[field.map] = value;
}
} else {
const extra = extraPrefixes.find(([prefix]) =>
key.startsWith(prefix),
);
if (extra) {
const [prefix, slot] = extra;
(params[slot] as Record<string, unknown>)[
key.slice(prefix.length)
] = value;
} else if ('allowExtra' in config && config.allowExtra) {
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
if (allowed) {
(params[slot as Slot] as Record<string, unknown>)[key] = value;
break;
}
}
}
}
}
}
}
stripEmptySlots(params);
return params;
};

View File

@@ -0,0 +1,181 @@
// This file is auto-generated by @hey-api/openapi-ts
interface SerializeOptions<T>
extends SerializePrimitiveOptions,
SerializerOptions<T> {}
interface SerializePrimitiveOptions {
allowReserved?: boolean;
name: string;
}
export interface SerializerOptions<T> {
/**
* @default true
*/
explode: boolean;
style: T;
}
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type MatrixStyle = 'label' | 'matrix' | 'simple';
export type ObjectStyle = 'form' | 'deepObject';
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string;
}
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'form':
return ',';
case 'pipeDelimited':
return '|';
case 'spaceDelimited':
return '%20';
default:
return ',';
}
};
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const serializeArrayParam = ({
allowReserved,
explode,
name,
style,
value,
}: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[];
}) => {
if (!explode) {
const joinedValues = (
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
).join(separatorArrayNoExplode(style));
switch (style) {
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
case 'simple':
return joinedValues;
default:
return `${name}=${joinedValues}`;
}
}
const separator = separatorArrayExplode(style);
const joinedValues = value
.map((v) => {
if (style === 'label' || style === 'simple') {
return allowReserved ? v : encodeURIComponent(v as string);
}
return serializePrimitiveParam({
allowReserved,
name,
value: v as string,
});
})
.join(separator);
return style === 'label' || style === 'matrix'
? separator + joinedValues
: joinedValues;
};
export const serializePrimitiveParam = ({
allowReserved,
name,
value,
}: SerializePrimitiveParam) => {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'object') {
throw new Error(
'Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.',
);
}
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
};
export const serializeObjectParam = ({
allowReserved,
explode,
name,
style,
value,
valueOnly,
}: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date;
valueOnly?: boolean;
}) => {
if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
}
if (style !== 'deepObject' && !explode) {
let values: string[] = [];
Object.entries(value).forEach(([key, v]) => {
values = [
...values,
key,
allowReserved ? (v as string) : encodeURIComponent(v as string),
];
});
const joinedValues = values.join(',');
switch (style) {
case 'form':
return `${name}=${joinedValues}`;
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
default:
return joinedValues;
}
}
const separator = separatorObjectExplode(style);
const joinedValues = Object.entries(value)
.map(([key, v]) =>
serializePrimitiveParam({
allowReserved,
name: style === 'deepObject' ? `${name}[${key}]` : key,
value: v as string,
}),
)
.join(separator);
return style === 'label' || style === 'matrix'
? separator + joinedValues
: joinedValues;
};

View File

@@ -0,0 +1,136 @@
// This file is auto-generated by @hey-api/openapi-ts
/**
* JSON-friendly union that mirrors what Pinia Colada can hash.
*/
export type JsonValue =
| null
| string
| number
| boolean
| JsonValue[]
| { [key: string]: JsonValue };
/**
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
*/
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
if (
value === undefined ||
typeof value === 'function' ||
typeof value === 'symbol'
) {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
return value;
};
/**
* Safely stringifies a value and parses it back into a JsonValue.
*/
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
try {
const json = JSON.stringify(input, queryKeyJsonReplacer);
if (json === undefined) {
return undefined;
}
return JSON.parse(json) as JsonValue;
} catch {
return undefined;
}
};
/**
* Detects plain objects (including objects with a null prototype).
*/
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== 'object') {
return false;
}
const prototype = Object.getPrototypeOf(value as object);
return prototype === Object.prototype || prototype === null;
};
/**
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
*/
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
const entries = Array.from(params.entries()).sort(([a], [b]) =>
a.localeCompare(b),
);
const result: Record<string, JsonValue> = {};
for (const [key, value] of entries) {
const existing = result[key];
if (existing === undefined) {
result[key] = value;
continue;
}
if (Array.isArray(existing)) {
(existing as string[]).push(value);
} else {
result[key] = [existing, value];
}
}
return result;
};
/**
* Normalizes any accepted value into a JSON-friendly shape for query keys.
*/
export const serializeQueryKeyValue = (
value: unknown,
): JsonValue | undefined => {
if (value === null) {
return null;
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value;
}
if (
value === undefined ||
typeof value === 'function' ||
typeof value === 'symbol'
) {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return stringifyToJsonValue(value);
}
if (
typeof URLSearchParams !== 'undefined' &&
value instanceof URLSearchParams
) {
return serializeSearchParams(value);
}
if (isPlainObject(value)) {
return stringifyToJsonValue(value);
}
return undefined;
};

View File

@@ -0,0 +1,264 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Config } from './types.gen';
export type ServerSentEventsOptions<TData = unknown> = Omit<
RequestInit,
'method'
> &
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Implementing clients can call request interceptors inside this hook.
*/
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
/**
* Callback invoked when a network or parsing error occurs during streaming.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param error The error that occurred.
*/
onSseError?: (error: unknown) => void;
/**
* Callback invoked when an event is streamed from the server.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param event Event streamed from the server.
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void;
serializedBody?: RequestInit['body'];
/**
* Default retry delay in milliseconds.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 3000
*/
sseDefaultRetryDelay?: number;
/**
* Maximum number of retry attempts before giving up.
*/
sseMaxRetryAttempts?: number;
/**
* Maximum retry delay in milliseconds.
*
* Applies only when exponential backoff is used.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 30000
*/
sseMaxRetryDelay?: number;
/**
* Optional sleep function for retry backoff.
*
* Defaults to using `setTimeout`.
*/
sseSleepFn?: (ms: number) => Promise<void>;
url: string;
};
export interface StreamEvent<TData = unknown> {
data: TData;
event?: string;
id?: string;
retry?: number;
}
export type ServerSentEventsResult<
TData = unknown,
TReturn = void,
TNext = unknown,
> = {
stream: AsyncGenerator<
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
TReturn,
TNext
>;
};
export const createSseClient = <TData = unknown>({
onRequest,
onSseError,
onSseEvent,
responseTransformer,
responseValidator,
sseDefaultRetryDelay,
sseMaxRetryAttempts,
sseMaxRetryDelay,
sseSleepFn,
url,
...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
let lastEventId: string | undefined;
const sleep =
sseSleepFn ??
((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const createStream = async function* () {
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
let attempt = 0;
const signal = options.signal ?? new AbortController().signal;
while (true) {
if (signal.aborted) break;
attempt++;
const headers =
options.headers instanceof Headers
? options.headers
: new Headers(options.headers as Record<string, string> | undefined);
if (lastEventId !== undefined) {
headers.set('Last-Event-ID', lastEventId);
}
try {
const requestInit: RequestInit = {
redirect: 'follow',
...options,
body: options.serializedBody,
headers,
signal,
};
let request = new Request(url, requestInit);
if (onRequest) {
request = await onRequest(url, requestInit);
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = options.fetch ?? globalThis.fetch;
const response = await _fetch(request);
if (!response.ok)
throw new Error(
`SSE failed: ${response.status} ${response.statusText}`,
);
if (!response.body) throw new Error('No body in SSE response');
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = '';
const abortHandler = () => {
try {
reader.cancel();
} catch {
// noop
}
};
signal.addEventListener('abort', abortHandler);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const lines = chunk.split('\n');
const dataLines: Array<string> = [];
let eventName: string | undefined;
for (const line of lines) {
if (line.startsWith('data:')) {
dataLines.push(line.replace(/^data:\s*/, ''));
} else if (line.startsWith('event:')) {
eventName = line.replace(/^event:\s*/, '');
} else if (line.startsWith('id:')) {
lastEventId = line.replace(/^id:\s*/, '');
} else if (line.startsWith('retry:')) {
const parsed = Number.parseInt(
line.replace(/^retry:\s*/, ''),
10,
);
if (!Number.isNaN(parsed)) {
retryDelay = parsed;
}
}
}
let data: unknown;
let parsedJson = false;
if (dataLines.length) {
const rawData = dataLines.join('\n');
try {
data = JSON.parse(rawData);
parsedJson = true;
} catch {
data = rawData;
}
}
if (parsedJson) {
if (responseValidator) {
await responseValidator(data);
}
if (responseTransformer) {
data = await responseTransformer(data);
}
}
onSseEvent?.({
data,
event: eventName,
id: lastEventId,
retry: retryDelay,
});
if (dataLines.length) {
yield data as any;
}
}
}
} finally {
signal.removeEventListener('abort', abortHandler);
reader.releaseLock();
}
break; // exit loop on normal completion
} catch (error) {
// connection failed or aborted; retry after delay
onSseError?.(error);
if (
sseMaxRetryAttempts !== undefined &&
attempt >= sseMaxRetryAttempts
) {
break; // stop after firing error
}
// exponential backoff: double retry each attempt, cap at 30s
const backoff = Math.min(
retryDelay * 2 ** (attempt - 1),
sseMaxRetryDelay ?? 30000,
);
await sleep(backoff);
}
}
};
const stream = createStream();
return { stream };
};

View File

@@ -0,0 +1,118 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth, AuthToken } from './auth.gen';
import type {
BodySerializer,
QuerySerializer,
QuerySerializerOptions,
} from './bodySerializer.gen';
export type HttpMethod =
| 'connect'
| 'delete'
| 'get'
| 'head'
| 'options'
| 'patch'
| 'post'
| 'put'
| 'trace';
export type Client<
RequestFn = never,
Config = unknown,
MethodFn = never,
BuildUrlFn = never,
SseFn = never,
> = {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn;
getConfig: () => Config;
request: RequestFn;
setConfig: (config: Config) => Config;
} & {
[K in HttpMethod]: MethodFn;
} & ([SseFn] extends [never]
? { sse?: never }
: { sse: { [K in HttpMethod]: SseFn } });
export interface Config {
/**
* Auth token or a function returning auth token. The resolved value will be
* added to the request payload as defined by its `security` array.
*/
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
/**
* A function for serializing request body parameter. By default,
* {@link JSON.stringify()} will be used.
*/
bodySerializer?: BodySerializer | null;
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| RequestInit['headers']
| Record<
string,
| string
| number
| boolean
| (string | number | boolean)[]
| null
| undefined
| unknown
>;
/**
* The request method.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?: Uppercase<HttpMethod>;
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject
* style, and reserved characters are percent-encoded.
*
* This method will have no effect if the native `paramsSerializer()` Axios
* API function is used.
*
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
*/
querySerializer?: QuerySerializer | QuerySerializerOptions;
/**
* A function validating request data. This is useful if you want to ensure
* the request conforms to the desired shape, so it can be safely sent to
* the server.
*/
requestValidator?: (data: unknown) => Promise<unknown>;
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g. converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>;
/**
* A function validating response data. This is useful if you want to ensure
* the response conforms to the desired shape, so it can be safely passed to
* the transformers and returned to the user.
*/
responseValidator?: (data: unknown) => Promise<unknown>;
}
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
? true
: [T] extends [never | undefined]
? [undefined] extends [T]
? false
: true
: false;
export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
? never
: K]: T[K];
};

View File

@@ -0,0 +1,143 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
import {
type ArraySeparatorStyle,
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from './pathSerializer.gen';
export interface PathSerializer {
path: Record<string, unknown>;
url: string;
}
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = 'simple';
if (name.endsWith('*')) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith('.')) {
name = name.substring(1);
style = 'label';
} else if (name.startsWith(';')) {
name = name.substring(1);
style = 'matrix';
}
const value = path[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
url = url.replace(
match,
serializeArrayParam({ explode, name, style, value }),
);
continue;
}
if (typeof value === 'object') {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
);
continue;
}
if (style === 'matrix') {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
);
continue;
}
const replaceValue = encodeURIComponent(
style === 'label' ? `.${value as string}` : (value as string),
);
url = url.replace(match, replaceValue);
}
}
return url;
};
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url,
}: {
baseUrl?: string;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
}) => {
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
let url = (baseUrl ?? '') + pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : '';
if (search.startsWith('?')) {
search = search.substring(1);
}
if (search) {
url += `?${search}`;
}
return url;
};
export function getValidRequestBody(options: {
body?: unknown;
bodySerializer?: BodySerializer | null;
serializedBody?: unknown;
}) {
const hasBody = options.body !== undefined;
const isSerializedBody = hasBody && options.bodySerializer;
if (isSerializedBody) {
if ('serializedBody' in options) {
const hasSerializedBody =
options.serializedBody !== undefined && options.serializedBody !== '';
return hasSerializedBody ? options.serializedBody : null;
}
// not all clients implement a serializedBody property (i.e. client-axios)
return options.body !== '' ? options.body : null;
}
// plain/text body
if (hasBody) {
return options.body;
}
// no body was provided
return undefined;
}

View File

@@ -0,0 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts
export type * from './types.gen';
export * from './sdk.gen';

View File

@@ -0,0 +1,391 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
* You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a
* custom client.
*/
client?: Client;
/**
* You can pass arbitrary values through the `meta` object. This can be
* used to access values that aren't defined as part of the SDK function.
*/
meta?: Record<string, unknown>;
};
/**
* Register a new user
*/
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => (options?.client ?? client).post<RegisterResponses, unknown, ThrowOnError>({
url: '/api/v1/auth/register',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* Login with username and password
*/
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => (options?.client ?? client).post<LoginResponses, unknown, ThrowOnError>({
url: '/api/v1/auth/login',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* Logout current user
*/
export const logout = <ThrowOnError extends boolean = false>(options?: Options<LogoutData, ThrowOnError>) => (options?.client ?? client).post<LogoutResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/logout', ...options });
/**
* Get current authenticated user
*/
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => (options?.client ?? client).get<GetMeResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/me', ...options });
/**
* Get authentication system status
*/
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/status', ...options });
/**
* Change current user password
*/
export const changePassword = <ThrowOnError extends boolean = false>(options?: Options<ChangePasswordData, ThrowOnError>) => (options?.client ?? client).post<ChangePasswordResponses, unknown, ThrowOnError>({
url: '/api/v1/auth/change-password',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* List all volumes
*/
export const listVolumes = <ThrowOnError extends boolean = false>(options?: Options<ListVolumesData, ThrowOnError>) => (options?.client ?? client).get<ListVolumesResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes', ...options });
/**
* Create a new volume
*/
export const createVolume = <ThrowOnError extends boolean = false>(options?: Options<CreateVolumeData, ThrowOnError>) => (options?.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* Test connection to backend
*/
export const testConnection = <ThrowOnError extends boolean = false>(options?: Options<TestConnectionData, ThrowOnError>) => (options?.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes/test-connection',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* Delete a volume
*/
export const deleteVolume = <ThrowOnError extends boolean = false>(options: Options<DeleteVolumeData, ThrowOnError>) => (options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}', ...options });
/**
* Get a volume by name
*/
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) => (options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{name}', ...options });
/**
* Update a volume's configuration
*/
export const updateVolume = <ThrowOnError extends boolean = false>(options: Options<UpdateVolumeData, ThrowOnError>) => (options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
url: '/api/v1/volumes/{name}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Get containers using a volume by name
*/
export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(options: Options<GetContainersUsingVolumeData, ThrowOnError>) => (options.client ?? client).get<GetContainersUsingVolumeResponses, GetContainersUsingVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{name}/containers', ...options });
/**
* Mount a volume
*/
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => (options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}/mount', ...options });
/**
* Unmount a volume
*/
export const unmountVolume = <ThrowOnError extends boolean = false>(options: Options<UnmountVolumeData, ThrowOnError>) => (options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}/unmount', ...options });
/**
* Perform a health check on a volume
*/
export const healthCheckVolume = <ThrowOnError extends boolean = false>(options: Options<HealthCheckVolumeData, ThrowOnError>) => (options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{name}/health-check', ...options });
/**
* List files in a volume directory
*/
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => (options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}/files', ...options });
/**
* Browse directories on the host filesystem
*/
export const browseFilesystem = <ThrowOnError extends boolean = false>(options?: Options<BrowseFilesystemData, ThrowOnError>) => (options?.client ?? client).get<BrowseFilesystemResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/filesystem/browse', ...options });
/**
* List all repositories
*/
export const listRepositories = <ThrowOnError extends boolean = false>(options?: Options<ListRepositoriesData, ThrowOnError>) => (options?.client ?? client).get<ListRepositoriesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories', ...options });
/**
* Create a new restic repository
*/
export const createRepository = <ThrowOnError extends boolean = false>(options?: Options<CreateRepositoryData, ThrowOnError>) => (options?.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* List all configured rclone remotes on the host system
*/
export const listRcloneRemotes = <ThrowOnError extends boolean = false>(options?: Options<ListRcloneRemotesData, ThrowOnError>) => (options?.client ?? client).get<ListRcloneRemotesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/rclone-remotes', ...options });
/**
* Delete a repository
*/
export const deleteRepository = <ThrowOnError extends boolean = false>(options: Options<DeleteRepositoryData, ThrowOnError>) => (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}', ...options });
/**
* Get a single repository by name
*/
export const getRepository = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}', ...options });
/**
* Update a repository's name or settings
*/
export const updateRepository = <ThrowOnError extends boolean = false>(options: Options<UpdateRepositoryData, ThrowOnError>) => (options.client ?? client).patch<UpdateRepositoryResponses, UpdateRepositoryErrors, ThrowOnError>({
url: '/api/v1/repositories/{name}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* List all snapshots in a repository
*/
export const listSnapshots = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotsData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots', ...options });
/**
* Delete a specific snapshot from a repository
*/
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options });
/**
* Get details of a specific snapshot
*/
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotDetailsData, ThrowOnError>) => (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options });
/**
* List files and directories in a snapshot
*/
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotFilesData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files', ...options });
/**
* Restore a snapshot to a target path on the filesystem
*/
export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: Options<RestoreSnapshotData, ThrowOnError>) => (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{name}/restore',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
*/
export const doctorRepository = <ThrowOnError extends boolean = false>(options: Options<DoctorRepositoryData, ThrowOnError>) => (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/doctor', ...options });
/**
* List all backup schedules
*/
export const listBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ListBackupSchedulesData, ThrowOnError>) => (options?.client ?? client).get<ListBackupSchedulesResponses, unknown, ThrowOnError>({ url: '/api/v1/backups', ...options });
/**
* Create a new backup schedule for a volume
*/
export const createBackupSchedule = <ThrowOnError extends boolean = false>(options?: Options<CreateBackupScheduleData, ThrowOnError>) => (options?.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
url: '/api/v1/backups',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* Delete a backup schedule
*/
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<DeleteBackupScheduleData, ThrowOnError>) => (options.client ?? client).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}', ...options });
/**
* Get a backup schedule by ID
*/
export const getBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleData, ThrowOnError>) => (options.client ?? client).get<GetBackupScheduleResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}', ...options });
/**
* Update a backup schedule
*/
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<UpdateBackupScheduleData, ThrowOnError>) => (options.client ?? client).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Get a backup schedule for a specific volume
*/
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleForVolumeData, ThrowOnError>) => (options.client ?? client).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/volume/{volumeId}', ...options });
/**
* Trigger a backup immediately for a schedule
*/
export const runBackupNow = <ThrowOnError extends boolean = false>(options: Options<RunBackupNowData, ThrowOnError>) => (options.client ?? client).post<RunBackupNowResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/run', ...options });
/**
* Stop a backup that is currently in progress
*/
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => (options.client ?? client).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/stop', ...options });
/**
* Manually apply retention policy to clean up old snapshots
*/
export const runForget = <ThrowOnError extends boolean = false>(options: Options<RunForgetData, ThrowOnError>) => (options.client ?? client).post<RunForgetResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/forget', ...options });
/**
* Get notification assignments for a backup schedule
*/
export const getScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<GetScheduleNotificationsData, ThrowOnError>) => (options.client ?? client).get<GetScheduleNotificationsResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/notifications', ...options });
/**
* Update notification assignments for a backup schedule
*/
export const updateScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleNotificationsData, ThrowOnError>) => (options.client ?? client).put<UpdateScheduleNotificationsResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}/notifications',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Get mirror repository assignments for a backup schedule
*/
export const getScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<GetScheduleMirrorsData, ThrowOnError>) => (options.client ?? client).get<GetScheduleMirrorsResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/mirrors', ...options });
/**
* Update mirror repository assignments for a backup schedule
*/
export const updateScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleMirrorsData, ThrowOnError>) => (options.client ?? client).put<UpdateScheduleMirrorsResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}/mirrors',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Get mirror compatibility info for all repositories relative to a backup schedule's primary repository
*/
export const getMirrorCompatibility = <ThrowOnError extends boolean = false>(options: Options<GetMirrorCompatibilityData, ThrowOnError>) => (options.client ?? client).get<GetMirrorCompatibilityResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/mirrors/compatibility', ...options });
/**
* List all notification destinations
*/
export const listNotificationDestinations = <ThrowOnError extends boolean = false>(options?: Options<ListNotificationDestinationsData, ThrowOnError>) => (options?.client ?? client).get<ListNotificationDestinationsResponses, unknown, ThrowOnError>({ url: '/api/v1/notifications/destinations', ...options });
/**
* Create a new notification destination
*/
export const createNotificationDestination = <ThrowOnError extends boolean = false>(options?: Options<CreateNotificationDestinationData, ThrowOnError>) => (options?.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({
url: '/api/v1/notifications/destinations',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* Delete a notification destination
*/
export const deleteNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<DeleteNotificationDestinationData, ThrowOnError>) => (options.client ?? client).delete<DeleteNotificationDestinationResponses, DeleteNotificationDestinationErrors, ThrowOnError>({ url: '/api/v1/notifications/destinations/{id}', ...options });
/**
* Get a notification destination by ID
*/
export const getNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<GetNotificationDestinationData, ThrowOnError>) => (options.client ?? client).get<GetNotificationDestinationResponses, GetNotificationDestinationErrors, ThrowOnError>({ url: '/api/v1/notifications/destinations/{id}', ...options });
/**
* Update a notification destination
*/
export const updateNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<UpdateNotificationDestinationData, ThrowOnError>) => (options.client ?? client).patch<UpdateNotificationDestinationResponses, UpdateNotificationDestinationErrors, ThrowOnError>({
url: '/api/v1/notifications/destinations/{id}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Test a notification destination by sending a test message
*/
export const testNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<TestNotificationDestinationData, ThrowOnError>) => (options.client ?? client).post<TestNotificationDestinationResponses, TestNotificationDestinationErrors, ThrowOnError>({ url: '/api/v1/notifications/destinations/{id}/test', ...options });
/**
* Get system information including available capabilities
*/
export const getSystemInfo = <ThrowOnError extends boolean = false>(options?: Options<GetSystemInfoData, ThrowOnError>) => (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({ url: '/api/v1/system/info', ...options });
/**
* Download the Restic password file for backup recovery. Requires password re-authentication.
*/
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options?: Options<DownloadResticPasswordData, ThrowOnError>) => (options?.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
url: '/api/v1/system/restic-password',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
import { Link, useMatches, type UIMatch } from "react-router";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "~/client/components/ui/breadcrumb";
export interface BreadcrumbItemData {
label: string;
href?: string;
}
interface RouteHandle {
breadcrumb?: (match: UIMatch) => BreadcrumbItemData[] | null;
}
export function AppBreadcrumb() {
const matches = useMatches();
// Find the last match with a breadcrumb handler
const lastMatchWithBreadcrumb = [...matches].reverse().find((match) => {
const handle = match.handle as RouteHandle | undefined;
return handle?.breadcrumb;
});
if (!lastMatchWithBreadcrumb) {
return null;
}
const handle = lastMatchWithBreadcrumb.handle as RouteHandle;
const breadcrumbs = handle.breadcrumb?.(lastMatchWithBreadcrumb);
if (!breadcrumbs || breadcrumbs.length === 0) {
return null;
}
return (
<Breadcrumb>
<BreadcrumbList>
{breadcrumbs.map((breadcrumb, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<div key={`${breadcrumb.label}-${index}`} className="contents">
<BreadcrumbItem>
{isLast || !breadcrumb.href ? (
<BreadcrumbPage>{breadcrumb.label}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link to={breadcrumb.href}>{breadcrumb.label}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />}
</div>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
}

View File

@@ -0,0 +1,106 @@
import { Bell, CalendarClock, Database, HardDrive, Settings } from "lucide-react";
import { Link, NavLink } from "react-router";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "~/client/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
import { cn } from "~/client/lib/utils";
import { APP_VERSION } from "~/client/lib/version";
const items = [
{
title: "Volumes",
url: "/volumes",
icon: HardDrive,
},
{
title: "Repositories",
url: "/repositories",
icon: Database,
},
{
title: "Backups",
url: "/backups",
icon: CalendarClock,
},
{
title: "Notifications",
url: "/notifications",
icon: Bell,
},
{
title: "Settings",
url: "/settings",
icon: Settings,
},
];
export function AppSidebar() {
const { state } = useSidebar();
return (
<Sidebar variant="inset" collapsible="icon" className="p-0">
<SidebarHeader className="bg-card-header border-b border-border/50 hidden md:flex h-[65px] flex-row items-center p-4">
<Link to="/volumes" className="flex items-center gap-3 font-semibold pl-2">
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} />
<span
className={cn("text-base transition-all duration-200 -ml-1", {
"opacity-0 w-0 overflow-hidden ": state === "collapsed",
})}
>
Zerobyte
</span>
</Link>
</SidebarHeader>
<SidebarContent className="p-2 border-r">
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton asChild>
<NavLink to={item.url}>
{({ isActive }) => (
<>
<item.icon className={cn({ "text-strong-accent": isActive })} />
<span className={cn({ "text-strong-accent": isActive })}>{item.title}</span>
</>
)}
</NavLink>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right" className={cn({ hidden: state !== "collapsed" })}>
<p>{item.title}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="p-4 border-r border-t border-border/50">
<div
className={cn("text-xs text-muted-foreground transition-all duration-200", {
"opacity-0 w-0 overflow-hidden": state === "collapsed",
})}
>
{APP_VERSION}
</div>
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -0,0 +1,33 @@
import type { ReactNode } from "react";
type AuthLayoutProps = {
title: string;
description: string;
children: ReactNode;
};
export function AuthLayout({ title, description, children }: AuthLayoutProps) {
return (
<div className="flex mt-[25%] lg:mt-0 lg:min-h-screen">
<div className="flex flex-1 items-center justify-center bg-background p-8">
<div className="w-full max-w-md space-y-8">
<div className="flex items-center gap-3">
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className="h-5 w-5 object-contain" />
<span className="text-lg font-semibold">Zerobyte</span>
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children}
</div>
</div>
<div
className="hidden lg:block lg:flex-1 dither-lg bg-cover bg-center"
style={{ backgroundImage: "url(/images/background.jpg)" }}
/>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React from "react";
import type React from "react";
type ByteSizeProps = {
bytes: number;
@@ -54,7 +54,7 @@ export function formatBytes(
idx = Math.max(0, Math.min(idx, units.length - 1));
}
const numeric = (abs / Math.pow(base, idx)) * sign;
const numeric = (abs / base ** idx) * sign;
const maxFrac = (() => {
if (!smartRounding) return maximumFractionDigits;

View File

@@ -0,0 +1,793 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { Check, Pencil, Save, X } from "lucide-react";
import { cn, slugify } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { Button } from "./ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { useQuery } from "@tanstack/react-query";
import { Alert, AlertDescription } from "./ui/alert";
import { ExternalLink, AlertTriangle } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic";
import { listRcloneRemotesOptions } from "../api-client/@tanstack/react-query.gen";
import { Checkbox } from "./ui/checkbox";
import { DirectoryBrowser } from "./directory-browser";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "./ui/alert-dialog";
import { Textarea } from "./ui/textarea";
export const formSchema = type({
name: "2<=string<=32",
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
}).and(repositoryConfigSchema);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type RepositoryFormValues = typeof formSchema.inferIn;
type Props = {
onSubmit: (values: RepositoryFormValues) => void;
mode?: "create" | "update";
initialValues?: Partial<RepositoryFormValues>;
formId?: string;
loading?: boolean;
className?: string;
};
const defaultValuesForType = {
local: { backend: "local" as const, compressionMode: "auto" as const },
s3: { backend: "s3" as const, compressionMode: "auto" as const },
r2: { backend: "r2" as const, compressionMode: "auto" as const },
gcs: { backend: "gcs" as const, compressionMode: "auto" as const },
azure: { backend: "azure" as const, compressionMode: "auto" as const },
rclone: { backend: "rclone" as const, compressionMode: "auto" as const },
rest: { backend: "rest" as const, compressionMode: "auto" as const },
sftp: { backend: "sftp" as const, compressionMode: "auto" as const, port: 22 },
};
export const CreateRepositoryForm = ({
onSubmit,
mode = "create",
initialValues,
formId,
loading,
className,
}: Props) => {
const form = useForm<RepositoryFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues,
resetOptions: {
keepDefaultValues: true,
keepDirtyValues: false,
},
});
const { watch, setValue } = form;
const watchedBackend = watch("backend");
const watchedIsExistingRepository = watch("isExistingRepository");
const [passwordMode, setPasswordMode] = useState<"default" | "custom">("default");
const [showPathBrowser, setShowPathBrowser] = useState(false);
const [showPathWarning, setShowPathWarning] = useState(false);
const { capabilities } = useSystemInfo();
const { data: rcloneRemotes, isLoading: isLoadingRemotes } = useQuery({
...listRcloneRemotesOptions(),
enabled: capabilities.rclone,
});
useEffect(() => {
form.reset({
name: form.getValues().name,
isExistingRepository: form.getValues().isExistingRepository,
customPassword: form.getValues().customPassword,
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
});
}, [watchedBackend, form]);
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Repository name"
onChange={(e) => field.onChange(slugify(e.target.value))}
max={32}
min={2}
/>
</FormControl>
<FormDescription>Unique identifier for the repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="backend"
render={({ field }) => (
<FormItem>
<FormLabel>Backend</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a backend" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="s3">S3</SelectItem>
<SelectItem value="r2">Cloudflare R2</SelectItem>
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
<SelectItem value="azure">Azure Blob Storage</SelectItem>
<SelectItem value="rest">REST Server</SelectItem>
<SelectItem value="sftp">SFTP</SelectItem>
<Tooltip>
<TooltipTrigger>
<SelectItem disabled={!capabilities.rclone} value="rclone">
rclone (40+ cloud providers)
</SelectItem>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: capabilities.rclone })}>
<p>Setup rclone to use this backend</p>
</TooltipContent>
</Tooltip>
</SelectContent>
</Select>
<FormDescription>Choose the storage backend for this repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="compressionMode"
render={({ field }) => (
<FormItem>
<FormLabel>Compression Mode</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select compression mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="auto">Auto (fast)</SelectItem>
<SelectItem value="max">Max (slower, better compression)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Compression mode for backups stored in this repository.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isExistingRepository"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
setPasswordMode("default");
setValue("customPassword", undefined);
}
}}
/>
</FormControl>
<div className="space-y-1">
<FormLabel>Import existing repository</FormLabel>
<FormDescription>Check this if the repository already exists at the specified location</FormDescription>
</div>
</FormItem>
)}
/>
{watchedIsExistingRepository && (
<>
<FormItem>
<FormLabel>Repository Password</FormLabel>
<Select
onValueChange={(value) => {
setPasswordMode(value as "default" | "custom");
if (value === "default") {
setValue("customPassword", undefined);
}
}}
defaultValue={passwordMode}
value={passwordMode}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select password option" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="default">Use Zerobyte's password</SelectItem>
<SelectItem value="custom">Enter password manually</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose whether to use Zerobyte's master password or enter a custom password for the existing repository.
</FormDescription>
</FormItem>
{passwordMode === "custom" && (
<FormField
control={form.control}
name="customPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Repository Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter repository password" {...field} />
</FormControl>
<FormDescription>
The password used to encrypt this repository. It will be stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
{watchedBackend === "local" && (
<>
<FormItem>
<FormLabel>Repository Directory</FormLabel>
<div className="flex items-center gap-2">
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
{form.watch("path") || "/var/lib/zerobyte/repositories"}
</div>
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
<Pencil className="h-4 w-4 mr-2" />
Change
</Button>
</div>
<FormDescription>The directory where the repository will be stored.</FormDescription>
</FormItem>
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
Important: Host mount required
</AlertDialogTitle>
<AlertDialogDescription className="space-y-3">
<p>When selecting a custom path, ensure it is mounted from the host machine into the container.</p>
<p className="font-medium">
If the path is not a host mount, you will lose your repository data when the container restarts.
</p>
<p className="text-sm text-muted-foreground">
The default path <code className="bg-muted px-1 rounded">/var/lib/zerobyte/repositories</code> is
already mounted from the host and is safe to use.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setShowPathBrowser(true);
setShowPathWarning(false);
}}
>
I Understand, Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showPathBrowser} onOpenChange={setShowPathBrowser}>
<AlertDialogContent className="max-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle>Select Repository Directory</AlertDialogTitle>
<AlertDialogDescription>
Choose a directory from the filesystem to store the repository.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="py-4">
<DirectoryBrowser
onSelectPath={(path) => form.setValue("path", path)}
selectedPath={form.watch("path") || "/var/lib/zerobyte/repositories"}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>
<X className="h-4 w-4 mr-2" />
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={() => setShowPathBrowser(false)}>
<Check className="h-4 w-4 mr-2" />
Done
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
{watchedBackend === "s3" && (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input placeholder="s3.amazonaws.com" {...field} />
</FormControl>
<FormDescription>S3-compatible endpoint URL.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>S3 bucket name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key ID</FormLabel>
<FormControl>
<Input placeholder="AKIAIOSFODNN7EXAMPLE" {...field} />
</FormControl>
<FormDescription>S3 access key ID for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>S3 secret access key for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "r2" && (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint</FormLabel>
<FormControl>
<Input placeholder="<account-id>.r2.cloudflarestorage.com" {...field} />
</FormControl>
<FormDescription>
R2 endpoint (without https://). Find in R2 dashboard under bucket settings.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>R2 bucket name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Access Key ID</FormLabel>
<FormControl>
<Input placeholder="Access Key ID from R2 API tokens" {...field} />
</FormControl>
<FormDescription>R2 API token Access Key ID (create in Cloudflare R2 dashboard).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secretAccessKey"
render={({ field }) => (
<FormItem>
<FormLabel>Secret Access Key</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>R2 API token Secret Access Key (shown once when creating token).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "gcs" && (
<>
<FormField
control={form.control}
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input placeholder="my-backup-bucket" {...field} />
</FormControl>
<FormDescription>GCS bucket name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="projectId"
render={({ field }) => (
<FormItem>
<FormLabel>Project ID</FormLabel>
<FormControl>
<Input placeholder="my-gcp-project-123" {...field} />
</FormControl>
<FormDescription>Google Cloud project ID.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="credentialsJson"
render={({ field }) => (
<FormItem>
<FormLabel>Service Account JSON</FormLabel>
<FormControl>
<Input type="password" placeholder="Paste service account JSON key..." {...field} />
</FormControl>
<FormDescription>Service account JSON credentials for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "azure" && (
<>
<FormField
control={form.control}
name="container"
render={({ field }) => (
<FormItem>
<FormLabel>Container</FormLabel>
<FormControl>
<Input placeholder="my-backup-container" {...field} />
</FormControl>
<FormDescription>Azure Blob Storage container name for storing backups.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accountName"
render={({ field }) => (
<FormItem>
<FormLabel>Account Name</FormLabel>
<FormControl>
<Input placeholder="mystorageaccount" {...field} />
</FormControl>
<FormDescription>Azure Storage account name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accountKey"
render={({ field }) => (
<FormItem>
<FormLabel>Account Key</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>Azure Storage account key for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endpointSuffix"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint Suffix (Optional)</FormLabel>
<FormControl>
<Input placeholder="core.windows.net" {...field} />
</FormControl>
<FormDescription>Custom Azure endpoint suffix (defaults to core.windows.net).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "rclone" &&
(!rcloneRemotes || rcloneRemotes.length === 0 ? (
<Alert>
<AlertDescription className="space-y-2">
<p className="font-medium">No rclone remotes configured</p>
<p className="text-sm text-muted-foreground">
To use rclone, you need to configure remotes on your host system
</p>
<a
href="https://rclone.org/docs/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-strong-accent inline-flex items-center gap-1"
>
View rclone documentation
<ExternalLink className="w-3 h-3" />
</a>
</AlertDescription>
</Alert>
) : (
<>
<FormField
control={form.control}
name="remote"
render={({ field }) => (
<FormItem>
<FormLabel>Remote</FormLabel>
<Select onValueChange={(v) => field.onChange(v)} defaultValue={field.value} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an rclone remote" />
</SelectTrigger>
</FormControl>
<SelectContent>
{isLoadingRemotes ? (
<SelectItem value="loading" disabled>
Loading remotes...
</SelectItem>
) : (
rcloneRemotes.map((remote: { name: string; type: string }) => (
<SelectItem key={remote.name} value={remote.name}>
{remote.name} ({remote.type})
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormDescription>Select the rclone remote configured on your host system.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="backups/zerobyte" {...field} />
</FormControl>
<FormDescription>Path within the remote where backups will be stored.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
))}
{watchedBackend === "rest" && (
<>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>REST Server URL</FormLabel>
<FormControl>
<Input placeholder="http://192.168.1.30:8000" {...field} />
</FormControl>
<FormDescription>URL of the REST server.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Repository Path (Optional)</FormLabel>
<FormControl>
<Input placeholder="my-backup-repo" {...field} />
</FormControl>
<FormDescription>Path to the repository on the REST server (leave empty for root).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input placeholder="username" {...field} />
</FormControl>
<FormDescription>Username for REST server authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>Password for REST server authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "sftp" && (
<>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
<FormDescription>SFTP server hostname or IP address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input
type="number"
placeholder="22"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>SSH port (default: 22).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="user"
render={({ field }) => (
<FormItem>
<FormLabel>User</FormLabel>
<FormControl>
<Input placeholder="backup-user" {...field} />
</FormControl>
<FormDescription>SSH username for authentication.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="backups/ironmount" {...field} />
</FormControl>
<FormDescription>Repository path on the SFTP server. </FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="privateKey"
render={({ field }) => (
<FormItem>
<FormLabel>SSH Private Key</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
/>
</FormControl>
<FormDescription>Paste the contents of your SSH private key.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{mode === "update" && (
<Button type="submit" className="w-full" loading={loading}>
<Save className="h-4 w-4 mr-2" />
Save Changes
</Button>
)}
</form>
</Form>
);
};

View File

@@ -1,20 +1,23 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { volumeConfigSchema } from "@ironmount/schemas";
import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { CheckCircle, Loader2, XCircle } from "lucide-react";
import { CheckCircle, Loader2, Pencil, Plug, Save, XCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { testConnectionMutation } from "~/api-client/@tanstack/react-query.gen";
import { slugify } from "~/lib/utils";
import { cn, slugify } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import { DirectoryBrowser } from "./directory-browser";
import { Button } from "./ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { volumeConfigSchema } from "~/schemas/volumes";
import { testConnectionMutation } from "../api-client/@tanstack/react-query.gen";
export const formSchema = type({
name: "2<=string<=32",
}).and(volumeConfigSchema);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type FormValues = typeof formSchema.inferIn;
@@ -24,18 +27,19 @@ type Props = {
initialValues?: Partial<FormValues>;
formId?: string;
loading?: boolean;
className?: string;
};
const defaultValuesForType = {
directory: { backend: "directory" as const },
directory: { backend: "directory" as const, path: "/" },
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
webdav: { backend: "webdav" as const, port: 80, ssl: false },
};
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading }: Props) => {
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
const form = useForm<FormValues>({
resolver: arktypeResolver(formSchema),
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues,
resetOptions: {
keepDefaultValues: true,
@@ -46,28 +50,31 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
const { watch, getValues } = form;
const watchedBackend = watch("backend");
const watchedName = watch("name");
useEffect(() => {
form.reset({ name: watchedName, ...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType] });
}, [watchedBackend, watchedName, form.reset]);
if (mode === "create") {
form.reset({
name: form.getValues().name,
...defaultValuesForType[watchedBackend as keyof typeof defaultValuesForType],
});
}
}, [watchedBackend, form, mode]);
const [testMessage, setTestMessage] = useState<string>("");
const [testMessage, setTestMessage] = useState<{ success: boolean; message: string } | null>(null);
const testBackendConnection = useMutation({
...testConnectionMutation(),
onMutate: () => {
setTestMessage("");
setTestMessage(null);
},
onError: () => {
setTestMessage("Failed to test connection. Please try again.");
onError: (error) => {
setTestMessage({
success: false,
message: error?.message || "Failed to test connection. Please try again.",
});
},
onSuccess: (data) => {
if (data?.success) {
setTestMessage(data.message);
} else {
setTestMessage(data?.message || "Connection test failed");
}
setTestMessage(data);
},
});
@@ -83,7 +90,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
<FormField
control={form.control}
name="name"
@@ -97,8 +104,6 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
onChange={(e) => field.onChange(slugify(e.target.value))}
max={32}
min={1}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/>
</FormControl>
<FormDescription>Unique identifier for the volume.</FormDescription>
@@ -131,6 +136,38 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
)}
/>
{watchedBackend === "directory" && (
<FormField
control={form.control}
name="path"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Directory Path</FormLabel>
<FormControl>
{field.value ? (
<div className="flex items-center gap-2">
<div className="flex-1 border rounded-md p-3 bg-muted/50">
<div className="text-xs font-medium text-muted-foreground mb-1">Selected path:</div>
<div className="text-sm font-mono break-all">{field.value}</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => field.onChange("")}>
<Pencil className="h-4 w-4 mr-2" />
Change
</Button>
</div>
) : (
<DirectoryBrowser onSelectPath={(path) => field.onChange(path)} selectedPath={field.value} />
)}
</FormControl>
<FormDescription>Browse and select a directory on the host filesystem to track.</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}
{watchedBackend === "nfs" && (
<>
<FormField
@@ -169,7 +206,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input type="number" placeholder="2049" {...field} />
<Input
type="number"
placeholder="2049"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl>
<FormDescription>NFS server port (default: 2049).</FormDescription>
<FormMessage />
@@ -200,6 +242,31 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</FormItem>
)}
/>
<FormField
control={form.control}
name="readOnly"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Read-only Mode</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Mount volume as read-only</span>
</div>
</FormControl>
<FormDescription>
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
@@ -269,7 +336,12 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input type="number" placeholder="80" {...field} />
<Input
type="number"
placeholder="80"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || undefined)}
/>
</FormControl>
<FormDescription>WebDAV server port (default: 80 for HTTP, 443 for HTTPS).</FormDescription>
<FormMessage />
@@ -299,6 +371,31 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</FormItem>
)}
/>
<FormField
control={form.control}
name="readOnly"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Read-only Mode</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Mount volume as read-only</span>
</div>
</FormControl>
<FormDescription>
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
@@ -311,7 +408,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem>
<FormLabel>Server</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" value={field.value ?? ""} onChange={field.onChange} />
<Input placeholder="192.168.1.100" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>SMB server IP address or hostname.</FormDescription>
<FormMessage />
@@ -325,7 +422,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem>
<FormLabel>Share</FormLabel>
<FormControl>
<Input placeholder="myshare" value={field.value ?? ""} onChange={field.onChange} />
<Input placeholder="myshare" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>SMB share name on the server.</FormDescription>
<FormMessage />
@@ -339,7 +436,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="admin" value={field.value ?? ""} onChange={field.onChange} />
<Input placeholder="admin" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>Username for SMB authentication.</FormDescription>
<FormMessage />
@@ -353,7 +450,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" value={field.value ?? ""} onChange={field.onChange} />
<Input type="password" placeholder="••••••••" value={field.value} onChange={field.onChange} />
</FormControl>
<FormDescription>Password for SMB authentication.</FormDescription>
<FormMessage />
@@ -420,10 +517,35 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
</FormItem>
)}
/>
<FormField
control={form.control}
name="readOnly"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Read-only Mode</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Mount volume as read-only</span>
</div>
</FormControl>
<FormDescription>
Prevent any modifications to the volume. Recommended for backup sources and sensitive data.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedBackend === "smb" && (
{watchedBackend && watchedBackend !== "directory" && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
@@ -434,101 +556,37 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
className="flex-1"
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{testBackendConnection.isSuccess && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />}
{testBackendConnection.isError && <XCircle className="mr-2 h-4 w-4 text-red-500" />}
{testBackendConnection.isIdle && "Test Connection"}
{testBackendConnection.isPending && "Testing..."}
{testBackendConnection.isSuccess && "Connection Successful"}
{testBackendConnection.isError && "Test Failed"}
{!testBackendConnection.isPending && testMessage?.success && (
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
)}
{!testBackendConnection.isPending && testMessage && !testMessage.success && (
<XCircle className="mr-2 h-4 w-4 text-red-500" />
)}
{!testBackendConnection.isPending && !testMessage && <Plug className="mr-2 h-4 w-4" />}
{testBackendConnection.isPending
? "Testing..."
: testMessage
? testMessage.success
? "Connection Successful"
: "Test Failed"
: "Test Connection"}
</Button>
</div>
{testMessage && (
<div
className={`text-sm p-2 rounded-md ${
testBackendConnection.isSuccess
? "bg-green-50 text-green-700 border border-green-200"
: testBackendConnection.isError
? "bg-red-50 text-red-700 border border-red-200"
: "bg-gray-50 text-gray-700 border border-gray-200"
}`}
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
"bg-green-50 text-green-700 border border-green-200": testMessage.success,
"bg-red-50 text-red-700 border border-red-200": !testMessage.success,
})}
>
{testMessage}
</div>
)}
</div>
)}
{watchedBackend === "nfs" && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testBackendConnection.isPending}
className="flex-1"
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{testBackendConnection.isSuccess && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />}
{testBackendConnection.isError && <XCircle className="mr-2 h-4 w-4 text-red-500" />}
{testBackendConnection.isIdle && "Test Connection"}
{testBackendConnection.isPending && "Testing..."}
{testBackendConnection.isSuccess && "Connection Successful"}
{testBackendConnection.isError && "Test Failed"}
</Button>
</div>
{testMessage && (
<div
className={`text-sm p-2 rounded-md ${
testBackendConnection.isSuccess
? "bg-green-50 text-green-700 border border-green-200"
: testBackendConnection.isError
? "bg-red-50 text-red-700 border border-red-200"
: "bg-gray-50 text-gray-700 border border-gray-200"
}`}
>
{testMessage}
</div>
)}
</div>
)}
{watchedBackend === "webdav" && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testBackendConnection.isPending}
className="flex-1"
>
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{testBackendConnection.isSuccess && <CheckCircle className="mr-2 h-4 w-4 text-green-500" />}
{testBackendConnection.isError && <XCircle className="mr-2 h-4 w-4 text-red-500" />}
{testBackendConnection.isIdle && "Test Connection"}
{testBackendConnection.isPending && "Testing..."}
{testBackendConnection.isSuccess && "Connection Successful"}
{testBackendConnection.isError && "Test Failed"}
</Button>
</div>
{testMessage && (
<div
className={`text-sm p-2 rounded-md ${
testBackendConnection.isSuccess
? "bg-green-50 text-green-700 border border-green-200"
: testBackendConnection.isError
? "bg-red-50 text-red-700 border border-red-200"
: "bg-gray-50 text-gray-700 border border-gray-200"
}`}
>
{testMessage}
{testMessage.message}
</div>
)}
</div>
)}
{mode === "update" && (
<Button type="submit" className="w-full mt-4" loading={loading}>
<Button type="submit" className="w-full" loading={loading}>
<Save className="h-4 w-4 mr-2" />
Save Changes
</Button>
)}

View File

@@ -0,0 +1,74 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FileTree } from "./file-tree";
import { ScrollArea } from "./ui/scroll-area";
import { browseFilesystemOptions } from "../api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "../hooks/use-file-browser";
type Props = {
onSelectPath: (path: string) => void;
selectedPath?: string;
};
export const DirectoryBrowser = ({ onSelectPath, selectedPath }: Props) => {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
...browseFilesystemOptions({ query: { path: "/" } }),
});
const fileBrowser = useFileBrowser({
initialData: data,
isLoading,
fetchFolder: async (path) => {
return await queryClient.ensureQueryData(browseFilesystemOptions({ query: { path } }));
},
prefetchFolder: (path) => {
queryClient.prefetchQuery(browseFilesystemOptions({ query: { path } }));
},
});
if (fileBrowser.isLoading) {
return (
<div className="border rounded-lg overflow-hidden">
<ScrollArea className="h-64">
<div className="text-sm text-gray-500 p-4">Loading directories...</div>
</ScrollArea>
</div>
);
}
if (fileBrowser.isEmpty) {
return (
<div className="border rounded-lg overflow-hidden">
<ScrollArea className="h-64">
<div className="text-sm text-gray-500 p-4">No subdirectories found</div>
</ScrollArea>
</div>
);
}
return (
<div className="border rounded-lg overflow-hidden">
<ScrollArea className="h-64">
<FileTree
files={fileBrowser.fileArray}
onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
foldersOnly
selectableFolders
selectedFolder={selectedPath}
onFolderSelect={onSelectPath}
/>
</ScrollArea>
{selectedPath && (
<div className="bg-muted/50 border-t p-2 text-sm">
<div className="font-medium text-muted-foreground">Selected path:</div>
<div className="font-mono text-xs break-all">{selectedPath}</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,32 @@
import { Card } from "./ui/card";
type EmptyStateProps = {
title?: string;
description?: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
button?: React.ReactNode;
};
export function EmptyState(props: EmptyStateProps) {
const { title, description, icon: Cicon, button } = props;
return (
<Card className="p-0 gap-0">
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="relative mb-8">
<div className="absolute inset-0 animate-pulse">
<div className="w-32 h-32 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-32 h-32 rounded-full bg-linear-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Cicon className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<div className="max-w-md space-y-3 mb-8">
<h3 className="text-2xl font-semibold text-foreground">{title}</h3>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
{button}
</div>
</Card>
);
}

View File

@@ -0,0 +1,596 @@
/**
* FileTree Component
*
* Adapted from bolt.new by StackBlitz
* Copyright (c) 2024 StackBlitz, Inc.
* Licensed under the MIT License
*
* Original source: https://github.com/stackblitz/bolt.new
*/
import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, FolderOpen, Loader2 } from "lucide-react";
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { cn } from "~/client/lib/utils";
import { Checkbox } from "~/client/components/ui/checkbox";
const NODE_PADDING_LEFT = 12;
export interface FileEntry {
name: string;
path: string;
type: string;
size?: number;
modifiedAt?: number;
}
interface Props {
files?: FileEntry[];
selectedFile?: string;
onFileSelect?: (filePath: string) => void;
onFolderExpand?: (folderPath: string) => void;
onFolderHover?: (folderPath: string) => void;
expandedFolders?: Set<string>;
loadingFolders?: Set<string>;
className?: string;
withCheckboxes?: boolean;
selectedPaths?: Set<string>;
onSelectionChange?: (selectedPaths: Set<string>) => void;
foldersOnly?: boolean;
selectableFolders?: boolean;
onFolderSelect?: (folderPath: string) => void;
selectedFolder?: string;
}
export const FileTree = memo((props: Props) => {
const {
files = [],
onFileSelect,
selectedFile,
onFolderExpand,
onFolderHover,
expandedFolders = new Set(),
loadingFolders = new Set(),
className,
withCheckboxes = false,
selectedPaths = new Set(),
onSelectionChange,
foldersOnly = false,
selectableFolders = false,
onFolderSelect,
selectedFolder,
} = props;
const fileList = useMemo(() => {
return buildFileList(files, foldersOnly);
}, [files, foldersOnly]);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
const filteredFileList = useMemo(() => {
const list = [];
let lastDepth = Number.MAX_SAFE_INTEGER;
for (const fileOrFolder of fileList) {
const depth = fileOrFolder.depth;
// if the depth is equal we reached the end of the collapsed group
if (lastDepth === depth) {
lastDepth = Number.MAX_SAFE_INTEGER;
}
// ignore collapsed folders
if (collapsedFolders.has(fileOrFolder.fullPath)) {
lastDepth = Math.min(lastDepth, depth);
}
// ignore files and folders below the last collapsed folder
if (lastDepth < depth) {
continue;
}
list.push(fileOrFolder);
}
return list;
}, [fileList, collapsedFolders]);
const toggleCollapseState = useCallback(
(fullPath: string) => {
setCollapsedFolders((prevSet) => {
const newSet = new Set(prevSet);
if (newSet.has(fullPath)) {
newSet.delete(fullPath);
onFolderExpand?.(fullPath);
} else {
newSet.add(fullPath);
}
return newSet;
});
},
[onFolderExpand],
);
// Add new folders to collapsed set when file list changes
useEffect(() => {
setCollapsedFolders((prevSet) => {
const newSet = new Set(prevSet);
for (const item of fileList) {
if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) {
newSet.add(item.fullPath);
}
}
return newSet;
});
}, [fileList, expandedFolders]);
const handleFileSelect = useCallback(
(filePath: string) => {
onFileSelect?.(filePath);
},
[onFileSelect],
);
const handleFolderSelect = useCallback(
(folderPath: string) => {
onFolderSelect?.(folderPath);
},
[onFolderSelect],
);
const handleSelectionChange = useCallback(
(path: string, checked: boolean) => {
const newSelection = new Set(selectedPaths);
if (checked) {
// Add the path itself
newSelection.add(path);
// Remove any descendants from selection since parent now covers them
for (const item of fileList) {
if (item.fullPath.startsWith(`${path}/`)) {
newSelection.delete(item.fullPath);
}
}
} else {
// Remove the path itself
newSelection.delete(path);
// Check if any parent is selected - if so, we need to add siblings back
const pathSegments = path.split("/").filter(Boolean);
let parentIsSelected = false;
let selectedParentPath = "";
// Check each parent level to see if any are selected
for (let i = pathSegments.length - 1; i > 0; i--) {
const parentPath = `/${pathSegments.slice(0, i).join("/")}`;
if (newSelection.has(parentPath)) {
parentIsSelected = true;
selectedParentPath = parentPath;
break;
}
}
if (parentIsSelected) {
// Remove the selected parent
newSelection.delete(selectedParentPath);
// Add all siblings and descendants of the selected parent, except the unchecked path and its descendants
for (const item of fileList) {
if (
item.fullPath.startsWith(`${selectedParentPath}/`) &&
!item.fullPath.startsWith(`${path}/`) &&
item.fullPath !== path
) {
newSelection.add(item.fullPath);
}
}
}
}
const childrenByParent = new Map<string, string[]>();
for (const selectedPath of newSelection) {
const lastSlashIndex = selectedPath.lastIndexOf("/");
if (lastSlashIndex > 0) {
const parentPath = selectedPath.slice(0, lastSlashIndex);
if (!childrenByParent.has(parentPath)) {
childrenByParent.set(parentPath, []);
}
childrenByParent.get(parentPath)?.push(selectedPath);
}
}
// For each parent, check if all its children are selected
for (const [parentPath, selectedChildren] of childrenByParent.entries()) {
// Get all children of this parent from the file list
const allChildren = fileList.filter((item) => {
const itemParentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/"));
return itemParentPath === parentPath;
});
// If all children are selected, replace them with the parent
if (allChildren.length > 0 && selectedChildren.length === allChildren.length) {
// Check that we have every child
const allChildrenPaths = new Set(allChildren.map((c) => c.fullPath));
const allChildrenSelected = selectedChildren.every((c) => allChildrenPaths.has(c));
if (allChildrenSelected) {
// Remove all children
for (const childPath of selectedChildren) {
newSelection.delete(childPath);
}
// Add the parent
newSelection.add(parentPath);
}
}
}
onSelectionChange?.(newSelection);
},
[selectedPaths, onSelectionChange, fileList],
);
// Helper to check if a path is selected (either directly or via parent)
const isPathSelected = useCallback(
(path: string): boolean => {
// Check if directly selected
if (selectedPaths.has(path)) {
return true;
}
// Check if any parent is selected
const pathSegments = path.split("/").filter(Boolean);
for (let i = pathSegments.length - 1; i > 0; i--) {
const parentPath = `/${pathSegments.slice(0, i).join("/")}`;
if (selectedPaths.has(parentPath)) {
return true;
}
}
return false;
},
[selectedPaths],
);
// Determine if a folder is partially selected (some children selected)
const isPartiallySelected = useCallback(
(folderPath: string): boolean => {
// If the folder itself is selected, it's not partial
if (selectedPaths.has(folderPath)) {
return false;
}
// Check if this folder is implicitly selected via a parent
const pathSegments = folderPath.split("/").filter(Boolean);
for (let i = pathSegments.length - 1; i > 0; i--) {
const parentPath = `/${pathSegments.slice(0, i).join("/")}`;
if (selectedPaths.has(parentPath)) {
// Parent is selected, so this folder is fully selected, not partial
return false;
}
}
for (const selectedPath of selectedPaths) {
if (selectedPath.startsWith(`${folderPath}/`)) {
return true;
}
}
return false;
},
[selectedPaths],
);
return (
<div className={cn("text-sm", className)}>
{filteredFileList.map((fileOrFolder) => {
switch (fileOrFolder.kind) {
case "file": {
return (
<File
key={fileOrFolder.id}
selected={selectedFile === fileOrFolder.fullPath}
file={fileOrFolder}
onFileSelect={handleFileSelect}
withCheckbox={withCheckboxes}
checked={isPathSelected(fileOrFolder.fullPath)}
onCheckboxChange={handleSelectionChange}
/>
);
}
case "folder": {
return (
<Folder
key={fileOrFolder.id}
folder={fileOrFolder}
collapsed={collapsedFolders.has(fileOrFolder.fullPath)}
loading={loadingFolders.has(fileOrFolder.fullPath)}
onToggle={toggleCollapseState}
onHover={onFolderHover}
withCheckbox={withCheckboxes}
checked={isPathSelected(fileOrFolder.fullPath) && !isPartiallySelected(fileOrFolder.fullPath)}
partiallyChecked={isPartiallySelected(fileOrFolder.fullPath)}
onCheckboxChange={handleSelectionChange}
selectableMode={selectableFolders}
onFolderSelect={handleFolderSelect}
selected={selectedFolder === fileOrFolder.fullPath}
/>
);
}
default: {
return undefined;
}
}
})}
</div>
);
});
interface FolderProps {
folder: FolderNode;
collapsed: boolean;
loading?: boolean;
onToggle: (fullPath: string) => void;
onHover?: (fullPath: string) => void;
withCheckbox?: boolean;
checked?: boolean;
partiallyChecked?: boolean;
onCheckboxChange?: (path: string, checked: boolean) => void;
selectableMode?: boolean;
onFolderSelect?: (folderPath: string) => void;
selected?: boolean;
}
const Folder = memo(
({
folder,
collapsed,
loading,
onToggle,
onHover,
withCheckbox,
checked,
partiallyChecked,
onCheckboxChange,
selectableMode,
onFolderSelect,
selected,
}: FolderProps) => {
const { depth, name, fullPath } = folder;
const FolderIconComponent = collapsed ? FolderIcon : FolderOpen;
const handleChevronClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onToggle(fullPath);
},
[onToggle, fullPath],
);
const handleMouseEnter = useCallback(() => {
if (collapsed) {
onHover?.(fullPath);
}
}, [onHover, fullPath, collapsed]);
const handleCheckboxChange = useCallback(
(value: boolean) => {
onCheckboxChange?.(fullPath, value);
},
[onCheckboxChange, fullPath],
);
const handleFolderClick = useCallback(() => {
if (selectableMode) {
onFolderSelect?.(fullPath);
}
}, [selectableMode, onFolderSelect, fullPath]);
return (
<NodeButton
className={cn("group", {
"hover:bg-accent/50 text-foreground": !selected,
"bg-accent text-accent-foreground": selected,
"cursor-pointer": selectableMode,
})}
depth={depth}
icon={
loading ? (
<Loader2 className="w-4 h-4 shrink-0 animate-spin" />
) : collapsed ? (
<ChevronRight className="w-4 h-4 shrink-0 cursor-pointer" onClick={handleChevronClick} />
) : (
<ChevronDown className="w-4 h-4 shrink-0 cursor-pointer" onClick={handleChevronClick} />
)
}
onClick={selectableMode ? handleFolderClick : undefined}
onMouseEnter={handleMouseEnter}
>
{withCheckbox && (
<Checkbox
checked={checked ? true : partiallyChecked ? "indeterminate" : false}
onCheckedChange={handleCheckboxChange}
onClick={(e) => e.stopPropagation()}
/>
)}
<FolderIconComponent className="w-4 h-4 shrink-0 text-strong-accent" />
<span className="truncate">{name}</span>
</NodeButton>
);
},
);
interface FileProps {
file: FileNode;
selected: boolean;
onFileSelect: (filePath: string) => void;
withCheckbox?: boolean;
checked?: boolean;
onCheckboxChange?: (path: string, checked: boolean) => void;
}
const File = memo(({ file, onFileSelect, selected, withCheckbox, checked, onCheckboxChange }: FileProps) => {
const { depth, name, fullPath } = file;
const handleClick = useCallback(() => {
onFileSelect(fullPath);
}, [onFileSelect, fullPath]);
const handleCheckboxChange = useCallback(
(value: boolean) => {
onCheckboxChange?.(fullPath, value);
},
[onCheckboxChange, fullPath],
);
return (
<NodeButton
className={cn("group cursor-pointer", {
"hover:bg-accent/50 text-foreground": !selected,
"bg-accent text-accent-foreground": selected,
})}
depth={depth}
icon={<FileIcon className="w-4 h-4 shrink-0 text-gray-500" />}
onClick={handleClick}
>
{withCheckbox && (
<Checkbox checked={checked} onCheckedChange={handleCheckboxChange} onClick={(e) => e.stopPropagation()} />
)}
<span className="truncate">{name}</span>
</NodeButton>
);
});
interface ButtonProps {
depth: number;
icon: ReactNode;
children: ReactNode;
className?: string;
onClick?: () => void;
onMouseEnter?: () => void;
}
const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, children }: ButtonProps) => {
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
return (
<button
type="button"
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
style={{ paddingLeft }}
onClick={onClick}
onMouseEnter={onMouseEnter}
>
{icon}
<div className="truncate w-full flex items-center gap-2">{children}</div>
</button>
);
});
type Node = FileNode | FolderNode;
interface BaseNode {
id: number;
depth: number;
name: string;
fullPath: string;
}
interface FileNode extends BaseNode {
kind: "file";
}
interface FolderNode extends BaseNode {
kind: "folder";
}
function buildFileList(files: FileEntry[], foldersOnly = false): Node[] {
const fileMap = new Map<string, Node>();
for (const file of files) {
if (foldersOnly && file.type === "file") {
continue;
}
const segments = file.path.split("/").filter((segment) => segment);
const depth = segments.length - 1;
const name = segments[segments.length - 1];
if (!fileMap.has(file.path)) {
fileMap.set(file.path, {
kind: file.type === "file" ? "file" : "folder",
id: fileMap.size,
name,
fullPath: file.path,
depth,
});
}
}
// Convert map to array and sort
return sortFileList(Array.from(fileMap.values()));
}
function sortFileList(nodeList: Node[]): Node[] {
const nodeMap = new Map<string, Node>();
const childrenMap = new Map<string, Node[]>();
// Pre-sort nodes by name and type
nodeList.sort((a, b) => compareNodes(a, b));
for (const node of nodeList) {
nodeMap.set(node.fullPath, node);
const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf("/")) || "/";
if (parentPath !== "/") {
if (!childrenMap.has(parentPath)) {
childrenMap.set(parentPath, []);
}
childrenMap.get(parentPath)?.push(node);
}
}
const sortedList: Node[] = [];
const depthFirstTraversal = (path: string): void => {
const node = nodeMap.get(path);
if (node) {
sortedList.push(node);
}
const children = childrenMap.get(path);
if (children) {
for (const child of children) {
if (child.kind === "folder") {
depthFirstTraversal(child.fullPath);
} else {
sortedList.push(child);
}
}
}
};
// Start with root level items
const rootItems = nodeList.filter((node) => {
const parentPath = node.fullPath.slice(0, node.fullPath.lastIndexOf("/")) || "/";
return parentPath === "/";
});
for (const item of rootItems) {
depthFirstTraversal(item.fullPath);
}
return sortedList;
}
function compareNodes(a: Node, b: Node): number {
if (a.kind !== b.kind) {
return a.kind === "folder" ? -1 : 1;
}
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
}

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import { cn } from "~/client/lib/utils";
interface GridBackgroundProps {
children: ReactNode;
className?: string;
containerClassName?: string;
}
export function GridBackground({ children, className, containerClassName }: GridBackgroundProps) {
return (
<div
className={cn(
"relative min-h-full w-full overflow-x-hidden",
"bg-size-[20px_20px] sm:bg-size-[40px_40px]",
"bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
containerClassName,
)}
>
<div className={cn("relative container m-auto", className)}>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useMutation } from "@tanstack/react-query";
import { LifeBuoy } from "lucide-react";
import { Outlet, redirect, useNavigate } from "react-router";
import { toast } from "sonner";
import { appContext } from "~/context";
import { authMiddleware } from "~/middleware/auth";
import type { Route } from "./+types/layout";
import { AppBreadcrumb } from "./app-breadcrumb";
import { GridBackground } from "./grid-background";
import { Button } from "./ui/button";
import { SidebarProvider, SidebarTrigger } from "./ui/sidebar";
import { AppSidebar } from "./app-sidebar";
import { logoutMutation } from "../api-client/@tanstack/react-query.gen";
export const clientMiddleware = [authMiddleware];
export async function clientLoader({ context }: Route.LoaderArgs) {
const ctx = context.get(appContext);
if (ctx.user && !ctx.user.hasDownloadedResticPassword) {
throw redirect("/download-recovery-key");
}
return ctx;
}
export default function Layout({ loaderData }: Route.ComponentProps) {
const navigate = useNavigate();
const logout = useMutation({
...logoutMutation(),
onSuccess: async () => {
navigate("/login", { replace: true });
},
onError: (error) => {
console.error(error);
toast.error("Logout failed", { description: error.message });
},
});
return (
<SidebarProvider defaultOpen={true}>
<AppSidebar />
<div className="w-full relative flex flex-col h-screen overflow-hidden">
<header className="z-50 bg-card-header border-b border-border/50 shrink-0">
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container">
<div className="flex items-center gap-4">
<SidebarTrigger />
<AppBreadcrumb />
</div>
{loaderData.user && (
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground hidden md:inline-flex">
Welcome,&nbsp;
<span className="text-strong-accent">{loaderData.user?.username}</span>
</span>
<Button variant="default" size="sm" onClick={() => logout.mutate({})} loading={logout.isPending}>
Logout
</Button>
<Button variant="default" size="sm" className="relative overflow-hidden hidden lg:inline-flex">
<a
href="https://github.com/nicotsx/zerobyte/issues/new"
target="_blank"
rel="noreferrer"
className="flex items-center gap-2"
>
<span className="flex items-center gap-2">
<LifeBuoy />
<span>Report an issue</span>
</span>
</a>
</Button>
</div>
)}
</div>
</header>
<div className="main-content flex-1 overflow-y-auto">
<GridBackground>
<main className="flex flex-col p-2 pb-6 pt-2 sm:p-8 sm:pt-6 mx-auto">
<Outlet />
</main>
</GridBackground>
</div>
</div>
</SidebarProvider>
);
}

View File

@@ -1,4 +1,4 @@
import { cn } from "~/lib/utils";
import { cn } from "~/client/lib/utils";
import { Switch } from "./ui/switch";
type Props = {
@@ -20,7 +20,12 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
)}
>
<span>{isOn ? enabledLabel : disabledLabel}</span>
<Switch disabled={disabled} checked={isOn} onCheckedChange={toggle} />
<Switch
disabled={disabled}
checked={isOn}
onCheckedChange={toggle}
aria-label={isOn ? `Toggle ${enabledLabel}` : `Toggle ${disabledLabel}`}
/>
</div>
);
};

View File

@@ -0,0 +1,39 @@
import { useState } from "react";
import { DirectoryBrowser } from "./directory-browser";
import { Button } from "./ui/button";
type Props = {
value: string;
onChange: (path: string) => void;
label?: string;
};
export const PathSelector = ({ value, onChange }: Props) => {
const [showBrowser, setShowBrowser] = useState(false);
if (showBrowser) {
return (
<div className="space-y-2">
<DirectoryBrowser
onSelectPath={(path) => {
onChange(path);
setShowBrowser(false);
}}
selectedPath={value}
/>
<Button type="button" variant="ghost" size="sm" onClick={() => setShowBrowser(false)}>
Cancel
</Button>
</div>
);
}
return (
<div className="flex items-center gap-2">
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">{value}</div>
<Button type="button" variant="outline" onClick={() => setShowBrowser(true)} size="sm">
Change
</Button>
</div>
);
};

View File

@@ -0,0 +1,23 @@
import { Database, HardDrive, Cloud, Server } from "lucide-react";
import type { RepositoryBackend } from "~/schemas/restic";
type Props = {
backend: RepositoryBackend;
className?: string;
};
export const RepositoryIcon = ({ backend, className = "h-4 w-4" }: Props) => {
switch (backend) {
case "local":
return <HardDrive className={className} />;
case "s3":
return <Cloud className={className} />;
case "gcs":
return <Cloud className={className} />;
case "rest":
case "sftp":
return <Server className={className} />;
default:
return <Database className={className} />;
}
};

View File

@@ -0,0 +1,326 @@
import { useCallback, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Checkbox } from "~/client/components/ui/checkbox";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { PathSelector } from "~/client/components/path-selector";
import { FileTree } from "~/client/components/file-tree";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
import type { Snapshot } from "~/client/lib/types";
type RestoreLocation = "original" | "custom";
interface RestoreFormProps {
snapshot: Snapshot;
repositoryName: string;
snapshotId: string;
returnPath: string;
}
export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }: RestoreFormProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
const [customTargetPath, setCustomTargetPath] = useState("");
const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always");
const [showAdvanced, setShowAdvanced] = useState(false);
const [excludeXattr, setExcludeXattr] = useState("");
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const { data: filesData, isLoading: filesLoading } = useQuery({
...listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId },
query: { path: volumeBasePath },
}),
enabled: !!repositoryName && !!snapshotId,
});
const stripBasePath = useCallback(
(path: string): string => {
if (!volumeBasePath) return path;
if (path === volumeBasePath) return "/";
if (path.startsWith(`${volumeBasePath}/`)) {
const stripped = path.slice(volumeBasePath.length);
return stripped;
}
return path;
},
[volumeBasePath],
);
const addBasePath = useCallback(
(displayPath: string): string => {
const vbp = volumeBasePath === "/" ? "" : volumeBasePath;
if (!vbp) return displayPath;
if (displayPath === "/") return vbp;
return `${vbp}${displayPath}`;
},
[volumeBasePath],
);
const fileBrowser = useFileBrowser({
initialData: filesData,
isLoading: filesLoading,
fetchFolder: async (path) => {
return await queryClient.ensureQueryData(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId },
query: { path },
}),
);
},
prefetchFolder: (path) => {
queryClient.prefetchQuery(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId },
query: { path },
}),
);
},
pathTransform: {
strip: stripBasePath,
add: addBasePath,
},
});
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
...restoreSnapshotMutation(),
onSuccess: (data) => {
toast.success("Restore completed", {
description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`,
});
navigate(returnPath);
},
onError: (error) => {
toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" });
},
});
const handleRestore = useCallback(() => {
if (!repositoryName || !snapshotId) return;
const excludeXattrArray = excludeXattr
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
const isCustomLocation = restoreLocation === "custom";
const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined;
const pathsArray = Array.from(selectedPaths);
const includePaths = pathsArray.map((path) => addBasePath(path));
restoreSnapshot({
path: { name: repositoryName },
body: {
snapshotId,
include: includePaths.length > 0 ? includePaths : undefined,
delete: deleteExtraFiles,
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
targetPath,
overwrite: overwriteMode,
},
});
}, [
repositoryName,
snapshotId,
excludeXattr,
restoreLocation,
customTargetPath,
selectedPaths,
addBasePath,
deleteExtraFiles,
overwriteMode,
restoreSnapshot,
]);
const canRestore = restoreLocation === "original" || customTargetPath.trim();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Restore Snapshot</h1>
<p className="text-sm text-muted-foreground">
{repositoryName} / {snapshotId}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => navigate(returnPath)}>
Cancel
</Button>
<Button variant="primary" onClick={handleRestore} disabled={isRestoring || !canRestore}>
<RotateCcw className="h-4 w-4 mr-2" />
{isRestoring
? "Restoring..."
: selectedPaths.size > 0
? `Restore ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`
: "Restore All"}
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Restore Location</CardTitle>
<CardDescription>Choose where to restore the files</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-2">
<Button
type="button"
variant={restoreLocation === "original" ? "secondary" : "outline"}
size="sm"
className="flex justify-start gap-2"
onClick={() => setRestoreLocation("original")}
>
<RotateCcw size={16} className="mr-1" />
Original location
</Button>
<Button
type="button"
variant={restoreLocation === "custom" ? "secondary" : "outline"}
size="sm"
className="justify-start gap-2"
onClick={() => setRestoreLocation("custom")}
>
<FolderOpen size={16} className="mr-1" />
Custom location
</Button>
</div>
{restoreLocation === "custom" && (
<div className="space-y-2">
<PathSelector value={customTargetPath || "/"} onChange={setCustomTargetPath} />
<p className="text-xs text-muted-foreground">Files will be restored directly to this path</p>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Overwrite Mode</CardTitle>
<CardDescription>How to handle existing files</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Select value={overwriteMode} onValueChange={(value) => setOverwriteMode(value as OverwriteMode)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select overwrite behavior" />
</SelectTrigger>
<SelectContent>
<SelectItem value={OVERWRITE_MODES.always}>Always overwrite</SelectItem>
<SelectItem value={OVERWRITE_MODES.ifChanged}>Only if content changed</SelectItem>
<SelectItem value={OVERWRITE_MODES.ifNewer}>Only if snapshot is newer</SelectItem>
<SelectItem value={OVERWRITE_MODES.never}>Never overwrite</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{overwriteMode === OVERWRITE_MODES.always &&
"Existing files will always be replaced with the snapshot version."}
{overwriteMode === OVERWRITE_MODES.ifChanged &&
"Files are only replaced if their content differs from the snapshot."}
{overwriteMode === OVERWRITE_MODES.ifNewer &&
"Files are only replaced if the snapshot version has a newer modification time."}
{overwriteMode === OVERWRITE_MODES.never &&
"Existing files will never be replaced, only missing files are restored."}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="cursor-pointer" onClick={() => setShowAdvanced(!showAdvanced)}>
<div className="flex items-center justify-between">
<CardTitle className="text-base">Advanced options</CardTitle>
<ChevronDown size={16} className={`transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</div>
</CardHeader>
{showAdvanced && (
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="exclude-xattr" className="text-sm">
Exclude extended attributes
</Label>
<Input
id="exclude-xattr"
placeholder="com.apple.metadata,user.*,nfs4.*"
value={excludeXattr}
onChange={(e) => setExcludeXattr(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Exclude specific extended attributes during restore (comma-separated)
</p>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="delete-extra"
checked={deleteExtraFiles}
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
/>
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
Delete files not present in the snapshot
</Label>
</div>
</CardContent>
)}
</Card>
</div>
<Card className="lg:col-span-2 flex flex-col">
<CardHeader>
<CardTitle>Select Files to Restore</CardTitle>
<CardDescription>
{selectedPaths.size > 0
? `${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"} selected`
: "Select specific files or folders, or leave empty to restore everything"}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
{fileBrowser.isLoading && (
<div className="flex items-center justify-center flex-1">
<p className="text-muted-foreground">Loading files...</p>
</div>
)}
{fileBrowser.isEmpty && (
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No files in this snapshot</p>
</div>
)}
{!fileBrowser.isLoading && !fileBrowser.isEmpty && (
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
<FileTree
files={fileBrowser.fileArray}
onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
className="px-2 py-2"
withCheckboxes={true}
selectedPaths={selectedPaths}
onSelectionChange={setSelectedPaths}
/>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import { ByteSize } from "~/client/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import { Button } from "~/client/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { formatDuration } from "~/utils/utils";
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import type { BackupSchedule, Snapshot } from "../lib/types";
type Props = {
snapshots: Snapshot[];
backups: BackupSchedule[];
repositoryName: string;
};
export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
const deleteSnapshot = useMutation({
...deleteSnapshotMutation(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["listSnapshots"] });
setShowDeleteConfirm(false);
setSnapshotToDelete(null);
},
});
const handleDeleteClick = (e: React.MouseEvent, snapshotId: string) => {
e.stopPropagation();
setSnapshotToDelete(snapshotId);
setShowDeleteConfirm(true);
};
const handleConfirmDelete = () => {
if (snapshotToDelete) {
toast.promise(
deleteSnapshot.mutateAsync({
path: { name: repositoryName, snapshotId: snapshotToDelete },
}),
{
loading: "Deleting snapshot...",
success: "Snapshot deleted successfully",
error: (error) => parseError(error)?.message || "Failed to delete snapshot",
},
);
}
};
const handleRowClick = (snapshotId: string) => {
navigate(`/repositories/${repositoryName}/${snapshotId}`);
};
return (
<>
<div className="overflow-x-auto">
<Table className="border-t">
<TableHeader className="bg-card-header">
<TableRow>
<TableHead className="uppercase">Snapshot ID</TableHead>
<TableHead className="uppercase">Schedule</TableHead>
<TableHead className="uppercase">Date & Time</TableHead>
<TableHead className="uppercase">Size</TableHead>
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
<TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead>
<TableHead className="uppercase text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{snapshots.map((snapshot) => {
const backupIds = snapshot.tags.map(Number).filter((tag) => !Number.isNaN(tag));
const backup = backups.find((b) => backupIds.includes(b.id));
return (
<TableRow
key={snapshot.short_id}
className="hover:bg-accent/50 cursor-pointer"
onClick={() => handleRowClick(snapshot.short_id)}
>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-strong-accent">{snapshot.short_id}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Link
hidden={!backup}
to={backup ? `/backups/${backup.id}` : "#"}
onClick={(e) => e.stopPropagation()}
className="hover:underline"
>
<span className="text-sm">{backup ? backup.id : "-"}</span>
</Link>
<span hidden={!!backup} className="text-sm text-muted-foreground">
-
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
<ByteSize bytes={snapshot.size} base={1024} />
</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex items-center justify-end gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<div className="flex items-center justify-end gap-2">
<FolderTree className="h-4 w-4 text-muted-foreground" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-md">
<div className="flex flex-col gap-1">
{snapshot.paths.map((path) => (
<div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono">
{path}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDeleteClick(e, snapshot.short_id)}
disabled={deleteSnapshot.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the snapshot and all its data from the
repository.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleteSnapshot.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete snapshot
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};

View File

@@ -1,36 +1,48 @@
import type { VolumeStatus } from "~/lib/types";
import { cn } from "~/lib/utils";
import { cn } from "~/client/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export const StatusDot = ({ status }: { status: VolumeStatus }) => {
type StatusVariant = "success" | "neutral" | "error" | "warning" | "info";
interface StatusDotProps {
variant: StatusVariant;
label: string;
animated?: boolean;
}
export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
const statusMapping = {
mounted: {
success: {
color: "bg-green-500",
colorLight: "bg-emerald-400",
animated: true,
animated: animated ?? true,
},
unmounted: {
neutral: {
color: "bg-gray-500",
colorLight: "bg-gray-400",
animated: false,
animated: animated ?? false,
},
error: {
color: "bg-red-500",
colorLight: "bg-amber-700",
animated: true,
colorLight: "bg-red-400",
animated: animated ?? true,
},
unknown: {
warning: {
color: "bg-yellow-500",
colorLight: "bg-yellow-400",
animated: true,
animated: animated ?? true,
},
}[status];
info: {
color: "bg-blue-500",
colorLight: "bg-blue-400",
animated: animated ?? true,
},
}[variant];
return (
<Tooltip>
<TooltipTrigger>
<span className="relative flex size-3 mx-auto">
{statusMapping.animated && (
{statusMapping?.animated && (
<span
className={cn(
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
@@ -38,11 +50,11 @@ export const StatusDot = ({ status }: { status: VolumeStatus }) => {
)}
/>
)}
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping.color}`)} />
<span className={cn("relative inline-flex size-3 rounded-full", `${statusMapping?.color}`)} />
</span>
</TooltipTrigger>
<TooltipContent>
<p className="capitalize">{status}</p>
<p>{label}</p>
</TooltipContent>
</Tooltip>
);

View File

@@ -1,7 +1,7 @@
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import type * as React from "react";
import { buttonVariants } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import { buttonVariants } from "~/client/components/ui/button";
import { cn } from "~/client/lib/utils";
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;

View File

@@ -0,0 +1,44 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/client/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
warning: "border-orange-500/20 bg-orange-500/10 text-orange-500 [&>svg]:text-orange-500",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
),
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
),
);
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,36 @@
import type * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/client/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,92 @@
import type * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "~/client/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm wrap-break-words sm:gap-2.5",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5", className)} {...props} />;
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp data-slot="breadcrumb-link" className={cn("hover:text-foreground transition-colors", className)} {...props} />
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -0,0 +1,62 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { Loader2 } from "lucide-react";
import type * as React from "react";
import { cn } from "~/client/lib/utils";
const buttonVariants = cva(
"inline-flex cursor-pointer uppercase rounded-sm items-center justify-center gap-2 whitespace-nowrap text-xs font-semibold tracking-wide transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring border-0",
{
variants: {
variant: {
default: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50",
destructive:
"border border-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/50 text-destructive hover:text-white",
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-5 py-2 has-[>svg]:px-4",
sm: "h-8 px-3 py-1.5 has-[>svg]:px-2.5",
lg: "h-10 px-6 py-2.5 has-[>svg]:px-5",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
loading,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
} & { loading?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
disabled={loading}
data-slot="button"
className={cn(buttonVariants({ variant, size, className }), "transition-all")}
{...props}
>
<Loader2 className={cn("h-4 w-4 animate-spin absolute", { invisible: !loading })} />
<div className={cn("flex items-center justify-center", { invisible: loading })}>{props.children}</div>
</Comp>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,68 @@
import type * as React from "react";
import { cn } from "~/client/lib/utils";
function Card({ className, children, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn("bg-card text-card-foreground relative flex flex-col gap-6 border-2 py-6 shadow-sm", className)}
{...props}
>
<span aria-hidden="true" className="pointer-events-none absolute inset-0 z-10 select-none">
<span className="absolute left-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute left-[-2px] top-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute right-[-2px] top-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute right-[-2px] top-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute left-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute left-[-2px] bottom-[-2px] h-4 w-0.5 bg-white/80" />
<span className="absolute right-[-2px] bottom-[-2px] h-0.5 w-4 bg-white/80" />
<span className="absolute right-[-2px] bottom-[-2px] h-4 w-0.5 bg-white/80" />
</span>
{children}
</div>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

View File

@@ -0,0 +1,298 @@
// @ts-nocheck
// biome-ignore-all lint: reason
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "~/client/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string" ? config[label as keyof typeof config]?.label || label : itemConfig?.label;
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn("[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3")}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };

View File

@@ -0,0 +1,27 @@
import type * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "~/client/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -1,7 +1,4 @@
import React, { useEffect } from "react";
import Prism from "prismjs";
import "prismjs/themes/prism-twilight.css";
import "prismjs/components/prism-yaml";
import type React from "react";
import { toast } from "sonner";
import { copyToClipboard } from "~/utils/clipboard";
@@ -11,35 +8,31 @@ interface CodeBlockProps {
filename?: string;
}
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language = "jsx", filename }) => {
useEffect(() => {
Prism.highlightAll();
}, []);
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
const handleCopy = async () => {
await copyToClipboard(code);
toast.success("Code copied to clipboard");
};
return (
<div className="overflow-hidden rounded-sm bg-slate-900 ring-1 ring-white/10">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs text-slate-400">
<div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-white/10">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs">
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-rose-500" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
{filename && <span className="ml-3 font-medium text-slate-300">{filename}</span>}
{filename && <span className="ml-3 font-medium">{filename}</span>}
</div>
<button
type="button"
onClick={() => handleCopy()}
className="cursor-pointer rounded-md bg-white/5 px-2 py-1 text-[11px] font-medium text-slate-300 ring-1 ring-inset ring-white/10 transition hover:bg-white/10 active:translate-y-px"
className="cursor-pointer rounded-md bg-white/5 px-2 py-1 text-[11px] font-medium ring-1 ring-inset ring-white/10 transition hover:bg-white/10 active:translate-y-px"
>
Copy
</button>
</div>
<pre className="overflow-x-auto leading-6 text-xs m-0" style={{ marginTop: 0, marginBottom: 0 }}>
<code className={`language-${language}`}>{code}</code>
<pre className="text-xs m-0 px-4 py-2 bg-card-header">
<code className="text-white/80">{code}</code>
</pre>
</div>
);

View File

@@ -0,0 +1,121 @@
import type * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "~/client/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import type * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
@@ -11,8 +11,8 @@ import {
type FieldValues,
} from "react-hook-form";
import { cn } from "~/lib/utils";
import { Label } from "~/components/ui/label";
import { cn } from "~/client/lib/utils";
import { Label } from "~/client/components/ui/label";
const Form = FormProvider;

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import type * as React from "react";
import { cn } from "~/lib/utils";
import { cn } from "~/client/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 border bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,

View File

@@ -0,0 +1,21 @@
"use client";
import type * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "~/client/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,22 @@
import type * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "~/client/lib/utils";
function Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -0,0 +1,46 @@
import type * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "~/client/lib/utils";
function ScrollArea({ className, children, ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn("relative", className)} {...props}>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@@ -1,24 +1,18 @@
import * as React from "react";
import type * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { cn } from "~/client/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
@@ -83,10 +77,7 @@ function SelectContent({
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
@@ -96,11 +87,7 @@ function SelectLabel({
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
@@ -120,10 +107,7 @@ function SelectItem({
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
@@ -133,17 +117,11 @@ function SelectSeparator({
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
@@ -158,10 +136,7 @@ function SelectScrollDownButton({
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />

View File

@@ -0,0 +1,26 @@
import type * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "~/client/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -0,0 +1,103 @@
"use client";
import type * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "~/client/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5 p-4", className)} {...props} />;
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };

View File

@@ -0,0 +1,677 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "~/client/hooks/use-mobile";
import { cn } from "~/client/lib/utils";
import { Button } from "~/client/components/ui/button";
import { Input } from "~/client/components/ui/input";
import { Separator } from "~/client/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/client/components/ui/sheet";
import { Skeleton } from "~/client/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "~/client/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "14rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper flex min-h-svh w-full", className)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn("bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", className)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-in-out",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-in-out md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-in-out group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-in-out focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltip} />
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -0,0 +1,7 @@
import { cn } from "~/client/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="skeleton" className={cn("bg-accent animate-pulse rounded-md", className)} {...props} />;
}
export { Skeleton };

View File

@@ -2,7 +2,7 @@ import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
const { theme = "dark" } = useTheme();
return (
<Sonner

View File

@@ -1,7 +1,7 @@
import * as SwitchPrimitive from "@radix-ui/react-switch";
import type * as React from "react";
import { cn } from "~/lib/utils";
import { cn } from "~/client/lib/utils";
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (

View File

@@ -0,0 +1,73 @@
import type * as React from "react";
import { cn } from "~/client/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption data-slot="table-caption" className={cn("text-muted-foreground mt-4 text-sm", className)} {...props} />
);
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };

View File

@@ -0,0 +1,58 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import type * as React from "react";
import { cn } from "~/client/lib/utils";
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;
}
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn("inline-flex h-7 items-center gap-4 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"cursor-pointer group relative inline-flex h-7 items-center whitespace-nowrap text-xs font-medium transition-colors",
"text-muted-foreground data-[state=active]:text-foreground disabled:pointer-events-none disabled:opacity-50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
// Padding: 20px horizontal (8px for bracket tick + 12px gap to text)
"px-5",
// Transparent orange background for active state
"data-[state=active]:bg-[#FF453A]/10",
// Left bracket - vertical line
"before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-[#5D6570] before:transition-colors data-[state=active]:before:bg-[#FF453A]",
// Left bracket - top tick
"after:absolute after:left-0 after:top-[-1px] after:w-2 after:h-0.5 after:bg-[#5D6570] after:transition-colors data-[state=active]:after:bg-[#FF453A]",
className,
)}
{...props}
>
<span className="relative z-10">{props.children}</span>
{/* Left bracket - bottom tick */}
<span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
{/* Right bracket - top tick */}
<span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
{/* Right bracket - vertical line */}
<span className="absolute right-0 top-0 h-7 w-0.5 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
{/* Right bracket - bottom tick */}
<span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" />
</TabsPrimitive.Trigger>
);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props} />;
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,18 @@
import type * as React from "react";
import { cn } from "~/client/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -0,0 +1,46 @@
import type * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "~/client/lib/utils";
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -0,0 +1,99 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FolderOpen } from "lucide-react";
import { FileTree } from "~/client/components/file-tree";
import { listFilesOptions } from "../api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "../hooks/use-file-browser";
type VolumeFileBrowserProps = {
volumeName: string;
enabled?: boolean;
withCheckboxes?: boolean;
selectedPaths?: Set<string>;
onSelectionChange?: (paths: Set<string>) => void;
foldersOnly?: boolean;
className?: string;
emptyMessage?: string;
emptyDescription?: string;
};
export const VolumeFileBrowser = ({
volumeName,
enabled = true,
withCheckboxes = false,
selectedPaths,
onSelectionChange,
foldersOnly = false,
className,
emptyMessage = "This volume appears to be empty.",
emptyDescription,
}: VolumeFileBrowserProps) => {
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
...listFilesOptions({ path: { name: volumeName } }),
enabled,
});
const fileBrowser = useFileBrowser({
initialData: data,
isLoading,
fetchFolder: async (path) => {
return await queryClient.ensureQueryData(
listFilesOptions({
path: { name: volumeName },
query: { path },
}),
);
},
prefetchFolder: (path) => {
queryClient.prefetchQuery(
listFilesOptions({
path: { name: volumeName },
query: { path },
}),
);
},
});
if (fileBrowser.isLoading) {
return (
<div className="flex items-center justify-center h-full min-h-[200px]">
<p className="text-muted-foreground">Loading files...</p>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full min-h-[200px]">
<p className="text-destructive">Failed to load files: {(error as Error).message}</p>
</div>
);
}
if (fileBrowser.isEmpty) {
return (
<div className="flex flex-col items-center justify-center h-full text-center p-8 min-h-[200px]">
<FolderOpen className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">{emptyMessage}</p>
{emptyDescription && <p className="text-sm text-muted-foreground mt-2">{emptyDescription}</p>}
</div>
);
}
return (
<div className={className}>
<FileTree
files={fileBrowser.fileArray}
onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
withCheckboxes={withCheckboxes}
selectedPaths={selectedPaths}
onSelectionChange={onSelectionChange}
foldersOnly={foldersOnly}
/>
</div>
);
};

View File

@@ -1,9 +1,8 @@
import type { BackendType } from "@ironmount/schemas";
import { Cloud, Folder, Server, Share2 } from "lucide-react";
import type { BackendType } from "~/schemas/volumes";
type VolumeIconProps = {
backend: BackendType;
size?: number;
};
const getIconAndColor = (backend: BackendType) => {
@@ -41,12 +40,12 @@ const getIconAndColor = (backend: BackendType) => {
}
};
export const VolumeIcon = ({ backend, size = 10 }: VolumeIconProps) => {
const { icon: Icon, color, label } = getIconAndColor(backend);
export const VolumeIcon = ({ backend }: VolumeIconProps) => {
const { icon: Icon, label } = getIconAndColor(backend);
return (
<span className={`flex items-center gap-2 ${color} rounded-md px-2 py-1`}>
<Icon size={size} />
<span className={`flex items-center gap-2 rounded-md px-2 py-1`}>
<Icon className="h-4 w-4" />
{label}
</span>
);

View File

@@ -0,0 +1,135 @@
import { useCallback, useMemo, useState } from "react";
import type { FileEntry } from "../components/file-tree";
type FetchFolderFn = (
path: string,
) => Promise<{ files?: FileEntry[]; directories?: Array<{ name: string; path: string }> }>;
type PathTransformFns = {
strip?: (path: string) => string;
add?: (path: string) => string;
};
type UseFileBrowserOptions = {
initialData?: { files?: FileEntry[]; directories?: Array<{ name: string; path: string }> };
isLoading?: boolean;
fetchFolder: FetchFolderFn;
prefetchFolder?: (path: string) => void;
pathTransform?: PathTransformFns;
rootPath?: string;
};
export const useFileBrowser = (props: UseFileBrowserOptions) => {
const { initialData, isLoading, fetchFolder, prefetchFolder, pathTransform, rootPath = "/" } = props;
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [fetchedFolders, setFetchedFolders] = useState<Set<string>>(new Set([rootPath]));
const [loadingFolders, setLoadingFolders] = useState<Set<string>>(new Set());
const [allFiles, setAllFiles] = useState<Map<string, FileEntry>>(new Map());
const stripPath = pathTransform?.strip;
const addPath = pathTransform?.add;
useMemo(() => {
if (initialData?.files) {
const files = initialData.files;
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of files) {
const path = stripPath ? stripPath(file.path) : file.path;
if (path !== rootPath) {
next.set(path, { ...file, path });
}
}
return next;
});
if (rootPath) {
setFetchedFolders((prev) => new Set(prev).add(rootPath));
}
} else if (initialData?.directories) {
const directories = initialData.directories;
setAllFiles((prev) => {
const next = new Map(prev);
for (const dir of directories) {
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
}
return next;
});
}
}, [initialData, stripPath, rootPath]);
const fileArray = useMemo(() => Array.from(allFiles.values()), [allFiles]);
const handleFolderExpand = useCallback(
async (folderPath: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(folderPath);
return next;
});
if (!fetchedFolders.has(folderPath)) {
setLoadingFolders((prev) => new Set(prev).add(folderPath));
try {
const pathToFetch = addPath ? addPath(folderPath) : folderPath;
const result = await fetchFolder(pathToFetch);
if (result.files) {
const files = result.files;
setAllFiles((prev) => {
const next = new Map(prev);
for (const file of files) {
const strippedPath = stripPath ? stripPath(file.path) : file.path;
// Skip the directory itself
if (strippedPath !== folderPath) {
next.set(strippedPath, { ...file, path: strippedPath });
}
}
return next;
});
} else if (result.directories) {
const directories = result.directories;
setAllFiles((prev) => {
const next = new Map(prev);
for (const dir of directories) {
next.set(dir.path, { name: dir.name, path: dir.path, type: "folder" });
}
return next;
});
}
setFetchedFolders((prev) => new Set(prev).add(folderPath));
} catch (error) {
console.error("Failed to fetch folder contents:", error);
} finally {
setLoadingFolders((prev) => {
const next = new Set(prev);
next.delete(folderPath);
return next;
});
}
}
},
[fetchedFolders, fetchFolder, stripPath, addPath],
);
const handleFolderHover = useCallback(
(folderPath: string) => {
if (!fetchedFolders.has(folderPath) && !loadingFolders.has(folderPath) && prefetchFolder) {
const pathToPrefetch = addPath ? addPath(folderPath) : folderPath;
prefetchFolder(pathToPrefetch);
}
},
[fetchedFolders, loadingFolders, prefetchFolder, addPath],
);
return {
fileArray,
expandedFolders,
loadingFolders,
handleFolderExpand,
handleFolderHover,
isLoading: isLoading && fileArray.length === 0,
isEmpty: fileArray.length === 0 && !isLoading,
};
};

View File

@@ -0,0 +1,19 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

View File

@@ -0,0 +1,182 @@
import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
type ServerEventType =
| "connected"
| "heartbeat"
| "backup:started"
| "backup:progress"
| "backup:completed"
| "volume:mounted"
| "volume:unmounted"
| "volume:updated"
| "mirror:started"
| "mirror:completed";
export interface BackupEvent {
scheduleId: number;
volumeName: string;
repositoryName: string;
status?: "success" | "error";
}
export interface BackupProgressEvent {
scheduleId: number;
volumeName: string;
repositoryName: string;
seconds_elapsed: number;
percent_done: number;
total_files: number;
files_done: number;
total_bytes: number;
bytes_done: number;
current_files: string[];
}
export interface VolumeEvent {
volumeName: string;
}
export interface MirrorEvent {
scheduleId: number;
repositoryId: string;
repositoryName: string;
status?: "success" | "error";
error?: string;
}
type EventHandler = (data: unknown) => void;
/**
* Hook to listen to Server-Sent Events (SSE) from the backend
* Automatically handles cache invalidation for backup and volume events
*/
export function useServerEvents() {
const queryClient = useQueryClient();
const eventSourceRef = useRef<EventSource | null>(null);
const handlersRef = useRef<Map<ServerEventType, Set<EventHandler>>>(new Map());
useEffect(() => {
const eventSource = new EventSource("/api/v1/events");
eventSourceRef.current = eventSource;
eventSource.addEventListener("connected", () => {
console.log("[SSE] Connected to server events");
});
eventSource.addEventListener("heartbeat", () => {});
eventSource.addEventListener("backup:started", (e) => {
const data = JSON.parse(e.data) as BackupEvent;
console.log("[SSE] Backup started:", data);
handlersRef.current.get("backup:started")?.forEach((handler) => {
handler(data);
});
});
eventSource.addEventListener("backup:progress", (e) => {
const data = JSON.parse(e.data) as BackupProgressEvent;
handlersRef.current.get("backup:progress")?.forEach((handler) => {
handler(data);
});
});
eventSource.addEventListener("backup:completed", (e) => {
const data = JSON.parse(e.data) as BackupEvent;
console.log("[SSE] Backup completed:", data);
queryClient.invalidateQueries();
queryClient.refetchQueries();
handlersRef.current.get("backup:completed")?.forEach((handler) => {
handler(data);
});
});
eventSource.addEventListener("volume:mounted", (e) => {
const data = JSON.parse(e.data) as VolumeEvent;
console.log("[SSE] Volume mounted:", data);
handlersRef.current.get("volume:mounted")?.forEach((handler) => {
handler(data);
});
});
eventSource.addEventListener("volume:unmounted", (e) => {
const data = JSON.parse(e.data) as VolumeEvent;
console.log("[SSE] Volume unmounted:", data);
handlersRef.current.get("volume:unmounted")?.forEach((handler) => {
handler(data);
});
});
eventSource.addEventListener("volume:updated", (e) => {
const data = JSON.parse(e.data) as VolumeEvent;
console.log("[SSE] Volume updated:", data);
queryClient.invalidateQueries();
handlersRef.current.get("volume:updated")?.forEach((handler) => {
handler(data);
});
});
eventSource.addEventListener("volume:status_updated", (e) => {
const data = JSON.parse(e.data) as VolumeEvent;
console.log("[SSE] Volume status updated:", data);
queryClient.invalidateQueries();
handlersRef.current.get("volume:updated")?.forEach((handler) => {
handler(data);
});
});
eventSource.addEventListener("mirror:started", (e) => {
const data = JSON.parse(e.data) as MirrorEvent;
console.log("[SSE] Mirror copy started:", data);
handlersRef.current.get("mirror:started")?.forEach((handler) => {
handler(data);
});
});
eventSource.addEventListener("mirror:completed", (e) => {
const data = JSON.parse(e.data) as MirrorEvent;
console.log("[SSE] Mirror copy completed:", data);
// Invalidate queries to refresh mirror status in the UI
queryClient.invalidateQueries();
handlersRef.current.get("mirror:completed")?.forEach((handler) => {
handler(data);
});
});
eventSource.onerror = (error) => {
console.error("[SSE] Connection error:", error);
};
return () => {
console.log("[SSE] Disconnecting from server events");
eventSource.close();
eventSourceRef.current = null;
};
}, [queryClient]);
const addEventListener = (event: ServerEventType, handler: EventHandler) => {
if (!handlersRef.current.has(event)) {
handlersRef.current.set(event, new Set());
}
handlersRef.current.get(event)?.add(handler);
return () => {
handlersRef.current.get(event)?.delete(handler);
};
};
return { addEventListener };
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { getSystemInfoOptions } from "../api-client/@tanstack/react-query.gen";
export function useSystemInfo() {
const { data, isLoading, error } = useQuery({
...getSystemInfoOptions(),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
});
return {
capabilities: data?.capabilities ?? { docker: false, rclone: false },
isLoading,
error,
systemInfo: data,
};
}

22
app/client/lib/types.ts Normal file
View File

@@ -0,0 +1,22 @@
import type {
GetBackupScheduleResponse,
GetMeResponse,
GetRepositoryResponse,
GetVolumeResponse,
ListNotificationDestinationsResponse,
ListSnapshotsResponse,
} from "../api-client";
export type Volume = GetVolumeResponse["volume"];
export type StatFs = GetVolumeResponse["statfs"];
export type VolumeStatus = Volume["status"];
export type User = GetMeResponse["user"];
export type Repository = GetRepositoryResponse;
export type BackupSchedule = GetBackupScheduleResponse;
export type Snapshot = ListSnapshotsResponse[number];
export type NotificationDestination = ListNotificationDestinationsResponse[number];

View File

@@ -0,0 +1 @@
export const APP_VERSION = import.meta.env.VITE_APP_VERSION || "dev";

View File

@@ -0,0 +1,106 @@
import { useMutation } from "@tanstack/react-query";
import { AlertTriangle, Download } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { AuthLayout } from "~/client/components/auth-layout";
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
import { Button } from "~/client/components/ui/button";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { authMiddleware } from "~/middleware/auth";
import type { Route } from "./+types/download-recovery-key";
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
export const clientMiddleware = [authMiddleware];
export function meta(_: Route.MetaArgs) {
return [
{ title: "Zerobyte - Download Recovery Key" },
{
name: "description",
content: "Download your backup recovery key to ensure you can restore your data.",
},
];
}
export default function DownloadRecoveryKeyPage() {
const navigate = useNavigate();
const [password, setPassword] = useState("");
const downloadResticPassword = useMutation({
...downloadResticPasswordMutation(),
onSuccess: (data) => {
const blob = new Blob([data], { type: "text/plain" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "restic.pass";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
toast.success("Recovery key downloaded successfully!");
navigate("/volumes", { replace: true });
},
onError: (error) => {
toast.error("Failed to download recovery key", { description: error.message });
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!password) {
toast.error("Password is required");
return;
}
downloadResticPassword.mutate({
body: {
password,
},
});
};
return (
<AuthLayout
title="Download Your Recovery Key"
description="This is a critical step to ensure you can recover your backups"
>
<Alert variant="warning" className="mb-6">
<AlertTriangle className="size-5" />
<AlertTitle>Important: Save This File Securely</AlertTitle>
<AlertDescription>
Your Restic password is essential for recovering your backup data. If you lose access to this server without
this file, your backups will be unrecoverable. Store it in a password manager or encrypted storage.
</AlertDescription>
</Alert>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Confirm Your Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
autoFocus
disabled={downloadResticPassword.isPending}
/>
<p className="text-xs text-muted-foreground">Enter your account password to download the recovery key</p>
</div>
<div className="flex flex-col gap-2">
<Button type="submit" loading={downloadResticPassword.isPending} className="w-full">
<Download size={16} className="mr-2" />
Download Recovery Key
</Button>
</div>
</form>
</AuthLayout>
);
}

View File

@@ -0,0 +1,115 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { AuthLayout } from "~/client/components/auth-layout";
import { Button } from "~/client/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { authMiddleware } from "~/middleware/auth";
import type { Route } from "./+types/login";
import { loginMutation } from "~/client/api-client/@tanstack/react-query.gen";
export const clientMiddleware = [authMiddleware];
export function meta(_: Route.MetaArgs) {
return [
{ title: "Zerobyte - Login" },
{
name: "description",
content: "Sign in to your Zerobyte account.",
},
];
}
const loginSchema = type({
username: "2<=string<=50",
password: "string>=1",
});
type LoginFormValues = typeof loginSchema.inferIn;
export default function LoginPage() {
const navigate = useNavigate();
const form = useForm<LoginFormValues>({
resolver: arktypeResolver(loginSchema),
defaultValues: {
username: "",
password: "",
},
});
const login = useMutation({
...loginMutation(),
onSuccess: async (data) => {
if (data.user && !data.user.hasDownloadedResticPassword) {
navigate("/download-recovery-key");
} else {
navigate("/volumes");
}
},
onError: (error) => {
console.error(error);
toast.error("Login failed", { description: error.message });
},
});
const onSubmit = (values: LoginFormValues) => {
login.mutate({
body: {
username: values.username.trim(),
password: values.password.trim(),
},
});
};
return (
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} type="text" placeholder="admin" disabled={login.isPending} autoFocus />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Password</FormLabel>
<button
type="button"
className="text-xs text-muted-foreground hover:underline"
onClick={() => toast.info("Password reset not implemented")}
>
Forgot your password?
</button>
</div>
<FormControl>
<Input {...field} type="password" disabled={login.isPending} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" loading={login.isPending}>
Login
</Button>
</form>
</Form>
</AuthLayout>
);
}

View File

@@ -0,0 +1,146 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useMutation } from "@tanstack/react-query";
import { type } from "arktype";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/client/components/ui/form";
import { authMiddleware } from "~/middleware/auth";
import type { Route } from "./+types/onboarding";
import { AuthLayout } from "~/client/components/auth-layout";
import { Input } from "~/client/components/ui/input";
import { Button } from "~/client/components/ui/button";
import { registerMutation } from "~/client/api-client/@tanstack/react-query.gen";
export const clientMiddleware = [authMiddleware];
export function meta(_: Route.MetaArgs) {
return [
{ title: "Zerobyte - Onboarding" },
{
name: "description",
content: "Welcome to Zerobyte. Create your admin account to get started.",
},
];
}
const onboardingSchema = type({
username: "2<=string<=50",
password: "string>=8",
confirmPassword: "string>=1",
});
type OnboardingFormValues = typeof onboardingSchema.inferIn;
export default function OnboardingPage() {
const navigate = useNavigate();
const form = useForm<OnboardingFormValues>({
resolver: arktypeResolver(onboardingSchema),
defaultValues: {
username: "",
password: "",
confirmPassword: "",
},
});
const registerUser = useMutation({
...registerMutation(),
onSuccess: async () => {
toast.success("Admin user created successfully!");
navigate("/download-recovery-key");
},
onError: (error) => {
console.error(error);
toast.error("Failed to create admin user", { description: error.message });
},
});
const onSubmit = (values: OnboardingFormValues) => {
if (values.password !== values.confirmPassword) {
form.setError("confirmPassword", {
type: "manual",
message: "Passwords do not match",
});
return;
}
registerUser.mutate({
body: {
username: values.username.trim(),
password: values.password.trim(),
},
});
};
return (
<AuthLayout title="Welcome to Zerobyte" description="Create the admin user to get started">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} type="text" placeholder="admin" disabled={registerUser.isPending} autoFocus />
</FormControl>
<FormDescription>Choose a username for the admin account</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="Enter a secure password"
disabled={registerUser.isPending}
/>
</FormControl>
<FormDescription>Password must be at least 8 characters long.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
{...field}
type="password"
placeholder="Re-enter your password"
disabled={registerUser.isPending}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" loading={registerUser.isPending}>
Create Admin User
</Button>
</form>
</Form>
</AuthLayout>
);
}

View File

@@ -0,0 +1,100 @@
import { useEffect, useState } from "react";
import { ByteSize, formatBytes } from "~/client/components/bytes-size";
import { Card } from "~/client/components/ui/card";
import { Progress } from "~/client/components/ui/progress";
import { type BackupProgressEvent, useServerEvents } from "~/client/hooks/use-server-events";
import { formatDuration } from "~/utils/utils";
type Props = {
scheduleId: number;
};
export const BackupProgressCard = ({ scheduleId }: Props) => {
const { addEventListener } = useServerEvents();
const [progress, setProgress] = useState<BackupProgressEvent | null>(null);
useEffect(() => {
const unsubscribe = addEventListener("backup:progress", (data) => {
const progressData = data as BackupProgressEvent;
if (progressData.scheduleId === scheduleId) {
setProgress(progressData);
}
});
const unsubscribeComplete = addEventListener("backup:completed", (data) => {
const completedData = data as { scheduleId: number };
if (completedData.scheduleId === scheduleId) {
setProgress(null);
}
});
return () => {
unsubscribe();
unsubscribeComplete();
};
}, [addEventListener, scheduleId]);
if (!progress) {
return (
<Card className="p-4">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="font-medium">Backup in progress</span>
</div>
</Card>
);
}
const percentDone = Math.round(progress.percent_done * 100);
const currentFile = progress.current_files[0] || "";
const fileName = currentFile.split("/").pop() || currentFile;
const speed = formatBytes(progress.bytes_done / progress.seconds_elapsed);
return (
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="font-medium">Backup in progress</span>
</div>
<span className="text-sm font-medium text-primary">{percentDone}%</span>
</div>
<Progress value={percentDone} className="h-2" />
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-xs uppercase text-muted-foreground">Files</p>
<p className="font-medium">
{progress.files_done.toLocaleString()} / {progress.total_files.toLocaleString()}
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Data</p>
<p className="font-medium">
<ByteSize bytes={progress.bytes_done} /> / <ByteSize bytes={progress.total_bytes} />
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Elapsed</p>
<p className="font-medium">{formatDuration(progress.seconds_elapsed)}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Speed</p>
<p className="font-medium">
{progress.seconds_elapsed > 0 ? `${speed.text} ${speed.unit}/s` : "Calculating..."}
</p>
</div>
</div>
{fileName && (
<div className="pt-2 border-t border-border">
<p className="text-xs uppercase text-muted-foreground mb-1">Current file</p>
<p className="text-xs font-mono text-muted-foreground truncate" title={currentFile}>
{fileName}
</p>
</div>
)}
</Card>
);
};

View File

@@ -0,0 +1,30 @@
import { StatusDot } from "~/client/components/status-dot";
export const BackupStatusDot = ({
enabled,
hasError,
isInProgress,
}: {
enabled: boolean;
hasError?: boolean;
isInProgress?: boolean;
}) => {
let variant: "success" | "neutral" | "error" | "info";
let label: string;
if (isInProgress) {
variant = "info";
label = "Backup in progress";
} else if (hasError) {
variant = "error";
label = "Error";
} else if (enabled) {
variant = "success";
label = "Active";
} else {
variant = "neutral";
label = "Paused";
}
return <StatusDot variant={variant} label={label} />;
};

View File

@@ -0,0 +1,650 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { useQuery } from "@tanstack/react-query";
import { type } from "arktype";
import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { RepositoryIcon } from "~/client/components/repository-icon";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Textarea } from "~/client/components/ui/textarea";
import { VolumeFileBrowser } from "~/client/components/volume-file-browser";
import type { BackupSchedule, Volume } from "~/client/lib/types";
import { deepClean } from "~/utils/object";
const internalFormSchema = type({
name: "1 <= string <= 32",
repositoryId: "string",
excludePatternsText: "string?",
excludeIfPresentText: "string?",
includePatternsText: "string?",
includePatterns: "string[]?",
frequency: "string",
dailyTime: "string?",
weeklyDay: "string?",
keepLast: "number?",
keepHourly: "number?",
keepDaily: "number?",
keepWeekly: "number?",
keepMonthly: "number?",
keepYearly: "number?",
});
const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
export const weeklyDays = [
{ label: "Monday", value: "1" },
{ label: "Tuesday", value: "2" },
{ label: "Wednesday", value: "3" },
{ label: "Thursday", value: "4" },
{ label: "Friday", value: "5" },
{ label: "Saturday", value: "6" },
{ label: "Sunday", value: "0" },
];
type InternalFormValues = typeof internalFormSchema.infer;
export type BackupScheduleFormValues = Omit<
InternalFormValues,
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText"
> & {
excludePatterns?: string[];
excludeIfPresent?: string[];
};
type Props = {
volume: Volume;
initialValues?: BackupSchedule;
onSubmit: (data: BackupScheduleFormValues) => void;
loading?: boolean;
summaryContent?: React.ReactNode;
formId: string;
};
const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
if (!schedule) {
return undefined;
}
const parts = schedule.cronExpression.split(" ");
const [minutePart, hourPart, , , dayOfWeekPart] = parts;
const isHourly = hourPart === "*";
const isDaily = !isHourly && dayOfWeekPart === "*";
const frequency = isHourly ? "hourly" : isDaily ? "daily" : "weekly";
const dailyTime = isHourly ? undefined : `${hourPart.padStart(2, "0")}:${minutePart.padStart(2, "0")}`;
const weeklyDay = frequency === "weekly" ? dayOfWeekPart : undefined;
const patterns = schedule.includePatterns || [];
const isGlobPattern = (p: string) => /[*?[\]]/.test(p);
const fileBrowserPaths = patterns.filter((p) => !isGlobPattern(p));
const textPatterns = patterns.filter(isGlobPattern);
return {
name: schedule.name,
repositoryId: schedule.repositoryId,
frequency,
dailyTime,
weeklyDay,
includePatterns: fileBrowserPaths.length > 0 ? fileBrowserPaths : undefined,
includePatternsText: textPatterns.length > 0 ? textPatterns.join("\n") : undefined,
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
...schedule.retentionPolicy,
};
};
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
const form = useForm<InternalFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof internalFormSchema),
defaultValues: backupScheduleToFormValues(initialValues),
});
const handleSubmit = useCallback(
(data: InternalFormValues) => {
const {
excludePatternsText,
excludeIfPresentText,
includePatternsText,
includePatterns: fileBrowserPatterns,
...rest
} = data;
const excludePatterns = excludePatternsText
? excludePatternsText
.split("\n")
.map((p) => p.trim())
.filter(Boolean)
: [];
const excludeIfPresent = excludeIfPresentText
? excludeIfPresentText
.split("\n")
.map((p) => p.trim())
.filter(Boolean)
: [];
const textPatterns = includePatternsText
? includePatternsText
.split("\n")
.map((p) => p.trim())
.filter(Boolean)
: [];
const includePatterns = [...(fileBrowserPatterns || []), ...textPatterns];
onSubmit({
...rest,
includePatterns: includePatterns.length > 0 ? includePatterns : [],
excludePatterns,
excludeIfPresent,
});
},
[onSubmit],
);
const { data: repositoriesData } = useQuery({
...listRepositoriesOptions(),
});
const frequency = form.watch("frequency");
const formValues = form.watch();
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set(initialValues?.includePatterns || []));
const handleSelectionChange = useCallback(
(paths: Set<string>) => {
setSelectedPaths(paths);
form.setValue("includePatterns", Array.from(paths));
},
[form],
);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]"
id={formId}
>
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle>Backup automation</CardTitle>
<CardDescription className="mt-1">
Schedule automated backups of <strong>{volume.name}</strong> to a secure repository.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>Backup name</FormLabel>
<FormControl>
<Input placeholder="My backup" {...field} />
</FormControl>
<FormDescription>A unique name to identify this backup schedule.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repositoryId"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>Backup repository</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a repository" />
</SelectTrigger>
<SelectContent>
{repositoriesData?.map((repo) => (
<SelectItem key={repo.id} value={repo.id}>
<span className="flex items-center gap-2">
<RepositoryIcon backend={repo.type} />
{repo.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Choose where encrypted backups for <strong>{volume.name}</strong> will be stored.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="frequency"
render={({ field }) => (
<FormItem>
<FormLabel>Backup frequency</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>Define how often snapshots should be taken.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{frequency !== "hourly" && (
<FormField
control={form.control}
name="dailyTime"
render={({ field }) => (
<FormItem>
<FormLabel>Execution time</FormLabel>
<FormControl>
<Input type="time" {...field} />
</FormControl>
<FormDescription>Time of day when the backup will run.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{frequency === "weekly" && (
<FormField
control={form.control}
name="weeklyDay"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>Execution day</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a day" />
</SelectTrigger>
<SelectContent>
{weeklyDays.map((day) => (
<SelectItem key={day.value} value={day.value}>
{day.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>Choose which day of the week to run the backup.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Backup paths</CardTitle>
<CardDescription>
Select which folders or files to include in the backup. If no paths are selected, the entire volume will
be backed up.
</CardDescription>
</CardHeader>
<CardContent>
<VolumeFileBrowser
key={volume.id}
volumeName={volume.name}
selectedPaths={selectedPaths}
onSelectionChange={handleSelectionChange}
withCheckboxes={true}
foldersOnly={false}
className="flex-1 border rounded-md bg-card p-2 min-h-[300px] max-h-[400px] overflow-auto"
/>
{selectedPaths.size > 0 && (
<div className="mt-4">
<p className="text-xs text-muted-foreground mb-2">Selected paths:</p>
<div className="flex flex-wrap gap-2">
{Array.from(selectedPaths).map((path) => (
<span key={path} className="text-xs bg-accent px-2 py-1 rounded-md font-mono">
{path}
</span>
))}
</div>
</div>
)}
<FormField
control={form.control}
name="includePatternsText"
render={({ field }) => (
<FormItem className="mt-6">
<FormLabel>Additional include patterns</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="/data/**&#10;/config/*.json&#10;*.db"
className="font-mono text-sm min-h-[100px]"
/>
</FormControl>
<FormDescription>
Optionally add custom include patterns using glob syntax. Enter one pattern per line. These will
be combined with the paths selected above.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Exclude patterns</CardTitle>
<CardDescription>
Optionally specify patterns to exclude from backups. Enter one pattern per line (e.g., *.tmp,
node_modules/**, .cache/).
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="excludePatternsText"
render={({ field }) => (
<FormItem>
<FormLabel>Exclusion patterns</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="*.tmp&#10;node_modules/**&#10;.cache/&#10;*.log"
className="font-mono text-sm min-h-[120px]"
/>
</FormControl>
<FormDescription>
Patterns support glob syntax. See&nbsp;
<a
href="https://restic.readthedocs.io/en/stable/040_backup.html#excluding-files"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
Restic documentation
</a>
&nbsp;for more details.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="excludeIfPresentText"
render={({ field }) => (
<FormItem className="mt-6">
<FormLabel>Exclude if file present</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder=".nobackup&#10;.exclude-from-backup&#10;CACHEDIR.TAG"
className="font-mono text-sm min-h-20"
/>
</FormControl>
<FormDescription>
Exclude folders containing a file with the specified name. Enter one filename per line. For
example, use <code className="bg-muted px-1 rounded">.nobackup</code> to skip any folder
containing a <code className="bg-muted px-1 rounded">.nobackup</code> file.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Retention policy</CardTitle>
<CardDescription>Define how many snapshots to keep. Leave empty to keep all.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="keepLast"
render={({ field }) => (
<FormItem>
<FormLabel>Keep last N snapshots</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={0}
placeholder="Optional"
onChange={(v) => field.onChange(Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the N most recent snapshots.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="keepHourly"
render={({ field }) => (
<FormItem>
<FormLabel>Keep hourly</FormLabel>
<FormControl>
<Input
type="number"
min={0}
placeholder="Optional"
{...field}
onChange={(v) => field.onChange(Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the last N hourly snapshots.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="keepDaily"
render={({ field }) => (
<FormItem>
<FormLabel>Keep daily</FormLabel>
<FormControl>
<Input
type="number"
min={0}
placeholder="e.g., 7"
{...field}
onChange={(v) => field.onChange(Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the last N daily snapshots.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="keepWeekly"
render={({ field }) => (
<FormItem>
<FormLabel>Keep weekly</FormLabel>
<FormControl>
<Input
type="number"
min={0}
placeholder="e.g., 4"
{...field}
onChange={(v) => field.onChange(Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the last N weekly snapshots.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="keepMonthly"
render={({ field }) => (
<FormItem>
<FormLabel>Keep monthly</FormLabel>
<FormControl>
<Input
type="number"
min={0}
placeholder="e.g., 6"
{...field}
onChange={(v) => field.onChange(Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the last N monthly snapshots.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="keepYearly"
render={({ field }) => (
<FormItem>
<FormLabel>Keep yearly</FormLabel>
<FormControl>
<Input
type="number"
min={0}
placeholder="Optional"
{...field}
onChange={(v) => field.onChange(Number(v.target.value))}
/>
</FormControl>
<FormDescription>Keep the last N yearly snapshots.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
<div className="h-full">
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle>Schedule summary</CardTitle>
<CardDescription>Review the backup configuration.</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<div>
<p className="text-xs uppercase text-muted-foreground">Volume</p>
<p className="font-medium">{volume.name}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Schedule</p>
<p className="font-medium">
{frequency ? frequency.charAt(0).toUpperCase() + frequency.slice(1) : "-"}
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Repository</p>
<p className="font-medium">
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
</p>
</div>
{(formValues.includePatterns && formValues.includePatterns.length > 0) ||
formValues.includePatternsText ? (
<div>
<p className="text-xs uppercase text-muted-foreground">Include paths/patterns</p>
<div className="flex flex-col gap-1">
{formValues.includePatterns?.map((path) => (
<span key={path} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
{path}
</span>
))}
{formValues.includePatternsText
?.split("\n")
.filter(Boolean)
.map((pattern) => (
<span key={pattern} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
{pattern.trim()}
</span>
))}
</div>
</div>
) : null}
{formValues.excludePatternsText && (
<div>
<p className="text-xs uppercase text-muted-foreground">Exclude patterns</p>
<div className="flex flex-col gap-1">
{formValues.excludePatternsText
.split("\n")
.filter(Boolean)
.map((pattern) => (
<span key={pattern} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
{pattern.trim()}
</span>
))}
</div>
</div>
)}
{formValues.excludeIfPresentText && (
<div>
<p className="text-xs uppercase text-muted-foreground">Exclude if present</p>
<div className="flex flex-col gap-1">
{formValues.excludeIfPresentText
.split("\n")
.filter(Boolean)
.map((filename) => (
<span key={filename} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
{filename.trim()}
</span>
))}
</div>
</div>
)}
<div>
<p className="text-xs uppercase text-muted-foreground">Retention</p>
<p className="font-medium">
{Object.entries(formValues)
.filter(([key, value]) => key.startsWith("keep") && Boolean(value))
.map(([key, value]) => {
const label = key.replace("keep", "").toLowerCase();
return `${value} ${label}`;
})
.join(", ") || "-"}
</p>
</div>
</CardContent>
</Card>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,352 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Copy, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Switch } from "~/client/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Badge } from "~/client/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import {
getScheduleMirrorsOptions,
getMirrorCompatibilityOptions,
updateScheduleMirrorsMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import type { Repository } from "~/client/lib/types";
import { RepositoryIcon } from "~/client/components/repository-icon";
import { StatusDot } from "~/client/components/status-dot";
import { formatDistanceToNow } from "date-fns";
import { Link } from "react-router";
import { cn } from "~/client/lib/utils";
type Props = {
scheduleId: number;
primaryRepositoryId: string;
repositories: Repository[];
};
type MirrorAssignment = {
repositoryId: string;
enabled: boolean;
lastCopyAt: number | null;
lastCopyStatus: "success" | "error" | null;
lastCopyError: string | null;
};
export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, repositories }: Props) => {
const [assignments, setAssignments] = useState<Map<string, MirrorAssignment>>(new Map());
const [hasChanges, setHasChanges] = useState(false);
const [isAddingNew, setIsAddingNew] = useState(false);
const { data: currentMirrors } = useQuery({
...getScheduleMirrorsOptions({ path: { scheduleId: scheduleId.toString() } }),
});
const { data: compatibility } = useQuery({
...getMirrorCompatibilityOptions({ path: { scheduleId: scheduleId.toString() } }),
});
const updateMirrors = useMutation({
...updateScheduleMirrorsMutation(),
onSuccess: () => {
toast.success("Mirror settings saved successfully");
setHasChanges(false);
},
onError: (error) => {
toast.error("Failed to save mirror settings", {
description: parseError(error)?.message,
});
},
});
const compatibilityMap = useMemo(() => {
const map = new Map<string, { compatible: boolean; reason: string | null }>();
if (compatibility) {
for (const item of compatibility) {
map.set(item.repositoryId, { compatible: item.compatible, reason: item.reason });
}
}
return map;
}, [compatibility]);
useEffect(() => {
if (currentMirrors && !hasChanges) {
const map = new Map<string, MirrorAssignment>();
for (const mirror of currentMirrors) {
map.set(mirror.repositoryId, {
repositoryId: mirror.repositoryId,
enabled: mirror.enabled,
lastCopyAt: mirror.lastCopyAt,
lastCopyStatus: mirror.lastCopyStatus,
lastCopyError: mirror.lastCopyError,
});
}
setAssignments(map);
}
}, [currentMirrors, hasChanges]);
const addRepository = (repositoryId: string) => {
const newAssignments = new Map(assignments);
newAssignments.set(repositoryId, {
repositoryId,
enabled: true,
lastCopyAt: null,
lastCopyStatus: null,
lastCopyError: null,
});
setAssignments(newAssignments);
setHasChanges(true);
setIsAddingNew(false);
};
const removeRepository = (repositoryId: string) => {
const newAssignments = new Map(assignments);
newAssignments.delete(repositoryId);
setAssignments(newAssignments);
setHasChanges(true);
};
const toggleEnabled = (repositoryId: string) => {
const assignment = assignments.get(repositoryId);
if (!assignment) return;
const newAssignments = new Map(assignments);
newAssignments.set(repositoryId, {
...assignment,
enabled: !assignment.enabled,
});
setAssignments(newAssignments);
setHasChanges(true);
};
const handleSave = () => {
const mirrorsList = Array.from(assignments.values()).map((a) => ({
repositoryId: a.repositoryId,
enabled: a.enabled,
}));
updateMirrors.mutate({
path: { scheduleId: scheduleId.toString() },
body: {
mirrors: mirrorsList,
},
});
};
const handleReset = () => {
if (currentMirrors) {
const map = new Map<string, MirrorAssignment>();
for (const mirror of currentMirrors) {
map.set(mirror.repositoryId, {
repositoryId: mirror.repositoryId,
enabled: mirror.enabled,
lastCopyAt: mirror.lastCopyAt,
lastCopyStatus: mirror.lastCopyStatus,
lastCopyError: mirror.lastCopyError,
});
}
setAssignments(map);
setHasChanges(false);
}
};
const selectableRepositories =
repositories?.filter((r) => {
if (r.id === primaryRepositoryId) return false;
if (assignments.has(r.id)) return false;
return true;
}) || [];
const hasAvailableRepositories = selectableRepositories.some((r) => {
const compat = compatibilityMap.get(r.id);
return compat?.compatible !== false;
});
const assignedRepositories = Array.from(assignments.keys())
.map((id) => repositories?.find((r) => r.id === id))
.filter((r) => r !== undefined);
const getStatusVariant = (status: "success" | "error" | null) => {
if (status === "success") return "success";
if (status === "error") return "error";
return "neutral";
};
const getStatusLabel = (assignment: MirrorAssignment) => {
if (assignment.lastCopyStatus === "error" && assignment.lastCopyError) {
return assignment.lastCopyError;
}
if (assignment.lastCopyStatus === "success") {
return "Last copy successful";
}
return "Never copied";
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Copy className="h-5 w-5" />
Mirror Repositories
</CardTitle>
<CardDescription>
Configure secondary repositories where snapshots will be automatically copied after each backup
</CardDescription>
</div>
{!isAddingNew && selectableRepositories.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setIsAddingNew(true)}>
<Plus className="h-4 w-4 mr-2" />
Add mirror
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isAddingNew && (
<div className="mb-6 flex items-center gap-2 max-w-md">
<Select onValueChange={addRepository}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a repository to mirror to..." />
</SelectTrigger>
<SelectContent>
{selectableRepositories.map((repository) => {
const compat = compatibilityMap.get(repository.id);
return (
<Tooltip key={repository.id}>
<TooltipTrigger asChild>
<div>
<SelectItem value={repository.id} disabled={!compat?.compatible}>
<div className="flex items-center gap-2">
<RepositoryIcon backend={repository.type} className="h-4 w-4" />
<span>{repository.name}</span>
<span className="text-xs uppercase text-muted-foreground">({repository.type})</span>
</div>
</SelectItem>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={cn("max-w-xs", { hidden: compat?.compatible })}>
<p>{compat?.reason || "This repository is not compatible for mirroring."}</p>
<p className="mt-1 text-xs text-muted-foreground">
Consider creating a new backup scheduler with the desired destination instead.
</p>
</TooltipContent>
</Tooltip>
);
})}
{!hasAvailableRepositories && selectableRepositories.length > 0 && (
<div className="px-2 py-3 text-sm text-muted-foreground text-center">
All available repositories have conflicting backends.
<br />
<span className="text-xs">
Consider creating a new backup scheduler with the desired destination instead.
</span>
</div>
)}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" onClick={() => setIsAddingNew(false)}>
Cancel
</Button>
</div>
)}
{assignedRepositories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<Copy className="h-8 w-8 mb-2 opacity-20" />
<p className="text-sm">No mirror repositories configured for this schedule.</p>
<p className="text-xs mt-1">Click "Add mirror" to replicate backups to additional repositories.</p>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead className="text-center w-[100px]">Enabled</TableHead>
<TableHead className="w-[180px]">Last Copy</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignedRepositories.map((repository) => {
const assignment = assignments.get(repository.id);
if (!assignment) return null;
return (
<TableRow key={repository.id}>
<TableCell>
<div className="flex items-center gap-2">
<Link
to={`/repositories/${repository.name}`}
className="hover:underline flex items-center gap-2"
>
<RepositoryIcon backend={repository.type} className="h-4 w-4" />
<span className="font-medium">{repository.name}</span>
</Link>
<Badge variant="outline" className="text-[10px] align-middle">
{repository.type}
</Badge>
</div>
</TableCell>
<TableCell className="text-center">
<Switch
className="align-middle"
checked={assignment.enabled}
onCheckedChange={() => toggleEnabled(repository.id)}
/>
</TableCell>
<TableCell>
{assignment.lastCopyAt ? (
<div className="flex items-center gap-2">
<StatusDot
variant={getStatusVariant(assignment.lastCopyStatus)}
label={getStatusLabel(assignment)}
/>
<span className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(assignment.lastCopyAt), { addSuffix: true })}
</span>
</div>
) : (
<span className="text-sm text-muted-foreground">Never</span>
)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => removeRepository(repository.id)}
className="h-8 w-8 text-muted-foreground hover:text-destructive align-baseline"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{hasChanges && (
<div className="flex gap-2 justify-end mt-4 pt-4">
<Button variant="outline" size="sm" onClick={handleReset}>
Cancel
</Button>
<Button variant="default" size="sm" onClick={handleSave} loading={updateMirrors.isPending}>
Save changes
</Button>
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,267 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Bell, Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Switch } from "~/client/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Badge } from "~/client/components/ui/badge";
import {
getScheduleNotificationsOptions,
updateScheduleNotificationsMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import type { NotificationDestination } from "~/client/lib/types";
type Props = {
scheduleId: number;
destinations: NotificationDestination[];
};
type NotificationAssignment = {
destinationId: number;
notifyOnStart: boolean;
notifyOnSuccess: boolean;
notifyOnFailure: boolean;
};
export const ScheduleNotificationsConfig = ({ scheduleId, destinations }: Props) => {
const [assignments, setAssignments] = useState<Map<number, NotificationAssignment>>(new Map());
const [hasChanges, setHasChanges] = useState(false);
const [isAddingNew, setIsAddingNew] = useState(false);
const { data: currentAssignments } = useQuery({
...getScheduleNotificationsOptions({ path: { scheduleId: scheduleId.toString() } }),
});
const updateNotifications = useMutation({
...updateScheduleNotificationsMutation(),
onSuccess: () => {
toast.success("Notification settings saved successfully");
setHasChanges(false);
},
onError: (error) => {
toast.error("Failed to save notification settings", {
description: parseError(error)?.message,
});
},
});
useEffect(() => {
if (currentAssignments) {
const map = new Map<number, NotificationAssignment>();
for (const assignment of currentAssignments) {
map.set(assignment.destinationId, {
destinationId: assignment.destinationId,
notifyOnStart: assignment.notifyOnStart,
notifyOnSuccess: assignment.notifyOnSuccess,
notifyOnFailure: assignment.notifyOnFailure,
});
}
setAssignments(map);
}
}, [currentAssignments]);
const addDestination = (destinationId: string) => {
const id = Number.parseInt(destinationId, 10);
const newAssignments = new Map(assignments);
newAssignments.set(id, {
destinationId: id,
notifyOnStart: false,
notifyOnSuccess: false,
notifyOnFailure: true,
});
setAssignments(newAssignments);
setHasChanges(true);
setIsAddingNew(false);
};
const removeDestination = (destinationId: number) => {
const newAssignments = new Map(assignments);
newAssignments.delete(destinationId);
setAssignments(newAssignments);
setHasChanges(true);
};
const toggleEvent = (destinationId: number, event: "notifyOnStart" | "notifyOnSuccess" | "notifyOnFailure") => {
const assignment = assignments.get(destinationId);
if (!assignment) return;
const newAssignments = new Map(assignments);
newAssignments.set(destinationId, {
...assignment,
[event]: !assignment[event],
});
setAssignments(newAssignments);
setHasChanges(true);
};
const handleSave = () => {
const assignmentsList = Array.from(assignments.values());
updateNotifications.mutate({
path: { scheduleId: scheduleId.toString() },
body: {
assignments: assignmentsList,
},
});
};
const handleReset = () => {
if (currentAssignments) {
const map = new Map<number, NotificationAssignment>();
for (const assignment of currentAssignments) {
map.set(assignment.destinationId, {
destinationId: assignment.destinationId,
notifyOnStart: assignment.notifyOnStart,
notifyOnSuccess: assignment.notifyOnSuccess,
notifyOnFailure: assignment.notifyOnFailure,
});
}
setAssignments(map);
setHasChanges(false);
}
};
const getDestinationById = (id: number) => {
return destinations?.find((d) => d.id === id);
};
const availableDestinations = destinations?.filter((d) => !assignments.has(d.id)) || [];
const assignedDestinations = Array.from(assignments.keys())
.map((id) => getDestinationById(id))
.filter((d) => d !== undefined);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Notifications
</CardTitle>
<CardDescription>Configure which notifications to send for this backup schedule</CardDescription>
</div>
{!isAddingNew && availableDestinations.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setIsAddingNew(true)}>
<Plus className="h-4 w-4 mr-2" />
Add notification
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isAddingNew && (
<div className="mb-6 flex items-center gap-2 max-w-md">
<Select onValueChange={addDestination}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a notification destination..." />
</SelectTrigger>
<SelectContent>
{availableDestinations.map((destination) => (
<SelectItem key={destination.id} value={destination.id.toString()}>
<div className="flex items-center gap-2">
<span>{destination.name}</span>
<span className="text-xs uppercase text-muted-foreground">({destination.type})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" onClick={() => setIsAddingNew(false)}>
Cancel
</Button>
</div>
)}
{assignedDestinations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<Bell className="h-8 w-8 mb-2 opacity-20" />
<p className="text-sm">No notifications configured for this schedule.</p>
<p className="text-xs mt-1">Click "Add notification" to get started.</p>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Destination</TableHead>
<TableHead className="text-center w-[100px]">Start</TableHead>
<TableHead className="text-center w-[100px]">Success</TableHead>
<TableHead className="text-center w-[100px]">Failure</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignedDestinations.map((destination) => {
const assignment = assignments.get(destination.id);
if (!assignment) return null;
return (
<TableRow key={destination.id}>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-medium">{destination.name}</span>
<Badge variant="outline" className="text-[10px] align-middle">
{destination.type}
</Badge>
</div>
</TableCell>
<TableCell className="text-center">
<Switch
className="align-middle"
checked={assignment.notifyOnStart}
onCheckedChange={() => toggleEvent(destination.id, "notifyOnStart")}
/>
</TableCell>
<TableCell className="text-center">
<Switch
className="align-middle"
checked={assignment.notifyOnSuccess}
onCheckedChange={() => toggleEvent(destination.id, "notifyOnSuccess")}
/>
</TableCell>
<TableCell className="text-center">
<Switch
className="align-middle"
checked={assignment.notifyOnFailure}
onCheckedChange={() => toggleEvent(destination.id, "notifyOnFailure")}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => removeDestination(destination.id)}
className="h-8 w-8 text-muted-foreground hover:text-destructive align-baseline"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{hasChanges && (
<div className="flex gap-2 justify-end mt-4 pt-4">
<Button variant="outline" size="sm" onClick={handleReset}>
Cancel
</Button>
<Button variant="default" size="sm" onClick={handleSave} loading={updateNotifications.isPending}>
Save changes
</Button>
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,244 @@
import { Check, Database, Eraser, HardDrive, Pencil, Play, Square, Trash2, X } from "lucide-react";
import { useMemo, useState } from "react";
import { OnOff } from "~/client/components/onoff";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import type { BackupSchedule } from "~/client/lib/types";
import { BackupProgressCard } from "./backup-progress-card";
import { runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { parseError } from "~/client/lib/errors";
import { Link } from "react-router";
type Props = {
schedule: BackupSchedule;
handleToggleEnabled: (enabled: boolean) => void;
handleRunBackupNow: () => void;
handleStopBackup: () => void;
handleDeleteSchedule: () => void;
setIsEditMode: (isEdit: boolean) => void;
};
export const ScheduleSummary = (props: Props) => {
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
props;
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showForgetConfirm, setShowForgetConfirm] = useState(false);
const runForget = useMutation({
...runForgetMutation(),
onSuccess: () => {
toast.success("Retention policy applied successfully");
},
onError: (error) => {
toast.error("Failed to apply retention policy", { description: parseError(error)?.message });
},
});
const summary = useMemo(() => {
const scheduleLabel = schedule ? schedule.cronExpression : "-";
const retentionParts: string[] = [];
if (schedule?.retentionPolicy) {
const rp = schedule.retentionPolicy;
if (rp.keepLast) retentionParts.push(`${rp.keepLast} last`);
if (rp.keepHourly) retentionParts.push(`${rp.keepHourly} hourly`);
if (rp.keepDaily) retentionParts.push(`${rp.keepDaily} daily`);
if (rp.keepWeekly) retentionParts.push(`${rp.keepWeekly} weekly`);
if (rp.keepMonthly) retentionParts.push(`${rp.keepMonthly} monthly`);
if (rp.keepYearly) retentionParts.push(`${rp.keepYearly} yearly`);
}
return {
vol: schedule.volume.name,
scheduleLabel,
repositoryLabel: schedule.repositoryId || "No repository selected",
retentionLabel: retentionParts.length > 0 ? retentionParts.join(" • ") : "No retention policy",
};
}, [schedule, schedule.volume.name]);
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
handleDeleteSchedule();
};
const handleConfirmForget = () => {
setShowForgetConfirm(false);
runForget.mutate({ path: { scheduleId: schedule.id.toString() } });
};
return (
<div className="space-y-4">
<Card>
<CardHeader className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<CardTitle>{schedule.name}</CardTitle>
<CardDescription className="mt-1">
<Link to={`/volumes/${schedule.volume.name}`} className="hover:underline">
<HardDrive className="inline h-4 w-4 mr-2" />
<span>{schedule.volume.name}</span>
</Link>
<span className="mx-2"></span>
<Link to={`/repositories/${schedule.repository.name}`} className="hover:underline">
<Database className="inline h-4 w-4 mr-2 text-strong-accent" />
<span className="text-strong-accent">{schedule.repository.name}</span>
</Link>
</CardDescription>
</div>
<div className="flex items-center gap-2 justify-between sm:justify-start">
<OnOff
isOn={schedule.enabled}
toggle={handleToggleEnabled}
enabledLabel="Enabled"
disabledLabel="Paused"
/>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2">
{schedule.lastBackupStatus === "in_progress" ? (
<Button variant="destructive" size="sm" onClick={handleStopBackup} className="w-full sm:w-auto">
<Square className="h-4 w-4 mr-2" />
<span className="sm:inline">Stop backup</span>
</Button>
) : (
<Button variant="default" size="sm" onClick={handleRunBackupNow} className="w-full sm:w-auto">
<Play className="h-4 w-4 mr-2" />
<span className="sm:inline">Backup now</span>
</Button>
)}
{schedule.retentionPolicy && (
<Button
variant="outline"
size="sm"
loading={runForget.isPending}
onClick={() => setShowForgetConfirm(true)}
className="w-full sm:w-auto"
>
<Eraser className="h-4 w-4 mr-2" />
<span className="sm:inline">Run cleanup</span>
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full sm:w-auto">
<Pencil className="h-4 w-4 mr-2" />
<span className="sm:inline">Edit schedule</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="text-destructive hover:text-destructive w-full sm:w-auto"
>
<Trash2 className="h-4 w-4 mr-2" />
<span className="sm:inline">Delete</span>
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<p className="text-xs uppercase text-muted-foreground">Schedule</p>
<p className="font-medium">{summary.scheduleLabel}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Repository</p>
<p className="font-medium">{schedule.repository.name}</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Last backup</p>
<p className="font-medium">
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleString() : "Never"}
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Next backup</p>
<p className="font-medium">
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleString() : "Never"}
</p>
</div>
<div>
<p className="text-xs uppercase text-muted-foreground">Status</p>
<p className="font-medium">
{schedule.lastBackupStatus === "success" && "✓ Success"}
{schedule.lastBackupStatus === "error" && "✗ Error"}
{schedule.lastBackupStatus === "in_progress" && "⟳ in progress..."}
{schedule.lastBackupStatus === "warning" && "! Warning"}
{!schedule.lastBackupStatus && "—"}
</p>
</div>
{schedule.lastBackupStatus === "warning" && (
<div className="md:col-span-2 lg:col-span-4">
<p className="text-xs uppercase text-muted-foreground">Warning Details</p>
<p className="font-mono text-sm text-yellow-600 whitespace-pre-wrap break-all">
Last backup completed with warnings. Check your container logs for more details.
</p>
</div>
)}
{schedule.lastBackupError && (
<div className="md:col-span-2 lg:col-span-4">
<p className="text-xs uppercase text-muted-foreground">Error Details</p>
<p className="font-mono text-sm text-red-600 whitespace-pre-wrap break-all">{schedule.lastBackupError}</p>
</div>
)}
</CardContent>
</Card>
{schedule.lastBackupStatus === "in_progress" && <BackupProgressCard scheduleId={schedule.id} />}
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete backup schedule?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this backup schedule for <strong>{schedule.volume.name}</strong>? This
action cannot be undone. Existing snapshots will not be deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete schedule
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showForgetConfirm} onOpenChange={setShowForgetConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Run retention policy cleanup?</AlertDialogTitle>
<AlertDialogDescription>
This will apply the retention policy and permanently delete old snapshots according to the configured
rules ({summary.retentionLabel}). This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex gap-3 justify-end">
<AlertDialogCancel>
<X className="h-4 w-4 mr-2" />
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmForget}>
<Check className="h-4 w-4 mr-2" />
Run cleanup
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -0,0 +1,149 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FileIcon, RotateCcw, Trash2 } from "lucide-react";
import { Link } from "react-router";
import { FileTree } from "~/client/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Button, buttonVariants } from "~/client/components/ui/button";
import type { Snapshot } from "~/client/lib/types";
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
interface Props {
snapshot: Snapshot;
repositoryName: string;
backupId?: string;
onDeleteSnapshot?: (snapshotId: string) => void;
isDeletingSnapshot?: boolean;
}
export const SnapshotFileBrowser = (props: Props) => {
const { snapshot, repositoryName, backupId, onDeleteSnapshot, isDeletingSnapshot } = props;
const queryClient = useQueryClient();
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
const { data: filesData, isLoading: filesLoading } = useQuery({
...listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path: volumeBasePath },
}),
});
const stripBasePath = useCallback(
(path: string): string => {
if (!volumeBasePath) return path;
if (path === volumeBasePath) return "/";
if (path.startsWith(`${volumeBasePath}/`)) {
const stripped = path.slice(volumeBasePath.length);
return stripped;
}
return path;
},
[volumeBasePath],
);
const addBasePath = useCallback(
(displayPath: string): string => {
const vbp = volumeBasePath === "/" ? "" : volumeBasePath;
if (!vbp) return displayPath;
if (displayPath === "/") return vbp;
return `${vbp}${displayPath}`;
},
[volumeBasePath],
);
const fileBrowser = useFileBrowser({
initialData: filesData,
isLoading: filesLoading,
fetchFolder: async (path) => {
return await queryClient.ensureQueryData(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path },
}),
);
},
prefetchFolder: (path) => {
queryClient.prefetchQuery(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId: snapshot.short_id },
query: { path },
}),
);
},
pathTransform: {
strip: stripBasePath,
add: addBasePath,
},
});
return (
<div className="space-y-4">
<Card className="h-[600px] flex flex-col">
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>File Browser</CardTitle>
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
</div>
<div className="flex gap-2">
<Link
to={
backupId
? `/backups/${backupId}/${snapshot.short_id}/restore`
: `/repositories/${repositoryName}/${snapshot.short_id}/restore`
}
className={buttonVariants({ variant: "primary", size: "sm" })}
>
<RotateCcw className="h-4 w-4" />
Restore
</Link>
{onDeleteSnapshot && (
<Button
variant="destructive"
size="sm"
onClick={() => onDeleteSnapshot(snapshot.short_id)}
disabled={isDeletingSnapshot}
loading={isDeletingSnapshot}
>
<Trash2 className="h-4 w-4 mr-2" />
{isDeletingSnapshot ? "Deleting..." : "Delete Snapshot"}
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
{fileBrowser.isLoading && (
<div className="flex items-center justify-center flex-1">
<p className="text-muted-foreground">Loading files...</p>
</div>
)}
{fileBrowser.isEmpty && (
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No files in this snapshot</p>
</div>
)}
{!fileBrowser.isLoading && !fileBrowser.isEmpty && (
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
<FileTree
files={fileBrowser.fileArray}
onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
className="px-2 py-2"
/>
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,108 @@
import { cn } from "~/client/lib/utils";
import { Card } from "~/client/components/ui/card";
import { ByteSize } from "~/client/components/bytes-size";
import { useEffect } from "react";
import type { ListSnapshotsResponse } from "~/client/api-client";
interface Props {
snapshots: ListSnapshotsResponse;
snapshotId?: string;
loading?: boolean;
error?: string;
onSnapshotSelect: (snapshotId: string) => void;
}
export const SnapshotTimeline = (props: Props) => {
const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props;
useEffect(() => {
if (!snapshotId && snapshots.length > 0) {
onSnapshotSelect(snapshots[snapshots.length - 1].short_id);
}
}, [snapshotId, snapshots, onSnapshotSelect]);
if (error) {
return (
<Card>
<div className="flex items-center justify-center h-24 p-4 text-center">
<p className="text-destructive">Error loading snapshots: {error}</p>
</div>
</Card>
);
}
if (loading) {
return (
<Card>
<div className="flex items-center justify-center h-24">
<p className="text-muted-foreground">Loading snapshots...</p>
</div>
</Card>
);
}
if (snapshots.length === 0) {
return (
<Card>
<div className="flex items-center justify-center h-24">
<p className="text-muted-foreground">No snapshots available</p>
</div>
</Card>
);
}
return (
<Card className="p-0 pt-2">
<div className="w-full bg-card">
<div className="relative flex items-center">
<div className="flex-1 overflow-hidden">
<div className="flex gap-4 overflow-x-auto pb-2 *:first:ml-2 *:last:mr-2">
{snapshots.map((snapshot, index) => {
const date = new Date(snapshot.time);
const isSelected = snapshotId === snapshot.short_id;
const isLatest = index === snapshots.length - 1;
return (
<button
type="button"
key={snapshot.short_id}
onClick={() => onSnapshotSelect(snapshot.short_id)}
className={cn(
"shrink-0 flex flex-col items-center gap-2 p-3 rounded-lg transition-all",
"border-2 cursor-pointer",
{
"border-primary bg-primary/10 shadow-md": isSelected,
"border-border hover:border-accent hover:bg-accent/5": !isSelected,
},
)}
>
<div className="text-xs font-semibold text-foreground">
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</div>
<div className="text-xs text-muted-foreground">
{date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
</div>
<div className="text-xs text-muted-foreground opacity-75">
<ByteSize bytes={snapshot.size} />
</div>
{isLatest && (
<div className="text-xs font-semibold text-primary px-2 py-0.5 bg-primary/20 rounded">Latest</div>
)}
</button>
);
})}
</div>
</div>
</div>
<div className="px-4 py-2 text-xs text-muted-foreground bg-card-header border-t border-border flex justify-between">
<span>{snapshots.length} snapshots</span>
<span>
{new Date(snapshots[0].time).toLocaleDateString()} -{" "}
{new Date(snapshots.at(-1)?.time ?? 0).toLocaleDateString()}
</span>
</div>
</div>
</Card>
);
};

View File

@@ -0,0 +1,285 @@
import { useId, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { redirect, useNavigate } from "react-router";
import { toast } from "sonner";
import { Save, X } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import {
getBackupScheduleOptions,
runBackupNowMutation,
deleteBackupScheduleMutation,
listSnapshotsOptions,
updateBackupScheduleMutation,
stopBackupMutation,
deleteSnapshotMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import { getCronExpression } from "~/utils/utils";
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
import { ScheduleSummary } from "../components/schedule-summary";
import type { Route } from "./+types/backup-details";
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
import { SnapshotTimeline } from "../components/snapshot-timeline";
import { getBackupSchedule, listNotificationDestinations, listRepositories } from "~/client/api-client";
import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config";
import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config";
import { cn } from "~/client/lib/utils";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => {
const data = match.loaderData;
return [{ label: "Backups", href: "/backups" }, { label: data.schedule.name }];
},
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Zerobyte - Backup Job Details" },
{
name: "description",
content: "View and manage backup job configuration, schedule, and snapshots.",
},
];
}
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
const notifs = await listNotificationDestinations();
const repos = await listRepositories();
if (!schedule.data) return redirect("/backups");
return { schedule: schedule.data, notifs: notifs.data, repos: repos.data };
};
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
const navigate = useNavigate();
const [isEditMode, setIsEditMode] = useState(false);
const formId = useId();
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);
const { data: schedule } = useQuery({
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
initialData: loaderData.schedule,
});
const {
data: snapshots,
isLoading,
failureReason,
} = useQuery({
...listSnapshotsOptions({ path: { name: schedule.repository.name }, query: { backupId: schedule.id.toString() } }),
});
const updateSchedule = useMutation({
...updateBackupScheduleMutation(),
onSuccess: () => {
toast.success("Backup schedule saved successfully");
setIsEditMode(false);
},
onError: (error) => {
toast.error("Failed to save backup schedule", {
description: parseError(error)?.message,
});
},
});
const runBackupNow = useMutation({
...runBackupNowMutation(),
onSuccess: () => {
toast.success("Backup started successfully");
},
onError: (error) => {
toast.error("Failed to start backup", { description: parseError(error)?.message });
},
});
const stopBackup = useMutation({
...stopBackupMutation(),
onSuccess: () => {
toast.success("Backup stopped successfully");
},
onError: (error) => {
toast.error("Failed to stop backup", { description: parseError(error)?.message });
},
});
const deleteSchedule = useMutation({
...deleteBackupScheduleMutation(),
onSuccess: () => {
toast.success("Backup schedule deleted successfully");
navigate("/backups");
},
onError: (error) => {
toast.error("Failed to delete backup schedule", { description: parseError(error)?.message });
},
});
const deleteSnapshot = useMutation({
...deleteSnapshotMutation(),
onSuccess: () => {
setShowDeleteConfirm(false);
setSnapshotToDelete(null);
if (selectedSnapshotId === snapshotToDelete) {
setSelectedSnapshotId(undefined);
}
},
});
const handleSubmit = (formValues: BackupScheduleFormValues) => {
if (!schedule) return;
const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay);
const retentionPolicy: Record<string, number> = {};
if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast;
if (formValues.keepHourly) retentionPolicy.keepHourly = formValues.keepHourly;
if (formValues.keepDaily) retentionPolicy.keepDaily = formValues.keepDaily;
if (formValues.keepWeekly) retentionPolicy.keepWeekly = formValues.keepWeekly;
if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly;
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
updateSchedule.mutate({
path: { scheduleId: schedule.id.toString() },
body: {
name: formValues.name,
repositoryId: formValues.repositoryId,
enabled: schedule.enabled,
cronExpression,
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
includePatterns: formValues.includePatterns,
excludePatterns: formValues.excludePatterns,
excludeIfPresent: formValues.excludeIfPresent,
},
});
};
const handleToggleEnabled = (enabled: boolean) => {
updateSchedule.mutate({
path: { scheduleId: schedule.id.toString() },
body: {
repositoryId: schedule.repositoryId,
enabled,
cronExpression: schedule.cronExpression,
retentionPolicy: schedule.retentionPolicy || undefined,
includePatterns: schedule.includePatterns || [],
excludePatterns: schedule.excludePatterns || [],
excludeIfPresent: schedule.excludeIfPresent || [],
},
});
};
const handleDeleteSnapshot = (snapshotId: string) => {
setSnapshotToDelete(snapshotId);
setShowDeleteConfirm(true);
};
const handleConfirmDelete = () => {
if (snapshotToDelete) {
toast.promise(
deleteSnapshot.mutateAsync({
path: { name: schedule.repository.name, snapshotId: snapshotToDelete },
}),
{
loading: "Deleting snapshot...",
success: "Snapshot deleted successfully",
error: (error) => parseError(error)?.message || "Failed to delete snapshot",
},
);
}
};
if (isEditMode) {
return (
<div>
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} formId={formId} />
<div className="flex justify-end mt-4 gap-2">
<Button type="submit" className="ml-auto" variant="primary" form={formId} loading={updateSchedule.isPending}>
<Save className="h-4 w-4 mr-2" />
Update schedule
</Button>
<Button variant="outline" onClick={() => setIsEditMode(false)}>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
</div>
</div>
);
}
const selectedSnapshot = snapshots?.find((s) => s.short_id === selectedSnapshotId);
return (
<div className="flex flex-col gap-6">
<ScheduleSummary
handleToggleEnabled={handleToggleEnabled}
handleRunBackupNow={() => runBackupNow.mutate({ path: { scheduleId: schedule.id.toString() } })}
handleStopBackup={() => stopBackup.mutate({ path: { scheduleId: schedule.id.toString() } })}
handleDeleteSchedule={() => deleteSchedule.mutate({ path: { scheduleId: schedule.id.toString() } })}
setIsEditMode={setIsEditMode}
schedule={schedule}
/>
<div className={cn({ hidden: !loaderData.notifs?.length })}>
<ScheduleNotificationsConfig scheduleId={schedule.id} destinations={loaderData.notifs ?? []} />
</div>
<div className={cn({ hidden: !loaderData.repos?.length || loaderData.repos.length < 2 })}>
<ScheduleMirrorsConfig
scheduleId={schedule.id}
primaryRepositoryId={schedule.repositoryId}
repositories={loaderData.repos ?? []}
/>
</div>
<SnapshotTimeline
loading={isLoading}
snapshots={snapshots ?? []}
snapshotId={selectedSnapshot?.short_id}
error={failureReason?.message}
onSnapshotSelect={setSelectedSnapshotId}
/>
{selectedSnapshot && (
<SnapshotFileBrowser
key={selectedSnapshot?.short_id}
snapshot={selectedSnapshot}
repositoryName={schedule.repository.name}
backupId={schedule.id.toString()}
onDeleteSnapshot={handleDeleteSnapshot}
isDeletingSnapshot={deleteSnapshot.isPending}
/>
)}
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete snapshot?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the snapshot and all its data from the
repository.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={deleteSnapshot.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete snapshot
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { useQuery } from "@tanstack/react-query";
import { CalendarClock, Database, HardDrive, Plus } from "lucide-react";
import { Link } from "react-router";
import { BackupStatusDot } from "../components/backup-status-dot";
import { EmptyState } from "~/client/components/empty-state";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import type { Route } from "./+types/backups";
import { listBackupSchedules } from "~/client/api-client";
import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen";
export const handle = {
breadcrumb: () => [{ label: "Backups" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Zerobyte - Backup Jobs" },
{
name: "description",
content: "Automate volume backups with scheduled jobs and retention policies.",
},
];
}
export const clientLoader = async () => {
const jobs = await listBackupSchedules();
if (jobs.data) return jobs.data;
return [];
};
export default function Backups({ loaderData }: Route.ComponentProps) {
const { data: schedules, isLoading } = useQuery({
...listBackupSchedulesOptions(),
initialData: loaderData,
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Loading backup schedules...</p>
</div>
);
}
if (!schedules || schedules.length === 0) {
return (
<EmptyState
icon={CalendarClock}
title="No backup job"
description="Backup jobs help you automate the process of backing up your volumes on a regular schedule to ensure your data is safe and secure."
button={
<Button>
<Link to="/backups/create" className="flex items-center">
<Plus className="h-4 w-4 mr-2" />
Create a backup job
</Link>
</Button>
}
/>
);
}
return (
<div className="container mx-auto space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 auto-rows-fr">
{schedules.map((schedule) => (
<Link key={schedule.id} to={`/backups/${schedule.id}`}>
<Card key={schedule.id} className="flex flex-col h-full">
<CardHeader className="pb-3 overflow-hidden">
<div className="flex items-center justify-between gap-2 w-full">
<div className="flex items-center gap-2 flex-1 min-w-0 w-0">
<CalendarClock className="h-5 w-5 text-muted-foreground shrink-0" />
<CardTitle className="text-lg truncate">{schedule.name}</CardTitle>
</div>
<BackupStatusDot
enabled={schedule.enabled}
hasError={!!schedule.lastBackupError}
isInProgress={schedule.lastBackupStatus === "in_progress"}
/>
</div>
<CardDescription className="ml-0.5 flex items-center gap-2 text-xs">
<HardDrive className="h-3.5 w-3.5" />
<span className="truncate">{schedule.volume.name}</span>
<span className="text-muted-foreground"></span>
<Database className="h-3.5 w-3.5 text-strong-accent" />
<span className="truncate text-strong-accent">{schedule.repository.name}</span>
</CardDescription>
</CardHeader>
<CardContent className="flex-1 space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Schedule</span>
<code className="text-xs bg-muted px-2 py-1 rounded">{schedule.cronExpression}</code>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last backup</span>
<span className="font-medium">
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Next backup</span>
<span className="font-medium">
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"}
</span>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
<Link to="/backups/create">
<Card className="flex flex-col items-center justify-center h-full hover:bg-muted/50 transition-colors cursor-pointer">
<CardContent className="flex flex-col items-center justify-center gap-2">
<Plus className="h-8 w-8 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">Create a backup job</span>
</CardContent>
</Card>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
import { useId, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Database, HardDrive, Plus } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import {
createBackupScheduleMutation,
listRepositoriesOptions,
listVolumesOptions,
} from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent } from "~/client/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { parseError } from "~/client/lib/errors";
import { EmptyState } from "~/client/components/empty-state";
import { getCronExpression } from "~/utils/utils";
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
import type { Route } from "./+types/create-backup";
import { listRepositories, listVolumes } from "~/client/api-client";
export const handle = {
breadcrumb: () => [{ label: "Backups", href: "/backups" }, { label: "Create" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Zerobyte - Create Backup Job" },
{
name: "description",
content: "Create a new automated backup job for your volumes.",
},
];
}
export const clientLoader = async () => {
const volumes = await listVolumes();
const repositories = await listRepositories();
if (volumes.data && repositories.data) return { volumes: volumes.data, repositories: repositories.data };
return { volumes: [], repositories: [] };
};
export default function CreateBackup({ loaderData }: Route.ComponentProps) {
const navigate = useNavigate();
const formId = useId();
const [selectedVolumeId, setSelectedVolumeId] = useState<number | undefined>();
const { data: volumesData, isLoading: loadingVolumes } = useQuery({
...listVolumesOptions(),
initialData: loaderData.volumes,
});
const { data: repositoriesData } = useQuery({
...listRepositoriesOptions(),
initialData: loaderData.repositories,
});
const createSchedule = useMutation({
...createBackupScheduleMutation(),
onSuccess: (data) => {
toast.success("Backup job created successfully");
navigate(`/backups/${data.id}`);
},
onError: (error) => {
toast.error("Failed to create backup job", {
description: parseError(error)?.message,
});
},
});
const handleSubmit = (formValues: BackupScheduleFormValues) => {
if (!selectedVolumeId) return;
const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay);
const retentionPolicy: Record<string, number> = {};
if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast;
if (formValues.keepHourly) retentionPolicy.keepHourly = formValues.keepHourly;
if (formValues.keepDaily) retentionPolicy.keepDaily = formValues.keepDaily;
if (formValues.keepWeekly) retentionPolicy.keepWeekly = formValues.keepWeekly;
if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly;
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
createSchedule.mutate({
body: {
name: formValues.name,
volumeId: selectedVolumeId,
repositoryId: formValues.repositoryId,
enabled: true,
cronExpression,
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
includePatterns: formValues.includePatterns,
excludePatterns: formValues.excludePatterns,
excludeIfPresent: formValues.excludeIfPresent,
},
});
};
const selectedVolume = volumesData.find((v) => v.id === selectedVolumeId);
if (loadingVolumes) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Loading...</p>
</div>
);
}
if (!volumesData.length) {
return (
<EmptyState
icon={HardDrive}
title="No volume to backup"
description="To create a backup job, you need to create a volume first. Volumes are the data sources that will be backed up."
button={
<Button>
<Link to="/volumes">Go to volumes</Link>
</Button>
}
/>
);
}
if (!repositoriesData?.length) {
return (
<EmptyState
icon={Database}
title="No repository"
description="To create a backup job, you need to set up a backup repository first. Backup repositories are the destinations where your backups will be stored."
button={
<Button>
<Link to="/repositories">Go to repositories</Link>
</Button>
}
/>
);
}
return (
<div className="container mx-auto space-y-6">
<Card>
<CardContent>
<Select value={selectedVolumeId?.toString()} onValueChange={(v) => setSelectedVolumeId(Number(v))}>
<SelectTrigger id="volume-select">
<SelectValue placeholder="Choose a volume to backup" />
</SelectTrigger>
<SelectContent>
{volumesData.map((volume) => (
<SelectItem key={volume.id} value={volume.id.toString()}>
<span className="flex items-center gap-2">
<HardDrive className="h-4 w-4" />
{volume.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{selectedVolume ? (
<>
<CreateScheduleForm volume={selectedVolume} onSubmit={handleSubmit} formId={formId} />
<div className="flex justify-end mt-4 gap-2">
<Button type="submit" variant="primary" form={formId} loading={createSchedule.isPending}>
<Plus className="h-4 w-4 mr-2" />
Create
</Button>
</div>
</>
) : (
<Card>
<CardContent className="py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="relative mb-6">
<div className="absolute inset-0 animate-pulse">
<div className="w-24 h-24 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-24 h-24 rounded-full bg-linear-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<Database className="w-12 h-12 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<h3 className="text-xl font-semibold mb-2">Select a volume</h3>
<p className="text-muted-foreground text-sm max-w-md">
Choose a volume from the dropdown above to configure its backup schedule.
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { redirect } from "react-router";
import { getBackupSchedule, getSnapshotDetails } from "~/client/api-client";
import { RestoreForm } from "~/client/components/restore-form";
import type { Route } from "./+types/restore-snapshot";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Backups", href: "/backups" },
{ label: `Schedule #${match.params.id}`, href: `/backups/${match.params.id}` },
{ label: match.params.snapshotId },
{ label: "Restore" },
],
};
export function meta({ params }: Route.MetaArgs) {
return [
{ title: `Zerobyte - Restore Snapshot ${params.snapshotId}` },
{
name: "description",
content: "Restore files from a backup snapshot.",
},
];
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
if (!schedule.data) return redirect("/backups");
const repositoryName = schedule.data.repository.name;
const snapshot = await getSnapshotDetails({
path: { name: repositoryName, snapshotId: params.snapshotId },
});
if (!snapshot.data) return redirect(`/backups/${params.id}`);
return {
snapshot: snapshot.data,
repositoryName,
snapshotId: params.snapshotId,
backupId: params.id,
};
};
export default function RestoreSnapshotFromBackupPage({ loaderData }: Route.ComponentProps) {
const { snapshot, repositoryName, snapshotId, backupId } = loaderData;
return (
<RestoreForm
snapshot={snapshot}
repositoryName={repositoryName}
snapshotId={snapshotId}
returnPath={`/backups/${backupId}`}
/>
);
}

View File

@@ -0,0 +1,689 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { cn, slugify } from "~/client/lib/utils";
import { deepClean } from "~/utils/object";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Checkbox } from "~/client/components/ui/checkbox";
import { notificationConfigSchema } from "~/schemas/notifications";
export const formSchema = type({
name: "2<=string<=32",
}).and(notificationConfigSchema);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type NotificationFormValues = typeof formSchema.inferIn;
type Props = {
onSubmit: (values: NotificationFormValues) => void;
mode?: "create" | "update";
initialValues?: Partial<NotificationFormValues>;
formId?: string;
className?: string;
};
const defaultValuesForType = {
email: {
type: "email" as const,
smtpHost: "",
smtpPort: 587,
username: "",
password: "",
from: "",
to: [],
useTLS: true,
},
slack: {
type: "slack" as const,
webhookUrl: "",
},
discord: {
type: "discord" as const,
webhookUrl: "",
},
gotify: {
type: "gotify" as const,
serverUrl: "",
token: "",
priority: 5,
},
ntfy: {
type: "ntfy" as const,
topic: "",
priority: "default" as const,
},
pushover: {
type: "pushover" as const,
userKey: "",
apiToken: "",
priority: 0 as const,
},
telegram: {
type: "telegram" as const,
botToken: "",
chatId: "",
},
custom: {
type: "custom" as const,
shoutrrrUrl: "",
},
};
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
const form = useForm<NotificationFormValues>({
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
defaultValues: initialValues,
resetOptions: {
keepDefaultValues: true,
keepDirtyValues: false,
},
});
const { watch } = form;
const watchedType = watch("type");
useEffect(() => {
if (!initialValues) {
form.reset({
name: form.getValues().name,
...defaultValuesForType[watchedType as keyof typeof defaultValuesForType],
});
}
}, [watchedType, form, initialValues]);
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-4", className)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="My notification"
onChange={(e) => field.onChange(slugify(e.target.value))}
max={32}
min={2}
/>
</FormControl>
<FormDescription>Unique identifier for this notification destination.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
disabled={mode === "update"}
>
<FormControl>
<SelectTrigger className={mode === "update" ? "bg-gray-50" : ""}>
<SelectValue placeholder="Select notification type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="email">Email (SMTP)</SelectItem>
<SelectItem value="slack">Slack</SelectItem>
<SelectItem value="discord">Discord</SelectItem>
<SelectItem value="gotify">Gotify</SelectItem>
<SelectItem value="ntfy">Ntfy</SelectItem>
<SelectItem value="pushover">Pushover</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="custom">Custom (Shoutrrr URL)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Choose the notification delivery method.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{watchedType === "email" && (
<>
<FormField
control={form.control}
name="smtpHost"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Host</FormLabel>
<FormControl>
<Input {...field} placeholder="smtp.example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpPort"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Port</FormLabel>
<FormControl>
<Input
{...field}
type="number"
placeholder="587"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} placeholder="user@example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="••••••••" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="from"
render={({ field }) => (
<FormItem>
<FormLabel>From Address</FormLabel>
<FormControl>
<Input {...field} placeholder="noreply@example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="to"
render={({ field }) => (
<FormItem>
<FormLabel>To Addresses</FormLabel>
<FormControl>
<Input
{...field}
placeholder="user@example.com, admin@example.com"
value={Array.isArray(field.value) ? field.value.join(", ") : ""}
onChange={(e) => field.onChange(e.target.value.split(",").map((email) => email.trim()))}
/>
</FormControl>
<FormDescription>Comma-separated list of recipient email addresses.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="useTLS"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Use TLS</FormLabel>
<FormDescription>Enable TLS encryption for SMTP connection.</FormDescription>
</div>
</FormItem>
)}
/>
</>
)}
{watchedType === "slack" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXX"
/>
</FormControl>
<FormDescription>Get this from your Slack app's Incoming Webhooks settings.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="channel"
render={({ field }) => (
<FormItem>
<FormLabel>Channel (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="#backups" />
</FormControl>
<FormDescription>Override the default channel (use # for channels, @ for users).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Zerobyte" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="iconEmoji"
render={({ field }) => (
<FormItem>
<FormLabel>Icon Emoji (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder=":floppy_disk:" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "discord" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN" />
</FormControl>
<FormDescription>Get this from your Discord server's Integrations settings.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="Zerobyte" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="avatarUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Avatar URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://example.com/avatar.png" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="threadId"
render={({ field }) => (
<FormItem>
<FormLabel>Thread ID (Optional)</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
ID of the thread to post messages in. Leave empty to post in the main channel.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "gotify" && (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://gotify.example.com" />
</FormControl>
<FormDescription>Your self-hosted Gotify server URL.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem>
<FormLabel>App Token</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="••••••••" />
</FormControl>
<FormDescription>Application token from Gotify.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={0}
max={10}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>Priority level (0-10, where 10 is highest).</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="/custom/path" />
</FormControl>
<FormDescription>Custom path on the Gotify server, if applicable.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "ntfy" && (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="https://ntfy.example.com" />
</FormControl>
<FormDescription>Leave empty to use ntfy.sh public service.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topic"
render={({ field }) => (
<FormItem>
<FormLabel>Topic</FormLabel>
<FormControl>
<Input {...field} placeholder="ironmount-backups" />
</FormControl>
<FormDescription>The ntfy topic name to publish to.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="username" />
</FormControl>
<FormDescription>Username for server authentication, if required.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="••••••••" />
</FormControl>
<FormDescription>Password for server authentication, if required.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select onValueChange={field.onChange} defaultValue={String(field.value)} value={String(field.value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="max">Max (5)</SelectItem>
<SelectItem value="high">High (4)</SelectItem>
<SelectItem value="default">Default (3)</SelectItem>
<SelectItem value="low">Low (2)</SelectItem>
<SelectItem value="min">Min (1)</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "pushover" && (
<>
<FormField
control={form.control}
name="userKey"
render={({ field }) => (
<FormItem>
<FormLabel>User Key</FormLabel>
<FormControl>
<Input {...field} placeholder="uQiRzpo4DXghDmr9QzzfQu27cmVRsG" />
</FormControl>
<FormDescription>Your Pushover user key from the dashboard.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="••••••••" />
</FormControl>
<FormDescription>Application API token from your Pushover application.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="devices"
render={({ field }) => (
<FormItem>
<FormLabel>Devices (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="iphone,android" />
</FormControl>
<FormDescription>Comma-separated list of device names. Leave empty for all devices.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select
onValueChange={(value) => field.onChange(Number(value))}
defaultValue={String(field.value)}
value={String(field.value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="-1">Low (-1)</SelectItem>
<SelectItem value="0">Normal (0)</SelectItem>
<SelectItem value="1">High (1)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Message priority level.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "telegram" && (
<>
<FormField
control={form.control}
name="botToken"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Token</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
</FormControl>
<FormDescription>
Telegram bot token. Get this from BotFather when you create your bot.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="chatId"
render={({ field }) => (
<FormItem>
<FormLabel>Chat ID</FormLabel>
<FormControl>
<Input {...field} placeholder="-1231234567890" />
</FormControl>
<FormDescription>Telegram chat ID to send notifications to.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "custom" && (
<FormField
control={form.control}
name="shoutrrrUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Shoutrrr URL</FormLabel>
<FormControl>
<Input
{...field}
placeholder="smtp://user:pass@smtp.gmail.com:587/?from=you@gmail.com&to=recipient@example.com"
/>
</FormControl>
<FormDescription>
Direct Shoutrrr URL for power users. See&nbsp;
<a
href="https://shoutrrr.nickfedor.com/v0.12.0/services/overview/"
target="_blank"
rel="noopener noreferrer"
className="text-strong-accent hover:underline"
>
Shoutrrr documentation
</a>
&nbsp;for supported services and URL formats.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
);
};

View File

@@ -0,0 +1,79 @@
import { useMutation } from "@tanstack/react-query";
import { Bell, Plus } from "lucide-react";
import { useId } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { createNotificationDestinationMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
import type { Route } from "./+types/create-notification";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
export const handle = {
breadcrumb: () => [{ label: "Notifications", href: "/notifications" }, { label: "Create" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Zerobyte - Create Notification" },
{
name: "description",
content: "Create a new notification destination for backup alerts.",
},
];
}
export default function CreateNotification() {
const navigate = useNavigate();
const formId = useId();
const createNotification = useMutation({
...createNotificationDestinationMutation(),
onSuccess: () => {
toast.success("Notification destination created successfully");
navigate(`/notifications`);
},
});
const handleSubmit = (values: NotificationFormValues) => {
createNotification.mutate({ body: { name: values.name, config: values } });
};
return (
<div className="container mx-auto space-y-6">
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<Bell className="w-5 h-5 text-primary" />
</div>
<CardTitle>Create Notification Destination</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-6">
{createNotification.isError && (
<Alert variant="destructive">
<AlertDescription>
<strong>Failed to create notification destination:</strong>
<br />
{parseError(createNotification.error)?.message}
</AlertDescription>
</Alert>
)}
<CreateNotificationForm mode="create" formId={formId} onSubmit={handleSubmit} />
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="button" variant="secondary" onClick={() => navigate("/notifications")}>
Cancel
</Button>
<Button type="submit" form={formId} loading={createNotification.isPending}>
<Plus className="h-4 w-4 mr-2" />
Create Destination
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,208 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { redirect, useNavigate } from "react-router";
import { toast } from "sonner";
import { useState, useId } from "react";
import {
deleteNotificationDestinationMutation,
getNotificationDestinationOptions,
testNotificationDestinationMutation,
updateNotificationDestinationMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { parseError } from "~/client/lib/errors";
import { getNotificationDestination } from "~/client/api-client/sdk.gen";
import type { Route } from "./+types/notification-details";
import { cn } from "~/client/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Bell, Save, TestTube2, Trash2, X } from "lucide-react";
import { Alert, AlertDescription } from "~/client/components/ui/alert";
import { CreateNotificationForm, type NotificationFormValues } from "../components/create-notification-form";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Notifications", href: "/notifications" },
{ label: match.params.id },
],
};
export function meta({ params }: Route.MetaArgs) {
return [
{ title: `Zerobyte - Notification ${params.id}` },
{
name: "description",
content: "View and edit notification destination settings.",
},
];
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const destination = await getNotificationDestination({ path: { id: params.id ?? "" } });
if (destination.data) return destination.data;
return redirect("/notifications");
};
export default function NotificationDetailsPage({ loaderData }: Route.ComponentProps) {
const navigate = useNavigate();
const formId = useId();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const { data } = useQuery({
...getNotificationDestinationOptions({ path: { id: String(loaderData.id) } }),
initialData: loaderData,
});
const deleteDestination = useMutation({
...deleteNotificationDestinationMutation(),
onSuccess: () => {
toast.success("Notification destination deleted successfully");
navigate("/notifications");
},
onError: (error) => {
toast.error("Failed to delete notification destination", {
description: parseError(error)?.message,
});
},
});
const updateDestination = useMutation({
...updateNotificationDestinationMutation(),
onSuccess: () => {
toast.success("Notification destination updated successfully");
},
onError: (error) => {
toast.error("Failed to update notification destination", {
description: parseError(error)?.message,
});
},
});
const testDestination = useMutation({
...testNotificationDestinationMutation(),
onSuccess: () => {
toast.success("Test notification sent successfully");
},
onError: (error) => {
toast.error("Failed to send test notification", {
description: parseError(error)?.message,
});
},
});
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
deleteDestination.mutate({ path: { id: String(data.id) } });
};
const handleSubmit = (values: NotificationFormValues) => {
updateDestination.mutate({
path: { id: String(data.id) },
body: {
name: values.name,
config: values,
},
});
};
const handleTest = () => {
testDestination.mutate({ path: { id: String(data.id) } });
};
return (
<>
<div className="flex items-center justify-between mb-4">
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
<span
className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
"bg-green-500/10 text-green-500": data.enabled,
"bg-red-500/10 text-red-500": !data.enabled,
})}
>
{data.enabled ? "Enabled" : "Disabled"}
</span>
<span className="text-xs bg-primary/10 rounded-md px-2 py-1 capitalize">{data.type}</span>
</div>
<div className="flex gap-2">
<Button
onClick={handleTest}
disabled={testDestination.isPending || !data.enabled}
variant="outline"
loading={testDestination.isPending}
>
<TestTube2 className="h-4 w-4 mr-2" />
Test
</Button>
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="destructive"
loading={deleteDestination.isPending}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
<Bell className="w-5 h-5 text-primary" />
</div>
<CardTitle>{data.name}</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-6">
{updateDestination.isError && (
<Alert variant="destructive">
<AlertDescription>
<strong>Failed to update notification destination:</strong>
<br />
{parseError(updateDestination.error)?.message}
</AlertDescription>
</Alert>
)}
<CreateNotificationForm mode="update" formId={formId} onSubmit={handleSubmit} initialValues={data.config} />
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="submit" form={formId} loading={updateDestination.isPending}>
<Save className="h-4 w-4 mr-2" />
Save Changes
</Button>
</div>
</CardContent>
</Card>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Notification Destination</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the notification destination "{data.name}"? This action cannot be undone
and will remove this destination from all backup schedules.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<X className="h-4 w-4 mr-2" />
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete}>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,179 @@
import { useQuery } from "@tanstack/react-query";
import { Bell, Plus, RotateCcw } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { EmptyState } from "~/client/components/empty-state";
import { StatusDot } from "~/client/components/status-dot";
import { Button } from "~/client/components/ui/button";
import { Card } from "~/client/components/ui/card";
import { Input } from "~/client/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import type { Route } from "./+types/notifications";
import { listNotificationDestinations } from "~/client/api-client";
import { listNotificationDestinationsOptions } from "~/client/api-client/@tanstack/react-query.gen";
export const handle = {
breadcrumb: () => [{ label: "Notifications" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Zerobyte - Notifications" },
{
name: "description",
content: "Manage notification destinations for backup alerts.",
},
];
}
export const clientLoader = async () => {
const result = await listNotificationDestinations();
if (result.data) return result.data;
return [];
};
export default function Notifications({ loaderData }: Route.ComponentProps) {
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const clearFilters = () => {
setSearchQuery("");
setTypeFilter("");
setStatusFilter("");
};
const navigate = useNavigate();
const { data } = useQuery({
...listNotificationDestinationsOptions(),
initialData: loaderData,
});
const filteredNotifications =
data?.filter((notification) => {
const matchesSearch = notification.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = !typeFilter || notification.type === typeFilter;
const matchesStatus =
!statusFilter || (statusFilter === "enabled" ? notification.enabled : !notification.enabled);
return matchesSearch && matchesType && matchesStatus;
}) || [];
const hasNoNotifications = data.length === 0;
const hasNoFilteredNotifications = filteredNotifications.length === 0 && !hasNoNotifications;
if (hasNoNotifications) {
return (
<EmptyState
icon={Bell}
title="No notification destinations"
description="Set up notification channels to receive alerts when your backups complete or fail."
button={
<Button onClick={() => navigate("/notifications/create")}>
<Plus size={16} className="mr-2" />
Create Destination
</Button>
}
/>
);
}
return (
<Card className="p-0 gap-0">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap ">
<Input
className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"
placeholder="Search destinations…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="slack">Slack</SelectItem>
<SelectItem value="discord">Discord</SelectItem>
<SelectItem value="gotify">Gotify</SelectItem>
<SelectItem value="ntfy">Ntfy</SelectItem>
<SelectItem value="pushover">Pushover</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mt-px">
<SelectValue placeholder="All status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">Enabled</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
{(searchQuery || typeFilter || statusFilter) && (
<Button onClick={clearFilters} className="w-full lg:w-auto mt-2 lg:mt-0 lg:ml-2">
<RotateCcw className="h-4 w-4 mr-2" />
Clear filters
</Button>
)}
</span>
<Button onClick={() => navigate("/notifications/create")}>
<Plus size={16} className="mr-2" />
Create Destination
</Button>
</div>
<div className="overflow-x-auto">
<Table className="border-t">
<TableHeader className="bg-card-header">
<TableRow>
<TableHead className="w-[100px] uppercase">Name</TableHead>
<TableHead className="uppercase text-left">Type</TableHead>
<TableHead className="uppercase text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{hasNoFilteredNotifications ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-12">
<div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground">No destinations match your filters.</p>
<Button onClick={clearFilters} variant="outline" size="sm">
<RotateCcw className="h-4 w-4 mr-2" />
Clear filters
</Button>
</div>
</TableCell>
</TableRow>
) : (
filteredNotifications.map((notification) => (
<TableRow
key={notification.id}
className="hover:bg-accent/50 hover:cursor-pointer"
onClick={() => navigate(`/notifications/${notification.id}`)}
>
<TableCell className="font-medium text-strong-accent">{notification.name}</TableCell>
<TableCell className="capitalize">{notification.type}</TableCell>
<TableCell className="text-center">
<StatusDot
variant={notification.enabled ? "success" : "neutral"}
label={notification.enabled ? "Enabled" : "Disabled"}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t">
<span>
<span className="text-strong-accent">{filteredNotifications.length}</span> destination
{filteredNotifications.length !== 1 ? "s" : ""}
</span>
</div>
</Card>
);
}

Some files were not shown because too many files have changed in this diff Show More